Skip to content

feat: add GitHub Projects as PM provider (MNG-1050)#1498

Open
grzegorz-aniol wants to merge 3 commits into
mongrel-intelligence:devfrom
grzegorz-aniol:feature/github-projects-pm-support
Open

feat: add GitHub Projects as PM provider (MNG-1050)#1498
grzegorz-aniol wants to merge 3 commits into
mongrel-intelligence:devfrom
grzegorz-aniol:feature/github-projects-pm-support

Conversation

@grzegorz-aniol

@grzegorz-aniol grzegorz-aniol commented Jul 16, 2026

Copy link
Copy Markdown

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:

  • 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)

Test Plan

  • Unit tests pass (npm test) — 610 files, 11040 passed, 23 skipped
  • Linter passes (npm run lint) — exit 0 (only pre-existing style/complexity warnings, none in this PR's new logic)
  • Type check passes (npm run typecheck) — clean
  • Integration tests pass (npm run test:integration) — 37 files, 654 passed (Postgres + Redis up)
  • Tested manually (live E2E against a real GitHub Project — see below)

Automated coverage added for this provider (~120 unit tests + conformance harness):

  • adapter (38), GraphQL client (22), PMIntegration (12), manifest discovery (8)
  • status-changed trigger (16) — incl. the empty-{}-field-value regression net
  • router adapter (11) — incl. 3 isSelfAuthored loop-prevention regression tests
  • webhook wizard step (13)
  • pm-conformance harness iterates github-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):

  • Happy path: projects_v2_item.edited webhook → status-changed trigger → implementation dispatch + run record + ack comment posted on the GitHub issue
  • Adapter/client against the live API: getViewer, getStatusField, getWorkItem, listWorkItems, getWorkItemComments, addComment, moveWorkItem (content-id → ProjectV2Item resolution), addLabel/removeLabel, createWorkItem (real Issue + board add), inline-checklist round-trip
  • Guards: self-authored skip, non-Status field edit skip, unmapped-status no-dispatch, HMAC signature enforcement (401 on bad signature, accept on valid)
  • Confirmed the three E2E-hardening fixes (migration 0061, n.field?.name guard, isSelfAuthored project-id resolution) hold on a clean stack

Checklist

  • My code follows the project's code style
  • I have added tests for new functionality
  • I have updated documentation if needed
  • My commits follow Conventional Commits

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 nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. The projects discovery result returns { id, name, url } (manifest.ts handleProjectsDiscovery, matching the name convention Linear/JIRA use), but GitHubProjectsProjectOption declares title and the wizard reads p.titleundefined at runtime. The hooks.ts:81 cast projects as GitHubProjectsProjectOption[] hides it (the two shapes overlap on id/url, so the as assertion type-checks). Result: ContainerPickStep gets name: undefined; the searchable Combobox list renders empty rows and the selected trigger falls back to the raw PVT_… node id. See inline.
  • src/pm/github-projects/adapter.ts:506getWorkItemUrl builds a URL that won't resolve (PVT_… node id where GitHub expects the numeric project number, missing users/orgs path segment, content node id in item_id). It surfaces as the agent's {{workItemUrl}} (promptContext.ts) and in the Sentry alert handler. See inline.

Performance (consider)

  • adapter.ts listWorkItems + capacity gate — GitHub Projects v2 has no server-side field filter, so listWorkItems(undefined, {status}) calls listAllProjectItems, which paginates the entire board. isActivePipelineOverCapacity invokes it 3× (todo/inProgress/inReview) on every implementation status-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.ts is dead code: the manifest never declares lifecycle, so the conformance harness (LIFECYCLE_FIXTURES / manifest.lifecycle?.fixtureKey) never references it. Either wire it (declare lifecycle.enabled/fixtureKey and 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.ts maps autoLabelId to githubProjectsConfig?.labels?.processing; readyToProcess is the closer analog to the "auto"/cascade-ready label the other providers use.
  • config-schema.ts comment + manifest configFixture label Status option IDs as PVTSSF_xxx, but that prefix is the single-select field id — option ids are short hashes. resolveGitHubProjectsStatusFilter's status.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 / listWorkItems always return labels: [] — label state is invisible to reads (writes work). Reasonable for a status-focused provider, but worth adding to the "Not supported" table in 06-integration-layer.md alongside 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 })),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Renamed GitHubProjectsProjectOption.titlename 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.titlep.name) so the picker shows real project titles instead of blank rows / raw node ids.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — renamed titlename 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.

Comment thread src/pm/github-projects/adapter.ts Outdated
}

getWorkItemUrl(id: string): string {
return `https://github.com/${this.config.owner}/projects/${this.config.projectId}?pane=issue&item_id=${id}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 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
@grzegorz-aniol

Copy link
Copy Markdown
Author

I added more unit tests and enhanced code coverage @codecov

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. resolveAllProjectCredentials seeds projectSecrets.GITHUB_TOKEN = the configured PM PAT.
  2. secretOrchestrator.ts:207-208 then does projectSecrets.GITHUB_TOKEN = gitHubToken (the persona SCM token from getPersonaToken) for every agent whose profile needsGitHubToken — i.e. implementation, review, and most agents.
  3. cli/base.ts:78 scopes the github-projects PM subprocess from process.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:167 extractWorkItemId returns 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.
  • getWorkItemUrl returning a generic owner-Projects URL is a reasonable documented fallback since all WorkItem producers carry the real content.url.

🕵️ claude-code · claude-opus-4-8 · run details

Comment thread src/cli/base.ts
// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d84ecf0. Both roles now use provider-specific env-var keys:

  • tokenGITHUB_PROJECTS_TOKEN
  • webhook_secretGITHUB_PROJECTS_WEBHOOK_SECRET

This resolves both problems you flagged:

  1. Persona clobberGITHUB_PROJECTS_TOKEN is no longer the key secretOrchestrator overwrites, so the configured PM PAT reaches the agent subprocess intact.
  2. 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-hook GITHUB_WEBHOOK_SECRET vs. the PM org/projects_v2_item-hook GITHUB_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants