feat: add GitHub Projects as PM provider (MNG-1050)#1498
feat: add GitHub Projects as PM provider (MNG-1050)#1498grzegorz-aniol wants to merge 3 commits into
Conversation
Adds GitHub Projects v2 as a first-class PM provider. Status-field changes
on the board dispatch agents, work items map to the linked issue/PR, and the
dashboard wizard configures owner, project, status mapping, and the webhook.
PMProvider surface:
- read/update work items and comments, with inline-image delivery (spec 016)
- Status single-select moves (content node ID resolved to its ProjectV2Item ID)
- board listing keyed by content node ID (pipeline-capacity gate compatible)
- repo-scoped label add/remove
- inline-markdown checklists via the shared inline-checklist engine (spec 008)
- work-item creation: creates a real Issue in the project's SCM repo and adds
it to the board, enabling friction/alert card materialization
Webhooks: route /github-projects/webhook with HMAC-SHA256 verification.
Organization-owned projects can create/list/delete the projects_v2_item
webhook programmatically (POST /orgs/{org}/hooks, admin:org_hook scope) via
the shared webhooks.* endpoints and a Create button in the wizard; user-owned
projects use manual setup.
E2E hardening (found + fixed during live testing against a real project):
- add migration 0061 allowing 'github-projects' in the project_integrations
chk_integration_category_provider constraint, so the PM integration is
configurable through the operator path (previously HTTP 500 on save)
- status-changed trigger: guard n.field?.name when scanning fieldValues; the
live getProjectItem read returns empty {} nodes for non-single-select field
values (Title/date), which previously threw and killed every dispatch
- router adapter isSelfAuthored: resolve the CASCADE project id via
resolveProject() before viewer lookup instead of passing the GitHub PVT_
node id, restoring loop-prevention (regression tests added)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJpSpZovzzvJhYdE2GoJw2
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
REQUEST_CHANGES — the backend is well-structured and thoroughly tested, but the new setup wizard's project picker renders blank project names (a title vs name field mismatch), and getWorkItemUrl returns a non‑resolving URL. Neither is a security/data issue; both are concrete, traceable defects in the new provider's user-facing surface. Lower-priority notes follow.
Code Issues
Should fix
web/.../github-projects/wizard.ts:316+state.ts:8— Project selection dropdown shows blank rows. Theprojectsdiscovery result returns{ id, name, url }(manifest.tshandleProjectsDiscovery, matching thenameconvention Linear/JIRA use), butGitHubProjectsProjectOptiondeclarestitleand the wizard readsp.title→undefinedat runtime. Thehooks.ts:81castprojects as GitHubProjectsProjectOption[]hides it (the two shapes overlap onid/url, so theasassertion type-checks). Result:ContainerPickStepgetsname: undefined; the searchable Combobox list renders empty rows and the selected trigger falls back to the rawPVT_…node id. See inline.src/pm/github-projects/adapter.ts:506—getWorkItemUrlbuilds a URL that won't resolve (PVT_…node id where GitHub expects the numeric project number, missingusers/orgspath segment, content node id initem_id). It surfaces as the agent's{{workItemUrl}}(promptContext.ts) and in the Sentry alert handler. See inline.
Performance (consider)
adapter.tslistWorkItems+ capacity gate — GitHub Projects v2 has no server-side field filter, solistWorkItems(undefined, {status})callslistAllProjectItems, which paginates the entire board.isActivePipelineOverCapacityinvokes it 3× (todo/inProgress/inReview) on everyimplementationstatus-change, i.e. 3 full board paginations (up to 3×1000 items) per dispatch. Correct, but heavy on large boards — consider fetching the board once and bucketing by Status option id in memory.
Nitpicks (optional)
tests/helpers/githubProjectsLifecycleFixture.tsis dead code: the manifest never declareslifecycle, so the conformance harness (LIFECYCLE_FIXTURES/manifest.lifecycle?.fixtureKey) never references it. Either wire it (declarelifecycle.enabled/fixtureKeyand register the key) or delete the file. Consequently github-projects skips the behavioral lifecycle conformance group that Trello/JIRA/Linear opt into.src/agents/shared/promptContext.tsmapsautoLabelIdtogithubProjectsConfig?.labels?.processing;readyToProcessis the closer analog to the "auto"/cascade-ready label the other providers use.config-schema.tscomment + manifestconfigFixturelabel Status option IDs asPVTSSF_xxx, but that prefix is the single-select field id — option ids are short hashes.resolveGitHubProjectsStatusFilter'sstatus.startsWith('PVTSSF_')fallback therefore never matches a real raw option id (harmless on the normal CASCADE-key path, but the comment/fixture will mislead CLI operators).getWorkItem/listWorkItemsalways returnlabels: []— label state is invisible to reads (writes work). Reasonable for a status-focused provider, but worth adding to the "Not supported" table in06-integration-layer.mdalongside attachments/custom-fields.
Verified sound
Migration 0061 correctly reconstructs the full chk_integration_category_provider constraint (matches 0049 + github-projects); capacity-gate invariant respected (withPMScopeForDispatch + static guard file list updated); loop-prevention fix (resolve CASCADE project id before viewer lookup) has regression coverage.
🕵️ claude-code · claude-opus-4-8 · run details
| }); | ||
|
|
||
| return { | ||
| projectOptions: state.githubProjectsProjects.map((p) => ({ id: p.id, name: p.title })), |
There was a problem hiding this comment.
Blank project picker: state.githubProjectsProjects is filled from the projects discovery result, which returns { id, name, url } (see manifest.ts handleProjectsDiscovery, matching the name convention used by LinearProjectOption/JiraProjectOption). But GitHubProjectsProjectOption declares title (state.ts:8) and this line reads p.title, which is undefined at runtime. The hooks.ts:81 cast projects as GitHubProjectsProjectOption[] hides the mismatch (the shapes overlap on id/url, so TS permits the as). ContainerPickStep then gets name: undefined → the searchable Combobox list renders empty rows and the selected trigger shows the raw PVT_… id. Fix: rename the field to name in state.ts and read p.name here.
There was a problem hiding this comment.
Fixed. Renamed GitHubProjectsProjectOption.title → name in state.ts and updated this line to read p.name, so the projects discovery result ({ id, name, url }) flows through unchanged and ContainerPickStep gets real project names instead of blank rows / the raw PVT_… id.
I also dropped the masking projects as GitHubProjectsProjectOption[] cast in hooks.ts:81 — with the field renamed the two shapes are structurally identical, so the cast is unnecessary. (It was actually a latent TS2352 under the web/ tsconfig, since title vs name don't sufficiently overlap for a plain as.) Fix in ac29abf2.
|
|
||
| export interface GitHubProjectsProjectOption { | ||
| id: string; | ||
| title: string; |
There was a problem hiding this comment.
title doesn't match the projects discovery output ({ id, name, url }) — other providers use name (LinearProjectOption, JiraProjectOption). Rename to name and update wizard.ts:316 (p.title → p.name) so the picker shows real project titles instead of blank rows / raw node ids.
There was a problem hiding this comment.
Done — renamed title → name here so it matches the projects discovery output ({ id, name, url }) and the LinearProjectOption / JiraProjectOption convention. wizard.ts now reads p.name, and the redundant as cast in hooks.ts is gone. Fix in ac29abf2.
| } | ||
|
|
||
| getWorkItemUrl(id: string): string { | ||
| return `https://github.com/${this.config.owner}/projects/${this.config.projectId}?pane=issue&item_id=${id}`; |
There was a problem hiding this comment.
This synthetic URL won't resolve: this.config.projectId is the PVT_… node id (GitHub project URLs use the numeric project number under /users/<login>/projects/<n> or /orgs/<login>/projects/<n>), owner is missing the users/orgs segment, and item_id should be a numeric item id rather than the content node id. It surfaces as the agent's {{workItemUrl}} (promptContext.ts) and in the Sentry alert handler. Prefer returning the issue/PR URL (already available as WorkItem.url from content.url) or at least a correctly-shaped project URL.
There was a problem hiding this comment.
Fixed. getWorkItemUrl now returns a correctly-shaped, resolving owner Projects URL — https://github.com/{users|orgs}/<owner>/projects (the users/orgs segment keyed off ownerType) — instead of the non-resolving /<owner>/projects/<PVT_ node id>?item_id=<content node id>.
An item-specific deep link can't be built inside the synchronous getWorkItemUrl(id): id is the opaque content (Issue/PR) node ID, and the project's numeric number (needed for /projects/<n>) isn't persisted in config. But every WorkItem-producing method (getWorkItem / listWorkItems / createWorkItem) already carries the accurate content.url, and the fallback-style callers prefer it (workItem.url || getWorkItemUrl(id)), so this fallback just needs to resolve rather than mislead. Added unit coverage for the user and org segments. Fix in ac29abf2.
…item URL, and follow-ups
- state.ts/wizard.ts: rename `GitHubProjectsProjectOption.title` → `name` so the
discovery result (`{ id, name, url }`) matches; the picker now shows real
project titles instead of blank rows / raw PVT_ node ids. Drop the masking
`as` cast in hooks.ts (which also fixes a latent web-tsc TS2352).
- adapter.getWorkItemUrl: return a correctly-shaped, resolving owner Projects
URL (users/orgs segment) instead of the non-resolving
`/<owner>/projects/<PVT_ node id>?item_id=<content node id>` string.
- adapter.listWorkItems: coalesce concurrent full-board fetches so the capacity
gate's todo/inProgress/inReview burst pages the board once, not three times.
- promptContext: map github-projects `autoLabelId` to `readyToProcess` (the
cascade-ready analog) instead of `processing` (agent-is-working).
- config-schema comment + manifest configFixture: Status *option* IDs are short
opaque hashes, not the `PVTSSF_` single-select *field* prefix; drop the dead
`PVTSSF_`-prefix passthrough in resolveGitHubProjectsStatusFilter.
- manifest: opt into the lifecycle conformance scenario and wire the fixture in
pm-conformance (github-projects was not previously iterated by the harness).
- docs: note label reads always return `[]` in the "Not supported" table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Codecov patch coverage on the GitHub Projects PM provider commit (cdc017a) was below threshold on 10+ files (as low as 9% on the webhook management router). Adds targeted unit tests across the client, adapter, integration, router adapter, platform client, ack generator, webhook parsing/verification, friction reporting, and worker-entry dispatch paths, taking coverage on all originally-flagged files to ~100% statement/line coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSnWeF6spNgBtcD2HffjWm
|
I added more unit tests and enhanced code coverage @codecov |
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
Strong, thoroughly-tested addition that follows the PM manifest pattern closely — conformance harness opt-in, the capacity-gate withPMScopeForDispatch wrap (with the static guard updated), inline-checklist reuse, the spec-016 image pipeline, deferred-ack, and loop-prevention are all wired correctly, and the E2E-hardening fixes (migration 0061, the n.field?.name empty-{} guard, and the isSelfAuthored project-id resolution) are well-covered by regression tests. Requesting changes on one design issue that affects the default (claude-code / native-tool) worker path: the GitHub Projects PM token collides with the SCM persona-token override, so agent-invoked cascade-tools pm calls run with the SCM persona token rather than the configured GitHub Projects PAT.
On the red CI check: lint-and-test fails only on npm audit --omit=dev --audit-level=high (js-yaml GHSA-52cp-r559-cp3m, plus others). This PR touches no package.json/lockfile, so the advisory is pre-existing and equally present on dev — not introduced here. Locally, build, build:web, typecheck, lint, and the full unit suite (11,225 passed / 39 skipped) are all green.
Architecture & Design
[Should fix] The PM token shares the GITHUB_TOKEN env var with the SCM persona override.
The github-projects token credential role maps to env-var key GITHUB_TOKEN (integration.ts:22, manifest.ts credentialRoles). In the worker:
resolveAllProjectCredentialsseedsprojectSecrets.GITHUB_TOKEN= the configured PM PAT.secretOrchestrator.ts:207-208then doesprojectSecrets.GITHUB_TOKEN = gitHubToken(the persona SCM token fromgetPersonaToken) for every agent whose profileneedsGitHubToken— i.e. implementation, review, and most agents.cli/base.ts:78scopes the github-projects PM subprocess fromprocess.env.GITHUB_TOKEN, which is now the persona token.
Net effect: on the default claude-code engine, agent-authored PM tool calls (PostComment, MoveWorkItem, UpdateChecklistItem, ReportFriction, …) run as the SCM implementer/reviewer identity, not as the configured Projects PAT. Because GitHub Projects v2 has a permission model separate from repo scope, this silently 403s whenever the persona tokens lack Project write access — even though the wizard's verify step (which uses the PM PAT directly) passed.
Router dispatch/ack, trigger reads, and in-process lifecycle are unaffected — they resolve the token from the DB via integration.withCredentials / resolveGitHubProjectsCredentials, which correctly return the PM PAT. So the happy-path E2E in the description (webhook → dispatch → router-side ack) would not have exercised this gap.
Suggested fix: give the provider a dedicated env-var key (e.g. GITHUB_PROJECTS_TOKEN) for the token role so the subprocess uses the configured PAT and survives the persona override. This also removes the GITHUB_WEBHOOK_SECRET key-sharing with the SCM github integration.
Questions
- In your live E2E, did the implementer/reviewer persona tokens also carry Projects v2 (
project) scope / Project collaborator access? That would explain why in-worker PM writes worked despite the override — and confirm whether operators without that setup will hit 403s.
Minor (non-blocking)
integration.ts:167extractWorkItemIdreturns the issue number, whereas the work-item identity everywhere else on this path is the content node ID. There's currently no call site for the PMIntegration variant, so it's latent, but it will mismatch if a future caller relies on it.getWorkItemUrlreturning a generic owner-Projects URL is a reasonable documented fallback since allWorkItemproducers carry the realcontent.url.
🕵️ claude-code · claude-opus-4-8 · run details
| // the SCM `withGitHubToken` scope. Establish it only for GitHub-Projects PM | ||
| // workers so `cascade-tools pm` calls don't throw "No GitHub Projects | ||
| // credentials in scope". | ||
| const githubProjectsToken = process.env.GITHUB_TOKEN; |
There was a problem hiding this comment.
This scopes the GitHub Projects PM subprocess from process.env.GITHUB_TOKEN, but secretOrchestrator.ts:207-208 overwrites GITHUB_TOKEN with the persona SCM token (getPersonaToken) for every agent whose profile needsGitHubToken (implementation, review, and most agents). Since the github-projects token role also maps to GITHUB_TOKEN (integration.ts:22 / manifest.ts), the configured PM PAT is clobbered before the worker runs — so agent-invoked cascade-tools pm calls (PostComment, MoveWorkItem, UpdateChecklistItem, ReportFriction) execute as the SCM implementer/reviewer identity on the default claude-code engine. GitHub Projects v2 permissions are separate from repo scope, so this 403s whenever the persona tokens lack Project write access, even though the wizard's verify step (which uses the PM PAT) succeeded. Router/ack and in-process integration.withCredentials paths are unaffected (they read the token from the DB by role). Consider a dedicated GITHUB_PROJECTS_TOKEN env-var key so the subprocess uses the configured PAT and survives the persona override.
There was a problem hiding this comment.
Fixed in d84ecf0. The token role now maps to a dedicated env-var key GITHUB_PROJECTS_TOKEN (see src/pm/github-projects/integration.ts + manifest.ts), and cli/base.ts scopes the subprocess from process.env.GITHUB_PROJECTS_TOKEN instead of GITHUB_TOKEN.
Because secretOrchestrator only overwrites GITHUB_TOKEN (the SCM persona token), the configured Projects PAT now survives the override, so agent-invoked cascade-tools pm calls (PostComment, MoveWorkItem, UpdateChecklistItem, ReportFriction, …) run as the configured PM identity on the default claude-code engine. getAllProjectCredentials injects the PAT under the new key, so the worker subprocess env carries both GITHUB_TOKEN (SCM persona) and GITHUB_PROJECTS_TOKEN (PM PAT) independently.
Updated tests/unit/cli/credential-scoping.test.ts to prove the decoupling: with both keys set to distinct values, the PM scope uses GITHUB_PROJECTS_TOKEN while withGitHubToken still uses the persona GITHUB_TOKEN, and PM scoping no longer fires when only the SCM token is present.
|
|
||
| // Self-register credential roles at module load time. | ||
| registerCredentialRoles('github-projects', 'pm', [ | ||
| { role: 'token', label: 'Personal Access Token', envVarKey: 'GITHUB_TOKEN' }, |
There was a problem hiding this comment.
The token role maps to GITHUB_TOKEN — the same env var secretOrchestrator overwrites with the SCM persona token — and webhook_secret reuses GITHUB_WEBHOOK_SECRET, shared with the SCM github integration. A project that has both a GitHub SCM integration and a GitHub Projects PM integration collides on these keys in the worker env map (and, since project_credentials is keyed by envVarKey, cannot store distinct webhook secrets for the org-hook vs repo-hook). A provider-specific key such as GITHUB_PROJECTS_TOKEN avoids both the persona clobber and the SCM webhook-secret coupling.
There was a problem hiding this comment.
Fixed in d84ecf0. Both roles now use provider-specific env-var keys:
token→GITHUB_PROJECTS_TOKENwebhook_secret→GITHUB_PROJECTS_WEBHOOK_SECRET
This resolves both problems you flagged:
- Persona clobber —
GITHUB_PROJECTS_TOKENis no longer the keysecretOrchestratoroverwrites, so the configured PM PAT reaches the agent subprocess intact. - SCM collision — since credential rows are keyed by
(projectId, envVarKey), a project with both a GitHub SCM integration and a GitHub Projects PM integration can now store distinct webhook secrets (the SCM repo-hookGITHUB_WEBHOOK_SECRETvs. the PM org/projects_v2_item-hookGITHUB_PROJECTS_WEBHOOK_SECRET).
Changed the registration here plus the mirrored manifest.ts credentialRoles, the wizard credential persistence / edit-hydration / webhook-secret lookups, the webhook-context token resolver (creds.GITHUB_PROJECTS_TOKEN), the 06-integration-layer.md role table, and the affected resolveWebhookSecret / webhook-step / webhooks-router tests.
Summary
Adds GitHub Projects v2 as a first-class PM provider (alongside Trello/JIRA/Linear). Status-field changes on the board dispatch agents, work items map to the linked issue/PR, and the dashboard wizard configures owner, project, status mapping, and the webhook.
PMProvider surface:
Webhooks: route
/github-projects/webhookwith HMAC-SHA256 verification. Organization-owned projects can create/list/delete theprojects_v2_itemwebhook programmatically (POST /orgs/{org}/hooks,admin:org_hookscope) via the sharedwebhooks.*endpoints and a Create button in the wizard; user-owned projects use manual setup.E2E hardening (found + fixed during live testing against a real project):
0061allowing'github-projects'in theproject_integrationschk_integration_category_providerconstraint, so the PM integration is configurable through the operator path (previously HTTP 500 on save)n.field?.namewhen scanningfieldValues; the livegetProjectItemread returns empty{}nodes for non-single-select field values (Title/date), which previously threw and killed every dispatchisSelfAuthored: resolve the CASCADE project id viaresolveProject()before viewer lookup instead of passing the GitHubPVT_node id, restoring loop-prevention (regression tests added)Test Plan
npm test) — 610 files, 11040 passed, 23 skippednpm run lint) — exit 0 (only pre-existing style/complexity warnings, none in this PR's new logic)npm run typecheck) — cleannpm run test:integration) — 37 files, 654 passed (Postgres + Redis up)Automated coverage added for this provider (~120 unit tests + conformance harness):
{}-field-value regression netisSelfAuthoredloop-prevention regression testspm-conformanceharness iteratesgithub-projects(id/webhookRoute/credentialRoles/dispatchPMAck)Live E2E (2 rounds) against a real user-owned GitHub Project + throwaway repo, full local stack (router + dashboard + Postgres + Redis):
projects_v2_item.editedwebhook → status-changed trigger →implementationdispatch + run record + ack comment posted on the GitHub issuegetViewer,getStatusField,getWorkItem,listWorkItems,getWorkItemComments,addComment,moveWorkItem(content-id → ProjectV2Item resolution),addLabel/removeLabel,createWorkItem(real Issue + board add), inline-checklist round-tripn.field?.nameguard,isSelfAuthoredproject-id resolution) hold on a clean stackChecklist