From cdc017a0b6e653350497a3bbb7722333eead471b Mon Sep 17 00:00:00 2001 From: Grzegorz Aniol Date: Thu, 16 Jul 2026 10:30:45 +0200 Subject: [PATCH 1/3] feat: add GitHub Projects as PM provider (MNG-1050) 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) Claude-Session: https://claude.ai/code/session_01XJpSpZovzzvJhYdE2GoJw2 --- README.md | 6 +- docs/ARCHITECTURE.md | 12 +- docs/architecture/02-webhook-pipeline.md | 6 +- docs/architecture/03-trigger-system.md | 12 +- docs/architecture/06-integration-layer.md | 31 +- docs/architecture/08-config-credentials.md | 1 + docs/getting-started.md | 34 +- src/agents/prompts/index.ts | 8 +- src/agents/shared/promptContext.ts | 54 +- src/api/routers/webhooks.ts | 88 +- src/api/routers/webhooks/context.ts | 9 +- src/api/routers/webhooks/github-projects.ts | 137 +++ src/api/routers/webhooks/types.ts | 8 +- src/backends/secretBuilder.ts | 29 +- src/cli/base.ts | 108 ++- src/cli/dashboard/webhooks/create.ts | 20 + src/cli/dashboard/webhooks/delete.ts | 19 + src/config/provider.ts | 7 + src/config/schema.ts | 5 +- ...0061_allow_github_projects_pm_provider.sql | 18 + src/db/migrations/meta/_journal.json | 7 + src/db/repositories/configMapper.ts | 48 +- src/db/repositories/configRepository.ts | 22 +- src/gadgets/pm/core/reportFriction.ts | 91 +- src/github-projects/client.ts | 834 ++++++++++++++++++ src/github-projects/types.ts | 124 +++ .../pm/github-projects/config-schema.ts | 36 + src/integrations/pm/github-projects/index.ts | 10 + .../pm/github-projects/manifest.ts | 220 +++++ src/integrations/pm/index.ts | 1 + src/pm/config.ts | 51 ++ src/pm/download-and-prepare.ts | 5 + src/pm/github-projects/adapter.ts | 518 +++++++++++ src/pm/github-projects/integration.ts | 175 ++++ src/pm/types.ts | 2 +- src/router/ackMessageGenerator.ts | 31 + src/router/adapters/github-projects.ts | 294 ++++++ src/router/adapters/sentry.ts | 14 + src/router/config.ts | 22 +- src/router/index.ts | 27 + src/router/platformClients/credentials.ts | 21 +- src/router/platformClients/github-projects.ts | 65 ++ src/router/platformClients/index.ts | 2 + src/router/queue.ts | 28 +- src/router/webhook-trigger-outcomes.ts | 7 +- src/router/webhookVerification.ts | 35 +- src/router/worker-env.ts | 2 + .../github-projects/status-changed.ts | 176 ++++ .../github-projects/webhook-handler.ts | 22 + src/triggers/shared/backlog-check.ts | 11 +- src/webhook/webhookHandlers.ts | 1 + src/webhook/webhookParsers.ts | 22 + src/worker-entry.ts | 74 +- .../helpers/githubProjectsLifecycleFixture.ts | 20 + tests/unit/api/routers/webhooks.test.ts | 1 + tests/unit/backends/secretBuilder.test.ts | 29 + .../cli/dashboard/webhooks/webhooks.test.ts | 8 + .../pm-router-adapter-pm-scope.test.ts | 2 +- tests/unit/pm/github-projects/adapter.test.ts | 565 ++++++++++++ tests/unit/pm/github-projects/client.test.ts | 432 +++++++++ .../pm/github-projects/integration.test.ts | 148 ++++ .../manifest-discovery.test.ts | 116 +++ .../router/adapters/github-projects.test.ts | 267 ++++++ .../github-projects-status-changed.test.ts | 271 ++++++ .../web/github-projects-webhook-step.test.ts | 172 ++++ .../pm-providers/github-projects/auth.ts | 16 + .../pm-providers/github-projects/hooks.ts | 232 +++++ .../pm-providers/github-projects/index.ts | 12 + .../pm-providers/github-projects/state.ts | 119 +++ .../github-projects/webhook-step.tsx | 319 +++++++ .../pm-providers/github-projects/wizard.ts | 348 ++++++++ .../components/projects/pm-providers/index.ts | 1 + .../components/projects/pm-wizard-state.ts | 25 +- 73 files changed, 6582 insertions(+), 131 deletions(-) create mode 100644 src/api/routers/webhooks/github-projects.ts create mode 100644 src/db/migrations/0061_allow_github_projects_pm_provider.sql create mode 100644 src/github-projects/client.ts create mode 100644 src/github-projects/types.ts create mode 100644 src/integrations/pm/github-projects/config-schema.ts create mode 100644 src/integrations/pm/github-projects/index.ts create mode 100644 src/integrations/pm/github-projects/manifest.ts create mode 100644 src/pm/github-projects/adapter.ts create mode 100644 src/pm/github-projects/integration.ts create mode 100644 src/router/adapters/github-projects.ts create mode 100644 src/router/platformClients/github-projects.ts create mode 100644 src/triggers/github-projects/status-changed.ts create mode 100644 src/triggers/github-projects/webhook-handler.ts create mode 100644 tests/helpers/githubProjectsLifecycleFixture.ts create mode 100644 tests/unit/pm/github-projects/adapter.test.ts create mode 100644 tests/unit/pm/github-projects/client.test.ts create mode 100644 tests/unit/pm/github-projects/integration.test.ts create mode 100644 tests/unit/pm/github-projects/manifest-discovery.test.ts create mode 100644 tests/unit/router/adapters/github-projects.test.ts create mode 100644 tests/unit/triggers/github-projects-status-changed.test.ts create mode 100644 tests/unit/web/github-projects-webhook-step.test.ts create mode 100644 web/src/components/projects/pm-providers/github-projects/auth.ts create mode 100644 web/src/components/projects/pm-providers/github-projects/hooks.ts create mode 100644 web/src/components/projects/pm-providers/github-projects/index.ts create mode 100644 web/src/components/projects/pm-providers/github-projects/state.ts create mode 100644 web/src/components/projects/pm-providers/github-projects/webhook-step.tsx create mode 100644 web/src/components/projects/pm-providers/github-projects/wizard.ts diff --git a/README.md b/README.md index a8085ad18..64d104cc8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js 22+](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org/) -> **Cascade orchestrates AI agents (Claude Code, Codex, opencode, LLMist) across your workflows in GitHub, Trello, Jira, and Linear.** +> **Cascade orchestrates AI agents (Claude Code, Codex, opencode, LLMist) across your workflows in GitHub, Trello, Jira, Linear, and GitHub Projects.** Cascade is an open-source platform that automates the full software development lifecycle. Connect your PM tool and GitHub repository, and Cascade drives work items from plan to merge: @@ -38,7 +38,7 @@ For the full setup walkthrough — projects, credentials, webhooks, and triggers ## ⚡ Features -- **Multi-PM support** — Works with Trello, JIRA, and Linear out of the box +- **Multi-PM support** — Works with Trello, JIRA, Linear, and GitHub Projects out of the box - **12 agent types** — Splitting, planning, implementation, review, debug, respond-to-review, respond-to-CI, alerting, and more - **Dual-persona GitHub model** — Separate implementer and reviewer bot accounts to prevent feedback loops - **Web dashboard + CLI** — Monitor runs, manage projects, configure triggers @@ -151,7 +151,7 @@ All project-level credentials (GitHub tokens, PM keys, LLM API keys) are stored **Dual-persona GitHub model** — Cascade uses two separate GitHub bot accounts per project (implementer and reviewer) to prevent feedback loops. The implementer writes code and creates PRs; the reviewer reviews and approves them. -**Trigger system** — Events from Trello, JIRA, Linear, GitHub, and Sentry webhooks are matched against registered `TriggerHandler` instances. Triggers are configured per-project in the database. Event names are category-prefixed, for example `pm:status-changed`, `scm:check-suite-success`, and `alerting:issue-alert`. +**Trigger system** — Events from Trello, JIRA, Linear, GitHub Projects, GitHub, and Sentry webhooks are matched against registered `TriggerHandler` instances. Triggers are configured per-project in the database. Event names are category-prefixed, for example `pm:status-changed`, `scm:check-suite-success`, and `alerting:issue-alert`. **Agent engines** — Agents run through a shared execution lifecycle with a pluggable engine registry. Default engine is `claude-code` (Anthropic Claude Code SDK). Alternatives: `llmist` (supports OpenRouter, Anthropic, OpenAI), `codex` (OpenAI Codex CLI), `opencode` (OpenCode server). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c52075319..f8c7b195d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # CASCADE Architecture -CASCADE is a PM-to-Code automation platform that connects project management tools (Trello, JIRA, Linear), source control (GitHub), and monitoring (Sentry) to AI-powered agents that autonomously implement features, review PRs, debug failures, and manage backlogs. Webhooks from external providers flow through a router, get queued in Redis, and are processed by ephemeral worker containers that run agents against cloned repositories. +CASCADE is a PM-to-Code automation platform that connects project management tools (Trello, JIRA, Linear, GitHub Projects), source control (GitHub), and monitoring (Sentry) to AI-powered agents that autonomously implement features, review PRs, debug failures, and manage backlogs. Webhooks from external providers flow through a router, get queued in Redis, and are processed by ephemeral worker containers that run agents against cloned repositories. > **Relationship to CLAUDE.md**: `CLAUDE.md` is the operational reference (commands, env vars, how-to). This document and its deep-dives cover the *system design* — how components fit together and why. @@ -12,6 +12,7 @@ graph TB Trello JIRA Linear + GitHubProjects["GitHub Projects"] GitHub Sentry end @@ -32,6 +33,7 @@ graph TB Trello -->|webhook| Router JIRA -->|webhook| Router Linear -->|webhook| Router + GitHubProjects -->|webhook| Router GitHub -->|webhook| Router Sentry -->|webhook| Router @@ -42,6 +44,7 @@ graph TB Worker -->|status updates| Trello Worker -->|status updates| JIRA Worker -->|status updates| Linear + Worker -->|status updates| GitHubProjects Router <--> DB Worker <--> DB @@ -68,7 +71,7 @@ The canonical path from webhook to pull request: ```mermaid sequenceDiagram - participant P as Provider
(Trello/JIRA/Linear/GitHub/Sentry) + participant P as Provider
(Trello/JIRA/Linear/GitHub Projects/GitHub/Sentry) participant R as Router participant Q as Redis/BullMQ participant W as Worker @@ -100,7 +103,7 @@ sequenceDiagram **YAML-based agent definitions** — Agents are defined declaratively in YAML files specifying identity, capabilities, triggers, prompts, and lifecycle hooks. Definitions resolve via three tiers: in-memory cache, database, then YAML files on disk. -**AsyncLocalStorage credential scoping** — Provider clients (GitHub, Trello, JIRA, Linear, and PM dispatch scopes) use Node.js `AsyncLocalStorage` to scope credentials and active PM provider context per request, preventing cross-request credential leakage. +**AsyncLocalStorage credential scoping** — Provider clients (GitHub, Trello, JIRA, Linear, GitHub Projects, and PM dispatch scopes) use Node.js `AsyncLocalStorage` to scope credentials and active PM provider context per request, preventing cross-request credential leakage. ## Directory Map @@ -113,8 +116,9 @@ sequenceDiagram | `src/backends/` | LLM execution engines: Claude Code, LLMist, Codex, OpenCode | | `src/gadgets/` | Tool implementations agents use (file ops, PM, SCM, alerting, shell) | | `src/integrations/` | Unified integration interfaces, registry, bootstrap | -| `src/pm/` | PM abstraction layer: provider interface, Trello/JIRA/Linear adapters, lifecycle | +| `src/pm/` | PM abstraction layer: provider interface, Trello/JIRA/Linear/GitHub Projects adapters, lifecycle | | `src/github/` | GitHub API client, dual-persona model, PR operations | +| `src/github-projects/` | GitHub Projects (Projects v2) GraphQL client | | `src/trello/` | Trello API client | | `src/jira/` | JIRA API client (jira.js wrapper) | | `src/linear/` | Linear GraphQL API client | diff --git a/docs/architecture/02-webhook-pipeline.md b/docs/architecture/02-webhook-pipeline.md index 214fd2070..47ea9e502 100644 --- a/docs/architecture/02-webhook-pipeline.md +++ b/docs/architecture/02-webhook-pipeline.md @@ -1,6 +1,6 @@ # Webhook Pipeline -Webhooks from external providers (Trello, JIRA, Linear, GitHub, Sentry) are processed through a two-layer system: a **webhook handler factory** that handles HTTP concerns, and a **router platform adapter** that implements the business logic pipeline. +Webhooks from external providers (Trello, JIRA, Linear, GitHub Projects, GitHub, Sentry) are processed through a two-layer system: a **webhook handler factory** that handles HTTP concerns, and a **router platform adapter** that implements the business logic pipeline. ## Webhook Handler Factory @@ -16,7 +16,7 @@ Each webhook endpoint provides a `WebhookHandlerConfig`: ```typescript interface WebhookHandlerConfig { - source: string; // 'trello' | 'github' | 'jira' | 'linear' | 'sentry' + source: string; // 'trello' | 'github' | 'jira' | 'linear' | 'github-projects' | 'sentry' parsePayload: (c: Context) => ParseResult; verifySignature?: (ctx, rawBody, projectId?) => VerificationResult | null; processWebhook: (payload, eventType?, headers?) => Promise; @@ -38,6 +38,7 @@ The factory handles: | `parseTrelloPayload()` | JSON body | `action.type` field | | `parseJiraPayload()` | JSON body | `webhookEvent` field | | `parseLinearPayload()` | JSON body | `type` field | +| `parseGitHubProjectsPayload()` | JSON body | `projects_v2_item.action` field | | `parseSentryPayload()` | JSON body | `Sentry-Hook-Resource` header | ## Platform Adapters @@ -83,6 +84,7 @@ interface ParsedWebhookEvent { | `GitHubRouterAdapter` | `src/router/adapters/github.ts` | `repoFullName` | | `JiraRouterAdapter` | `src/router/adapters/jira.ts` | JIRA project key | | `LinearRouterAdapter` | `src/router/adapters/linear.ts` | Linear team ID | +| `GitHubProjectsRouterAdapter` | `src/router/adapters/github-projects.ts` | Project node ID (`project_node_id`) | | `SentryRouterAdapter` | `src/router/adapters/sentry.ts` | CASCADE `projectId` (from URL) | Sentry keeps the route shape `/sentry/webhook/:projectId`. The project ID selects the Cascade project, whose Sentry config includes a paired `organizationSlug`/`projectSlug`. The adapter filters payloads by matching Sentry project identifiers against the configured `projectSlug`; organization-level deliveries whose payload project does not match are acknowledged but do not dispatch agents. diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index f5d6900f6..268792a30 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -41,7 +41,7 @@ interface TriggerHandler { ```typescript interface TriggerContext { project: ProjectConfig; - source: TriggerSource; // 'trello' | 'github' | 'jira' | 'linear' | 'sentry' + source: TriggerSource; // 'trello' | 'github' | 'jira' | 'linear' | 'github-projects' | 'sentry' payload: unknown; // Raw webhook payload personaIdentities?: PersonaIdentities; // GitHub bot identities } @@ -75,7 +75,7 @@ interface TriggerResult { ## Built-in Triggers -Registration happens in `src/triggers/builtins.ts`. PM providers (Trello, JIRA, Linear) contribute triggers via the manifest registry; SCM and alerting providers use their own `register.ts` functions: +Registration happens in `src/triggers/builtins.ts`. PM providers (Trello, JIRA, Linear, GitHub Projects) contribute triggers via the manifest registry; SCM and alerting providers use their own `register.ts` functions: ```typescript function registerBuiltInTriggers(registry: TriggerRegistry): void { @@ -110,6 +110,14 @@ function registerBuiltInTriggers(registry: TriggerRegistry): void { | `JiraStatusChangedTrigger` | Issue status transition | Per-status mapping | | `JiraLabelAddedTrigger` | "cascade-ready" label added | `splitting` | +### GitHub Projects triggers (`src/triggers/github-projects/`) + +| Handler | Event | Agent | +|---------|-------|-------| +| `GitHubProjectsStatusChangedTrigger` | Board Status field change (`projects_v2_item`) | Per-status mapping | + +The handler reads the item's current Status option ID authoritatively via GraphQL (the `projects_v2_item` webhook does not reliably carry the new value), then resolves it against the configured status→agent mapping. + ### GitHub triggers (`src/triggers/github/`) | Handler | Event | Agent | diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md index 3988693fb..2be8f0f93 100644 --- a/docs/architecture/06-integration-layer.md +++ b/docs/architecture/06-integration-layer.md @@ -10,7 +10,7 @@ The base contract for SCM and alerting integrations, and the compatibility surfa ```typescript interface IntegrationModule { - readonly type: string; // 'trello', 'jira', 'linear', 'github', 'sentry' + readonly type: string; // 'trello', 'jira', 'linear', 'github-projects', 'github', 'sentry' readonly category: IntegrationCategory; // 'pm' | 'scm' | 'alerting' withCredentials(projectId: string, fn: () => Promise): Promise; @@ -52,7 +52,7 @@ const integrationRegistry: IntegrationRegistry; // singleton ### PMProviderManifest and PMIntegration -`src/integrations/pm/manifest.ts` — the manifest is the single PM-provider contract. Trello, JIRA, and Linear each declare identity, credential roles, webhook route/signature verification, router adapter, trigger handlers, platform ack client, config schema, discovery capabilities, wizard spec, and lifecycle fixture in one provider-owned object. +`src/integrations/pm/manifest.ts` — the manifest is the single PM-provider contract. Trello, JIRA, Linear, and GitHub Projects each declare identity, credential roles, webhook route/signature verification, router adapter, trigger handlers, platform ack client, config schema, discovery capabilities, wizard spec, and lifecycle fixture in one provider-owned object. The PM barrel (`src/integrations/pm/index.ts`) imports each provider once, then mirrors each manifest's `pmIntegration` into `integrationRegistry`. New PM providers add one provider folder plus one import in that barrel; shared router, worker, dashboard, CLI, and config files are guarded against provider-specific edits by conformance tests. @@ -99,6 +99,7 @@ Each provider declares its credential roles — the mapping from logical role na | Trello | pm | `api_key` → `TRELLO_API_KEY`, `token` → `TRELLO_TOKEN` | `api_secret` | | JIRA | pm | `email` → `JIRA_EMAIL`, `api_token` → `JIRA_API_TOKEN` | `webhook_secret` | | Linear | pm | `api_key` → `LINEAR_API_KEY` | `webhook_secret` → `LINEAR_WEBHOOK_SECRET` | +| GitHub Projects | pm | `token` → `GITHUB_TOKEN` | `webhook_secret` → `GITHUB_WEBHOOK_SECRET` | | GitHub | scm | `implementer_token` → `GITHUB_TOKEN_IMPLEMENTER`, `reviewer_token` → `GITHUB_TOKEN_REVIEWER` | `webhook_secret` | | Sentry | alerting | `api_token` → `SENTRY_API_TOKEN` | `webhook_secret` | @@ -135,6 +136,32 @@ Each provider declares its credential roles — the mapping from logical role na - Issue identifier extraction via regex: `[A-Z][A-Z0-9]*-\d+` (e.g. `TEAM-123`) - Work item URL format: `https://linear.app//issue/` +### GitHub Projects (`src/integrations/pm/github-projects/`, `src/pm/github-projects/`, `src/github-projects/`) + +- `githubProjectsManifest` declares the PM provider contract and registers with `pmProviderRegistry` +- `GitHubProjectsIntegration` implements the mirrored `PMIntegration` +- `GitHubProjectsPMProvider` implements `PMProvider` for **Projects v2** boards +- `src/github-projects/client.ts` — GraphQL API v4 client with AsyncLocalStorage credential scoping (`withGitHubProjectsCredentials`, distinct from the SCM GitHub token scope) +- Status = the board's **Status** single-select field; transitions map Cascade lifecycle keys to option IDs. The status-changed trigger reads the item's current Status option ID authoritatively via GraphQL rather than trusting the sparse `projects_v2_item` webhook payload +- Webhooks: route `/github-projects/webhook`, HMAC-SHA256 verification via the shared verifier. `projects_v2_item` is a valid **organization** webhook event, so for **org-owned** projects the wizard can create the webhook programmatically (`POST /orgs/{org}/hooks`, via the shared `webhooks.create/list/delete` tRPC endpoints with `githubProjectsOnly: true`; needs an `admin:org_hook`-scoped token). **User-owned** projects have no webhook-create API and are configured manually (the wizard copy is scoped by owner type). +- **Supported `PMProvider` surface**: + - `getWorkItem` — with inline-image extraction; resolves the content (Issue/PR) node directly and reads its Status option ID for the configured project + - `getWorkItemComments` — issue/PR comments, with inline-image extraction (matches the Trello/JIRA/Linear spec-016 contract) + - `updateWorkItem` — issue/PR title/body, routed to the correct `updateIssue` / `updatePullRequest` mutation + - `addComment` / `updateComment` + - `createWorkItem` — creates a real Issue in the project's **SCM repo** (`project.repo`, threaded from `ProjectConfig`) via `createIssue`, then adds it to the board via `addProjectV2ItemById`; the content (Issue) node ID is the returned identity so comments/labels/checklists/moves work afterward. This is what makes alert/friction **materialization** available for GitHub Projects (the config `statuses.alerts` / `statuses.friction` slots already resolve). Throws with an actionable message when the project has no SCM repo. Draft issues are intentionally not used (they cannot be commented on or labeled). + - `moveWorkItem` — Status single-select. Resolves the content (Issue/PR) node ID carried across the path to its `ProjectV2Item` node ID for the configured project before writing the field (a `PVTI_…` ID is used directly) + - `listWorkItems` — pages the project's items, filters by Status option ID client-side, keyed by the content node ID (so the pipeline-capacity gate's exclusion filter matches) + - `addLabel` / `removeLabel` — config value resolved to a repo-scoped label node ID (`Repository.label(name:)`, treated as a name; `LA_…` node IDs used directly), applied via `addLabelsToLabelable` / `removeLabelsFromLabelable`; skipped with a warn when the repo has no such label + - `getChecklists` / `createChecklist` / `createChecklistWithItems` / `addChecklistItem` / `updateChecklistItem` / `deleteChecklistItem` — inline markdown task lists (`### {name}` + `- [ ]` rows) in the issue/PR body via the shared `inline-checklist.ts` engine (spec 008), serialized under `withDescriptionMutationLock` +- **Not supported (minimal scope)** — the following `PMProvider` methods are intentional no-ops, so the corresponding features are unavailable for GitHub Projects projects: + + | Method | Behavior | Consequence | + |--------|----------|-------------| + | `getAttachments` / `addAttachment*` | `[]` / no-op | formal attachments unavailable (inline-pasted images **are** delivered via the shared media pipeline) | + | `getCustomFieldNumber` / `updateCustomFieldNumber` | `0` / no-op | no cost/budget custom-field tracking (GitHub Projects number fields exist but are not wired — parity with Linear's stub) | + | `linkPR` | no-op | PRs link implicitly by being added to the project | + ### GitHub (`src/github/`) - `GitHubSCMIntegration` implements `SCMIntegration` diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index 338803114..cc35d09d7 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -17,6 +17,7 @@ The config provider loads project configuration from the database with in-memory | `loadProjectConfigByRepo(repo)` | GitHub `owner/repo` | `{ project, config }` | | `loadProjectConfigByJiraProjectKey(key)` | JIRA project key | `{ project, config }` | | `loadProjectConfigByLinearTeamId(teamId)` | Linear team ID | `{ project, config }` | +| `loadProjectConfigByGitHubProjectsProjectId(id)` | GitHub Project node ID (`PVT_…`) | `{ project, config }` | | `loadProjectConfigById(id)` | CASCADE project ID | `{ project, config }` | ### Caching diff --git a/docs/getting-started.md b/docs/getting-started.md index 84b301a7c..1eb59fb44 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -307,7 +307,36 @@ node bin/cascade.js projects integration-set my-project \ --config '{"teamId":"TEAM_UUID","statuses":{"todo":"STATE_UUID","inProgress":"STATE_UUID","done":"STATE_UUID"},"labels":{"readyToProcess":"LABEL_UUID","processing":"LABEL_UUID"}}' ``` -If you enable the alerting agent, configure the optional `alerts` PM slot as well. For Trello this is `lists.alerts`; for Jira and Linear this is `statuses.alerts`. Sentry alerts materialize into that list/status before the alerting agent runs. +### GitHub Projects + +Cascade can drive work from a **GitHub Projects (Projects v2)** board — the modern board, not the classic project board — reacting to changes of the board's **Status** field. + +1. Create a fine-grained or classic **GitHub token** with access to the project and its linked issues/PRs (classic PAT: `repo` + `project` scopes; fine-grained: Projects and Issues/Pull requests read & write). +2. Find your Project node ID (`PVT_…`) and the Status field's single-select **option IDs** — the setup wizard discovers these automatically once the token is entered. + +```bash +# GitHub Projects reuses the GITHUB_TOKEN credential (scoped to a dedicated +# AsyncLocalStorage store, separate from the SCM GitHub token). +node bin/cascade.js projects credentials-set my-project --key GITHUB_TOKEN --value ghp_... --name "GitHub Token" + +# Optional: webhook secret for HMAC-SHA256 signature verification +node bin/cascade.js projects credentials-set my-project --key GITHUB_WEBHOOK_SECRET --value ... --name "GitHub Webhook Secret" + +# Configure the integration +# projectId: the ProjectV2 node ID (PVT_…) +# owner: the user or organization login that owns the project +# ownerType: "user" or "organization" +# statuses: map Cascade lifecycle stages to Status single-select option IDs +node bin/cascade.js projects integration-set my-project \ + --category pm --provider github-projects \ + --config '{"projectId":"PVT_xxx","owner":"your-login","ownerType":"user","statuses":{"todo":"OPTION_ID","inProgress":"OPTION_ID","done":"OPTION_ID"}}' +``` + +**Webhook setup.** For an **organization**-owned project, the wizard can create the webhook programmatically (the `projects_v2_item` event is org-webhook-creatable via `POST /orgs/{org}/hooks`) — click **Create Webhook** in the Webhook step; the token needs the `admin:org_hook` scope. A **user**-owned project has no webhook-create API, so events must arrive via an org-owned project or a GitHub App subscribed to `projects_v2_item`; the wizard shows the manual-setup instructions instead. Either way the webhook is for the **`projects_v2_item`** event pointing at `https://your-router-host/github-projects/webhook` (set the secret to match `GITHUB_WEBHOOK_SECRET` if configured). + +**Scope.** GitHub Projects is a deliberately **status-focused** provider: only Status field changes dispatch agents. Supported: reading/updating the linked issue/PR, reading/posting comments, moving Status, board listing, add/remove label (config value resolved to a repo-scoped label), checklists (inline markdown task lists in the issue/PR body), and **work-item creation** — which creates a real Issue in the project's SCM repo and adds it to the board, enabling friction/alert card materialization when the `statuses.friction` / `statuses.alerts` slots are configured. It does **not** support attachments (formal attachment records — inline-pasted images *are* delivered) or custom number fields. See the GitHub Projects section of [Integration Layer](./architecture/06-integration-layer.md) for the full method-by-method breakdown. + +If you enable the alerting agent, configure the optional `alerts` PM slot as well. For Trello this is `lists.alerts`; for Jira, Linear, and GitHub Projects this is `statuses.alerts`. Sentry alerts materialize into that list/status before the alerting agent runs. ### Removing an integration @@ -344,13 +373,14 @@ node bin/cascade.js webhooks create my-project \ --callback-url https://your-tunnel.ngrok.io ``` -This creates webhooks on GitHub, Trello, and Jira when those integrations are configured, reusing existing hooks when the canonical callback URL already exists. Linear and Sentry are informational/manual setup paths: the dashboard and API show the correct callback URL and whether a signing secret is stored, but you create the webhook in the provider UI. For Sentry, the URL remains `https://your-router-host/sentry/webhook/:projectId`; organization-level deliveries may reach that URL, but Cascade dispatches only payloads whose Sentry project matches the configured `projectSlug`. +This creates webhooks on GitHub, Trello, Jira, and organization-owned GitHub Projects when those integrations are configured, reusing existing hooks when the canonical callback URL already exists. Linear and Sentry are informational/manual setup paths: the dashboard and API show the correct callback URL and whether a signing secret is stored, but you create the webhook in the provider UI. For Sentry, the URL remains `https://your-router-host/sentry/webhook/:projectId`; organization-level deliveries may reach that URL, but Cascade dispatches only payloads whose Sentry project matches the configured `projectSlug`. | Provider | Setup behavior | Callback URL | |----------|----------------|--------------| | GitHub | Programmatic create/list/delete with optional `GITHUB_WEBHOOK_SECRET` for HMAC-SHA256 signature verification | `https://your-router-host/github/webhook` | | Trello | Programmatic create/list/delete | `https://your-router-host/trello/webhook` | | Jira | Programmatic create/list/delete plus label ensure | `https://your-router-host/jira/webhook` | +| GitHub Projects | Programmatic create/list/delete for **organization**-owned projects (`admin:org_hook` scope); **user**-owned projects are manual | `https://your-router-host/github-projects/webhook` | | Linear | Manual setup with optional `LINEAR_WEBHOOK_SECRET` | `https://your-router-host/linear/webhook` | | Sentry | Manual setup with optional Sentry webhook secret; paired with configured `organizationSlug`/`projectSlug` and filtered by payload project matching `projectSlug` | `https://your-router-host/sentry/webhook/my-project` | diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 89afd8f80..69e5747de 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -34,7 +34,7 @@ export interface PromptContext { projectId?: string; // PM vocabulary (computed from pmType) - pmType?: 'trello' | 'jira' | 'linear'; + pmType?: 'trello' | 'jira' | 'linear' | 'github-projects'; workItemNoun?: string; // "card" or "issue" workItemNounPlural?: string; // "cards" or "issues" workItemNounCap?: string; // "Card" or "Issue" @@ -329,7 +329,11 @@ export function getTemplateVariables(): Array<{ { name: 'workItemId', group: 'Common', description: 'Work item ID' }, { name: 'workItemUrl', group: 'Common', description: 'Work item URL' }, { name: 'projectId', group: 'Common', description: 'Project identifier' }, - { name: 'pmType', group: 'PM', description: 'PM type: trello, jira, or linear' }, + { + name: 'pmType', + group: 'PM', + description: 'PM type: trello, jira, linear, or github-projects', + }, { name: 'workItemNoun', group: 'PM', description: 'card or issue' }, { name: 'workItemNounPlural', group: 'PM', description: 'cards or issues' }, { name: 'workItemNounCap', group: 'PM', description: 'Card or Issue' }, diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index 173c0a585..cf649fafa 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -1,4 +1,9 @@ -import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../../pm/config.js'; +import { + getGitHubProjectsConfig, + getJiraConfig, + getLinearConfig, + getTrelloConfig, +} from '../../pm/config.js'; import { getPMProviderOrNull } from '../../pm/index.js'; import type { ProjectConfig } from '../../types/index.js'; import type { PromptContext } from '../prompts/index.js'; @@ -7,12 +12,17 @@ function getListIds(project: ProjectConfig) { const trelloConfig = getTrelloConfig(project); const jiraConfig = getJiraConfig(project); const linearConfig = getLinearConfig(project); + const githubProjectsConfig = getGitHubProjectsConfig(project); const backlogStatusId = trelloConfig?.lists?.backlog ?? jiraConfig?.statuses?.backlog ?? - linearConfig?.statuses?.backlog; + linearConfig?.statuses?.backlog ?? + githubProjectsConfig?.statuses?.backlog; const workItemCreateContainerId = - trelloConfig?.lists?.backlog ?? jiraConfig?.projectKey ?? linearConfig?.teamId; + trelloConfig?.lists?.backlog ?? + jiraConfig?.projectKey ?? + linearConfig?.teamId ?? + githubProjectsConfig?.projectId; return { // Value the agent should pass as `expectedSourceState` when moving an @@ -26,36 +36,52 @@ function getListIds(project: ProjectConfig) { // workItemCreateContainerId for creation. backlogListId: backlogStatusId, todoListId: - trelloConfig?.lists?.todo ?? jiraConfig?.statuses?.todo ?? linearConfig?.statuses?.todo, + trelloConfig?.lists?.todo ?? + jiraConfig?.statuses?.todo ?? + linearConfig?.statuses?.todo ?? + githubProjectsConfig?.statuses?.todo, inProgressListId: trelloConfig?.lists?.inProgress ?? jiraConfig?.statuses?.inProgress ?? - linearConfig?.statuses?.inProgress, + linearConfig?.statuses?.inProgress ?? + githubProjectsConfig?.statuses?.inProgress, inReviewListId: trelloConfig?.lists?.inReview ?? jiraConfig?.statuses?.inReview ?? - linearConfig?.statuses?.inReview, + linearConfig?.statuses?.inReview ?? + githubProjectsConfig?.statuses?.inReview, doneListId: - trelloConfig?.lists?.done ?? jiraConfig?.statuses?.done ?? linearConfig?.statuses?.done, + trelloConfig?.lists?.done ?? + jiraConfig?.statuses?.done ?? + linearConfig?.statuses?.done ?? + githubProjectsConfig?.statuses?.done, mergedListId: - trelloConfig?.lists?.merged ?? jiraConfig?.statuses?.merged ?? linearConfig?.statuses?.merged, + trelloConfig?.lists?.merged ?? + jiraConfig?.statuses?.merged ?? + linearConfig?.statuses?.merged ?? + githubProjectsConfig?.statuses?.merged, debugListId: trelloConfig?.lists?.debug, processedLabelId: trelloConfig?.labels?.processed, autoLabelId: - trelloConfig?.labels?.auto ?? jiraConfig?.labels?.auto ?? linearConfig?.labels?.auto, + trelloConfig?.labels?.auto ?? + jiraConfig?.labels?.auto ?? + linearConfig?.labels?.auto ?? + githubProjectsConfig?.labels?.processing, }; } function getPromptTerminology(pmType: string | undefined) { const isJira = pmType === 'jira'; const isLinear = pmType === 'linear'; + const isGitHubProjects = pmType === 'github-projects'; + const isIssueLike = isJira || isLinear || isGitHubProjects; return { - workItemNoun: isJira || isLinear ? 'issue' : 'card', - workItemNounPlural: isJira || isLinear ? 'issues' : 'cards', - workItemNounCap: isJira || isLinear ? 'Issue' : 'Card', - workItemNounPluralCap: isJira || isLinear ? 'Issues' : 'Cards', - pmName: isJira ? 'JIRA' : isLinear ? 'Linear' : 'Trello', + workItemNoun: isIssueLike ? 'issue' : 'card', + workItemNounPlural: isIssueLike ? 'issues' : 'cards', + workItemNounCap: isIssueLike ? 'Issue' : 'Card', + workItemNounPluralCap: isIssueLike ? 'Issues' : 'Cards', + pmName: isJira ? 'JIRA' : isLinear ? 'Linear' : isGitHubProjects ? 'GitHub Projects' : 'Trello', }; } diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index f867109a3..18a94ac53 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -7,6 +7,11 @@ import { resolveProjectContext, } from './webhooks/context.js'; import { githubCreateWebhook, githubDeleteWebhook, githubListWebhooks } from './webhooks/github.js'; +import { + githubProjectsCreateWebhook, + githubProjectsDeleteWebhook, + githubProjectsListWebhooks, +} from './webhooks/github-projects.js'; import { jiraCreateWebhook, jiraDeleteWebhook, @@ -28,7 +33,15 @@ type CreateInput = { trelloOnly?: boolean; githubOnly?: boolean; jiraOnly?: boolean; + githubProjectsOnly?: boolean; }; + +/** True when any *other* provider's `…Only` toggle is set (so this one must skip). */ +function skipForOtherOnly(input: CreateInput, self: keyof CreateInput): boolean { + return (['trelloOnly', 'githubOnly', 'jiraOnly', 'githubProjectsOnly'] as const).some( + (k) => k !== self && input[k], + ); +} type ProjectContext = Awaited>; /** @@ -47,7 +60,7 @@ async function maybeCreateTrelloWebhook( input: CreateInput, baseUrl: string, ): Promise { - if (input.githubOnly || input.jiraOnly) return undefined; + if (skipForOtherOnly(input, 'trelloOnly')) return undefined; if (!pctx.trelloApiKey || !pctx.trelloToken || !pctx.boardId) return undefined; const callbackUrl = `${baseUrl}/trello/webhook`; @@ -64,7 +77,7 @@ async function maybeCreateJiraWebhook( input: CreateInput, baseUrl: string, ): Promise<{ jira?: JiraWebhookInfo | string; labelsEnsured?: string[] }> { - if (input.trelloOnly || input.githubOnly) return {}; + if (skipForOtherOnly(input, 'jiraOnly')) return {}; if (!pctx.jiraEmail || !pctx.jiraApiToken || !pctx.jiraBaseUrl) return {}; const callbackUrl = `${baseUrl}/jira/webhook`; @@ -98,7 +111,7 @@ async function maybeCreateGitHubWebhook( input: CreateInput, baseUrl: string, ): Promise { - if (input.trelloOnly || input.jiraOnly) return undefined; + if (skipForOtherOnly(input, 'githubOnly')) return undefined; if (!pctx.githubToken) return undefined; const callbackUrl = `${baseUrl}/github/webhook`; @@ -110,6 +123,24 @@ async function maybeCreateGitHubWebhook( return githubCreateWebhook(pctx, callbackUrl); } +async function maybeCreateGitHubProjectsWebhook( + pctx: ProjectContext, + input: CreateInput, + baseUrl: string, +): Promise { + if (skipForOtherOnly(input, 'githubProjectsOnly')) return undefined; + // Programmatic creation is org-owned only; user-owned projects list [] and skip. + if (pctx.githubProjectsOwnerType !== 'organization' || !pctx.githubProjectsOwner) + return undefined; + if (!pctx.githubProjectsToken) return undefined; + + const callbackUrl = `${baseUrl}/github-projects/webhook`; + const existing = await githubProjectsListWebhooks(pctx); + const duplicate = existing.find((w) => w.config?.url === callbackUrl); + if (duplicate) return `Already exists: ${duplicate.id}`; + return githubProjectsCreateWebhook(pctx, callbackUrl); +} + function buildSentryDisplayInfo( pctx: ProjectContext, projectId: string, @@ -152,11 +183,13 @@ export const webhooksRouter = router({ const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); - const [trelloResult, githubResult, jiraResult] = await Promise.allSettled([ - trelloListWebhooks(pctx), - githubListWebhooks(pctx), - jiraListWebhooks(pctx), - ]); + const [trelloResult, githubResult, jiraResult, githubProjectsResult] = + await Promise.allSettled([ + trelloListWebhooks(pctx), + githubListWebhooks(pctx), + jiraListWebhooks(pctx), + githubProjectsListWebhooks(pctx), + ]); const sentry = input.callbackBaseUrl ? (buildSentryDisplayInfo( @@ -181,12 +214,16 @@ export const webhooksRouter = router({ trello: trelloResult.status === 'fulfilled' ? trelloResult.value : [], github: githubResult.status === 'fulfilled' ? githubResult.value : [], jira: jiraResult.status === 'fulfilled' ? jiraResult.value : [], + githubProjects: + githubProjectsResult.status === 'fulfilled' ? githubProjectsResult.value : [], sentry, linear, errors: { trello: trelloResult.status === 'rejected' ? String(trelloResult.reason) : null, github: githubResult.status === 'rejected' ? String(githubResult.reason) : null, jira: jiraResult.status === 'rejected' ? String(jiraResult.reason) : null, + githubProjects: + githubProjectsResult.status === 'rejected' ? String(githubProjectsResult.reason) : null, linear: null, }, }; @@ -200,6 +237,7 @@ export const webhooksRouter = router({ trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), + githubProjectsOnly: z.boolean().optional(), oneTimeTokens: oneTimeTokensSchema, }), ) @@ -212,6 +250,7 @@ export const webhooksRouter = router({ trello?: TrelloWebhook | string; github?: GitHubWebhook | string; jira?: JiraWebhookInfo | string; + githubProjects?: GitHubWebhook | string; sentry?: SentryWebhookInfo; linear?: LinearWebhookInfo; labelsEnsured?: string[]; @@ -227,6 +266,9 @@ export const webhooksRouter = router({ const github = await maybeCreateGitHubWebhook(pctx, input, baseUrl); if (github !== undefined) results.github = github; + const githubProjects = await maybeCreateGitHubProjectsWebhook(pctx, input, baseUrl); + if (githubProjects !== undefined) results.githubProjects = githubProjects; + const sentry = buildSentryDisplayInfo(pctx, input.projectId, baseUrl); if (sentry !== undefined) results.sentry = sentry; @@ -244,6 +286,7 @@ export const webhooksRouter = router({ trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), + githubProjectsOnly: z.boolean().optional(), oneTimeTokens: oneTimeTokensSchema, }), ) @@ -251,14 +294,20 @@ export const webhooksRouter = router({ const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); - const deleted: { trello: string[]; github: number[]; jira: number[] } = { + const deleted: { + trello: string[]; + github: number[]; + jira: number[]; + githubProjects: number[]; + } = { trello: [], github: [], jira: [], + githubProjects: [], }; // Trello - if (!input.githubOnly && !input.jiraOnly && pctx.trelloApiKey && pctx.trelloToken) { + if (!skipForOtherOnly(input, 'trelloOnly') && pctx.trelloApiKey && pctx.trelloToken) { const trelloCallbackUrl = `${baseUrl}/trello/webhook`; const existing = await trelloListWebhooks(pctx); const matching = existing.filter( @@ -272,7 +321,7 @@ export const webhooksRouter = router({ } // JIRA - if (!input.trelloOnly && !input.githubOnly && pctx.jiraEmail && pctx.jiraApiToken) { + if (!skipForOtherOnly(input, 'jiraOnly') && pctx.jiraEmail && pctx.jiraApiToken) { const jiraCallbackUrl = `${baseUrl}/jira/webhook`; const existing = await jiraListWebhooks(pctx); const matching = existing.filter( @@ -285,7 +334,7 @@ export const webhooksRouter = router({ } // GitHub - if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { + if (!skipForOtherOnly(input, 'githubOnly') && pctx.githubToken) { const githubCallbackUrl = `${baseUrl}/github/webhook`; const existing = await githubListWebhooks(pctx); const matching = existing.filter( @@ -297,6 +346,21 @@ export const webhooksRouter = router({ } } + // GitHub Projects (org-owned only) + if ( + !skipForOtherOnly(input, 'githubProjectsOnly') && + pctx.githubProjectsOwnerType === 'organization' && + pctx.githubProjectsToken + ) { + const callbackUrl = `${baseUrl}/github-projects/webhook`; + const existing = await githubProjectsListWebhooks(pctx); + const matching = existing.filter((w) => w.config?.url === callbackUrl); + for (const w of matching) { + await githubProjectsDeleteWebhook(pctx, w.id); + deleted.githubProjects.push(w.id); + } + } + return deleted; }), }); diff --git a/src/api/routers/webhooks/context.ts b/src/api/routers/webhooks/context.ts index d94f97128..c3f59395b 100644 --- a/src/api/routers/webhooks/context.ts +++ b/src/api/routers/webhooks/context.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { getAllProjectCredentials } from '../../../config/provider.js'; import { findProjectByIdFromDb } from '../../../db/repositories/configRepository.js'; -import { getJiraConfig, getTrelloConfig } from '../../../pm/config.js'; +import { getGitHubProjectsConfig, getJiraConfig, getTrelloConfig } from '../../../pm/config.js'; import { getSentryIntegrationConfig } from '../../../sentry/integration.js'; import { verifyProjectOrgAccess } from '../_shared/projectAccess.js'; import type { ProjectContext } from './types.js'; @@ -35,6 +35,8 @@ export async function resolveProjectContext( ] : undefined; + const githubProjectsConfig = getGitHubProjectsConfig(project); + const sentryConfig = await getSentryIntegrationConfig(projectId); const sentryConfigured = !!creds.SENTRY_API_TOKEN && sentryConfig !== null; @@ -54,6 +56,9 @@ export async function resolveProjectContext( jiraEmail: creds.JIRA_EMAIL ?? '', jiraApiToken: creds.JIRA_API_TOKEN ?? '', webhookSecret: creds.GITHUB_WEBHOOK_SECRET ?? undefined, + githubProjectsOwner: githubProjectsConfig?.owner, + githubProjectsOwnerType: githubProjectsConfig?.ownerType, + githubProjectsToken: creds.GITHUB_TOKEN ?? undefined, sentryConfigured, sentryOrganizationSlug: sentryConfig?.organizationSlug, sentryProjectSlug: sentryConfig?.projectSlug, @@ -71,6 +76,7 @@ export const oneTimeTokensSchema = z jiraEmail: z.string().optional(), jiraApiToken: z.string().optional(), linearApiKey: z.string().optional(), + githubProjectsToken: z.string().optional(), }) .optional(); @@ -84,4 +90,5 @@ export function applyOneTimeTokens(pctx: ProjectContext, tokens: OneTimeTokens): if (tokens.jiraEmail) pctx.jiraEmail = tokens.jiraEmail; if (tokens.jiraApiToken) pctx.jiraApiToken = tokens.jiraApiToken; if (tokens.linearApiKey) pctx.linearApiKey = tokens.linearApiKey; + if (tokens.githubProjectsToken) pctx.githubProjectsToken = tokens.githubProjectsToken; } diff --git a/src/api/routers/webhooks/github-projects.ts b/src/api/routers/webhooks/github-projects.ts new file mode 100644 index 000000000..19fe79984 --- /dev/null +++ b/src/api/routers/webhooks/github-projects.ts @@ -0,0 +1,137 @@ +/** + * GitHub Projects webhook management (organization-owned projects only). + * + * `projects_v2_item` is a valid **organization** webhook event, so org-owned + * projects can register the CASCADE webhook programmatically via + * `POST /orgs/{org}/hooks` — mirroring the repo-hook pattern in `./github.ts`. + * + * User-owned Projects have no webhook-management API; for those the wizard falls + * back to manual setup instructions. These helpers therefore no-op (list) or + * throw an actionable error (create) unless the owner is an organization. + */ + +import { Octokit } from '@octokit/rest'; +import { TRPCError } from '@trpc/server'; +import { logger } from '../../../utils/logging.js'; +import type { GitHubWebhook, ProjectContext } from './types.js'; + +/** The single org-hook event CASCADE subscribes to for Projects v2. */ +export const GITHUB_PROJECTS_WEBHOOK_EVENTS = ['projects_v2_item']; + +/** True when this project's GitHub Project is org-owned and has a usable token. */ +function canManageOrgWebhooks(ctx: ProjectContext): boolean { + return ( + ctx.pmType === 'github-projects' && + ctx.githubProjectsOwnerType === 'organization' && + Boolean(ctx.githubProjectsOwner) && + Boolean(ctx.githubProjectsToken) + ); +} + +/** + * Translate an Octokit error on an org-hook call into an actionable message. + * A token missing the `admin:org_hook` scope (or a non-admin) gets 403/404. + */ +function orgHookErrorMessage(org: string, err: unknown): string { + const status = (err as { status?: number })?.status; + if (status === 403 || status === 404) { + return ( + `GitHub declined to manage webhooks for organization "${org}" (HTTP ${status}). The token ` + + 'needs the "admin:org_hook" scope and organization-owner/admin access. Add the scope, or ' + + 'register the webhook manually in Organization Settings → Webhooks.' + ); + } + return `GitHub webhook operation failed for organization "${org}": ${String(err)}`; +} + +export async function githubProjectsListWebhooks(ctx: ProjectContext): Promise { + if (!canManageOrgWebhooks(ctx)) return []; + const octokit = new Octokit({ auth: ctx.githubProjectsToken }); + try { + const { data } = await octokit.orgs.listWebhooks({ org: ctx.githubProjectsOwner as string }); + return data as GitHubWebhook[]; + } catch (err) { + // Listing is best-effort (used for dedup + UI). A scope-limited token should + // not break the wizard — surface [] and let create() report the real error. + logger.warn('[GitHubProjectsWebhook] Could not list org webhooks (continuing)', { + projectId: ctx.projectId, + org: ctx.githubProjectsOwner, + error: String(err), + }); + return []; + } +} + +export async function githubProjectsCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + if (ctx.githubProjectsOwnerType !== 'organization' || !ctx.githubProjectsOwner) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Programmatic webhook creation is only available for organization-owned GitHub Projects. ' + + 'User-owned projects must be configured manually (Organization Settings → Webhooks of an ' + + 'org that owns the project, or a GitHub App subscribed to projects_v2_item).', + }); + } + if (!ctx.githubProjectsToken) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'GitHub Projects token not configured' }); + } + + const org = ctx.githubProjectsOwner; + const octokit = new Octokit({ auth: ctx.githubProjectsToken }); + + // Delete any existing webhook with the same callback URL to prevent duplicates + // (org webhooks may include hooks from other integrations, so only match ours). + const existing = await githubProjectsListWebhooks(ctx); + for (const webhook of existing) { + if (webhook.config?.url === callbackURL) { + try { + await githubProjectsDeleteWebhook(ctx, webhook.id); + logger.info('[GitHubProjectsWebhook] Deleted existing webhook to prevent duplicates', { + webhookId: webhook.id, + projectId: ctx.projectId, + org, + }); + } catch (err) { + logger.warn('[GitHubProjectsWebhook] Failed to delete existing webhook (continuing)', { + webhookId: webhook.id, + projectId: ctx.projectId, + error: String(err), + }); + } + } + } + + const webhookConfig: { url: string; content_type: string; secret?: string } = { + url: callbackURL, + content_type: 'json', + }; + if (ctx.webhookSecret) { + webhookConfig.secret = ctx.webhookSecret; + } + + try { + const { data } = await octokit.orgs.createWebhook({ + org, + name: 'web', + config: webhookConfig, + events: GITHUB_PROJECTS_WEBHOOK_EVENTS, + active: true, + }); + return data as GitHubWebhook; + } catch (err) { + throw new TRPCError({ code: 'FORBIDDEN', message: orgHookErrorMessage(org, err) }); + } +} + +export async function githubProjectsDeleteWebhook( + ctx: ProjectContext, + hookId: number, +): Promise { + if (ctx.githubProjectsOwnerType !== 'organization' || !ctx.githubProjectsOwner) return; + if (!ctx.githubProjectsToken) return; + const octokit = new Octokit({ auth: ctx.githubProjectsToken }); + await octokit.orgs.deleteWebhook({ org: ctx.githubProjectsOwner, hook_id: hookId }); +} diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts index 0e7ca5cf9..f3a100db1 100644 --- a/src/api/routers/webhooks/types.ts +++ b/src/api/routers/webhooks/types.ts @@ -46,7 +46,7 @@ export interface ProjectContext { projectId: string; orgId: string; repo?: string; - pmType: 'trello' | 'jira' | 'linear'; + pmType: 'trello' | 'jira' | 'linear' | 'github-projects'; boardId?: string; jiraBaseUrl?: string; /** @@ -64,6 +64,12 @@ export interface ProjectContext { jiraEmail?: string; jiraApiToken?: string; webhookSecret?: string; + /** GitHub Projects owner login (org or user) from the PM config. */ + githubProjectsOwner?: string; + /** GitHub Projects owner type — programmatic webhooks require `'organization'`. */ + githubProjectsOwnerType?: 'user' | 'organization'; + /** GitHub Projects PM token (the `GITHUB_TOKEN` credential) for org-hook management. */ + githubProjectsToken?: string; sentryConfigured?: boolean; sentryOrganizationSlug?: string; sentryProjectSlug?: string; diff --git a/src/backends/secretBuilder.ts b/src/backends/secretBuilder.ts index 5c4452099..46b4d5e7b 100644 --- a/src/backends/secretBuilder.ts +++ b/src/backends/secretBuilder.ts @@ -6,7 +6,12 @@ import { resolveReviewEventPolicy, } from '../config/reviewEventPolicy.js'; import { getPersonaToken } from '../github/personas.js'; -import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../pm/config.js'; +import { + getGitHubProjectsConfig, + getJiraConfig, + getLinearConfig, + getTrelloConfig, +} from '../pm/config.js'; import type { AgentInput, ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; import { ENV_VAR_NAME } from './progressState.js'; @@ -53,6 +58,22 @@ function injectTrelloConfig(projectSecrets: Record, project: Pro projectSecrets.CASCADE_TRELLO_LABELS = JSON.stringify(trelloConfig.labels); } +function injectGitHubProjectsConfig( + projectSecrets: Record, + project: ProjectConfig, +): void { + const config = getGitHubProjectsConfig(project); + if (!config) return; + + projectSecrets.CASCADE_GITHUB_PROJECTS_PROJECT_ID = config.projectId; + projectSecrets.CASCADE_GITHUB_PROJECTS_OWNER = config.owner; + projectSecrets.CASCADE_GITHUB_PROJECTS_OWNER_TYPE = config.ownerType; + projectSecrets.CASCADE_GITHUB_PROJECTS_STATUSES = JSON.stringify(config.statuses ?? {}); + if (config.labels) { + projectSecrets.CASCADE_GITHUB_PROJECTS_LABELS = JSON.stringify(config.labels); + } +} + function injectAgentInputContext(projectSecrets: Record, input: AgentInput): void { const stringFields: Array<[keyof AgentInput, string]> = [ ['workItemId', 'CASCADE_WORK_ITEM_ID'], @@ -91,6 +112,12 @@ export async function augmentProjectSecrets( // Inject Trello integration config so friction reports can resolve optional PM slots. injectTrelloConfig(projectSecrets, project); + // Inject GitHub Projects integration config so cascade-tools can construct + // GitHubProjectsPMProvider. Without this, every `cascade-tools pm ` from + // inside a GitHub-Projects-backed worker throws "GitHub Projects integration + // requires projectId in config". Mirrors the JIRA/Linear injection below. + injectGitHubProjectsConfig(projectSecrets, project); + // Inject JIRA integration config so cascade-tools can construct JiraPMProvider const jiraConfig = getJiraConfig(project); if (jiraConfig) { diff --git a/src/cli/base.ts b/src/cli/base.ts index ef4230a71..7c4ad6324 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { Command } from '@oclif/core'; import { withGitHubToken } from '../github/client.js'; +import { withGitHubProjectsCredentials } from '../github-projects/client.js'; import { normalizeJiraAuthType } from '../jira/authType.js'; import { withJiraCredentials } from '../jira/client.js'; import { withLinearCredentials } from '../linear/client.js'; @@ -69,6 +70,16 @@ function wrapWithCredentialScopes(fn: () => Promise): () => Promise const prev = fn; fn = () => withLinearCredentials({ apiKey: linearApiKey }, prev); } + // GitHub Projects reuses the GITHUB_TOKEN credential but scopes it through a + // dedicated AsyncLocalStorage (getGitHubProjectsCredentials()), distinct from + // 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; + if (process.env.CASCADE_PM_TYPE === 'github-projects' && githubProjectsToken) { + const prev = fn; + fn = () => withGitHubProjectsCredentials({ token: githubProjectsToken }, prev); + } return fn; } @@ -87,47 +98,76 @@ function resolvePmType(): PMType { return 'trello'; } +function parseJsonEnv(value: string | undefined): Record { + return value ? JSON.parse(value) : {}; +} + +function synthesizeJiraFromEnv(): ProjectConfig { + return { + pm: { type: 'jira' }, + jira: { + projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', + baseUrl: resolveJiraBaseUrl() ?? '', + authType: normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE), + statuses: parseJsonEnv(process.env.CASCADE_JIRA_STATUSES), + }, + } as ProjectConfig; +} + +function synthesizeLinearFromEnv(): ProjectConfig { + const linearProjectId = process.env.CASCADE_LINEAR_PROJECT_ID; + return { + pm: { type: 'linear' }, + linear: { + teamId: process.env.CASCADE_LINEAR_TEAM_ID ?? '', + ...(linearProjectId && { projectId: linearProjectId }), + statuses: parseJsonEnv(process.env.CASCADE_LINEAR_STATUSES), + }, + } as ProjectConfig; +} + +function synthesizeGitHubProjectsFromEnv(): ProjectConfig { + const ghpLabels = process.env.CASCADE_GITHUB_PROJECTS_LABELS; + return { + pm: { type: 'github-projects' }, + githubProjects: { + projectId: process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID ?? '', + owner: process.env.CASCADE_GITHUB_PROJECTS_OWNER ?? '', + ownerType: + (process.env.CASCADE_GITHUB_PROJECTS_OWNER_TYPE as 'user' | 'organization') ?? 'user', + statuses: parseJsonEnv(process.env.CASCADE_GITHUB_PROJECTS_STATUSES), + ...(ghpLabels && { labels: parseJsonEnv(ghpLabels) }), + }, + } as ProjectConfig; +} + +function synthesizeTrelloFromEnv(): ProjectConfig { + return { + pm: { type: 'trello' }, + trello: { + boardId: process.env.CASCADE_TRELLO_BOARD_ID ?? '', + lists: parseJsonEnv(process.env.CASCADE_TRELLO_LISTS), + labels: parseJsonEnv(process.env.CASCADE_TRELLO_LABELS), + }, + } as ProjectConfig; +} + /** * Synthesize a minimal ProjectConfig shell from `CASCADE_*` env vars so * `createPMProvider` can construct the in-scope provider. Worker-spawned CLI * commands receive these env vars from `secretBuilder.augmentProjectSecrets`. */ function synthesizeProjectFromEnv(pmType: PMType): ProjectConfig { - if (pmType === 'jira') { - const jiraStatuses = process.env.CASCADE_JIRA_STATUSES; - const jiraBaseUrl = resolveJiraBaseUrl(); - return { - pm: { type: 'jira' }, - jira: { - projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', - baseUrl: jiraBaseUrl ?? '', - authType: normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE), - statuses: jiraStatuses ? JSON.parse(jiraStatuses) : {}, - }, - } as ProjectConfig; - } - if (pmType === 'linear') { - const linearProjectId = process.env.CASCADE_LINEAR_PROJECT_ID; - const linearStatuses = process.env.CASCADE_LINEAR_STATUSES; - return { - pm: { type: 'linear' }, - linear: { - teamId: process.env.CASCADE_LINEAR_TEAM_ID ?? '', - ...(linearProjectId && { projectId: linearProjectId }), - statuses: linearStatuses ? JSON.parse(linearStatuses) : {}, - }, - } as ProjectConfig; + switch (pmType) { + case 'jira': + return synthesizeJiraFromEnv(); + case 'linear': + return synthesizeLinearFromEnv(); + case 'github-projects': + return synthesizeGitHubProjectsFromEnv(); + default: + return synthesizeTrelloFromEnv(); } - const trelloLists = process.env.CASCADE_TRELLO_LISTS; - const trelloLabels = process.env.CASCADE_TRELLO_LABELS; - return { - pm: { type: 'trello' }, - trello: { - boardId: process.env.CASCADE_TRELLO_BOARD_ID ?? '', - lists: trelloLists ? JSON.parse(trelloLists) : {}, - labels: trelloLabels ? JSON.parse(trelloLabels) : {}, - }, - } as ProjectConfig; } export abstract class CredentialScopedCommand extends Command { diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index b381407cb..a95572014 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -15,9 +15,16 @@ export default class WebhooksCreate extends DashboardCommand { }), 'trello-only': Flags.boolean({ description: 'Only create Trello webhook', default: false }), 'github-only': Flags.boolean({ description: 'Only create GitHub webhook', default: false }), + 'github-projects-only': Flags.boolean({ + description: 'Only create the GitHub Projects org webhook', + default: false, + }), 'github-token': Flags.string({ description: 'One-time GitHub PAT with admin:repo_hook scope', }), + 'github-projects-token': Flags.string({ + description: 'One-time GitHub PAT with admin:org_hook scope (GitHub Projects org webhook)', + }), 'trello-api-key': Flags.string({ description: 'One-time Trello API key' }), 'trello-token': Flags.string({ description: 'One-time Trello token' }), 'jira-email': Flags.string({ description: 'One-time JIRA email' }), @@ -37,6 +44,8 @@ export default class WebhooksCreate extends DashboardCommand { if (flags['trello-token']) oneTimeTokens.trelloToken = flags['trello-token']; if (flags['jira-email']) oneTimeTokens.jiraEmail = flags['jira-email']; if (flags['jira-api-token']) oneTimeTokens.jiraApiToken = flags['jira-api-token']; + if (flags['github-projects-token']) + oneTimeTokens.githubProjectsToken = flags['github-projects-token']; const result = await this.withSpinner('Creating webhooks...', () => this.client.webhooks.create.mutate({ @@ -44,6 +53,7 @@ export default class WebhooksCreate extends DashboardCommand { callbackBaseUrl, trelloOnly: flags['trello-only'], githubOnly: flags['github-only'], + githubProjectsOnly: flags['github-projects-only'], oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }), ); @@ -79,6 +89,16 @@ export default class WebhooksCreate extends DashboardCommand { } } + if (result.githubProjects) { + if (typeof result.githubProjects === 'string') { + this.log(`GitHub Projects: ${result.githubProjects}`); + } else { + this.success( + `Created GitHub Projects webhook: [${result.githubProjects.id}] ${result.githubProjects.config.url}`, + ); + } + } + if (result.sentry) { this.log(''); this.log('Sentry (manual setup required):'); diff --git a/src/cli/dashboard/webhooks/delete.ts b/src/cli/dashboard/webhooks/delete.ts index dfc649094..f7334cd9b 100644 --- a/src/cli/dashboard/webhooks/delete.ts +++ b/src/cli/dashboard/webhooks/delete.ts @@ -15,15 +15,23 @@ export default class WebhooksDelete extends DashboardCommand { }), 'trello-only': Flags.boolean({ description: 'Only delete Trello webhooks', default: false }), 'github-only': Flags.boolean({ description: 'Only delete GitHub webhooks', default: false }), + 'github-projects-only': Flags.boolean({ + description: 'Only delete the GitHub Projects org webhook', + default: false, + }), 'github-token': Flags.string({ description: 'One-time GitHub PAT with admin:repo_hook scope', }), + 'github-projects-token': Flags.string({ + description: 'One-time GitHub PAT with admin:org_hook scope (GitHub Projects org webhook)', + }), 'trello-api-key': Flags.string({ description: 'One-time Trello API key' }), 'trello-token': Flags.string({ description: 'One-time Trello token' }), 'jira-email': Flags.string({ description: 'One-time JIRA email' }), 'jira-api-token': Flags.string({ description: 'One-time JIRA API token' }), }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider output formatting async run(): Promise { const { args, flags } = await this.parse(WebhooksDelete); @@ -36,6 +44,8 @@ export default class WebhooksDelete extends DashboardCommand { if (flags['trello-token']) oneTimeTokens.trelloToken = flags['trello-token']; if (flags['jira-email']) oneTimeTokens.jiraEmail = flags['jira-email']; if (flags['jira-api-token']) oneTimeTokens.jiraApiToken = flags['jira-api-token']; + if (flags['github-projects-token']) + oneTimeTokens.githubProjectsToken = flags['github-projects-token']; const result = await this.withSpinner('Deleting webhooks...', () => this.client.webhooks.delete.mutate({ @@ -43,6 +53,7 @@ export default class WebhooksDelete extends DashboardCommand { callbackBaseUrl, trelloOnly: flags['trello-only'], githubOnly: flags['github-only'], + githubProjectsOnly: flags['github-projects-only'], oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }), ); @@ -73,6 +84,14 @@ export default class WebhooksDelete extends DashboardCommand { } else { this.log('No matching JIRA webhooks found.'); } + + if (result.githubProjects.length > 0) { + this.success( + `Deleted ${result.githubProjects.length} GitHub Projects webhook(s): ${result.githubProjects.join(', ')}`, + ); + } else { + this.log('No matching GitHub Projects webhooks found.'); + } } catch (err) { this.handleError(err); } diff --git a/src/config/provider.ts b/src/config/provider.ts index d3a1eb813..60e4b5094 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -5,6 +5,7 @@ import { findProjectByLinearTeamIdFromDb, findProjectByRepoFromDb, findProjectWithConfigByBoardId, + findProjectWithConfigByGitHubProjectsProjectId, findProjectWithConfigById, findProjectWithConfigByJiraProjectKey, findProjectWithConfigByLinearTeamId, @@ -101,6 +102,12 @@ export async function loadProjectConfigByLinearTeamId( return findProjectWithConfigByLinearTeamId(teamId); } +export async function loadProjectConfigByGitHubProjectsProjectId( + projectId: string, +): Promise { + return findProjectWithConfigByGitHubProjectsProjectId(projectId); +} + export async function loadProjectConfigById(id: string): Promise { return findProjectWithConfigById(id); } diff --git a/src/config/schema.ts b/src/config/schema.ts index a5614e396..c6cb5ecd9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { githubProjectsConfigSchema } from '../integrations/pm/github-projects/config-schema.js'; import { jiraConfigSchema } from '../integrations/pm/jira/config-schema.js'; import { linearConfigSchema } from '../integrations/pm/linear/config-schema.js'; import { trelloConfigSchema } from '../integrations/pm/trello/config-schema.js'; @@ -70,7 +71,7 @@ export const ProjectConfigSchema = z.object({ // src/pm/no-pm-provider.ts. pm: z .object({ - type: z.enum(['trello', 'jira', 'linear']), + type: z.enum(['trello', 'jira', 'linear', 'github-projects']), }) .optional(), @@ -80,6 +81,8 @@ export const ProjectConfigSchema = z.object({ linear: linearConfigSchema.optional(), + githubProjects: githubProjectsConfigSchema.optional(), + model: z.string().default(PROJECT_DEFAULTS.model), agentModels: z.record(z.string()).optional(), maxIterations: z.number().int().positive().default(PROJECT_DEFAULTS.maxIterations), diff --git a/src/db/migrations/0061_allow_github_projects_pm_provider.sql b/src/db/migrations/0061_allow_github_projects_pm_provider.sql new file mode 100644 index 000000000..cbbe5c0a1 --- /dev/null +++ b/src/db/migrations/0061_allow_github_projects_pm_provider.sql @@ -0,0 +1,18 @@ +-- 0061_allow_github_projects_pm_provider.sql +-- Add github-projects to the allowed pm providers in the integration category/provider CHECK constraint. + +BEGIN; + +ALTER TABLE project_integrations + DROP CONSTRAINT IF EXISTS chk_integration_category_provider; + +ALTER TABLE project_integrations + ADD CONSTRAINT chk_integration_category_provider CHECK ( + (category = 'pm' AND provider IN ('trello', 'jira', 'linear', 'github-projects')) + OR (category = 'scm' AND provider IN ('github')) + OR (category = 'email' AND provider IN ('imap', 'gmail')) + OR (category = 'sms' AND provider IN ('twilio')) + OR (category = 'alerting' AND provider IN ('sentry')) + ); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 5871c7a4b..8527d7272 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -428,6 +428,13 @@ "when": 1795000000000, "tag": "0060_agent_config_review_event_policy", "breakpoints": false + }, + { + "idx": 61, + "version": "7", + "when": 1796000000000, + "tag": "0061_allow_github_projects_pm_provider", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index be8c23896..ba2f5434c 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -47,6 +47,17 @@ export interface LinearIntegrationConfig { customFields?: { cost?: string }; } +export interface GitHubProjectsIntegrationConfig { + projectId: string; + owner: string; + ownerType: 'user' | 'organization'; + statuses: Record; + labels?: { + processing?: string; + readyToProcess?: string; + }; +} + // biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials) export type GitHubIntegrationConfig = {}; @@ -84,6 +95,7 @@ export interface MapProjectInput { trelloConfig?: TrelloIntegrationConfig; jiraConfig?: JiraIntegrationConfig; linearConfig?: LinearIntegrationConfig; + githubProjectsConfig?: GitHubProjectsIntegrationConfig; githubConfig?: GitHubIntegrationConfig; } @@ -161,6 +173,16 @@ export interface ProjectConfigRaw { }; customFields?: { cost?: string }; }; + githubProjects?: { + projectId: string; + owner: string; + ownerType: 'user' | 'organization'; + statuses: Record; + labels?: { + processing?: string; + readyToProcess?: string; + }; + }; agentEngine?: { default?: string; overrides: Record; @@ -290,6 +312,18 @@ function buildLinearConfig(config: LinearIntegrationConfig): ProjectConfigRaw['l }; } +function buildGitHubProjectsConfig( + config: GitHubProjectsIntegrationConfig, +): ProjectConfigRaw['githubProjects'] { + return { + projectId: config.projectId, + owner: config.owner, + ownerType: config.ownerType, + statuses: config.statuses, + labels: config.labels, + }; +} + function buildAgentEngineConfig( row: ProjectRow, engines: Record, @@ -303,7 +337,7 @@ function buildAgentEngineConfig( function buildBaseProjectFields( row: ProjectRow, - pmType: 'trello' | 'jira' | 'linear' | undefined, + pmType: 'trello' | 'jira' | 'linear' | 'github-projects' | undefined, ): ProjectConfigRaw { return { id: row.id, @@ -359,17 +393,20 @@ export function extractIntegrationConfigs(integrations: IntegrationRow[]): { trelloConfig?: TrelloIntegrationConfig; jiraConfig?: JiraIntegrationConfig; linearConfig?: LinearIntegrationConfig; + githubProjectsConfig?: GitHubProjectsIntegrationConfig; githubConfig?: GitHubIntegrationConfig; } { const trelloRow = integrations.find((i) => i.provider === 'trello'); const jiraRow = integrations.find((i) => i.provider === 'jira'); const linearRow = integrations.find((i) => i.provider === 'linear'); + const githubProjectsRow = integrations.find((i) => i.provider === 'github-projects'); const githubRow = integrations.find((i) => i.provider === 'github'); return { trelloConfig: trelloRow?.config as TrelloIntegrationConfig | undefined, jiraConfig: jiraRow?.config as JiraIntegrationConfig | undefined, linearConfig: linearRow?.config as LinearIntegrationConfig | undefined, + githubProjectsConfig: githubProjectsRow?.config as GitHubProjectsIntegrationConfig | undefined, githubConfig: githubRow?.config as GitHubIntegrationConfig | undefined, }; } @@ -380,6 +417,7 @@ export function mapProjectRow({ trelloConfig, jiraConfig, linearConfig, + githubProjectsConfig, }: MapProjectInput): ProjectConfigRaw { const { models, @@ -398,7 +436,9 @@ export function mapProjectRow({ ? 'jira' : linearConfig ? 'linear' - : undefined; + : githubProjectsConfig + ? 'github-projects' + : undefined; const project: ProjectConfigRaw = { ...buildBaseProjectFields(row, pmType), @@ -422,6 +462,10 @@ export function mapProjectRow({ project.linear = buildLinearConfig(linearConfig); } + if (githubProjectsConfig) { + project.githubProjects = buildGitHubProjectsConfig(githubProjectsConfig); + } + const agentEngine = buildAgentEngineConfig(row, engines); if (agentEngine) { project.agentEngine = agentEngine; diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 9eb4bc6c4..fdc5588b3 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -38,7 +38,7 @@ function buildRawConfig({ return { projects: projectRows.map((row) => { const integrations = integrationsByProject.get(row.id) ?? []; - const { trelloConfig, jiraConfig, linearConfig, githubConfig } = + const { trelloConfig, jiraConfig, linearConfig, githubProjectsConfig, githubConfig } = extractIntegrationConfigs(integrations); return mapProjectRow({ row, @@ -46,6 +46,7 @@ function buildRawConfig({ trelloConfig, jiraConfig, linearConfig, + githubProjectsConfig, githubConfig, }); }), @@ -135,6 +136,13 @@ const linearTeamIdWhereClause = (teamId: string) => AND ${projectIntegrations.config}->>'teamId' = ${teamId} )`; +const githubProjectsProjectIdWhereClause = (projectId: string) => + sql`${projects.id} IN ( + SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations} + WHERE ${projectIntegrations.provider} = 'github-projects' + AND ${projectIntegrations.config}->>'projectId' = ${projectId} + )`; + export function findProjectByBoardIdFromDb(boardId: string): Promise { return findProjectFromDb(boardIdWhereClause(boardId)); } @@ -159,6 +167,12 @@ export function findProjectByLinearTeamIdFromDb( return findProjectFromDb(linearTeamIdWhereClause(teamId)); } +export function findProjectByGitHubProjectsProjectIdFromDb( + projectId: string, +): Promise { + return findProjectFromDb(githubProjectsProjectIdWhereClause(projectId)); +} + // WithConfig variants — return both the project and its org-scoped CascadeConfig export function findProjectWithConfigByBoardId( @@ -186,3 +200,9 @@ export function findProjectWithConfigByLinearTeamId( ): Promise { return findProjectConfigFromDb(linearTeamIdWhereClause(teamId)); } + +export function findProjectWithConfigByGitHubProjectsProjectId( + projectId: string, +): Promise { + return findProjectConfigFromDb(githubProjectsProjectIdWhereClause(projectId)); +} diff --git a/src/gadgets/pm/core/reportFriction.ts b/src/gadgets/pm/core/reportFriction.ts index e2954c9cd..865796f1a 100644 --- a/src/gadgets/pm/core/reportFriction.ts +++ b/src/gadgets/pm/core/reportFriction.ts @@ -71,6 +71,58 @@ function parseJsonRecord(value: string | undefined): Record { : {}; } +function jiraFromEnv(base: ProjectConfig): ProjectConfig { + return { + ...base, + jira: { + projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', + baseUrl: process.env.CASCADE_JIRA_BASE_URL ?? process.env.JIRA_BASE_URL ?? '', + authType: normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE), + statuses: parseJsonRecord(process.env.CASCADE_JIRA_STATUSES), + }, + } as ProjectConfig; +} + +function linearFromEnv(base: ProjectConfig): ProjectConfig { + return { + ...base, + linear: { + teamId: process.env.CASCADE_LINEAR_TEAM_ID ?? '', + ...(process.env.CASCADE_LINEAR_PROJECT_ID + ? { projectId: process.env.CASCADE_LINEAR_PROJECT_ID } + : {}), + statuses: parseJsonRecord(process.env.CASCADE_LINEAR_STATUSES), + }, + } as ProjectConfig; +} + +function githubProjectsFromEnv(base: ProjectConfig): ProjectConfig { + return { + ...base, + githubProjects: { + projectId: process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID ?? '', + owner: process.env.CASCADE_GITHUB_PROJECTS_OWNER ?? '', + ownerType: + (process.env.CASCADE_GITHUB_PROJECTS_OWNER_TYPE as 'user' | 'organization') ?? 'user', + statuses: parseJsonRecord(process.env.CASCADE_GITHUB_PROJECTS_STATUSES), + ...(process.env.CASCADE_GITHUB_PROJECTS_LABELS + ? { labels: parseJsonRecord(process.env.CASCADE_GITHUB_PROJECTS_LABELS) } + : {}), + }, + } as ProjectConfig; +} + +function trelloFromEnv(base: ProjectConfig): ProjectConfig { + return { + ...base, + trello: { + boardId: process.env.CASCADE_TRELLO_BOARD_ID ?? '', + lists: parseJsonRecord(process.env.CASCADE_TRELLO_LISTS), + labels: parseJsonRecord(process.env.CASCADE_TRELLO_LABELS), + }, + } as ProjectConfig; +} + function projectFromEnv(): ProjectConfig { const pmType = process.env.CASCADE_PM_TYPE as | NonNullable['type'] @@ -87,37 +139,16 @@ function projectFromEnv(): ProjectConfig { pm: pmType ? { type: pmType } : undefined, } as ProjectConfig; - if (base.pm?.type === 'jira') { - return { - ...base, - jira: { - projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', - baseUrl: process.env.CASCADE_JIRA_BASE_URL ?? process.env.JIRA_BASE_URL ?? '', - authType: normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE), - statuses: parseJsonRecord(process.env.CASCADE_JIRA_STATUSES), - }, - } as ProjectConfig; - } - if (base.pm?.type === 'linear') { - return { - ...base, - linear: { - teamId: process.env.CASCADE_LINEAR_TEAM_ID ?? '', - ...(process.env.CASCADE_LINEAR_PROJECT_ID - ? { projectId: process.env.CASCADE_LINEAR_PROJECT_ID } - : {}), - statuses: parseJsonRecord(process.env.CASCADE_LINEAR_STATUSES), - }, - } as ProjectConfig; + switch (base.pm?.type) { + case 'jira': + return jiraFromEnv(base); + case 'linear': + return linearFromEnv(base); + case 'github-projects': + return githubProjectsFromEnv(base); + default: + return trelloFromEnv(base); } - return { - ...base, - trello: { - boardId: process.env.CASCADE_TRELLO_BOARD_ID ?? '', - lists: parseJsonRecord(process.env.CASCADE_TRELLO_LISTS), - labels: parseJsonRecord(process.env.CASCADE_TRELLO_LABELS), - }, - } as ProjectConfig; } function parseOptionalInt(value: string | undefined): number | undefined { diff --git a/src/github-projects/client.ts b/src/github-projects/client.ts new file mode 100644 index 000000000..824ac1568 --- /dev/null +++ b/src/github-projects/client.ts @@ -0,0 +1,834 @@ +/** + * GitHub Projects GraphQL API client. + * + * Uses GitHub GraphQL API v4. Auth: Authorization: Bearer . + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { githubAuthHeader } from '../integrations/pm/_shared/auth-headers.js'; +import { logger } from '../utils/logging.js'; +import type { + GitHubIssueComment, + GitHubProject, + GitHubProjectField, + GitHubProjectItem, + GitHubProjectItemsPage, + GitHubProjectsCredentials, + GitHubWorkItemContent, +} from './types.js'; + +const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'; + +const githubCredentialStore = new AsyncLocalStorage(); + +export function withGitHubProjectsCredentials( + creds: GitHubProjectsCredentials, + fn: () => Promise, +): Promise { + return githubCredentialStore.run(creds, fn); +} + +export function getGitHubProjectsCredentials(): GitHubProjectsCredentials { + const scoped = githubCredentialStore.getStore(); + if (!scoped) { + throw new Error( + 'No GitHub Projects credentials in scope. Wrap the call with withGitHubProjectsCredentials().', + ); + } + return scoped; +} + +/** + * Download an image referenced in an issue/PR body. GitHub-hosted attachments + * (e.g. private-user-images.githubusercontent.com, user-attachments/assets) + * may require the bearer token for private repositories. The response + * `Content-Type` is the authoritative MIME per the spec-016 image contract. + * + * Returns null (never throws) so the shared download loop can record a failure + * without aborting the other images. + */ +export async function downloadImage( + url: string, +): Promise<{ buffer: Buffer; mimeType: string } | null> { + const { token } = getGitHubProjectsCredentials(); + + const response = await fetch(url, { + headers: { + ...githubAuthHeader(token), + }, + }); + + if (!response.ok) { + logger.warn('[GitHubProjects] Image download failed', { status: response.status }); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + const mimeType = response.headers.get('content-type') ?? 'application/octet-stream'; + return { buffer: Buffer.from(arrayBuffer), mimeType }; +} + +interface GraphQLResponse { + data?: T; + errors?: Array<{ message: string; extensions?: Record }>; +} + +export async function githubGraphQL( + query: string, + variables?: Record, +): Promise { + const { token } = getGitHubProjectsCredentials(); + + const response = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + ...githubAuthHeader(token), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`GitHub GraphQL HTTP error ${response.status}: ${body}`); + } + + const json = (await response.json()) as GraphQLResponse; + + if (json.errors && json.errors.length > 0) { + const messages = json.errors.map((e) => e.message).join('; '); + throw new Error(`GitHub GraphQL error: ${messages}`); + } + + if (json.data === undefined) { + throw new Error('GitHub GraphQL returned no data'); + } + + return json.data; +} + +// ============================================================================ +// Project queries +// ============================================================================ + +export async function getProject(projectId: string): Promise { + const query = ` + query GetProject($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + id + number + title + url + fields(first: 100) { + nodes { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + color + } + } + } + } + } + } + } + `; + + const data = await githubGraphQL<{ node: GitHubProject }>(query, { projectId }); + return data.node; +} + +export async function getProjectFields(projectId: string): Promise { + const project = await getProject(projectId); + return project.fields?.nodes ?? []; +} + +export async function getProjectItem(itemId: string): Promise { + const query = ` + query GetProjectItem($itemId: ID!) { + node(id: $itemId) { + ... on ProjectV2Item { + id + project { + id + number + } + content { + __typename + ... on Issue { + id + number + title + body + url + state + } + ... on PullRequest { + id + number + title + body + url + state + } + } + fieldValues(first: 100) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + id + name + optionId + field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + } + } + } + } + `; + + const data = await githubGraphQL<{ node: GitHubProjectItem }>(query, { itemId }); + return normalizeProjectItemContent(data.node); +} + +/** + * GraphQL exposes the content kind via `__typename` ('Issue' | 'PullRequest'); + * normalize it onto the discriminant `type` field the adapter branches on. + * Without this, `content.type` is undefined and PR-backed items are treated as + * issues (wrong mutation → GraphQL error). + */ +function normalizeProjectItemContent(node: GitHubProjectItem): GitHubProjectItem { + if (node.content) { + node.content.type = node.content.__typename === 'PullRequest' ? 'pull_request' : 'issue'; + } + return node; +} + +// The item shape shared by `getProjectItem` and the `items` connection in +// `getProjectItems`, factored out so both queries stay in sync. +const PROJECT_ITEM_FIELDS = ` + id + content { + __typename + ... on Issue { + id + number + title + body + url + state + } + ... on PullRequest { + id + number + title + body + url + state + } + } + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + id + name + optionId + field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + } +`; + +/** + * Fetch one page of a project's items. GitHub Projects v2 exposes no + * server-side field filter, so callers filter by Status client-side. + */ +export async function getProjectItems( + projectId: string, + opts?: { first?: number; after?: string }, +): Promise { + const query = ` + query GetProjectItems($projectId: ID!, $first: Int!, $after: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: $first, after: $after) { + nodes { + ${PROJECT_ITEM_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const data = await githubGraphQL<{ node: { items: GitHubProjectItemsPage } | null }>(query, { + projectId, + first: opts?.first ?? 100, + after: opts?.after ?? null, + }); + + const items = data.node?.items; + if (!items) return { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }; + + items.nodes = items.nodes.map(normalizeProjectItemContent); + return items; +} + +/** + * Fetch all of a project's items, paginating up to `maxItems`. Emits a warn + * (never silently truncates) if the cap is hit while more pages remain. + */ +export async function listAllProjectItems( + projectId: string, + opts?: { pageSize?: number; maxItems?: number }, +): Promise { + const pageSize = opts?.pageSize ?? 100; + const maxItems = opts?.maxItems ?? 1000; + + const all: GitHubProjectItem[] = []; + let after: string | undefined; + + while (all.length < maxItems) { + const page = await getProjectItems(projectId, { first: pageSize, after }); + all.push(...page.nodes); + if (!page.pageInfo.hasNextPage) return all; + if (all.length >= maxItems) { + logger.warn('[GitHubProjects] listAllProjectItems hit item cap; results truncated', { + projectId, + maxItems, + fetched: all.length, + }); + return all.slice(0, maxItems); + } + after = page.pageInfo.endCursor ?? undefined; + if (!after) return all; + } + return all; +} + +interface RawContentNode { + __typename?: string; + id: string; + number: number; + title: string; + body: string; + url: string; + state: string; + projectItems?: { + nodes: Array<{ + project?: { id: string }; + fieldValues?: { + nodes: Array<{ + optionId?: string; + name?: string; + field?: { id: string; name: string }; + }>; + }; + }>; + }; +} + +/** + * Resolve an Issue/PR directly from its *content* node ID (the work-item ID used + * throughout the github-projects path), and — when `projectId` is given — its + * Status option in that project via the content node's `projectItems` connection. + * + * This is distinct from `getProjectItem`, which starts from a `ProjectV2Item` + * node ID. Callers holding a content node ID (`getWorkItem`, `updateWorkItem`) + * must use this: `node()` resolves to an Issue/PullRequest, so a + * `... on ProjectV2Item` query would never match and would drop `content`. + */ +export async function getContentNode( + contentId: string, + projectId?: string, +): Promise { + const contentFields = ` + id + number + title + body + url + state + projectItems(first: 20) { + nodes { + project { id } + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + optionId + name + field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + } + } + } + `; + const query = ` + query GetContentNode($id: ID!) { + node(id: $id) { + __typename + ... on Issue { ${contentFields} } + ... on PullRequest { ${contentFields} } + } + } + `; + + const data = await githubGraphQL<{ node: RawContentNode | null }>(query, { id: contentId }); + const node = data.node; + if (!node || (node.__typename !== 'Issue' && node.__typename !== 'PullRequest')) { + throw new Error( + `GitHub Projects content node ${contentId} did not resolve to an Issue or PullRequest`, + ); + } + + let statusName: string | undefined; + let statusOptionId: string | undefined; + if (projectId) { + const projectItem = node.projectItems?.nodes.find((n) => n.project?.id === projectId); + const statusValue = projectItem?.fieldValues?.nodes.find((v) => v.field?.name === 'Status'); + statusName = statusValue?.name; + statusOptionId = statusValue?.optionId; + } + + return { + id: node.id, + number: node.number, + title: node.title, + body: node.body, + url: node.url, + state: node.state, + type: node.__typename === 'PullRequest' ? 'pull_request' : 'issue', + statusName, + statusOptionId, + }; +} + +/** + * Fetch comments on the Issue or Pull Request backing a project item. `id` is + * the content node ID (the same value used as the work-item ID throughout the + * github-projects path). + */ +export async function getIssueComments(id: string, first = 100): Promise { + const query = ` + query GetIssueComments($id: ID!, $first: Int!) { + node(id: $id) { + ... on Issue { + comments(first: $first) { + nodes { + id + body + createdAt + updatedAt + author { + login + ... on User { + id + name + } + } + } + } + } + ... on PullRequest { + comments(first: $first) { + nodes { + id + body + createdAt + updatedAt + author { + login + ... on User { + id + name + } + } + } + } + } + } + } + `; + + const data = await githubGraphQL<{ + node: { comments?: { nodes: GitHubIssueComment[] } } | null; + }>(query, { id, first }); + + return data.node?.comments?.nodes ?? []; +} + +// ============================================================================ +// Labels +// ============================================================================ + +/** + * Resolve a label *name* to its node ID within the repository that owns the + * content node (`contentId` is the Issue/PR node ID). GitHub labels are + * repo-scoped, so the same name has a different node ID per repository — hence + * we resolve against the content's own repo at call time. Returns `null` when + * the repository has no label with that name. + */ +export async function resolveContentRepoLabelId( + contentId: string, + labelName: string, +): Promise { + const query = ` + query ResolveRepoLabel($id: ID!, $name: String!) { + node(id: $id) { + ... on Issue { + repository { label(name: $name) { id } } + } + ... on PullRequest { + repository { label(name: $name) { id } } + } + } + } + `; + + const data = await githubGraphQL<{ + node: { repository?: { label?: { id: string } | null } } | null; + }>(query, { id: contentId, name: labelName }); + + return data.node?.repository?.label?.id ?? null; +} + +/** + * Add labels to an Issue/PR (both implement `Labelable`). `labelableId` is the + * content node ID; `labelIds` are repo-scoped label node IDs. + */ +export async function addLabelsToContent(labelableId: string, labelIds: string[]): Promise { + const mutation = ` + mutation AddLabels($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { + clientMutationId + } + } + `; + await githubGraphQL(mutation, { labelableId, labelIds }); +} + +/** + * Remove labels from an Issue/PR. `labelableId` is the content node ID; + * `labelIds` are repo-scoped label node IDs. + */ +export async function removeLabelsFromContent( + labelableId: string, + labelIds: string[], +): Promise { + const mutation = ` + mutation RemoveLabels($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { + clientMutationId + } + } + `; + await githubGraphQL(mutation, { labelableId, labelIds }); +} + +// ============================================================================ +// Work-item creation +// ============================================================================ + +/** + * Resolve a repository's node ID from its `owner`/`name`. Needed by + * `createRepositoryIssue`, whose `createIssue` mutation takes a `repositoryId`. + * Throws when the repo is not found or not visible to the configured token. + */ +export async function getRepositoryId(owner: string, name: string): Promise { + const query = ` + query GetRepositoryId($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { id } + } + `; + const data = await githubGraphQL<{ repository: { id: string } | null }>(query, { owner, name }); + if (!data.repository?.id) { + throw new Error( + `GitHub repository ${owner}/${name} not found or not accessible with the configured token`, + ); + } + return data.repository.id; +} + +/** + * Create a real GitHub Issue in a repository and return its content node ID, + * number, and URL. GitHub Projects has no first-class "create item" that yields + * a commentable/labelable work item; we create an Issue (which does) and then add + * it to the project via `addContentToProject`. + */ +export async function createRepositoryIssue( + repositoryId: string, + title: string, + body: string, +): Promise<{ id: string; number: number; url: string }> { + const mutation = ` + mutation CreateIssue($repositoryId: ID!, $title: String!, $body: String) { + createIssue(input: { repositoryId: $repositoryId, title: $title, body: $body }) { + issue { id number url } + } + } + `; + const data = await githubGraphQL<{ + createIssue: { issue: { id: string; number: number; url: string } }; + }>(mutation, { repositoryId, title, body }); + return data.createIssue.issue; +} + +/** + * Add an existing Issue/PR (by content node ID) to a project and return the new + * ProjectV2Item node ID. + */ +export async function addContentToProject(projectId: string, contentId: string): Promise { + const mutation = ` + mutation AddProjectItem($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `; + const data = await githubGraphQL<{ addProjectV2ItemById: { item: { id: string } } }>(mutation, { + projectId, + contentId, + }); + return data.addProjectV2ItemById.item.id; +} + +/** + * Resolve the ProjectV2Item node ID for a content (Issue/PR) node within a given + * project. `updateProjectV2ItemFieldValue` (status writes) requires the + * ProjectV2Item ID, but the work-item ID carried across the github-projects path + * is the *content* node ID — so a status move must resolve the item ID first. + * Returns `null` when the content is not part of the project. + */ +export async function resolveProjectItemId( + contentId: string, + projectId: string, +): Promise { + const projectItems = `projectItems(first: 20) { nodes { id project { id } } }`; + const query = ` + query ResolveProjectItem($id: ID!) { + node(id: $id) { + ... on Issue { ${projectItems} } + ... on PullRequest { ${projectItems} } + } + } + `; + const data = await githubGraphQL<{ + node: { projectItems?: { nodes: Array<{ id: string; project?: { id: string } }> } } | null; + }>(query, { id: contentId }); + const items = data.node?.projectItems?.nodes ?? []; + return items.find((n) => n.project?.id === projectId)?.id ?? null; +} + +// ============================================================================ +// Mutations +// ============================================================================ + +export async function updateProjectItemField( + projectId: string, + itemId: string, + fieldId: string, + optionId: string, +): Promise { + const mutation = ` + mutation UpdateProjectV2ItemFieldValue( + $projectId: ID! + $itemId: ID! + $fieldId: ID! + $optionId: String! + ) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { + id + } + } + } + `; + + await githubGraphQL(mutation, { projectId, itemId, fieldId, optionId }); +} + +export async function addCommentToIssue(issueId: string, body: string): Promise { + const mutation = ` + mutation AddComment($subjectId: ID!, $body: String!) { + addComment(input: { subjectId: $subjectId, body: $body }) { + commentEdge { + node { + id + } + } + } + } + `; + + const data = await githubGraphQL<{ addComment: { commentEdge: { node: { id: string } } } }>( + mutation, + { subjectId: issueId, body }, + ); + return data.addComment.commentEdge.node.id; +} + +export async function updateComment(commentId: string, body: string): Promise { + const mutation = ` + mutation UpdateComment($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { + issueComment { + id + } + } + } + `; + await githubGraphQL(mutation, { commentId, body }); +} + +export async function deleteComment(commentId: string): Promise { + const mutation = ` + mutation DeleteComment($commentId: ID!) { + deleteIssueComment(input: { id: $commentId }) { + clientMutationId + } + } + `; + await githubGraphQL(mutation, { commentId }); +} + +// ============================================================================ +// Discovery queries +// ============================================================================ + +export async function getUserProjects(login: string): Promise { + const query = ` + query GetUserProjects($login: String!) { + user(login: $login) { + projectsV2(first: 100) { + nodes { + id + number + title + url + } + } + } + } + `; + + const data = await githubGraphQL<{ user: { projectsV2: { nodes: GitHubProject[] } } }>(query, { + login, + }); + return data.user.projectsV2.nodes; +} + +export async function getOrganizationProjects(org: string): Promise { + const query = ` + query GetOrgProjects($org: String!) { + organization(login: $org) { + projectsV2(first: 100) { + nodes { + id + number + title + url + } + } + } + } + `; + + const data = await githubGraphQL<{ organization: { projectsV2: { nodes: GitHubProject[] } } }>( + query, + { org }, + ); + return data.organization.projectsV2.nodes; +} + +export async function getViewer(): Promise<{ id: string; login: string; name?: string }> { + const query = ` + query GetViewer { + viewer { + id + login + name + } + } + `; + + const data = await githubGraphQL<{ viewer: { id: string; login: string; name?: string } }>(query); + return data.viewer; +} + +// ============================================================================ +// Status field helpers +// ============================================================================ + +/** + * Find the ProjectV2SingleSelectField named "Status" and return its id + options. + */ +export async function getStatusField( + projectId: string, +): Promise<{ id: string; options: Array<{ id: string; name: string; color?: string }> } | null> { + const fields = await getProjectFields(projectId); + const statusField = fields.find((f) => f.name === 'Status'); + if (!statusField?.options) return null; + return { id: statusField.id, options: statusField.options }; +} + +/** + * Resolve the option name for a given option ID within the Status field. + */ +export async function resolveStatusOptionName( + projectId: string, + optionId: string, +): Promise { + const statusField = await getStatusField(projectId); + if (!statusField) return null; + return statusField.options.find((o) => o.id === optionId)?.name ?? null; +} + +/** + * Update the Status field of a project item. Throws if the Status field is missing + * or the destination cannot be resolved. + */ +export async function moveProjectItemToStatus( + projectId: string, + itemId: string, + statusOptionId: string, +): Promise { + const statusField = await getStatusField(projectId); + if (!statusField) { + throw new Error(`Project ${projectId} does not have a Status field`); + } + await updateProjectItemField(projectId, itemId, statusField.id, statusOptionId); + logger.debug('[GitHubProjects] Moved item to status', { projectId, itemId, statusOptionId }); +} diff --git a/src/github-projects/types.ts b/src/github-projects/types.ts new file mode 100644 index 000000000..c9d85f26a --- /dev/null +++ b/src/github-projects/types.ts @@ -0,0 +1,124 @@ +/** + * GitHub Projects v2 types. + */ + +export interface GitHubProjectsCredentials { + token: string; +} + +export interface GitHubProject { + id: string; + number: number; + title: string; + url: string; + fields?: { + nodes: GitHubProjectField[]; + }; +} + +export interface GitHubProjectField { + id: string; + name: string; + options?: Array<{ + id: string; + name: string; + color?: string; + }>; +} + +export interface GitHubProjectItem { + id: string; + project: { + id: string; + number: number; + }; + content?: GitHubProjectContent; + fieldValues?: { + nodes: Array<{ + id: string; + name: string; + /** + * The stable Status *option* ID (matches the IDs persisted in + * `GitHubProjectsConfig.statuses`). Distinct from `id`, which is the + * per-item field-value node ID and is NOT comparable to configured + * status IDs. + */ + optionId?: string; + field: { + id: string; + name: string; + }; + }>; + }; +} + +/** + * A project item's linked content — an Issue or a Pull Request. Both GraphQL + * types expose the same fields we consume; `__typename` (populated from the + * query) is normalized into the `type` discriminant by `getProjectItem`. + */ +export interface GitHubProjectContent { + id: string; + number: number; + title: string; + body: string; + url: string; + state: string; + /** GraphQL `__typename`, used to derive the `type` discriminant. */ + __typename?: 'Issue' | 'PullRequest'; + type: 'issue' | 'pull_request'; +} + +/** + * An Issue/PR resolved directly from its *content* node ID (the value used as + * the work-item ID across the github-projects path), with its Status in a given + * project resolved via the content node's `projectItems` connection. Returned by + * `getContentNode`. Distinct from `getProjectItem`, which starts from a + * `ProjectV2Item` node ID. + */ +export interface GitHubWorkItemContent { + id: string; + number: number; + title: string; + body: string; + url: string; + state: string; + type: 'issue' | 'pull_request'; + /** Status option name/ID for the requested project, when resolvable. */ + statusName?: string; + statusOptionId?: string; +} + +/** One page of a project's items, as returned by `getProjectItems`. */ +export interface GitHubProjectItemsPage { + nodes: GitHubProjectItem[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +} + +/** A comment on an Issue or Pull Request, as returned by `getIssueComments`. */ +export interface GitHubIssueComment { + id: string; + body: string; + createdAt: string; + updatedAt?: string; + author?: { + login: string; + /** Present only when the author is a User (not a Bot/Organization). */ + id?: string; + name?: string; + }; +} + +export interface GitHubProjectsConfig { + projectId: string; + owner: string; + ownerType: 'user' | 'organization'; + statuses: Record; + labels?: { + processing?: string; + readyToProcess?: string; + }; +} diff --git a/src/integrations/pm/github-projects/config-schema.ts b/src/integrations/pm/github-projects/config-schema.ts new file mode 100644 index 000000000..81368ebc6 --- /dev/null +++ b/src/integrations/pm/github-projects/config-schema.ts @@ -0,0 +1,36 @@ +/** + * GitHub Projects provider integration config schema. + */ + +import { z } from 'zod'; + +export const githubProjectsConfigSchema = z + .object({ + /** GitHub Project node ID (PVT_xxx). */ + projectId: z.string().min(1), + + /** GitHub username or organization login that owns the project. */ + owner: z.string().min(1), + + /** Whether the owner is a user or an organization. */ + ownerType: z.enum(['user', 'organization']), + + /** + * Mapping from CASCADE status keys (todo/inProgress/done/etc.) to + * GitHub Projects Status single-select option node IDs (PVTSSF_xxx). + */ + statuses: z.record(z.string(), z.string()), + + /** + * Optional GitHub label node IDs for lifecycle automation. + */ + labels: z + .object({ + processing: z.string().optional(), + readyToProcess: z.string().optional(), + }) + .optional(), + }) + .describe('GitHub Projects integration config'); + +export type GitHubProjectsIntegrationConfig = z.infer; diff --git a/src/integrations/pm/github-projects/index.ts b/src/integrations/pm/github-projects/index.ts new file mode 100644 index 000000000..b269f1d95 --- /dev/null +++ b/src/integrations/pm/github-projects/index.ts @@ -0,0 +1,10 @@ +/** + * GitHub Projects PM provider — side-effect module that registers the manifest. + */ + +import { registerPMProvider } from '../registry.js'; +import { githubProjectsManifest } from './manifest.js'; + +registerPMProvider(githubProjectsManifest); + +export { githubProjectsManifest }; diff --git a/src/integrations/pm/github-projects/manifest.ts b/src/integrations/pm/github-projects/manifest.ts new file mode 100644 index 000000000..7ab50cfa1 --- /dev/null +++ b/src/integrations/pm/github-projects/manifest.ts @@ -0,0 +1,220 @@ +/** + * GitHub Projects PM provider manifest. + * + * Wires GitHub Projects (Projects v2) into the CASCADE PM provider system. + * Uses GitHub GraphQL API for queries/mutations and GitHub webhook events + * for triggers. + */ + +import { + getOrganizationProjects, + getStatusField, + getUserProjects, + getViewer, + withGitHubProjectsCredentials, +} from '../../../github-projects/client.js'; +import { GitHubProjectsIntegration } from '../../../pm/github-projects/integration.js'; +import { parseContainerId, parseStateId } from '../../../pm/ids.js'; +import type { + DiscoveryArgs, + DiscoveryCapability, + DiscoveryResult, + PMProvider, +} from '../../../pm/types.js'; +import { GitHubProjectsRouterAdapter } from '../../../router/adapters/github-projects.js'; +import { GitHubProjectsPlatformClient } from '../../../router/platformClients/github-projects.js'; +import { GitHubProjectsStatusChangedTrigger } from '../../../triggers/github-projects/status-changed.js'; +import { makeHmacSha256Verifier } from '../_shared/webhook-verifier.js'; +import type { PMProviderManifest } from '../manifest.js'; +import { githubProjectsConfigSchema } from './config-schema.js'; + +/** + * Map GitHub Projects status option name to CASCADE-canonical category. + */ +function classifyGitHubStatus( + name: string, +): 'todo' | 'in_progress' | 'done' | 'canceled' | 'unknown' { + const n = name.toLowerCase(); + if (n === 'todo' || n === 'backlog' || n === 'to do' || n === 'no status') return 'todo'; + if (n === 'in progress' || n === 'in review' || n === 'doing' || n === 'review') + return 'in_progress'; + if (n === 'done' || n === 'complete' || n === 'completed') return 'done'; + if (n === 'canceled' || n === 'cancelled') return 'canceled'; + return 'unknown'; +} + +// ============================================================================ +// Discovery handlers +// ============================================================================ + +/** + * Discover projects for a given owner (user or organization). + * The containerId is expected to be in the format "login:ownerType". + */ +async function handleProjectsDiscovery( + args: DiscoveryArgs<'projects'>, + runWithCreds: (fn: () => Promise) => Promise, +): Promise> { + const a = args as { containerId?: string }; + const owner = a.containerId; + if (!owner) return []; + + const [login, ownerType] = owner.split(':'); + const projects = + ownerType === 'organization' + ? await runWithCreds(() => getOrganizationProjects(login)) + : await runWithCreds(() => getUserProjects(login)); + + return projects.map((p) => ({ + id: parseContainerId(p.id), + name: p.title, + url: p.url, + })); +} + +/** + * Discover states (Status field options) for a project. + * The containerId should be the project node ID. + */ +async function handleStatesDiscovery( + args: DiscoveryArgs<'states'>, + runWithCreds: (fn: () => Promise) => Promise, +): Promise> { + const a = args as { containerId: string }; + const statusField = await runWithCreds(() => getStatusField(a.containerId)); + if (!statusField) return []; + + return statusField.options.map((o) => ({ + id: parseStateId(o.id), + name: o.name, + category: classifyGitHubStatus(o.name), + })); +} + +/** + * Discover the current authenticated user. + */ +async function handleCurrentUserDiscovery( + _args: DiscoveryArgs<'currentUser'>, + runWithCreds: (fn: () => Promise) => Promise, +): Promise> { + const me = await runWithCreds(() => getViewer()); + return { + id: me.id, + name: me.name ?? me.login, + displayName: me.name ?? me.login, + }; +} + +const githubProjectsIntegration = new GitHubProjectsIntegration(); + +export const githubProjectsManifest: PMProviderManifest = { + id: 'github-projects', + label: 'GitHub Projects', + category: 'pm', + + credentialRoles: [ + { + role: 'token', + label: 'Personal Access Token', + envVarKey: 'GITHUB_TOKEN', + }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'GITHUB_WEBHOOK_SECRET', + optional: true, + }, + ], + + webhookRoute: '/github-projects/webhook', + verifyWebhookSignature: makeHmacSha256Verifier({ + headerName: 'x-hub-signature-256', + headerPrefix: 'sha256=', + }), + + routerAdapter: new GitHubProjectsRouterAdapter(), + + extractProjectIdFromJob: async (jobData) => { + const d = jobData as unknown as { type?: string; projectId?: string }; + if (d.type !== 'github-projects') return null; + return d.projectId ?? null; + }, + + pmIntegration: githubProjectsIntegration, + + triggerHandlers: [new GitHubProjectsStatusChangedTrigger()], + + platformClientFactory: (projectId) => new GitHubProjectsPlatformClient(projectId), + + // Discovery capabilities for wizard. `states` powers the status-mapping step + // (the wizard queries capability 'states' to list the project's Status + // options); it MUST be declared or the generic pm.discovery endpoint rejects + // the call and the provider cannot be configured. + discoveryCapabilities: { + projects: true, + states: true, + currentUser: true, + }, + + wizardSpec: { + steps: [ + { kind: 'credentials', id: 'github-projects-credentials' }, + { kind: 'project-scope', id: 'github-projects-scope' }, + { kind: 'container-pick', id: 'github-projects-selection' }, + { kind: 'status-mapping', id: 'github-projects-statuses' }, + { kind: 'webhook-url-display', id: 'github-projects-webhook' }, + ], + }, + + configSchema: githubProjectsConfigSchema, + configFixture: { + projectId: 'PVT_xxx', + owner: 'username', + ownerType: 'user', + statuses: { + todo: 'PVTSSF_xxx', + inProgress: 'PVTSSF_yyy', + done: 'PVTSSF_zzz', + }, + }, + + createDiscoveryProvider: (opts) => { + const token = opts?.credentials?.token ?? ''; + + const runWithCreds = (fn: () => Promise): Promise => + withGitHubProjectsCredentials({ token }, fn); + + const provider: Pick = { + type: 'github-projects', + async discover( + capability: K, + args: DiscoveryArgs, + ): Promise> { + switch (capability) { + case 'projects': + return (await handleProjectsDiscovery( + args as DiscoveryArgs<'projects'>, + runWithCreds, + )) as DiscoveryResult; + case 'states': + return (await handleStatesDiscovery( + args as DiscoveryArgs<'states'>, + runWithCreds, + )) as DiscoveryResult; + case 'currentUser': + return (await handleCurrentUserDiscovery( + args as DiscoveryArgs<'currentUser'>, + runWithCreds, + )) as DiscoveryResult; + default: + throw new Error( + `GitHub Projects provider does not support discovery capability '${capability}'`, + ); + } + }, + }; + + return provider as PMProvider; + }, +}; diff --git a/src/integrations/pm/index.ts b/src/integrations/pm/index.ts index 98c09515a..e9a7783a3 100644 --- a/src/integrations/pm/index.ts +++ b/src/integrations/pm/index.ts @@ -13,6 +13,7 @@ import { integrationRegistry } from '../registry.js'; import './trello/index.js'; import './jira/index.js'; import './linear/index.js'; +import './github-projects/index.js'; import { listPMProviders } from './registry.js'; // Mirror PM manifests into integrationRegistry. Idempotent: guarded by diff --git a/src/pm/config.ts b/src/pm/config.ts index 3955665e8..1c6d5c35b 100644 --- a/src/pm/config.ts +++ b/src/pm/config.ts @@ -93,6 +93,27 @@ export function getLinearConfig(project: ProjectConfig): LinearConfig | undefine return project.linear as LinearConfig | undefined; } +/** GitHub Projects v2-specific configuration (from project_integrations JSONB) */ +export interface GitHubProjectsConfig { + projectId: string; + owner: string; + ownerType: 'user' | 'organization'; + statuses: Record; + labels?: { + processing?: string; + readyToProcess?: string; + }; +} + +/** + * Get the GitHub Projects config for a project. + * Returns the config or undefined if this is not a GitHub Projects project. + */ +export function getGitHubProjectsConfig(project: ProjectConfig): GitHubProjectsConfig | undefined { + if (project.pm?.type !== 'github-projects') return undefined; + return project.githubProjects as GitHubProjectsConfig | undefined; +} + /** * Get the cost custom field ID for a project, regardless of PM type. */ @@ -103,6 +124,9 @@ export function getCostFieldId(project: ProjectConfig): string | undefined { if (project.pm?.type === 'linear') { return getLinearConfig(project)?.customFields?.cost; } + if (project.pm?.type === 'github-projects') { + return undefined; + } return getTrelloConfig(project)?.customFields?.cost; } @@ -142,6 +166,13 @@ export function getAlertsContainerId(project: ProjectConfig): string | undefined if (!linearConfig?.statuses?.alerts) return undefined; return linearConfig.teamId; } + if (pmType === 'github-projects') { + const githubProjectsConfig = getGitHubProjectsConfig(project); + // GitHub Projects items are created by adding existing issues to the project. + // Minimal integration does not support alert item creation. + if (!githubProjectsConfig?.statuses?.alerts) return undefined; + return githubProjectsConfig.projectId; + } return undefined; } @@ -170,6 +201,11 @@ export function getFrictionContainerId(project: ProjectConfig): string | undefin if (!linearConfig?.statuses?.friction) return undefined; return linearConfig.teamId; } + if (pmType === 'github-projects') { + const githubProjectsConfig = getGitHubProjectsConfig(project); + if (!githubProjectsConfig?.statuses?.friction) return undefined; + return githubProjectsConfig.projectId; + } return undefined; } @@ -192,6 +228,9 @@ export function getAlertLabelId(project: ProjectConfig): string | undefined { if (pmType === 'linear') { return getLinearConfig(project)?.labels?.cascadeAlert; } + if (pmType === 'github-projects') { + return undefined; + } return undefined; } @@ -217,6 +256,9 @@ export function getFrictionLabelId(project: ProjectConfig): string | undefined { if (pmType === 'linear') { return getLinearConfig(project)?.labels?.cascadeFriction; } + if (pmType === 'github-projects') { + return undefined; + } return undefined; } @@ -241,6 +283,9 @@ export function getAlertsStatusKey(project: ProjectConfig): 'alerts' | undefined if (pmType === 'linear') { return getLinearConfig(project)?.statuses?.alerts ? 'alerts' : undefined; } + if (pmType === 'github-projects') { + return getGitHubProjectsConfig(project)?.statuses?.alerts ? 'alerts' : undefined; + } return undefined; } @@ -265,6 +310,9 @@ export function getAlertsStatusDestination(project: ProjectConfig): string | und if (pmType === 'linear') { return getLinearConfig(project)?.statuses?.alerts; } + if (pmType === 'github-projects') { + return getGitHubProjectsConfig(project)?.statuses?.alerts; + } return undefined; } @@ -288,5 +336,8 @@ export function getFrictionStatusDestination(project: ProjectConfig): string | u if (pmType === 'linear') { return getLinearConfig(project)?.statuses?.friction; } + if (pmType === 'github-projects') { + return getGitHubProjectsConfig(project)?.statuses?.friction; + } return undefined; } diff --git a/src/pm/download-and-prepare.ts b/src/pm/download-and-prepare.ts index a1914ad65..f93004dfc 100644 --- a/src/pm/download-and-prepare.ts +++ b/src/pm/download-and-prepare.ts @@ -48,6 +48,9 @@ export async function downloadAndPrepareImages( const { jiraClient } = await import('../jira/client.js'); const { trelloClient } = await import('../trello/client.js'); const { linearClient } = await import('../linear/client.js'); + const { downloadImage: downloadGitHubProjectsImage } = await import( + '../github-projects/client.js' + ); const failures: { url: string; reason: string }[] = []; @@ -59,6 +62,8 @@ export async function downloadAndPrepareImages( downloaded = await jiraClient.downloadAttachment(ref.url); } else if (provider?.type === 'linear') { downloaded = await linearClient.downloadAttachment(ref.url); + } else if (provider?.type === 'github-projects') { + downloaded = await downloadGitHubProjectsImage(ref.url); } else { downloaded = await trelloClient.downloadAttachment(ref.url); } diff --git a/src/pm/github-projects/adapter.ts b/src/pm/github-projects/adapter.ts new file mode 100644 index 000000000..35700ace1 --- /dev/null +++ b/src/pm/github-projects/adapter.ts @@ -0,0 +1,518 @@ +/** + * GitHubProjectsPMProvider — implements PMProvider for GitHub Projects v2. + * + * Assumes GitHub Projects credentials are already in scope via + * withGitHubProjectsCredentials(). + */ + +import { + addCommentToIssue, + addContentToProject, + addLabelsToContent, + createRepositoryIssue, + getContentNode, + getIssueComments, + getRepositoryId, + listAllProjectItems, + moveProjectItemToStatus, + removeLabelsFromContent, + resolveContentRepoLabelId, + resolveProjectItemId, + updateComment, +} from '../../github-projects/client.js'; +import { logger } from '../../utils/logging.js'; +import { parseRepoFullName } from '../../utils/repo.js'; +import { withDescriptionMutationLock } from '../_shared/description-mutation-lock.js'; +import { + buildChecklistId, + findChecklistNameByHash, + hashChecklistItemId, + parseChecklistId, + parseInlineChecklists, + removeChecklistItem, + toggleChecklistItem, + upsertChecklistSection, + upsertItemInChecklist, +} from '../_shared/inline-checklist.js'; +import type { GitHubProjectsConfig } from '../config.js'; +import type { ContainerId, LabelId } from '../ids.js'; +import { extractMarkdownImages } from '../media.js'; +import type { + Attachment, + Checklist, + ChecklistItemDraft, + CreateWorkItemConfig, + ListWorkItemsFilter, + PMProvider, + WorkItem, + WorkItemComment, +} from '../types.js'; + +const CASCADE_STATUS_KEYS = new Set([ + 'backlog', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', + 'cancelled', + 'canceled', + 'splitting', + 'planning', + 'debug', + 'friction', + 'alerts', +]); + +function resolveGitHubProjectsStatusFilter( + status: string | undefined, + configStatuses: GitHubProjectsConfig['statuses'] | undefined, +): string | null | undefined { + if (!status) return undefined; + const mapped = configStatuses?.[status]; + if (mapped) return mapped; + if (CASCADE_STATUS_KEYS.has(status)) return null; + return status.startsWith('PVTSSF_') ? status : null; +} + +export class GitHubProjectsPMProvider implements PMProvider { + readonly type = 'github-projects' as const; + + /** + * @param config the GitHub Projects PM config (project node ID, owner, status map). + * @param repoFullName the project's SCM repository (`owner/repo`), used only by + * `createWorkItem` to create the backing Issue. A GitHub Project can span many + * repos, so there is no repo in the PM config itself — we borrow the project's + * configured SCM repo. Absent ⇒ `createWorkItem` throws with an actionable message. + */ + constructor( + private config: GitHubProjectsConfig, + private repoFullName?: string, + ) {} + + async getWorkItem(id: string): Promise { + // `id` is the content (Issue/PR) node ID used across the github-projects + // path — resolve the content node directly and read its Status for this + // project via the content node's `projectItems` connection. (Going through + // `getProjectItem`, which expects a ProjectV2Item node ID, would never match + // and would drop `content`.) + const content = await getContentNode(id, this.config.projectId); + + // Issue/PR bodies are markdown; user-pasted screenshots arrive as + // `![alt](url)`. Extract them so the shared image pipeline + // (downloadAndPrepareImages) delivers them to the agent — the same + // contract Trello/Linear/JIRA adapters follow (spec 016). + const inlineMedia = extractMarkdownImages(content.body ?? '', 'description'); + + return { + id, + title: content.title, + description: content.body ?? '', + url: content.url, + // GitHub Projects single-select value exposes the stable option ID via + // `optionId`; that is what maps to configured status IDs. + status: content.statusName, + statusId: content.statusOptionId, + labels: [], + inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + }; + } + + async getWorkItemComments(id: string): Promise { + // `id` is the content node ID (Issue/PR), which the GraphQL `node(id)` + // query resolves to the underlying Issue/PullRequest — so we can read its + // comments directly. Comment bodies are markdown; user-pasted screenshots + // arrive as `![alt](url)` and must reach the shared image pipeline, matching + // the Trello/Linear/JIRA contract (spec 016). + const comments = await getIssueComments(id); + return comments.map((c) => { + const inlineMedia = extractMarkdownImages(c.body, 'comment'); + return { + id: c.id, + date: c.createdAt, + text: c.body, + author: { + id: c.author?.id ?? '', + name: c.author?.name ?? c.author?.login ?? '', + username: c.author?.login ?? '', + }, + inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + ...(c.createdAt ? { createdAt: c.createdAt } : {}), + ...(c.updatedAt ? { updatedAt: c.updatedAt } : {}), + }; + }); + } + + /** + * Update the title of an Issue or Pull Request. + */ + private async updateContentTitle(contentId: string, title: string, isPR: boolean): Promise { + const issueMutation = ` + mutation UpdateIssueTitle($id: ID!, $title: String!) { + updateIssue(input: { id: $id, title: $title }) { + issue { id } + } + } + `; + const prMutation = ` + mutation UpdatePullRequestTitle($id: ID!, $title: String!) { + updatePullRequest(input: { id: $id, title: $title }) { + pullRequest { id } + } + } + `; + const { githubGraphQL } = await import('../../github-projects/client.js'); + await githubGraphQL(isPR ? prMutation : issueMutation, { id: contentId, title }); + } + + /** + * Update the body/description of an Issue or Pull Request. + */ + private async updateContentBody(contentId: string, body: string, isPR: boolean): Promise { + const issueMutation = ` + mutation UpdateIssueBody($id: ID!, $body: String!) { + updateIssue(input: { id: $id, body: $body }) { + issue { id } + } + } + `; + const prMutation = ` + mutation UpdatePullRequestBody($id: ID!, $body: String!) { + updatePullRequest(input: { id: $id, body: $body }) { + pullRequest { id } + } + } + `; + const { githubGraphQL } = await import('../../github-projects/client.js'); + await githubGraphQL(isPR ? prMutation : issueMutation, { id: contentId, body }); + } + + async updateWorkItem( + id: string, + updates: { title?: string; description?: string }, + ): Promise { + // `id` is the content (Issue/PR) node ID; resolve the node to pick the + // correct update mutation (Issue vs PullRequest). + const content = await getContentNode(id); + const isPR = content.type === 'pull_request'; + + if (updates.title !== undefined) { + await this.updateContentTitle(content.id, updates.title, isPR); + } + + if (updates.description !== undefined) { + await this.updateContentBody(content.id, updates.description, isPR); + } + } + + async addComment(id: string, text: string): Promise { + // `id` is the content (Issue/PR) node ID — `addComment`'s subjectId is an + // Issue/PR node, so comment on it directly (no ProjectV2Item lookup needed). + return addCommentToIssue(id, text); + } + + async updateComment(_id: string, commentId: string, text: string): Promise { + await updateComment(commentId, text); + } + + async createWorkItem(config: CreateWorkItemConfig): Promise { + // GitHub Projects has no first-class "create item" that yields a commentable, + // labelable work item — a real Issue is created in the project's repository + // and then added to the board. Keeping the content (Issue) node ID as the + // work-item identity means comments, labels, checklists, and status moves all + // work afterward. Draft issues are intentionally NOT used: they cannot receive + // comments or labels, which the friction/alert materializer and agents need. + if (!this.repoFullName) { + throw new Error( + 'Creating GitHub Projects work items requires the project to have an SCM repository ' + + 'configured (owner/repo). Set the project repository, or file the item manually.', + ); + } + const { owner, repo } = parseRepoFullName(this.repoFullName); + const repositoryId = await getRepositoryId(owner, repo); + const issue = await createRepositoryIssue(repositoryId, config.title, config.description ?? ''); + + // Add the new Issue to the target project (containerId is the project node ID; + // fall back to the configured project when the caller passes an empty value). + const projectId = config.containerId || this.config.projectId; + await addContentToProject(projectId, issue.id); + + // Apply any requested labels — resolved against the Issue's own repo by addLabel. + for (const label of config.labels ?? []) { + await this.addLabel(issue.id, label as LabelId); + } + + return { + id: issue.id, + title: config.title, + description: config.description ?? '', + url: issue.url, + labels: [], + }; + } + + async listWorkItems( + containerId: ContainerId | undefined, + filter?: ListWorkItemsFilter, + ): Promise { + const projectId = (containerId as string | undefined) ?? this.config.projectId; + if (!projectId) return []; + + // Maps a CASCADE status key to the GitHub Status *option* ID: + // - a string → keep only items whose Status field value has that optionId + // - null → known CASCADE key with no configured mapping → nothing to list + // - undefined → no status filter → list every item + const statusOptionId = resolveGitHubProjectsStatusFilter(filter?.status, this.config.statuses); + if (statusOptionId === null) return []; + + // GitHub Projects v2 exposes no server-side field filter, so we fetch the + // project's items and filter by Status option ID client-side. + const items = await listAllProjectItems(projectId); + + const result: WorkItem[] = []; + for (const item of items) { + const content = item.content; + // Draft issues (no linked Issue/PR) have no content — skip them. + if (!content?.id) continue; + + const statusField = item.fieldValues?.nodes.find((fv) => fv.field?.name === 'Status'); + + if (statusOptionId !== undefined && statusField?.optionId !== statusOptionId) { + continue; + } + + result.push({ + // Use the content node ID (Issue/PR) as the identity, matching the + // `content_node_id` convention used by the trigger/router/lock/ack — + // so the pipeline-capacity gate's `excludeWorkItemId` filter matches. + id: content.id, + title: content.title, + description: content.body ?? '', + url: content.url, + status: statusField?.name, + statusId: statusField?.optionId, + labels: [], + }); + } + return result; + } + + async moveWorkItem(id: string, destination: ContainerId): Promise { + const statusId = this.config.statuses?.[destination] ?? destination; + // Status writes target the ProjectV2Item node, but the work-item ID carried + // across the github-projects path (webhooks, lifecycle, materializer) is the + // *content* (Issue/PR) node ID. Resolve the item ID for the configured project + // first; a value already shaped like a ProjectV2Item ID (PVTI_…) is used directly. + const itemId = String(id).startsWith('PVTI_') + ? id + : await resolveProjectItemId(id, this.config.projectId); + if (!itemId) { + throw new Error( + `GitHub Projects item not found for content ${id} in project ${this.config.projectId}`, + ); + } + await moveProjectItemToStatus(this.config.projectId, itemId, statusId); + } + + async addLabel(id: string, labelIdOrName: LabelId): Promise { + const labelId = await this.resolveLabelNodeId(id, labelIdOrName); + if (!labelId) return; + await addLabelsToContent(id, [labelId]); + } + + async removeLabel(id: string, labelIdOrName: LabelId): Promise { + const labelId = await this.resolveLabelNodeId(id, labelIdOrName); + if (!labelId) return; + await removeLabelsFromContent(id, [labelId]); + } + + /** + * Resolve a configured label value to a repo-scoped label node ID for the + * Issue/PR behind `contentId`. Config values are treated as label *names* + * (the natural, discovery-free choice for GitHub, matching JIRA); a value + * that is already a GitHub label node ID (`LA_…`) is used directly. Returns + * `null` (and warns) when the content's repository has no such label — the + * label operation is then skipped rather than throwing. + */ + private async resolveLabelNodeId( + contentId: string, + labelIdOrName: LabelId, + ): Promise { + const value = String(labelIdOrName); + if (!value) return null; + // GitHub label node IDs are opaque and prefixed `LA_`; use them directly. + if (value.startsWith('LA_')) return value; + + const labelId = await resolveContentRepoLabelId(contentId, value); + if (!labelId) { + logger.warn('[GitHubProjects] label not found in the content repository; skipping', { + contentId, + label: value, + }); + return null; + } + return labelId; + } + + // --------------------------------------------------------------------------- + // Checklists — inline markdown task lists in the Issue/PR body. + // + // GitHub Projects v2 has no native checklist primitive, but GitHub renders + // markdown task lists (`- [ ]` / `- [x]`) natively, so we reuse the same + // shared inline-checklist engine Linear and JIRA use — `### {name}` heading + + // checkbox rows in the content body (spec 008). `workItemId` here is the + // content (Issue/PR) node ID used across the github-projects path. + // --------------------------------------------------------------------------- + + async getChecklists(workItemId: string): Promise { + const content = await getContentNode(workItemId); + const parsed = parseInlineChecklists(content.body ?? ''); + return parsed.map((c) => ({ + id: buildChecklistId(workItemId, c.name), + name: c.name, + workItemId, + items: c.items.map((i) => ({ id: i.id, name: i.name, complete: i.complete })), + })); + } + + async createChecklist(workItemId: string, name: string): Promise { + await this.updateDescription(workItemId, (desc) => upsertChecklistSection(desc, name, [])); + return { + id: buildChecklistId(workItemId, name), + name, + workItemId, + items: [], + }; + } + + async createChecklistWithItems( + workItemId: string, + name: string, + items: ChecklistItemDraft[], + ): Promise { + await this.updateDescription(workItemId, (desc) => + upsertChecklistSection( + desc, + name, + items.map((item) => ({ name: item.name, checked: item.checked ?? false })), + ), + ); + return { + id: buildChecklistId(workItemId, name), + name, + workItemId, + items: items.map((item) => ({ + id: hashChecklistItemId(name, item.name), + name: item.name, + complete: item.checked ?? false, + })), + }; + } + + async addChecklistItem( + checklistId: string, + name: string, + checked = false, + _description?: string, + ): Promise { + const parsed = parseChecklistId(checklistId); + if (!parsed) { + throw new Error(`Invalid GitHub Projects checklist ID: ${checklistId}`); + } + await this.updateDescription(parsed.workItemId, (desc) => { + const checklistName = findChecklistNameByHash(desc, parsed.nameHash); + if (!checklistName) { + throw new Error(`Checklist not found in description: ${checklistId}`); + } + return upsertItemInChecklist(desc, checklistName, name, checked); + }); + } + + async updateChecklistItem( + workItemId: string, + checkItemId: string, + complete: boolean, + ): Promise { + await this.updateDescription(workItemId, (desc) => { + const checklists = parseInlineChecklists(desc); + return toggleChecklistItem(desc, checkItemId, complete, checklists); + }); + } + + async deleteChecklistItem(workItemId: string, checkItemId: string): Promise { + await this.updateDescription(workItemId, (desc) => { + const checklists = parseInlineChecklists(desc); + return removeChecklistItem(desc, checkItemId, checklists); + }); + } + + /** + * Serialize a read-modify-write of the content (Issue/PR) body under the + * shared description-mutation lock, so concurrent `cascade-tools pm + * update-checklist-item` processes can't clobber each other's body snapshot. + * GitHub body is plain markdown (no ADF round-trip) and `updateIssue`/ + * `updatePullRequest` are strongly consistent, so no sidecar/recent-cache + * dance is needed (unlike Linear). + */ + private async updateDescription( + contentId: string, + mutate: (desc: string) => string, + ): Promise { + await withDescriptionMutationLock('github-projects', contentId, async () => { + const content = await getContentNode(contentId); + const isPR = content.type === 'pull_request'; + const markdown = content.body ?? ''; + const newMarkdown = mutate(markdown); + if (newMarkdown === markdown) return; + await this.updateContentBody(content.id, newMarkdown, isPR); + }); + } + + async getAttachments(_workItemId: string): Promise { + return []; + } + + async addAttachment(_workItemId: string, _url: string, _name: string): Promise { + logger.warn('[GitHubProjects] addAttachment not implemented'); + } + + async addAttachmentFile( + _workItemId: string, + _buffer: Buffer, + _name: string, + _mimeType: string, + ): Promise { + logger.warn('[GitHubProjects] addAttachmentFile not implemented'); + } + + async getCustomFieldNumber(_workItemId: string, _fieldId: string): Promise { + return 0; + } + + async updateCustomFieldNumber( + _workItemId: string, + fieldId: string, + _value: number, + ): Promise { + logger.warn('[GitHubProjects] updateCustomFieldNumber not implemented', { fieldId }); + } + + async linkPR(_workItemId: string, _prUrl: string, _prTitle: string): Promise { + // PR linking is implicit when a PR is added to a GitHub Project. + logger.debug('[GitHubProjects] linkPR is a no-op; PRs are linked by being in the project'); + } + + getWorkItemUrl(id: string): string { + return `https://github.com/${this.config.owner}/projects/${this.config.projectId}?pane=issue&item_id=${id}`; + } + + async getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }> { + const { getViewer } = await import('../../github-projects/client.js'); + const me = await getViewer(); + return { + id: me.id, + name: me.name ?? me.login, + username: me.login, + }; + } +} diff --git a/src/pm/github-projects/integration.ts b/src/pm/github-projects/integration.ts new file mode 100644 index 000000000..196f07901 --- /dev/null +++ b/src/pm/github-projects/integration.ts @@ -0,0 +1,175 @@ +/** + * GitHubProjectsIntegration — implements PMIntegration for GitHub Projects v2. + */ + +import { getCredentialRoles, registerCredentialRoles } from '../../config/integrationRoles.js'; +import { + getIntegrationCredential, + getIntegrationCredentialOrNull, + loadProjectConfigByGitHubProjectsProjectId, +} from '../../config/provider.js'; +import { getIntegrationProvider } from '../../db/repositories/credentialsRepository.js'; +import { addCommentToIssue, withGitHubProjectsCredentials } from '../../github-projects/client.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { getGitHubProjectsConfig } from '../config.js'; +import type { PMIntegration, PMWebhookEvent } from '../integration.js'; +import type { ProjectPMConfig } from '../lifecycle.js'; +import type { PMProvider } from '../types.js'; +import { GitHubProjectsPMProvider } from './adapter.js'; + +// Self-register credential roles at module load time. +registerCredentialRoles('github-projects', 'pm', [ + { role: 'token', label: 'Personal Access Token', envVarKey: 'GITHUB_TOKEN' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'GITHUB_WEBHOOK_SECRET', + optional: true, + }, +]); + +export class GitHubProjectsIntegration implements PMIntegration { + readonly type = 'github-projects'; + readonly category = 'pm' as const; + + async hasIntegration(projectId: string): Promise { + const provider = await getIntegrationProvider(projectId, 'pm'); + if (provider !== 'github-projects') return false; + + const roles = getCredentialRoles('github-projects'); + const requiredRoles = roles.filter((r) => !r.optional); + const values = await Promise.all( + requiredRoles.map((roleDef) => + getIntegrationCredentialOrNull(projectId, 'pm', 'github-projects', roleDef.role), + ), + ); + return values.every((v) => v !== null); + } + + createProvider(project: ProjectConfig): PMProvider { + const config = getGitHubProjectsConfig(project); + if (!config?.projectId) { + throw new Error('GitHub Projects integration requires projectId in config'); + } + // Pass the project's SCM repo (owner/repo) so createWorkItem can create the + // backing Issue there — a GitHub Project has no repo of its own in PM config. + return new GitHubProjectsPMProvider(config, project.repo); + } + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const token = await getIntegrationCredential(projectId, 'pm', 'github-projects', 'token'); + return withGitHubProjectsCredentials({ token }, fn); + } + + resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { + const config = getGitHubProjectsConfig(project); + const labels = config?.labels; + return { + labels: { + processing: labels?.processing, + processed: undefined, + error: undefined, + readyToProcess: labels?.readyToProcess, + auto: undefined, + }, + statuses: { ...(config?.statuses ?? {}) }, + }; + } + + parseWebhookPayload(raw: unknown): PMWebhookEvent | null { + if (!raw || typeof raw !== 'object') return null; + const p = raw as Record; + + // GitHub Projects v2 webhooks: action + projects_v2_item + const action = p.action as string | undefined; + const projectsV2Item = p.projects_v2_item as Record | undefined; + if (typeof action !== 'string' || !projectsV2Item) return null; + + const projectNodeId = projectsV2Item.project_node_id as string | undefined; + const contentNodeId = projectsV2Item.content_node_id as string | undefined; + if (!projectNodeId) return null; + + return { + eventType: `projects_v2_item.${action}`, + projectIdentifier: projectNodeId, + workItemId: contentNodeId, + raw, + }; + } + + async isSelfAuthored(event: PMWebhookEvent, projectId: string): Promise { + // Only comment events can be self-authored; GitHub Projects item events are not. + if (!event.eventType.startsWith('projects_v2_item.')) return false; + + const p = event.raw as Record; + const sender = p.sender as Record | undefined; + const senderLogin = sender?.login as string | undefined; + if (!senderLogin) return false; + + try { + const token = await getIntegrationCredential(projectId, 'pm', 'github-projects', 'token'); + const { getViewer } = await import('../../github-projects/client.js'); + const me = await withGitHubProjectsCredentials({ token }, () => getViewer()); + return me.login === senderLogin; + } catch { + return false; + } + } + + async postAckComment( + projectId: string, + workItemId: string, + message: string, + ): Promise { + try { + const token = await getIntegrationCredential(projectId, 'pm', 'github-projects', 'token'); + return await withGitHubProjectsCredentials({ token }, () => + addCommentToIssue(workItemId, message), + ); + } catch (err) { + const { logger } = await import('../../utils/logging.js'); + logger.warn('[GitHubProjects] Failed to post ack comment', { + projectId, + workItemId, + error: String(err), + }); + return null; + } + } + + async deleteAckComment(projectId: string, _workItemId: string, commentId: string): Promise { + try { + const token = await getIntegrationCredential(projectId, 'pm', 'github-projects', 'token'); + await withGitHubProjectsCredentials({ token }, async () => { + const { deleteComment } = await import('../../github-projects/client.js'); + await deleteComment(commentId); + }); + } catch (err) { + const { logger } = await import('../../utils/logging.js'); + logger.warn('[GitHubProjects] Failed to delete ack comment', { + projectId, + commentId, + error: String(err), + }); + } + } + + async sendReaction(_projectId: string, _event: PMWebhookEvent): Promise { + // GitHub Projects item webhooks do not support reactions; no-op. + } + + async lookupProject( + identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null> { + return (await loadProjectConfigByGitHubProjectsProjectId(identifier)) ?? null; + } + + extractWorkItemId(text: string): string | null { + // GitHub issue/PR URLs: https://github.com/owner/repo/issues/123 or /pull/123 + const issueMatch = text.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/); + if (issueMatch) return issueMatch[1]; + const prMatch = text.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); + if (prMatch) return prMatch[1]; + return null; + } +} diff --git a/src/pm/types.ts b/src/pm/types.ts index bc3a02de0..c221fa76e 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -5,7 +5,7 @@ import type { ContainerId, LabelId, StateId } from './ids.js'; -export type PMType = 'trello' | 'jira' | 'linear'; +export type PMType = 'trello' | 'jira' | 'linear' | 'github-projects'; // ── Discovery capability type machinery ─────────────────────────────────── // Plan 009/1 introduces an optional `discover?` method on PMProvider that diff --git a/src/router/ackMessageGenerator.ts b/src/router/ackMessageGenerator.ts index 0e7fd5a49..80d0ec386 100644 --- a/src/router/ackMessageGenerator.ts +++ b/src/router/ackMessageGenerator.ts @@ -186,6 +186,37 @@ export function extractLinearContext(payload: unknown): string { return truncate(parts.join('\n')); } +/** + * Extract context from a GitHub Projects v2 webhook payload. + * Pulls the item content type and, when available, the new status name. + */ +export function extractGitHubProjectsContext(payload: unknown): string { + if (!payload || typeof payload !== 'object') return ''; + + const p = payload as Record; + const parts: string[] = []; + + const item = p.projects_v2_item as Record | undefined; + if (item) { + const contentType = item.content_type as string | undefined; + if (contentType) { + parts.push(`Item: ${contentType}`); + } + } + + const changes = p.changes as Record | undefined; + const fieldValue = changes?.field_value as Record | undefined; + const to = fieldValue?.to as Record | undefined; + if (fieldValue?.field_name) { + parts.push(`Field: ${fieldValue.field_name as string}`); + } + if (to?.name) { + parts.push(`New value: ${to.name as string}`); + } + + return truncate(parts.join('\n')); +} + // --------------------------------------------------------------------------- // Core generator // --------------------------------------------------------------------------- diff --git a/src/router/adapters/github-projects.ts b/src/router/adapters/github-projects.ts new file mode 100644 index 000000000..8a3166a7e --- /dev/null +++ b/src/router/adapters/github-projects.ts @@ -0,0 +1,294 @@ +/** + * GitHubProjectsRouterAdapter — platform-specific logic for the router-side + * GitHub Projects webhook processing pipeline. + */ + +import { isPmPostingEnabled, resolveUpdateChannel } from '../../config/updateChannel.js'; +import { getViewer, withGitHubProjectsCredentials } from '../../github-projects/client.js'; +import type { TriggerRegistry } from '../../triggers/registry.js'; +import type { TriggerContext, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { buildWorkItemRunsLink, getDashboardUrl } from '../../utils/runLink.js'; +import { extractGitHubProjectsContext, generateAckMessage } from '../ackMessageGenerator.js'; +import { loadProjectConfig, type RouterProjectConfig } from '../config.js'; +import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; +import { resolveGitHubProjectsCredentials } from '../platformClients/index.js'; +import type { CascadeJob, GitHubProjectsJob } from '../queue.js'; +import { withPMScopeForDispatch } from './_shared.js'; + +// ============================================================================ +// Webhook payload types +// ============================================================================ + +interface GitHubProjectsWebhookPayload { + action: string; + projects_v2_item: { + id: number; + node_id: string; + project_node_id: string; + content_node_id: string; + content_type: 'Issue' | 'PullRequest'; + }; + changes?: { + field_value?: { + field_node_id: string; + field_type?: string; + // GitHub does not reliably send field_name / from / to on + // projects_v2_item.edited — treat them as optional hints. + field_name?: string; + from?: { id: string; name: string } | null; + to?: { id: string; name: string } | null; + }; + }; + sender?: { + login: string; + }; +} + +interface GitHubProjectsParsedEvent extends ParsedWebhookEvent { + projectId: string; + action: string; + contentType: 'issue' | 'pull_request'; + statusChange?: { + from: string | null; + to: string | null; + fieldId: string; + fieldName?: string; + }; +} + +// ============================================================================ +// Adapter +// ============================================================================ + +export class GitHubProjectsRouterAdapter implements RouterPlatformAdapter { + readonly type = 'github-projects' as const; + + /** + * Resolve credentials for a project. + * Logs a warning and returns null if credentials are missing. + */ + private async resolveCredentials( + projectId: string, + context: string, + ): Promise<{ token: string } | null> { + const creds = await resolveGitHubProjectsCredentials(projectId); + if (!creds) { + logger.warn(`GitHubProjectsRouterAdapter: missing credentials for ${context}`, { projectId }); + return null; + } + return creds; + } + + /** + * Resolve the current authenticated viewer for a project. + * Returns null if credentials are missing or the API call fails. + */ + private async resolveViewer(projectId: string): Promise<{ login: string } | null> { + try { + const creds = await this.resolveCredentials(projectId, 'viewer resolution'); + if (!creds) return null; + return await withGitHubProjectsCredentials({ token: creds.token }, () => getViewer()); + } catch { + return null; + } + } + + async parseWebhook(payload: unknown): Promise { + const p = payload as GitHubProjectsWebhookPayload; + + if (!p.action || !p.projects_v2_item) { + logger.debug('GitHubProjectsRouterAdapter: missing required fields', { payload }); + return null; + } + + const item = p.projects_v2_item; + if (!item.project_node_id || !item.content_node_id) { + logger.debug('GitHubProjectsRouterAdapter: missing project or content node id', { payload }); + return null; + } + + const changes = p.changes?.field_value; + + // Only process field-value edits. Creation events do not carry a status + // change in the same payload, so they are not processable here. + // + // GitHub's `projects_v2_item.edited` webhook does not reliably include the + // changed field's name, so we cannot require `field_name === 'Status'` at + // parse time. We forward any field-value edit and let the trigger confirm + // the Status field authoritatively (via a live GraphQL read). When the + // field name IS present and is not Status, skip early as an optimization. + if (p.action !== 'edited' || !changes) { + logger.debug('GitHubProjectsRouterAdapter: ignoring non-processable action/field', { + action: p.action, + hasFieldChange: !!changes, + }); + return null; + } + if (changes.field_name && changes.field_name !== 'Status') { + logger.debug('GitHubProjectsRouterAdapter: ignoring non-Status field edit', { + fieldName: changes.field_name, + }); + return null; + } + + return { + projectIdentifier: item.project_node_id, + eventType: `projects_v2_item/${p.action}`, + workItemId: item.content_node_id, + isCommentEvent: false, + actionId: item.node_id, + projectId: item.project_node_id, + action: p.action, + contentType: item.content_type === 'PullRequest' ? 'pull_request' : 'issue', + statusChange: changes + ? { + from: changes.from?.name ?? null, + to: changes.to?.name ?? null, + fieldId: changes.field_node_id, + fieldName: changes.field_name, + } + : undefined, + }; + } + + isProcessableEvent(event: ParsedWebhookEvent): boolean { + const e = event as GitHubProjectsParsedEvent; + return e.eventType.startsWith('projects_v2_item/'); + } + + async isSelfAuthored(event: ParsedWebhookEvent, payload: unknown): Promise { + const e = event as GitHubProjectsParsedEvent; + if (!e.projectId) return false; + + const sender = (payload as GitHubProjectsWebhookPayload).sender; + const senderLogin = sender?.login; + if (!senderLogin) return false; + + // Credential/viewer resolution keys on the CASCADE project id, not the + // GitHub Projects node id carried on the event (`PVT_…`). Resolve the + // project by its GitHub node id first and pass the CASCADE id through, + // mirroring dispatchWithCredentials / postAck. Passing the node id here + // makes resolveViewer always return null, silently disabling + // loop-prevention. + const project = await this.resolveProject(event); + if (!project) return false; + + const me = await this.resolveViewer(project.id); + return me?.login === senderLogin; + } + + sendReaction(_event: ParsedWebhookEvent, _payload: unknown): void { + // No reaction support for GitHub Projects item webhooks. + } + + async resolveProject(event: ParsedWebhookEvent): Promise { + const config = await loadProjectConfig(); + return ( + config.projects.find((p) => p.githubProjects?.projectId === event.projectIdentifier) ?? null + ); + } + + async dispatchWithCredentials( + _event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + triggerRegistry: TriggerRegistry, + ): Promise { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (!fullProject) { + logger.info('GitHubProjectsRouterAdapter: no full project config found', { + projectId: project.id, + }); + return null; + } + + const creds = await this.resolveCredentials(project.id, 'trigger dispatch'); + if (!creds) return null; + + const ctx: TriggerContext = { project: fullProject, source: 'github-projects', payload }; + return withGitHubProjectsCredentials({ token: creds.token }, () => + withPMScopeForDispatch(fullProject, () => triggerRegistry.dispatch(ctx)), + ); + } + + async postAck( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + agentType: string, + _triggerResult?: TriggerResult, + ): Promise { + const issueId = event.workItemId; + if (!issueId) return undefined; + + try { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + + if (fullProject && !isPmPostingEnabled(resolveUpdateChannel(fullProject, agentType))) { + logger.info('GitHubProjectsRouterAdapter: ack skipped, PM posting disabled for channel', { + projectId: project.id, + agentType, + issueId, + }); + return undefined; + } + + const context = extractGitHubProjectsContext(payload); + let message = await generateAckMessage(agentType, context, project.id); + + if (fullProject?.runLinksEnabled && event.workItemId) { + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + const link = buildWorkItemRunsLink({ + dashboardUrl, + projectId: project.id, + workItemId: event.workItemId, + }); + if (link) message += link; + } + } + + const creds = await this.resolveCredentials(project.id, 'ack comment'); + if (!creds) return undefined; + + const { addCommentToIssue, withGitHubProjectsCredentials } = await import( + '../../github-projects/client.js' + ); + const commentId = await withGitHubProjectsCredentials({ token: creds.token }, () => + addCommentToIssue(issueId, message), + ); + return { commentId, message }; + } catch (err) { + logger.warn('GitHubProjectsRouterAdapter: ack comment failed (non-fatal)', { + error: String(err), + issueId, + }); + return undefined; + } + } + + buildJob( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + result: TriggerResult, + ackResult?: AckResult, + ): CascadeJob { + const e = event as GitHubProjectsParsedEvent; + const job: GitHubProjectsJob = { + type: 'github-projects', + source: 'github-projects', + payload, + projectId: project.id, + workItemId: e.workItemId, + eventType: e.eventType, + receivedAt: new Date().toISOString(), + triggerResult: result, + ackCommentId: ackResult?.commentId as string | undefined, + }; + return job; + } +} diff --git a/src/router/adapters/sentry.ts b/src/router/adapters/sentry.ts index 36dbe0a19..cf6bab23f 100644 --- a/src/router/adapters/sentry.ts +++ b/src/router/adapters/sentry.ts @@ -7,6 +7,7 @@ * augmented payload by the router before the adapter processes it. */ +import { withGitHubProjectsCredentials } from '../../github-projects/client.js'; import { withJiraCredentials } from '../../jira/client.js'; import { withLinearCredentials } from '../../linear/client.js'; import { getSentryIntegrationConfig } from '../../sentry/integration.js'; @@ -23,6 +24,7 @@ import { logger } from '../../utils/logging.js'; import { loadProjectConfig, type RouterProjectConfig } from '../config.js'; import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; import { + resolveGitHubProjectsCredentials, resolveJiraCredentials, resolveLinearCredentials, resolveTrelloCredentials, @@ -181,6 +183,18 @@ export class SentryRouterAdapter implements RouterPlatformAdapter { return withLinearCredentials({ apiKey: creds.apiKey }, dispatch); } + if (pmType === 'github-projects') { + const creds = await resolveGitHubProjectsCredentials(fullProject.id); + if (!creds) { + logger.warn( + 'SentryRouterAdapter: missing GitHub Projects credentials, cannot dispatch triggers', + { projectId: fullProject.id }, + ); + return null; + } + return withGitHubProjectsCredentials({ token: creds.token }, dispatch); + } + // No PM integration configured — dispatch without PM credential scope. // The trigger handler will catch AlertSlotMissingError and return null // before any PM write is attempted. diff --git a/src/router/config.ts b/src/router/config.ts index 2e654cbf8..a6311fb01 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -1,12 +1,17 @@ import { loadConfig } from '../config/provider.js'; -import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../pm/config.js'; +import { + getGitHubProjectsConfig, + getJiraConfig, + getLinearConfig, + getTrelloConfig, +} from '../pm/config.js'; import type { CascadeConfig, ProjectConfig } from '../types/index.js'; // Minimal config types - what router needs for quick filtering export interface RouterProjectConfig { id: string; repo?: string; // owner/repo format (optional for projects without SCM integration) - pmType?: 'trello' | 'jira' | 'linear'; // undefined for SCM-only projects (no PM provider) + pmType?: 'trello' | 'jira' | 'linear' | 'github-projects'; // undefined for SCM-only projects (no PM provider) trello?: { boardId: string; lists: Record; @@ -20,6 +25,11 @@ export interface RouterProjectConfig { teamId: string; projectId?: string; }; + githubProjects?: { + projectId: string; + owner: string; + ownerType: 'user' | 'organization'; + }; } export interface RouterConfig { @@ -104,6 +114,7 @@ export async function loadProjectConfig(): Promise<{ const trelloConfig = getTrelloConfig(p); const jiraConfig = getJiraConfig(p); const linearConfig = getLinearConfig(p); + const githubProjectsConfig = getGitHubProjectsConfig(p); return { id: p.id, repo: p.repo, @@ -127,6 +138,13 @@ export async function loadProjectConfig(): Promise<{ ...(linearConfig.projectId ? { projectId: linearConfig.projectId } : {}), }, }), + ...(githubProjectsConfig && { + githubProjects: { + projectId: githubProjectsConfig.projectId, + owner: githubProjectsConfig.owner, + ownerType: githubProjectsConfig.ownerType, + }, + }), }; }), fullProjects: config.projects, diff --git a/src/router/index.ts b/src/router/index.ts index 4f05f05e7..c99518c71 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -18,12 +18,14 @@ import { logger } from '../utils/logging.js'; import { createWebhookHandler, parseGitHubPayload, + parseGitHubProjectsPayload, parseJiraPayload, parseLinearPayload, parseSentryPayload, parseTrelloPayload, } from '../webhook/webhookHandlers.js'; import { GitHubRouterAdapter, injectEventType } from './adapters/github.js'; +import { GitHubProjectsRouterAdapter } from './adapters/github-projects.js'; import { JiraRouterAdapter } from './adapters/jira.js'; import { LinearRouterAdapter } from './adapters/linear.js'; import { SentryRouterAdapter } from './adapters/sentry.js'; @@ -33,6 +35,7 @@ import { ROUTER_INSTANCE_ID } from './instance-id.js'; import { getQueueStats } from './queue.js'; import { processRouterWebhook } from './webhook-processor.js'; import { + verifyGitHubProjectsWebhookSignature, verifyGitHubWebhookSignature, verifyJiraWebhookSignature, verifyLinearWebhookSignature, @@ -199,6 +202,30 @@ app.post( }), ); +// GitHub Projects webhook verification +app.get('/github-projects/webhook', (c) => { + return c.text('OK', 200); +}); + +// GitHub Projects webhook handler +app.post( + '/github-projects/webhook', + createWebhookHandler({ + source: 'github-projects', + parsePayload: parseGitHubProjectsPayload, + verifySignature: verifyGitHubProjectsWebhookSignature, + processWebhook: async (payload) => { + const adapter = new GitHubProjectsRouterAdapter(); + const result = await processRouterWebhook(adapter, payload, triggerRegistry); + return { + processed: result.shouldProcess, + projectId: result.projectId, + decisionReason: result.decisionReason, + }; + }, + }), +); + // Graceful shutdown async function shutdown(signal: string): Promise { logger.info('Received shutdown signal', { signal }); diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index b511d14c9..cd005d4f1 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -70,6 +70,21 @@ export async function resolveLinearCredentials( } } +/** + * Resolve GitHub Projects credentials for a project. + * Returns `{ token }` or `null` if credentials are missing. + */ +export async function resolveGitHubProjectsCredentials( + projectId: string, +): Promise<{ token: string } | null> { + try { + const token = await getIntegrationCredential(projectId, 'pm', 'github-projects', 'token'); + return { token }; + } catch { + return null; + } +} + /** * Resolve the webhook secret for a given provider and project. * @@ -79,12 +94,13 @@ export async function resolveLinearCredentials( * API Key at https://trello.com/power-ups/admin/), not the public API Key. * - `'jira'`: resolves the `webhook_secret` credential from the PM integration. * - `'linear'`: resolves the `webhook_secret` credential from the PM integration. + * - `'github-projects'`: resolves the `webhook_secret` credential from the PM integration. * * Returns `null` if the credential is not configured. */ export async function resolveWebhookSecret( projectId: string, - provider: 'github' | 'trello' | 'jira' | 'sentry' | 'linear', + provider: 'github' | 'trello' | 'jira' | 'sentry' | 'linear' | 'github-projects', ): Promise { if (provider === 'github') { return getIntegrationCredentialOrNull(projectId, 'scm', 'github', 'webhook_secret'); @@ -98,6 +114,9 @@ export async function resolveWebhookSecret( if (provider === 'linear') { return getIntegrationCredentialOrNull(projectId, 'pm', 'linear', 'webhook_secret'); } + if (provider === 'github-projects') { + return getIntegrationCredentialOrNull(projectId, 'pm', 'github-projects', 'webhook_secret'); + } // Trello signs webhook payloads with the API Secret, not the public API Key. return getIntegrationCredentialOrNull(projectId, 'pm', 'trello', 'api_secret'); } diff --git a/src/router/platformClients/github-projects.ts b/src/router/platformClients/github-projects.ts new file mode 100644 index 000000000..aa7d6fc96 --- /dev/null +++ b/src/router/platformClients/github-projects.ts @@ -0,0 +1,65 @@ +/** + * GitHub Projects platform client for posting/deleting comments on issues/PRs + * via the GitHub GraphQL API. + */ + +import { + addCommentToIssue, + deleteComment, + updateComment, + withGitHubProjectsCredentials, +} from '../../github-projects/client.js'; +import { logger } from '../../utils/logging.js'; +import { resolveGitHubProjectsCredentials } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +export class GitHubProjectsPlatformClient implements PlatformCommentClient { + constructor(private readonly projectId: string) {} + + async postComment(workItemId: string, message: string): Promise { + const creds = await resolveGitHubProjectsCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing GitHub Projects credentials, skipping comment'); + return null; + } + + try { + const commentId = await withGitHubProjectsCredentials({ token: creds.token }, () => + addCommentToIssue(workItemId, message), + ); + logger.info('[PlatformClient] GitHub Projects comment posted for item:', workItemId); + return commentId; + } catch (err) { + logger.warn('[PlatformClient] Failed to post GitHub Projects comment:', String(err)); + return null; + } + } + + async deleteComment(workItemId: string, commentId: string | number): Promise { + const creds = await resolveGitHubProjectsCredentials(this.projectId); + if (!creds) return; + + try { + await withGitHubProjectsCredentials({ token: creds.token }, () => + deleteComment(String(commentId)), + ); + logger.info('[PlatformClient] GitHub Projects comment deleted:', { workItemId, commentId }); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete GitHub Projects comment:', String(err)); + } + } + + async updateComment(workItemId: string, commentId: string, message: string): Promise { + const creds = await resolveGitHubProjectsCredentials(this.projectId); + if (!creds) return; + + try { + await withGitHubProjectsCredentials({ token: creds.token }, () => + updateComment(commentId, message), + ); + logger.info('[PlatformClient] GitHub Projects comment updated:', { workItemId, commentId }); + } catch (err) { + logger.warn('[PlatformClient] Failed to update GitHub Projects comment:', String(err)); + } + } +} diff --git a/src/router/platformClients/index.ts b/src/router/platformClients/index.ts index 781255316..154bf91ae 100644 --- a/src/router/platformClients/index.ts +++ b/src/router/platformClients/index.ts @@ -10,11 +10,13 @@ export { resolveGitHubHeaders, + resolveGitHubProjectsCredentials, resolveJiraCredentials, resolveLinearCredentials, resolveTrelloCredentials, } from './credentials.js'; export { GitHubPlatformClient } from './github.js'; +export { GitHubProjectsPlatformClient } from './github-projects.js'; export { _resetJiraCloudIdCache, JiraPlatformClient } from './jira.js'; export { LinearPlatformClient } from './linear.js'; export { TrelloPlatformClient } from './trello.js'; diff --git a/src/router/queue.ts b/src/router/queue.ts index 2c4aa28a0..3612cd5e9 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -113,7 +113,33 @@ export interface LinearJob { ackContextHint?: string; } -export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob | LinearJob; +export interface GitHubProjectsJob { + type: 'github-projects'; + source: 'github-projects'; + payload: unknown; + projectId: string; + workItemId?: string; + eventType: string; + receivedAt: string; + ackCommentId?: string; + triggerResult?: TriggerResult; + /** When true, the worker must post the ack comment before processing (deferred ack). */ + pendingAck?: boolean; + /** + * Work-item title stored as a context hint, passed to `generateAckMessage` + * at deferred-ack fire time. NOT the literal comment text — the worker + * generates the actual ack message via the role-aware LLM path. + */ + ackContextHint?: string; +} + +export type CascadeJob = + | TrelloJob + | GitHubJob + | JiraJob + | SentryJob + | LinearJob + | GitHubProjectsJob; // Create the job queue export const jobQueue = new Queue('cascade-jobs', { diff --git a/src/router/webhook-trigger-outcomes.ts b/src/router/webhook-trigger-outcomes.ts index 53f9cfe0c..ca76c2928 100644 --- a/src/router/webhook-trigger-outcomes.ts +++ b/src/router/webhook-trigger-outcomes.ts @@ -184,7 +184,12 @@ async function maybeHandleCoalescedDispatch({ if (windowMs <= 0) return null; const job = adapter.buildJob(event, payload, project, result, undefined); - if (job.type === 'trello' || job.type === 'jira' || job.type === 'linear') { + if ( + job.type === 'trello' || + job.type === 'jira' || + job.type === 'linear' || + job.type === 'github-projects' + ) { job.pendingAck = true; job.ackContextHint = result.workItemTitle ?? undefined; } diff --git a/src/router/webhookVerification.ts b/src/router/webhookVerification.ts index 5ad268c74..322d6d17c 100644 --- a/src/router/webhookVerification.ts +++ b/src/router/webhookVerification.ts @@ -18,7 +18,7 @@ import { loadProjectConfig, routerConfig } from './config.js'; import { resolveWebhookSecret } from './platformClients/credentials.js'; /** The set of platforms that have a webhook secret in {@link resolveWebhookSecret}. */ -type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry' | 'linear'; +type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry' | 'linear' | 'github-projects'; // --------------------------------------------------------------------------- // Helpers @@ -302,3 +302,36 @@ export const verifyLinearWebhookSignature = createWebhookVerifier({ | undefined, verify: (rawBody, sig, secret) => verifyLinearSignature(rawBody, sig, secret), }); + +/** + * Extract the GitHub Projects project node ID from a raw webhook payload. + * GitHub sends it at `projects_v2_item.project_node_id`. + */ +export function extractGitHubProjectsProjectId(rawBody: string): string | undefined { + try { + const parsed = JSON.parse(rawBody) as Record; + const item = parsed?.projects_v2_item as Record | undefined; + return item?.project_node_id as string | undefined; + } catch { + return undefined; + } +} + +/** + * verifySignature callback for the GitHub Projects webhook handler. + * Returns null to skip verification when no secret is configured (backwards compat). + * + * GitHub signs the payload with HMAC-SHA256 and sends it as + * `sha256=` in the `X-Hub-Signature-256` header, identical to GitHub SCM webhooks. + */ +export const verifyGitHubProjectsWebhookSignature = createWebhookVerifier({ + headerName: 'X-Hub-Signature-256', + platform: 'github-projects', + platformLabel: 'GitHub Projects', + extractIdentifier: (_c, rawBody) => extractGitHubProjectsProjectId(rawBody), + findProject: (projectId, projects) => + projects.find( + (p) => (p.githubProjects as Record | undefined)?.projectId === projectId, + ) as { id: string } | undefined, + verify: (rawBody, sig, secret) => verifyGitHubSignature(rawBody, sig, secret), +}); diff --git a/src/router/worker-env.ts b/src/router/worker-env.ts index 8a847b15e..375ce7b9a 100644 --- a/src/router/worker-env.ts +++ b/src/router/worker-env.ts @@ -201,6 +201,8 @@ export function extractWorkItemId(data: CascadeJob): string | undefined { if (jobData.type === 'jira' && jobData.issueKey) return jobData.issueKey; if (jobData.type === 'github') return jobData.triggerResult?.workItemId; if (jobData.type === 'linear') return jobData.triggerResult?.workItemId ?? jobData.workItemId; + if (jobData.type === 'github-projects') + return jobData.triggerResult?.workItemId ?? jobData.workItemId; // Sentry jobs: lockKey takes priority (set when workItemId is deferred to the worker) if (jobData.type === 'sentry') { return jobData.triggerResult?.lockKey ?? jobData.triggerResult?.workItemId; diff --git a/src/triggers/github-projects/status-changed.ts b/src/triggers/github-projects/status-changed.ts new file mode 100644 index 000000000..7f8a7cbf7 --- /dev/null +++ b/src/triggers/github-projects/status-changed.ts @@ -0,0 +1,176 @@ +/** + * GitHub Projects status-changed trigger. + * + * Fires when a GitHub Projects v2 item's Status field is changed to a + * configured option that maps to a CASCADE agent type. + * + * IMPORTANT — webhook payload shape. GitHub's `projects_v2_item.edited` + * webhook reliably carries only `changes.field_value.field_node_id` and + * `field_type`; it does NOT dependably deliver the changed field's name or its + * new/old values (`field_name` / `from` / `to`). We therefore treat those as + * optional hints and read the item's *current* Status option ID from the + * GraphQL API (authoritative). This trigger runs on the router inside + * `withGitHubProjectsCredentials` scope, so the live read is safe here. + */ + +import { getProjectItem } from '../../github-projects/client.js'; +import { getGitHubProjectsConfig } from '../../pm/config.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { TRIGGER_EVENTS } from '../shared/events.js'; +import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; +import { + buildPMStatusDispatchResult, + resolvePMStatusAgentByIdFromWorkflowDefinitions, + shouldFirePMStatusEvent, +} from '../shared/pm-status.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; + +interface GitHubProjectsWebhookPayload { + action: 'edited' | 'created'; + projects_v2_item: { + node_id: string; + project_node_id: string; + content_node_id: string; + content_type: 'Issue' | 'PullRequest'; + }; + changes?: { + field_value?: { + field_node_id: string; + field_name?: string; + from?: { id: string; name: string } | null; + to?: { id: string; name: string } | null; + }; + }; +} + +export class GitHubProjectsStatusChangedTrigger implements TriggerHandler { + name = 'github-projects-status-changed'; + description = 'Triggers agent when a GitHub Projects item moves to a configured status'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'github-projects') return false; + + const payload = ctx.payload as GitHubProjectsWebhookPayload; + if (!payload.projects_v2_item) return false; + + // Only field-value edits can be status changes. Creation events do not + // carry a status change in the same payload. + if (payload.action !== 'edited') return false; + const fieldValue = payload.changes?.field_value; + if (!fieldValue) return false; + + // Fast-path filter: if GitHub told us the field name, require Status. + // When absent (the common case), defer the Status determination to + // handle(), which confirms it against the live field ID. + if (fieldValue.field_name && fieldValue.field_name !== 'Status') return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as GitHubProjectsWebhookPayload; + const item = payload.projects_v2_item; + const fieldValue = payload.changes?.field_value; + + const config = getGitHubProjectsConfig(ctx.project); + if (!config?.statuses) { + logger.debug('No GitHub Projects status configuration, skipping status-changed trigger', { + projectId: ctx.project.id, + }); + return null; + } + + // Authoritative read: fetch the item's current Status option ID. The + // webhook's `to.id` (when present) may be a value-node ID rather than the + // option ID persisted in config, so we always resolve from the API. + const projectItem = await getProjectItem(item.node_id); + const statusValue = projectItem.fieldValues?.nodes.find((n) => n.field?.name === 'Status'); + if (!statusValue) { + logger.debug('GitHub Projects item has no Status field value, skipping', { + itemId: item.node_id, + }); + return null; + } + + // Confirm the change actually touched the Status field, to avoid + // re-dispatching when an unrelated field (Priority, etc.) changed while + // the item merely sits in a mapped status. + const changedFieldId = fieldValue?.field_node_id; + const fieldNameHint = fieldValue?.field_name; + const isStatusChange = changedFieldId + ? changedFieldId === statusValue.field.id + : fieldNameHint === 'Status'; + if (!isStatusChange) { + logger.debug('GitHub Projects edit did not touch the Status field, skipping', { + itemId: item.node_id, + changedFieldId, + statusFieldId: statusValue.field.id, + }); + return null; + } + + const newStatusId = statusValue.optionId; + if (!newStatusId) { + return null; + } + + const resolved = await resolvePMStatusAgentByIdFromWorkflowDefinitions({ + statusId: newStatusId, + configuredStatuses: config.statuses, + }); + if (!resolved) { + logger.debug('GitHub Projects status transition does not map to any agent', { + itemId: item.node_id, + newStatusId, + configuredStatuses: config.statuses, + }); + return null; + } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; + + const { enabled, parameters } = await checkTriggerEnabledWithParams( + ctx.project.id, + agentType, + TRIGGER_EVENTS.PM.STATUS_CHANGED, + this.name, + ); + if (!enabled) return null; + + if (!shouldFirePMStatusEvent(false, parameters)) { + logger.debug('GitHub Projects status-changed event gated by trigger params', { + itemId: item.node_id, + agentType, + parameters, + }); + return null; + } + + if ( + await shouldBlockForPipelineCapacity({ + project: ctx.project, + agentType, + workItemId: item.content_node_id, + source: 'github-projects', + }) + ) { + return null; + } + + logger.info('GitHub Projects item entered agent-triggering status', { + itemId: item.node_id, + newStatusId, + cascadeStatus: matchedCascadeStatus, + agentType, + }); + + return buildPMStatusDispatchResult({ + projectId: ctx.project.id, + agentType, + workItemId: item.content_node_id, + agentInput: { + githubProjectsItemId: item.node_id, + }, + }); + } +} diff --git a/src/triggers/github-projects/webhook-handler.ts b/src/triggers/github-projects/webhook-handler.ts new file mode 100644 index 000000000..c564bb824 --- /dev/null +++ b/src/triggers/github-projects/webhook-handler.ts @@ -0,0 +1,22 @@ +/** + * GitHub Projects webhook handler. + * + * Thin wrapper around the generic PM webhook processor. + * Resolves the GitHub Projects integration from the registry and delegates. + */ + +import { pmRegistry } from '../../pm/index.js'; +import { processPMWebhook } from '../../pm/webhook-handler.js'; +import type { TriggerResult } from '../../types/index.js'; +import type { TriggerRegistry } from '../registry.js'; + +export async function processGitHubProjectsWebhook( + payload: unknown, + registry: TriggerRegistry, + ackCommentId?: string, + triggerResult?: TriggerResult, + projectId?: string, +): Promise { + const integration = pmRegistry.get('github-projects'); + await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult, projectId); +} diff --git a/src/triggers/shared/backlog-check.ts b/src/triggers/shared/backlog-check.ts index dd4a31784..f77fc59c2 100644 --- a/src/triggers/shared/backlog-check.ts +++ b/src/triggers/shared/backlog-check.ts @@ -11,7 +11,12 @@ * still runs normally. */ -import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../../pm/config.js'; +import { + getGitHubProjectsConfig, + getJiraConfig, + getLinearConfig, + getTrelloConfig, +} from '../../pm/config.js'; import type { PMProvider } from '../../pm/types.js'; import type { ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -95,6 +100,10 @@ function isProviderMisconfigured(project: ProjectConfig, provider: PMProvider): const linear = getLinearConfig(project); return !linear?.teamId || !linear.statuses?.backlog; } + case 'github-projects': { + const githubProjects = getGitHubProjectsConfig(project); + return !githubProjects?.projectId || !githubProjects.statuses?.backlog; + } // SCM-only projects have no PM provider (no backlog). This branch is never // reached on the PM status-changed capacity path, but keeps the switch exhaustive. case 'none': diff --git a/src/webhook/webhookHandlers.ts b/src/webhook/webhookHandlers.ts index 9a8580d05..d3f462826 100644 --- a/src/webhook/webhookHandlers.ts +++ b/src/webhook/webhookHandlers.ts @@ -22,6 +22,7 @@ import { handleProcessingError, logSuccessfulWebhook } from './webhookLogging.js export { parseGitHubPayload, + parseGitHubProjectsPayload, parseJiraPayload, parseLinearPayload, parseSentryPayload, diff --git a/src/webhook/webhookParsers.ts b/src/webhook/webhookParsers.ts index a38da0135..3d30cfa71 100644 --- a/src/webhook/webhookParsers.ts +++ b/src/webhook/webhookParsers.ts @@ -136,3 +136,25 @@ export async function parseLinearPayload(c: Context): Promise { return { ok: false, error: String(err) }; } } + +/** + * Parse a GitHub Projects v2 webhook request (plain JSON). + * Extracts `projects_v2_item.action` as the event type. + */ +export async function parseGitHubProjectsPayload(c: Context): Promise { + try { + const rawBody = await c.req.text(); + const payload = JSON.parse(rawBody); + const p = payload as Record; + const action = p?.action as string | undefined; + const eventType = action ? `projects_v2_item/${action}` : 'unknown'; + logger.info('Received GitHub Projects webhook', { + action, + eventType, + projectId: (p?.projects_v2_item as Record | undefined)?.project_node_id, + }); + return { ok: true, payload, eventType, rawBody }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} diff --git a/src/worker-entry.ts b/src/worker-entry.ts index 830e6921d..a0e57c997 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -24,6 +24,7 @@ import { loadEnvConfigSafe } from './config/env.js'; import { loadConfig } from './config/provider.js'; import { getDb } from './db/client.js'; import { + extractGitHubProjectsContext, extractJiraContext, extractLinearContext, extractTrelloContext, @@ -32,6 +33,7 @@ import { import { buildJobDataRedisKey, readOffloadedJobData } from './router/job-data-offload.js'; import { dispatchPMAck } from './router/pm-ack-dispatch.js'; import { captureException, flush, setTag } from './sentry.js'; +import { processGitHubProjectsWebhook } from './triggers/github-projects/webhook-handler.js'; import { createTriggerRegistry, processGitHubWebhook, @@ -140,6 +142,27 @@ export interface LinearJobData { ackContextHint?: string; } +export interface GitHubProjectsJobData { + type: 'github-projects'; + source: 'github-projects'; + payload: unknown; + projectId: string; + workItemId?: string; + /** GitHub Projects event type: e.g. 'projects_v2_item/edited' */ + eventType: string; + receivedAt: string; + ackCommentId?: string; + triggerResult?: TriggerResult; + /** When true, the worker must post the ack comment before processing (deferred ack). */ + pendingAck?: boolean; + /** + * Work-item title stored as a context hint, passed to `generateAckMessage` + * at deferred-ack fire time. NOT the literal comment text — the worker + * generates the actual ack message via the role-aware LLM path. + */ + ackContextHint?: string; +} + export interface ManualRunJobData { type: 'manual-run'; projectId: string; @@ -183,6 +206,7 @@ export type JobData = | JiraJobData | SentryJobData | LinearJobData + | GitHubProjectsJobData | DashboardJobData; export async function processDashboardJob(jobId: string, jobData: DashboardJobData): Promise { @@ -268,18 +292,22 @@ export async function processDashboardJob(jobId: string, jobData: DashboardJobDa async function postDeferredAck( projectId: string, workItemId: string, - pmType: 'trello' | 'jira' | 'linear', + pmType: 'trello' | 'jira' | 'linear' | 'github-projects', payload: unknown, agentType: string | undefined, contextHint: string | undefined, ): Promise { // Extract context from the raw payload (same source as the non-coalesced postAck path). - let contextSnippet = - pmType === 'jira' - ? extractJiraContext(payload) - : pmType === 'linear' - ? extractLinearContext(payload) - : extractTrelloContext(payload); + let contextSnippet: string; + if (pmType === 'jira') { + contextSnippet = extractJiraContext(payload); + } else if (pmType === 'linear') { + contextSnippet = extractLinearContext(payload); + } else if (pmType === 'github-projects') { + contextSnippet = extractGitHubProjectsContext(payload); + } else { + contextSnippet = extractTrelloContext(payload); + } // Fall back to the stored workItemTitle hint when the extractor yields nothing. if (!contextSnippet && contextHint) { @@ -435,6 +463,38 @@ export async function dispatchJob( ); break; } + case 'github-projects': { + logger.info('[Worker] Processing GitHub Projects job', { + jobId, + projectId: jobData.projectId, + workItemId: jobData.workItemId, + eventType: jobData.eventType, + ackCommentId: jobData.ackCommentId, + pendingAck: jobData.pendingAck, + hasTriggerResult: !!jobData.triggerResult, + }); + // Deferred ack: post the ack comment that was skipped at schedule time. + let githubProjectsAckCommentId = jobData.ackCommentId; + if (jobData.pendingAck && jobData.workItemId) { + githubProjectsAckCommentId = + (await postDeferredAck( + jobData.projectId, + jobData.workItemId, + 'github-projects', + jobData.payload, + jobData.triggerResult?.agentType ?? undefined, + jobData.ackContextHint, + )) ?? githubProjectsAckCommentId; + } + await processGitHubProjectsWebhook( + jobData.payload, + triggerRegistry, + githubProjectsAckCommentId, + jobData.triggerResult, + jobData.projectId, + ); + break; + } case 'manual-run': case 'retry-run': case 'debug-analysis': diff --git a/tests/helpers/githubProjectsLifecycleFixture.ts b/tests/helpers/githubProjectsLifecycleFixture.ts new file mode 100644 index 000000000..495f944ee --- /dev/null +++ b/tests/helpers/githubProjectsLifecycleFixture.ts @@ -0,0 +1,20 @@ +/** + * GitHub Projects lifecycle fixture for the behavioral conformance harness. + * + * Returns an in-memory PMProvider labeled `type: 'github-projects'` (via the + * shared fake) that the harness exercises through `runLifecycleScenario`. + */ + +import type { PMProvider } from '../../src/pm/types.js'; +import { createFakePMProvider } from './fakePMProvider.js'; + +export async function githubProjectsLifecycleFixture(): Promise<{ + provider: PMProvider; + containerId: string; +}> { + const { provider } = createFakePMProvider(); + return { + provider, + containerId: 'fake-container-a', + }; +} diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index 8e768273a..db505721c 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -882,6 +882,7 @@ describe('webhooksRouter', () => { trello: null, github: null, jira: null, + githubProjects: null, linear: null, }); }); diff --git a/tests/unit/backends/secretBuilder.test.ts b/tests/unit/backends/secretBuilder.test.ts index 84b9c26d8..b4d73d77b 100644 --- a/tests/unit/backends/secretBuilder.test.ts +++ b/tests/unit/backends/secretBuilder.test.ts @@ -229,6 +229,35 @@ describe('augmentProjectSecrets', () => { expect(secrets.CASCADE_LINEAR_STATUSES).toBe(JSON.stringify(statuses)); }); + it('injects CASCADE_GITHUB_PROJECTS_* env vars when project is GitHub-Projects-backed', async () => { + const statuses = { todo: 'opt-todo', done: 'opt-done' }; + const project = makeProject({ + pm: { type: 'github-projects' }, + githubProjects: { + projectId: 'PVT_project', + owner: 'octocat', + ownerType: 'user', + statuses, + labels: { processing: 'label-processing' }, + }, + } as Partial); + const secrets = await augmentProjectSecrets(project, 'implementation', {} as AgentInput); + + expect(secrets.CASCADE_GITHUB_PROJECTS_PROJECT_ID).toBe('PVT_project'); + expect(secrets.CASCADE_GITHUB_PROJECTS_OWNER).toBe('octocat'); + expect(secrets.CASCADE_GITHUB_PROJECTS_OWNER_TYPE).toBe('user'); + expect(secrets.CASCADE_GITHUB_PROJECTS_STATUSES).toBe(JSON.stringify(statuses)); + expect(secrets.CASCADE_GITHUB_PROJECTS_LABELS).toBe( + JSON.stringify({ processing: 'label-processing' }), + ); + expect(secrets.CASCADE_PM_TYPE).toBe('github-projects'); + }); + + it('does NOT inject CASCADE_GITHUB_PROJECTS_* for Trello projects', async () => { + const secrets = await augmentProjectSecrets(makeProject(), 'implementation', {} as AgentInput); + expect(secrets).not.toHaveProperty('CASCADE_GITHUB_PROJECTS_PROJECT_ID'); + }); + it('omits CASCADE_LINEAR_PROJECT_ID when linear.projectId is not set', async () => { const project = makeProject({ pm: { type: 'linear' }, diff --git a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts index cdb86e716..e8df55cad 100644 --- a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts +++ b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts @@ -49,6 +49,7 @@ function makeClient(overrides: Record = {}) { trello: { id: 'trello-wh-1', callbackURL: 'http://localhost:3001/webhook/trello' }, github: { id: 123, config: { url: 'http://localhost:3001/webhook/github' } }, jira: null, + githubProjects: null, }), }, delete: { @@ -56,6 +57,7 @@ function makeClient(overrides: Record = {}) { trello: ['trello-wh-1'], github: [123], jira: [], + githubProjects: [], }), }, }, @@ -196,6 +198,7 @@ describe('WebhooksCreate (webhooks create)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + githubProjectsOnly: false, oneTimeTokens: undefined, }); }); @@ -215,6 +218,7 @@ describe('WebhooksCreate (webhooks create)', () => { callbackBaseUrl: 'https://cascade.example.com', trelloOnly: false, githubOnly: false, + githubProjectsOnly: false, oneTimeTokens: undefined, }); }); @@ -234,6 +238,7 @@ describe('WebhooksCreate (webhooks create)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + githubProjectsOnly: false, oneTimeTokens: { github: 'ghp_testtoken123' }, }); }); @@ -303,6 +308,7 @@ describe('WebhooksDelete (webhooks delete)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + githubProjectsOnly: false, oneTimeTokens: undefined, }); }); @@ -322,6 +328,7 @@ describe('WebhooksDelete (webhooks delete)', () => { callbackBaseUrl: 'https://cascade.example.com', trelloOnly: false, githubOnly: false, + githubProjectsOnly: false, oneTimeTokens: undefined, }); }); @@ -341,6 +348,7 @@ describe('WebhooksDelete (webhooks delete)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + githubProjectsOnly: false, oneTimeTokens: { github: 'ghp_testtoken123' }, }); }); diff --git a/tests/unit/integrations/pm-router-adapter-pm-scope.test.ts b/tests/unit/integrations/pm-router-adapter-pm-scope.test.ts index 5c2ca6677..93602b5bf 100644 --- a/tests/unit/integrations/pm-router-adapter-pm-scope.test.ts +++ b/tests/unit/integrations/pm-router-adapter-pm-scope.test.ts @@ -25,7 +25,7 @@ const ROUTER_ADAPTERS_DIR = join(__dirname, '..', '..', '..', 'src', 'router', ' // Adding a new PM router adapter file here is part of the contract: the // guard fails fast on unregistered adapters too if they end up dispatching // without scope. -const PM_ROUTER_ADAPTER_FILES = ['linear.ts', 'trello.ts', 'jira.ts']; +const PM_ROUTER_ADAPTER_FILES = ['linear.ts', 'trello.ts', 'jira.ts', 'github-projects.ts']; const ACCEPTABLE_WRAPPERS = ['withPMScopeForDispatch', 'withPMProvider']; diff --git a/tests/unit/pm/github-projects/adapter.test.ts b/tests/unit/pm/github-projects/adapter.test.ts new file mode 100644 index 000000000..56f16f203 --- /dev/null +++ b/tests/unit/pm/github-projects/adapter.test.ts @@ -0,0 +1,565 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoist mocks before imports. The adapter reaches the client both via static +// imports (getContentNode, listAllProjectItems, getIssueComments, +// addCommentToIssue, moveProjectItemToStatus, updateComment) and via dynamic +// `await import()` (githubGraphQL, getViewer); vi.mock intercepts both. +const { mockClient } = vi.hoisted(() => ({ + mockClient: { + getContentNode: vi.fn(), + listAllProjectItems: vi.fn(), + getIssueComments: vi.fn(), + addCommentToIssue: vi.fn(), + moveProjectItemToStatus: vi.fn(), + updateComment: vi.fn(), + resolveContentRepoLabelId: vi.fn(), + addLabelsToContent: vi.fn(), + removeLabelsFromContent: vi.fn(), + resolveProjectItemId: vi.fn(), + getRepositoryId: vi.fn(), + createRepositoryIssue: vi.fn(), + addContentToProject: vi.fn(), + githubGraphQL: vi.fn(), + getViewer: vi.fn(), + }, +})); + +vi.mock('../../../../src/github-projects/client.js', () => mockClient); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +import { hashChecklistItemId } from '../../../../src/pm/_shared/inline-checklist.js'; +import type { GitHubProjectsConfig } from '../../../../src/pm/config.js'; +import { GitHubProjectsPMProvider } from '../../../../src/pm/github-projects/adapter.js'; + +const config: GitHubProjectsConfig = { + projectId: 'PVT_project', + owner: 'octocat', + ownerType: 'user', + statuses: { todo: 'opt-todo', inProgress: 'opt-inprogress', done: 'opt-done' }, +}; + +/** A content node as returned by `getContentNode` (Issue/PR resolved by content ID). */ +function makeContentNode(overrides: { + contentType?: 'issue' | 'pull_request'; + body?: string; + statusName?: string; + statusOptionId?: string; +}) { + return { + id: overrides.contentType === 'pull_request' ? 'PR_1' : 'I_1', + number: 42, + title: 'A work item', + body: overrides.body ?? 'plain body', + url: 'https://github.com/octocat/repo/issues/42', + state: 'OPEN', + type: overrides.contentType ?? 'issue', + statusName: overrides.statusName ?? 'Todo', + statusOptionId: overrides.statusOptionId ?? 'opt-todo', + }; +} + +function makeProjectItem(overrides: { + contentType?: 'issue' | 'pull_request'; + body?: string; + statusName?: string; + statusOptionId?: string; + noContent?: boolean; +}) { + return { + id: 'PVTI_item', + project: { id: 'PVT_project', number: 1 }, + content: overrides.noContent + ? undefined + : { + id: overrides.contentType === 'pull_request' ? 'PR_1' : 'I_1', + number: 42, + title: 'A work item', + body: overrides.body ?? 'plain body', + url: 'https://github.com/octocat/repo/issues/42', + state: 'OPEN', + type: overrides.contentType ?? 'issue', + }, + fieldValues: { + nodes: [ + { + id: 'value-node-id', + name: overrides.statusName ?? 'Todo', + optionId: overrides.statusOptionId ?? 'opt-todo', + field: { id: 'PVTSSF_status', name: 'Status' }, + }, + ], + }, + }; +} + +describe('GitHubProjectsPMProvider', () => { + let provider: GitHubProjectsPMProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new GitHubProjectsPMProvider(config); + }); + + it('has type "github-projects"', () => { + expect(provider.type).toBe('github-projects'); + }); + + describe('getWorkItem', () => { + it('resolves the content node by content ID and uses the Status option ID as statusId', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ statusName: 'Todo', statusOptionId: 'opt-todo' }), + ); + + const item = await provider.getWorkItem('I_1'); + + // Regression: the content node ID must be queried against the configured + // project (not treated as a ProjectV2Item node ID). + expect(mockClient.getContentNode).toHaveBeenCalledWith('I_1', 'PVT_project'); + expect(item.id).toBe('I_1'); + expect(item.title).toBe('A work item'); + expect(item.status).toBe('Todo'); + expect(item.statusId).toBe('opt-todo'); + }); + + it('extracts inline markdown images into inlineMedia', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ + body: 'Here is a screenshot ![shot](https://user-images.githubusercontent.com/a.png)', + }), + ); + + const item = await provider.getWorkItem('I_1'); + + expect(item.inlineMedia).toBeDefined(); + expect(item.inlineMedia).toHaveLength(1); + expect(item.inlineMedia?.[0].url).toBe('https://user-images.githubusercontent.com/a.png'); + }); + + it('leaves inlineMedia undefined when the body has no images', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ body: 'no images here' })); + const item = await provider.getWorkItem('I_1'); + expect(item.inlineMedia).toBeUndefined(); + }); + + it('propagates the client error when the content node is not an Issue/PR', async () => { + mockClient.getContentNode.mockRejectedValue( + new Error('did not resolve to an Issue or PullRequest'), + ); + await expect(provider.getWorkItem('I_1')).rejects.toThrow(/did not resolve/); + }); + }); + + describe('updateWorkItem', () => { + it('uses the updateIssue mutation for Issue-backed items', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ contentType: 'issue' })); + + await provider.updateWorkItem('I_1', { title: 'New title' }); + + expect(mockClient.githubGraphQL).toHaveBeenCalledTimes(1); + const [mutation, vars] = mockClient.githubGraphQL.mock.calls[0]; + expect(mutation).toContain('updateIssue'); + expect(mutation).not.toContain('updatePullRequest'); + expect(vars).toEqual({ id: 'I_1', title: 'New title' }); + }); + + it('uses the updatePullRequest mutation for PR-backed items (regression: isPR must resolve)', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ contentType: 'pull_request' })); + + await provider.updateWorkItem('PR_1', { description: 'New body' }); + + expect(mockClient.githubGraphQL).toHaveBeenCalledTimes(1); + const [mutation, vars] = mockClient.githubGraphQL.mock.calls[0]; + expect(mutation).toContain('updatePullRequest'); + expect(vars).toEqual({ id: 'PR_1', body: 'New body' }); + }); + + it('updates both title and body when both are provided', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ contentType: 'issue' })); + await provider.updateWorkItem('I_1', { title: 'T', description: 'B' }); + expect(mockClient.githubGraphQL).toHaveBeenCalledTimes(2); + }); + }); + + describe('addComment', () => { + it('comments directly on the content (Issue/PR) node ID without a project-item lookup', async () => { + mockClient.addCommentToIssue.mockResolvedValue('comment-node-1'); + + const commentId = await provider.addComment('I_1', 'hello'); + + expect(mockClient.addCommentToIssue).toHaveBeenCalledWith('I_1', 'hello'); + expect(mockClient.getContentNode).not.toHaveBeenCalled(); + expect(commentId).toBe('comment-node-1'); + }); + }); + + describe('moveWorkItem', () => { + it('maps a CASCADE status key to its configured option ID (PVTI_ ID used directly)', async () => { + await provider.moveWorkItem('PVTI_item', 'done' as never); + expect(mockClient.resolveProjectItemId).not.toHaveBeenCalled(); + expect(mockClient.moveProjectItemToStatus).toHaveBeenCalledWith( + 'PVT_project', + 'PVTI_item', + 'opt-done', + ); + }); + + it('passes an unmapped destination through unchanged', async () => { + await provider.moveWorkItem('PVTI_item', 'opt-raw' as never); + expect(mockClient.moveProjectItemToStatus).toHaveBeenCalledWith( + 'PVT_project', + 'PVTI_item', + 'opt-raw', + ); + }); + + it('resolves the ProjectV2Item ID when given a content (Issue) node ID', async () => { + mockClient.resolveProjectItemId.mockResolvedValue('PVTI_resolved'); + + await provider.moveWorkItem('I_1', 'done' as never); + + expect(mockClient.resolveProjectItemId).toHaveBeenCalledWith('I_1', 'PVT_project'); + expect(mockClient.moveProjectItemToStatus).toHaveBeenCalledWith( + 'PVT_project', + 'PVTI_resolved', + 'opt-done', + ); + }); + + it('throws when the content node is not part of the configured project', async () => { + mockClient.resolveProjectItemId.mockResolvedValue(null); + await expect(provider.moveWorkItem('I_1', 'done' as never)).rejects.toThrow( + /item not found for content I_1/, + ); + expect(mockClient.moveProjectItemToStatus).not.toHaveBeenCalled(); + }); + }); + + describe('listWorkItems', () => { + it('lists only items whose Status option ID matches the CASCADE key, keyed by content node ID', async () => { + mockClient.listAllProjectItems.mockResolvedValue([ + makeProjectItem({ statusName: 'Todo', statusOptionId: 'opt-todo' }), + makeProjectItem({ statusName: 'Done', statusOptionId: 'opt-done' }), + ]); + + const items = await provider.listWorkItems(undefined, { status: 'todo' }); + + expect(mockClient.listAllProjectItems).toHaveBeenCalledWith('PVT_project'); + expect(items).toHaveLength(1); + // Identity is the *content* node ID (matches the capacity-gate exclusion filter). + expect(items[0].id).toBe('I_1'); + expect(items[0].statusId).toBe('opt-todo'); + expect(items[0].status).toBe('Todo'); + }); + + it('lists every item when no status filter is given', async () => { + mockClient.listAllProjectItems.mockResolvedValue([ + makeProjectItem({ statusOptionId: 'opt-todo' }), + makeProjectItem({ statusOptionId: 'opt-done' }), + ]); + const items = await provider.listWorkItems(undefined); + expect(items).toHaveLength(2); + }); + + it('returns [] for a known CASCADE status key with no configured mapping (no API call)', async () => { + // 'backlog' is a known CASCADE key but absent from config.statuses. + await expect(provider.listWorkItems(undefined, { status: 'backlog' })).resolves.toEqual([]); + expect(mockClient.listAllProjectItems).not.toHaveBeenCalled(); + }); + + it('drops draft items with no linked content', async () => { + mockClient.listAllProjectItems.mockResolvedValue([ + makeProjectItem({ statusOptionId: 'opt-todo' }), + makeProjectItem({ noContent: true }), + ]); + const items = await provider.listWorkItems(undefined, { status: 'todo' }); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('I_1'); + }); + }); + + describe('getWorkItemComments', () => { + it('maps comments and extracts inline media from bodies', async () => { + mockClient.getIssueComments.mockResolvedValue([ + { + id: 'IC_1', + body: 'Looks good ![shot](https://user-images.githubusercontent.com/a.png)', + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-02T00:00:00Z', + author: { login: 'octocat', id: 'U_1', name: 'The Octocat' }, + }, + { + id: 'IC_2', + body: 'no images', + createdAt: '2026-07-03T00:00:00Z', + author: { login: 'hubot' }, + }, + ]); + + const comments = await provider.getWorkItemComments('I_1'); + + expect(mockClient.getIssueComments).toHaveBeenCalledWith('I_1'); + expect(comments).toHaveLength(2); + + expect(comments[0].id).toBe('IC_1'); + expect(comments[0].text).toContain('Looks good'); + expect(comments[0].author).toEqual({ id: 'U_1', name: 'The Octocat', username: 'octocat' }); + expect(comments[0].inlineMedia).toHaveLength(1); + expect(comments[0].createdAt).toBe('2026-07-01T00:00:00Z'); + expect(comments[0].updatedAt).toBe('2026-07-02T00:00:00Z'); + + // Bot/actor author without a User id/name falls back to login. + expect(comments[1].author).toEqual({ id: '', name: 'hubot', username: 'hubot' }); + expect(comments[1].inlineMedia).toBeUndefined(); + }); + + it('returns [] when the content node has no comments', async () => { + mockClient.getIssueComments.mockResolvedValue([]); + await expect(provider.getWorkItemComments('I_1')).resolves.toEqual([]); + }); + }); + + describe('labels', () => { + it('addLabel resolves a name to a repo label ID and adds it to the content node', async () => { + mockClient.resolveContentRepoLabelId.mockResolvedValue('LA_processing'); + + await provider.addLabel('I_1', 'processing' as never); + + expect(mockClient.resolveContentRepoLabelId).toHaveBeenCalledWith('I_1', 'processing'); + expect(mockClient.addLabelsToContent).toHaveBeenCalledWith('I_1', ['LA_processing']); + }); + + it('removeLabel resolves the name and removes it', async () => { + mockClient.resolveContentRepoLabelId.mockResolvedValue('LA_processing'); + + await provider.removeLabel('I_1', 'processing' as never); + + expect(mockClient.removeLabelsFromContent).toHaveBeenCalledWith('I_1', ['LA_processing']); + }); + + it('uses a GitHub label node ID (LA_…) directly without a name lookup', async () => { + await provider.addLabel('I_1', 'LA_preresolved' as never); + + expect(mockClient.resolveContentRepoLabelId).not.toHaveBeenCalled(); + expect(mockClient.addLabelsToContent).toHaveBeenCalledWith('I_1', ['LA_preresolved']); + }); + + it('skips (no mutation) when the label does not exist in the content repo', async () => { + mockClient.resolveContentRepoLabelId.mockResolvedValue(null); + + await provider.addLabel('I_1', 'nonexistent' as never); + + expect(mockClient.addLabelsToContent).not.toHaveBeenCalled(); + }); + }); + + describe('createWorkItem', () => { + it('creates an Issue in the project repo and adds it to the project', async () => { + const withRepo = new GitHubProjectsPMProvider(config, 'octocat/repo'); + mockClient.getRepositoryId.mockResolvedValue('R_repo'); + mockClient.createRepositoryIssue.mockResolvedValue({ + id: 'I_new', + number: 7, + url: 'https://github.com/octocat/repo/issues/7', + }); + mockClient.addContentToProject.mockResolvedValue('PVTI_new'); + + const item = await withRepo.createWorkItem({ + containerId: 'PVT_project', + title: 'Alert: boom', + description: 'details', + }); + + expect(mockClient.getRepositoryId).toHaveBeenCalledWith('octocat', 'repo'); + expect(mockClient.createRepositoryIssue).toHaveBeenCalledWith( + 'R_repo', + 'Alert: boom', + 'details', + ); + expect(mockClient.addContentToProject).toHaveBeenCalledWith('PVT_project', 'I_new'); + // Identity is the *content* (Issue) node ID, consistent with the rest of the path. + expect(item.id).toBe('I_new'); + expect(item.url).toBe('https://github.com/octocat/repo/issues/7'); + }); + + it('applies requested labels to the created Issue', async () => { + const withRepo = new GitHubProjectsPMProvider(config, 'octocat/repo'); + mockClient.getRepositoryId.mockResolvedValue('R_repo'); + mockClient.createRepositoryIssue.mockResolvedValue({ + id: 'I_new', + number: 7, + url: 'https://github.com/octocat/repo/issues/7', + }); + mockClient.addContentToProject.mockResolvedValue('PVTI_new'); + mockClient.resolveContentRepoLabelId.mockResolvedValue('LA_bug'); + + await withRepo.createWorkItem({ + containerId: 'PVT_project', + title: 't', + labels: ['bug'], + }); + + expect(mockClient.resolveContentRepoLabelId).toHaveBeenCalledWith('I_new', 'bug'); + expect(mockClient.addLabelsToContent).toHaveBeenCalledWith('I_new', ['LA_bug']); + }); + + it('falls back to the configured project when containerId is empty', async () => { + const withRepo = new GitHubProjectsPMProvider(config, 'octocat/repo'); + mockClient.getRepositoryId.mockResolvedValue('R_repo'); + mockClient.createRepositoryIssue.mockResolvedValue({ id: 'I_new', number: 7, url: 'u' }); + mockClient.addContentToProject.mockResolvedValue('PVTI_new'); + + await withRepo.createWorkItem({ containerId: '', title: 't' }); + + expect(mockClient.addContentToProject).toHaveBeenCalledWith('PVT_project', 'I_new'); + }); + + it('throws an actionable error when the project has no SCM repo configured', async () => { + // `provider` (from beforeEach) is constructed without a repo. + await expect( + provider.createWorkItem({ containerId: 'PVT_project', title: 't' }), + ).rejects.toThrow(/requires the project to have an SCM repository/i); + expect(mockClient.createRepositoryIssue).not.toHaveBeenCalled(); + }); + }); + + describe('checklists (inline markdown in the content body)', () => { + /** Extract the `body` written by the single githubGraphQL updateIssue/PR call. */ + function lastWrittenBody(): string { + const calls = mockClient.githubGraphQL.mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][1].body as string; + } + + it('getChecklists parses inline `### {name}` + checkbox rows from the body', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ + body: 'Intro prose\n\n### Implementation Steps\n- [x] first\n- [ ] second', + }), + ); + + const checklists = await provider.getChecklists('I_1'); + + expect(checklists).toHaveLength(1); + expect(checklists[0].name).toBe('Implementation Steps'); + expect(checklists[0].workItemId).toBe('I_1'); + expect(checklists[0].items).toEqual([ + { id: expect.any(String), name: 'first', complete: true }, + { id: expect.any(String), name: 'second', complete: false }, + ]); + }); + + it('createChecklist appends an empty section to the Issue body via updateIssue', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ body: 'Existing body' })); + + const checklist = await provider.createChecklist('I_1', 'Acceptance Criteria'); + + expect(checklist.name).toBe('Acceptance Criteria'); + expect(checklist.workItemId).toBe('I_1'); + const [mutation] = mockClient.githubGraphQL.mock.calls[0]; + expect(mutation).toContain('updateIssue'); + expect(lastWrittenBody()).toContain('### Acceptance Criteria'); + }); + + it('createChecklistWithItems writes the section and all rows in one body mutation', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ body: '' })); + + const checklist = await provider.createChecklistWithItems('I_1', 'Steps', [ + { name: 'do A', checked: true }, + { name: 'do B' }, + ]); + + expect(checklist.items).toEqual([ + { id: expect.any(String), name: 'do A', complete: true }, + { id: expect.any(String), name: 'do B', complete: false }, + ]); + expect(mockClient.githubGraphQL).toHaveBeenCalledTimes(1); + const body = lastWrittenBody(); + expect(body).toContain('### Steps'); + expect(body).toContain('- [x] do A'); + expect(body).toContain('- [ ] do B'); + }); + + it('createChecklistWithItems uses the updatePullRequest mutation for PR-backed items', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ contentType: 'pull_request', body: '' }), + ); + + await provider.createChecklistWithItems('PR_1', 'Steps', [{ name: 'x' }]); + + const [mutation] = mockClient.githubGraphQL.mock.calls[0]; + expect(mutation).toContain('updatePullRequest'); + expect(mutation).not.toContain('updateIssue'); + }); + + it('addChecklistItem resolves the section by its hashed ID and upserts a row', async () => { + mockClient.getContentNode.mockResolvedValue(makeContentNode({ body: '' })); + const { id: checklistId } = await provider.createChecklist('I_1', 'Steps'); + // createChecklist wrote the empty section; the next read must reflect it. + mockClient.getContentNode.mockResolvedValue(makeContentNode({ body: '### Steps' })); + + await provider.addChecklistItem(checklistId, 'newly added', false); + + expect(lastWrittenBody()).toContain('- [ ] newly added'); + }); + + it('addChecklistItem throws on an unparseable checklist ID', async () => { + await expect(provider.addChecklistItem('not-an-inline-id', 'x')).rejects.toThrow( + /Invalid GitHub Projects checklist ID/, + ); + }); + + it('updateChecklistItem toggles a row to checked', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ body: '### Steps\n- [ ] toggle me' }), + ); + const itemId = hashChecklistItemId('Steps', 'toggle me'); + + await provider.updateChecklistItem('I_1', itemId, true); + + expect(lastWrittenBody()).toContain('- [x] toggle me'); + }); + + it('deleteChecklistItem removes a row from the section', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ body: '### Steps\n- [ ] keep\n- [ ] remove me' }), + ); + const itemId = hashChecklistItemId('Steps', 'remove me'); + + await provider.deleteChecklistItem('I_1', itemId); + + const body = lastWrittenBody(); + expect(body).toContain('- [ ] keep'); + expect(body).not.toContain('remove me'); + }); + + it('does not write when the mutation is a no-op (idempotent re-toggle)', async () => { + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ body: '### Steps\n- [x] already done' }), + ); + const itemId = hashChecklistItemId('Steps', 'already done'); + + await provider.updateChecklistItem('I_1', itemId, true); + + expect(mockClient.githubGraphQL).not.toHaveBeenCalled(); + }); + }); + + describe('getAuthenticatedUser', () => { + it('maps the viewer to { id, name, username }', async () => { + mockClient.getViewer.mockResolvedValue({ id: 'U_1', login: 'octocat', name: 'The Octocat' }); + + const user = await provider.getAuthenticatedUser(); + + expect(user).toEqual({ id: 'U_1', name: 'The Octocat', username: 'octocat' }); + }); + + it('falls back to login when the viewer has no display name', async () => { + mockClient.getViewer.mockResolvedValue({ id: 'U_1', login: 'octocat' }); + const user = await provider.getAuthenticatedUser(); + expect(user.name).toBe('octocat'); + }); + }); +}); diff --git a/tests/unit/pm/github-projects/client.test.ts b/tests/unit/pm/github-projects/client.test.ts new file mode 100644 index 000000000..89e3bde70 --- /dev/null +++ b/tests/unit/pm/github-projects/client.test.ts @@ -0,0 +1,432 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +import { + addContentToProject, + addLabelsToContent, + createRepositoryIssue, + downloadImage, + getContentNode, + getIssueComments, + getProjectItem, + getRepositoryId, + listAllProjectItems, + removeLabelsFromContent, + resolveContentRepoLabelId, + resolveProjectItemId, + withGitHubProjectsCredentials, +} from '../../../../src/github-projects/client.js'; +import { logger } from '../../../../src/utils/logging.js'; + +/** Build a fetch Response-like stub for a GraphQL query result. */ +function graphqlResponse(data: unknown) { + return { + ok: true, + json: async () => ({ data }), + text: async () => '', + } as unknown as Response; +} + +describe('github-projects client', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('getProjectItem — __typename normalization (Fix 3)', () => { + it('sets content.type to "pull_request" when __typename is PullRequest', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + id: 'PVTI_item', + content: { + __typename: 'PullRequest', + id: 'PR_1', + number: 7, + title: 't', + body: '', + url: 'u', + state: 'OPEN', + }, + fieldValues: { nodes: [] }, + }, + }), + ); + + const item = await withGitHubProjectsCredentials({ token: 't' }, () => + getProjectItem('PVTI_item'), + ); + + expect(item.content?.type).toBe('pull_request'); + }); + + it('sets content.type to "issue" when __typename is Issue', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + id: 'PVTI_item', + content: { + __typename: 'Issue', + id: 'I_1', + number: 7, + title: 't', + body: '', + url: 'u', + state: 'OPEN', + }, + fieldValues: { nodes: [] }, + }, + }), + ); + + const item = await withGitHubProjectsCredentials({ token: 't' }, () => + getProjectItem('PVTI_item'), + ); + + expect(item.content?.type).toBe('issue'); + }); + }); + + describe('listAllProjectItems — pagination', () => { + function itemsPage(nodeIds: string[], hasNextPage: boolean, endCursor: string | null) { + return graphqlResponse({ + node: { + items: { + nodes: nodeIds.map((id) => ({ + id, + content: { + __typename: 'Issue', + id: `content-${id}`, + number: 1, + title: 't', + body: '', + url: 'u', + state: 'OPEN', + }, + fieldValues: { nodes: [] }, + })), + pageInfo: { hasNextPage, endCursor }, + }, + }, + }); + } + + it('follows endCursor across pages and concatenates every item', async () => { + fetchMock + .mockResolvedValueOnce(itemsPage(['PVTI_1', 'PVTI_2'], true, 'cursor-1')) + .mockResolvedValueOnce(itemsPage(['PVTI_3'], false, null)); + + const items = await withGitHubProjectsCredentials({ token: 't' }, () => + listAllProjectItems('PVT_project', { pageSize: 2 }), + ); + + expect(items).toHaveLength(3); + expect(items.map((i) => i.id)).toEqual(['PVTI_1', 'PVTI_2', 'PVTI_3']); + // Second call must forward the cursor. + const [, secondInit] = fetchMock.mock.calls[1]; + expect(JSON.parse((secondInit as { body: string }).body).variables.after).toBe('cursor-1'); + }); + + it('warns and truncates instead of paginating forever when the cap is hit', async () => { + fetchMock.mockResolvedValue(itemsPage(['PVTI_1', 'PVTI_2'], true, 'cursor-x')); + + const items = await withGitHubProjectsCredentials({ token: 't' }, () => + listAllProjectItems('PVT_project', { pageSize: 2, maxItems: 2 }), + ); + + expect(items).toHaveLength(2); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('hit item cap'), + expect.objectContaining({ maxItems: 2 }), + ); + }); + }); + + describe('getContentNode', () => { + function contentNode(typename: 'Issue' | 'PullRequest', projectItems: unknown[]) { + return graphqlResponse({ + node: { + __typename: typename, + id: typename === 'PullRequest' ? 'PR_1' : 'I_1', + number: 42, + title: 't', + body: 'b', + url: 'u', + state: 'OPEN', + projectItems: { nodes: projectItems }, + }, + }); + } + + it('resolves the Status option for the requested project from the content node', async () => { + fetchMock.mockResolvedValue( + contentNode('Issue', [ + { + project: { id: 'PVT_other' }, + fieldValues: { + nodes: [{ optionId: 'opt-x', name: 'Other', field: { id: 'f', name: 'Status' } }], + }, + }, + { + project: { id: 'PVT_project' }, + fieldValues: { + nodes: [{ optionId: 'opt-todo', name: 'Todo', field: { id: 'f', name: 'Status' } }], + }, + }, + ]), + ); + + const content = await withGitHubProjectsCredentials({ token: 't' }, () => + getContentNode('I_1', 'PVT_project'), + ); + + expect(content.type).toBe('issue'); + // Picks the Status from PVT_project, not the first project item. + expect(content.statusOptionId).toBe('opt-todo'); + expect(content.statusName).toBe('Todo'); + }); + + it('normalizes __typename PullRequest to type "pull_request"', async () => { + fetchMock.mockResolvedValue(contentNode('PullRequest', [])); + const content = await withGitHubProjectsCredentials({ token: 't' }, () => + getContentNode('PR_1'), + ); + expect(content.type).toBe('pull_request'); + expect(content.statusOptionId).toBeUndefined(); + }); + + it('throws when the node is neither an Issue nor a PullRequest', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ node: { __typename: 'ProjectV2Item' } })); + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => getContentNode('PVTI_x')), + ).rejects.toThrow(/did not resolve to an Issue or PullRequest/); + }); + }); + + describe('getIssueComments', () => { + it('returns the comment nodes of whichever content fragment matched', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + comments: { + nodes: [ + { + id: 'IC_1', + body: 'hi', + createdAt: '2026-07-01T00:00:00Z', + author: { login: 'octocat', id: 'U_1', name: 'The Octocat' }, + }, + ], + }, + }, + }), + ); + + const comments = await withGitHubProjectsCredentials({ token: 't' }, () => + getIssueComments('I_1'), + ); + + expect(comments).toHaveLength(1); + expect(comments[0].id).toBe('IC_1'); + expect(comments[0].author?.login).toBe('octocat'); + }); + + it('returns [] when the node exposes no comments connection', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ node: {} })); + const comments = await withGitHubProjectsCredentials({ token: 't' }, () => + getIssueComments('I_1'), + ); + expect(comments).toEqual([]); + }); + }); + + describe('labels', () => { + it('resolveContentRepoLabelId returns the repo-scoped label node ID for a name', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ node: { repository: { label: { id: 'LA_repo_processing' } } } }), + ); + + const labelId = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveContentRepoLabelId('I_1', 'processing'), + ); + + expect(labelId).toBe('LA_repo_processing'); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as { body: string }).body).variables).toEqual({ + id: 'I_1', + name: 'processing', + }); + }); + + it('resolveContentRepoLabelId returns null when the repo has no such label', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ node: { repository: { label: null } } })); + const labelId = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveContentRepoLabelId('I_1', 'missing'), + ); + expect(labelId).toBeNull(); + }); + + it('addLabelsToContent posts addLabelsToLabelable with the content node as labelableId', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ addLabelsToLabelable: {} })); + + await withGitHubProjectsCredentials({ token: 't' }, () => + addLabelsToContent('I_1', ['LA_x']), + ); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('addLabelsToLabelable'); + expect(parsed.variables).toEqual({ labelableId: 'I_1', labelIds: ['LA_x'] }); + }); + + it('removeLabelsFromContent posts removeLabelsFromLabelable', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ removeLabelsFromLabelable: {} })); + + await withGitHubProjectsCredentials({ token: 't' }, () => + removeLabelsFromContent('I_1', ['LA_x']), + ); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('removeLabelsFromLabelable'); + expect(parsed.variables).toEqual({ labelableId: 'I_1', labelIds: ['LA_x'] }); + }); + }); + + describe('work-item creation', () => { + it('getRepositoryId returns the repo node ID', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ repository: { id: 'R_kgDO' } })); + const id = await withGitHubProjectsCredentials({ token: 't' }, () => + getRepositoryId('octocat', 'repo'), + ); + expect(id).toBe('R_kgDO'); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as { body: string }).body).variables).toEqual({ + owner: 'octocat', + name: 'repo', + }); + }); + + it('getRepositoryId throws when the repo is not found/accessible', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ repository: null })); + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => getRepositoryId('octocat', 'nope')), + ).rejects.toThrow(/not found or not accessible/); + }); + + it('createRepositoryIssue returns the new issue node id/number/url', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + createIssue: { issue: { id: 'I_new', number: 7, url: 'https://gh/issues/7' } }, + }), + ); + const issue = await withGitHubProjectsCredentials({ token: 't' }, () => + createRepositoryIssue('R_kgDO', 'Title', 'Body'), + ); + expect(issue).toEqual({ id: 'I_new', number: 7, url: 'https://gh/issues/7' }); + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('createIssue'); + expect(parsed.variables).toEqual({ repositoryId: 'R_kgDO', title: 'Title', body: 'Body' }); + }); + + it('addContentToProject returns the created ProjectV2Item id', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ addProjectV2ItemById: { item: { id: 'PVTI_new' } } }), + ); + const itemId = await withGitHubProjectsCredentials({ token: 't' }, () => + addContentToProject('PVT_project', 'I_new'), + ); + expect(itemId).toBe('PVTI_new'); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as { body: string }).body).variables).toEqual({ + projectId: 'PVT_project', + contentId: 'I_new', + }); + }); + + it('resolveProjectItemId picks the item for the requested project', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + projectItems: { + nodes: [ + { id: 'PVTI_other', project: { id: 'PVT_other' } }, + { id: 'PVTI_match', project: { id: 'PVT_project' } }, + ], + }, + }, + }), + ); + const itemId = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveProjectItemId('I_1', 'PVT_project'), + ); + expect(itemId).toBe('PVTI_match'); + }); + + it('resolveProjectItemId returns null when the content is not in the project', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { projectItems: { nodes: [{ id: 'PVTI_x', project: { id: 'PVT_other' } }] } }, + }), + ); + const itemId = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveProjectItemId('I_1', 'PVT_project'), + ); + expect(itemId).toBeNull(); + }); + }); + + describe('downloadImage (Fix 5)', () => { + it('returns the buffer and the response Content-Type as the MIME', async () => { + fetchMock.mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + headers: new Headers({ 'content-type': 'image/png' }), + } as unknown as Response); + + const result = await withGitHubProjectsCredentials({ token: 't' }, () => + downloadImage('https://user-images.githubusercontent.com/a.png'), + ); + + expect(result).not.toBeNull(); + expect(result?.mimeType).toBe('image/png'); + expect(result?.buffer).toEqual(Buffer.from([1, 2, 3])); + }); + + it('sends the bearer token as the Authorization header', async () => { + fetchMock.mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(0), + headers: new Headers({ 'content-type': 'image/png' }), + } as unknown as Response); + + await withGitHubProjectsCredentials({ token: 'ghp_secret' }, () => + downloadImage('https://example.com/a.png'), + ); + + const [, init] = fetchMock.mock.calls[0]; + const headers = init.headers as Record; + expect(JSON.stringify(headers)).toContain('ghp_secret'); + }); + + it('returns null on a non-ok response instead of throwing', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404 } as unknown as Response); + + const result = await withGitHubProjectsCredentials({ token: 't' }, () => + downloadImage('https://example.com/missing.png'), + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/pm/github-projects/integration.test.ts b/tests/unit/pm/github-projects/integration.test.ts new file mode 100644 index 000000000..f7882050d --- /dev/null +++ b/tests/unit/pm/github-projects/integration.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — keep the integration module's DB/provider deps inert so we can test +// the pure methods (parse / lifecycle / createProvider / extractWorkItemId). +// --------------------------------------------------------------------------- + +const mockGetIntegrationCredential = vi.fn(); +const mockGetIntegrationCredentialOrNull = vi.fn(); +const mockLoadProjectConfigByGitHubProjectsProjectId = vi.fn(); +vi.mock('../../../../src/config/provider.js', () => ({ + getIntegrationCredential: (...args: unknown[]) => mockGetIntegrationCredential(...args), + getIntegrationCredentialOrNull: (...args: unknown[]) => + mockGetIntegrationCredentialOrNull(...args), + loadProjectConfigByGitHubProjectsProjectId: (...args: unknown[]) => + mockLoadProjectConfigByGitHubProjectsProjectId(...args), +})); + +const mockGetIntegrationProvider = vi.fn(); +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + getIntegrationProvider: (...args: unknown[]) => mockGetIntegrationProvider(...args), +})); + +vi.mock('../../../../src/github-projects/client.js', () => ({ + addCommentToIssue: vi.fn(), + withGitHubProjectsCredentials: vi.fn((_creds, fn) => fn()), + getViewer: vi.fn(), + deleteComment: vi.fn(), +})); + +import { GitHubProjectsIntegration } from '../../../../src/pm/github-projects/integration.js'; +import type { ProjectConfig } from '../../../../src/types/index.js'; + +const projectWithConfig = { + id: 'proj-ghp', + pm: { type: 'github-projects' }, + githubProjects: { + projectId: 'PVT_project', + owner: 'octocat', + ownerType: 'user', + statuses: { todo: 'opt-todo', done: 'opt-done', friction: 'opt-friction' }, + labels: { processing: 'label-processing', readyToProcess: 'label-ready' }, + }, +} as unknown as ProjectConfig; + +describe('GitHubProjectsIntegration', () => { + let integration: GitHubProjectsIntegration; + + beforeEach(() => { + vi.clearAllMocks(); + integration = new GitHubProjectsIntegration(); + }); + + it('has type "github-projects" and category "pm"', () => { + expect(integration.type).toBe('github-projects'); + expect(integration.category).toBe('pm'); + }); + + describe('parseWebhookPayload', () => { + it('parses a projects_v2_item webhook into a PMWebhookEvent', () => { + const event = integration.parseWebhookPayload({ + action: 'edited', + projects_v2_item: { + project_node_id: 'PVT_project', + content_node_id: 'I_content', + }, + }); + + expect(event).not.toBeNull(); + expect(event?.eventType).toBe('projects_v2_item.edited'); + expect(event?.projectIdentifier).toBe('PVT_project'); + expect(event?.workItemId).toBe('I_content'); + }); + + it('returns null for non-object payloads', () => { + expect(integration.parseWebhookPayload(null)).toBeNull(); + expect(integration.parseWebhookPayload('nope')).toBeNull(); + }); + + it('returns null when projects_v2_item is missing', () => { + expect(integration.parseWebhookPayload({ action: 'edited' })).toBeNull(); + }); + + it('returns null when project_node_id is missing', () => { + expect( + integration.parseWebhookPayload({ + action: 'edited', + projects_v2_item: { content_node_id: 'I_content' }, + }), + ).toBeNull(); + }); + }); + + describe('resolveLifecycleConfig', () => { + it('maps labels and spreads the full statuses record (custom keys survive)', () => { + const config = integration.resolveLifecycleConfig(projectWithConfig); + + expect(config.labels.processing).toBe('label-processing'); + expect(config.labels.readyToProcess).toBe('label-ready'); + // Full statuses record must be spread so custom/friction keys survive. + expect(config.statuses).toEqual({ + todo: 'opt-todo', + done: 'opt-done', + friction: 'opt-friction', + }); + }); + + it('tolerates a project with no GitHub Projects config', () => { + const config = integration.resolveLifecycleConfig({ + id: 'x', + pm: { type: 'github-projects' }, + } as unknown as ProjectConfig); + expect(config.statuses).toEqual({}); + }); + }); + + describe('createProvider', () => { + it('constructs a provider when projectId is present', () => { + const provider = integration.createProvider(projectWithConfig); + expect(provider.type).toBe('github-projects'); + }); + + it('throws when projectId is missing from config', () => { + expect(() => + integration.createProvider({ + id: 'x', + pm: { type: 'github-projects' }, + } as unknown as ProjectConfig), + ).toThrow(/requires projectId/); + }); + }); + + describe('extractWorkItemId', () => { + it('extracts an issue number from a GitHub issue URL', () => { + expect(integration.extractWorkItemId('see https://github.com/octocat/repo/issues/123')).toBe( + '123', + ); + }); + + it('extracts a PR number from a GitHub pull URL', () => { + expect(integration.extractWorkItemId('https://github.com/octocat/repo/pull/456')).toBe('456'); + }); + + it('returns null when no GitHub URL is present', () => { + expect(integration.extractWorkItemId('no url here')).toBeNull(); + }); + }); +}); diff --git a/tests/unit/pm/github-projects/manifest-discovery.test.ts b/tests/unit/pm/github-projects/manifest-discovery.test.ts new file mode 100644 index 000000000..3af6996b9 --- /dev/null +++ b/tests/unit/pm/github-projects/manifest-discovery.test.ts @@ -0,0 +1,116 @@ +/** + * GitHub Projects manifest discovery. + * + * The manifest must declare `states` (the wizard's status-mapping step queries + * capability 'states'; the generic pm.discovery endpoint rejects undeclared + * capabilities) and must NOT declare the dead `containers` capability. + */ + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/github-projects/client.js', () => { + const fakeUserProjects = [ + { + id: 'PVT_1', + number: 1, + title: 'My Project', + url: 'https://github.com/users/octocat/projects/1', + }, + ]; + const fakeOrgProjects = [ + { + id: 'PVT_org', + number: 2, + title: 'Org Project', + url: 'https://github.com/orgs/acme/projects/2', + }, + ]; + return { + withGitHubProjectsCredentials: vi.fn(async (_creds, fn) => fn()), + getUserProjects: vi.fn(async () => fakeUserProjects), + getOrganizationProjects: vi.fn(async () => fakeOrgProjects), + getStatusField: vi.fn(async () => ({ + id: 'PVTSSF_status', + options: [ + { id: 'opt-todo', name: 'Todo' }, + { id: 'opt-inprogress', name: 'In Progress' }, + { id: 'opt-done', name: 'Done' }, + ], + })), + getViewer: vi.fn(async () => ({ id: 'U_1', login: 'octocat', name: 'The Octocat' })), + }; +}); + +import { githubProjectsManifest } from '../../../../src/integrations/pm/github-projects/manifest.js'; + +describe('githubProjectsManifest.discoveryCapabilities', () => { + it('declares projects, states, currentUser', () => { + const caps = githubProjectsManifest.discoveryCapabilities; + expect(caps?.projects).toBe(true); + expect(caps?.states).toBe(true); + expect(caps?.currentUser).toBe(true); + }); + + it('does not declare the dead containers capability', () => { + expect(githubProjectsManifest.discoveryCapabilities?.containers).toBeUndefined(); + }); + + it('declares a createDiscoveryProvider factory', () => { + expect(typeof githubProjectsManifest.createDiscoveryProvider).toBe('function'); + }); +}); + +describe('githubProjectsManifest.discover via createDiscoveryProvider', () => { + function makeProvider() { + if (!githubProjectsManifest.createDiscoveryProvider) { + throw new Error('createDiscoveryProvider missing'); + } + return githubProjectsManifest.createDiscoveryProvider({ credentials: { token: 'ghp_x' } }); + } + + it('discover("projects", {containerId: "octocat:user"}) returns user projects', async () => { + const result = await makeProvider().discover?.('projects', { + containerId: 'octocat:user', + } as never); + expect(result).toEqual([expect.objectContaining({ id: 'PVT_1', name: 'My Project' })]); + }); + + it('discover("projects", {containerId: "acme:organization"}) returns org projects', async () => { + const result = await makeProvider().discover?.('projects', { + containerId: 'acme:organization', + } as never); + expect(result).toEqual([expect.objectContaining({ id: 'PVT_org', name: 'Org Project' })]); + }); + + it('discover("states", {containerId: projectId}) returns Status options with categories', async () => { + const result = (await makeProvider().discover?.('states', { + containerId: 'PVT_1', + } as never)) as Array<{ id: string; name: string; category: string }>; + + expect(result).toHaveLength(3); + expect(result[0]).toEqual( + expect.objectContaining({ id: 'opt-todo', name: 'Todo', category: 'todo' }), + ); + expect(result[1]).toEqual( + expect.objectContaining({ + id: 'opt-inprogress', + name: 'In Progress', + category: 'in_progress', + }), + ); + expect(result[2]).toEqual( + expect.objectContaining({ id: 'opt-done', name: 'Done', category: 'done' }), + ); + }); + + it('discover("currentUser") returns { id, name, displayName }', async () => { + const result = await makeProvider().discover?.('currentUser', {} as never); + expect(result).toEqual({ id: 'U_1', name: 'The Octocat', displayName: 'The Octocat' }); + }); + + it('discover("containers") throws (capability removed)', async () => { + await expect(makeProvider().discover?.('containers', {} as never)).rejects.toThrow( + /does not support discovery capability/, + ); + }); +}); diff --git a/tests/unit/router/adapters/github-projects.test.ts b/tests/unit/router/adapters/github-projects.test.ts new file mode 100644 index 000000000..547879baf --- /dev/null +++ b/tests/unit/router/adapters/github-projects.test.ts @@ -0,0 +1,267 @@ +/** + * Unit tests for GitHubProjectsRouterAdapter. + */ + +import { describe, expect, it, vi } from 'vitest'; +import * as client from '../../../../src/github-projects/client.js'; +import { GitHubProjectsRouterAdapter } from '../../../../src/router/adapters/github-projects.js'; +import type { RouterProjectConfig } from '../../../../src/router/config.js'; +import * as config from '../../../../src/router/config.js'; +import * as credentials from '../../../../src/router/platformClients/credentials.js'; +import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; +import type { TriggerResult } from '../../../../src/types/index.js'; + +vi.mock('../../../../src/router/platformClients/credentials.js', () => ({ + resolveGitHubProjectsCredentials: vi.fn(), +})); + +vi.mock('../../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn(), +})); + +vi.mock('../../../../src/github-projects/client.js', () => ({ + getViewer: vi.fn(), + // Run the scoped fn directly so getViewer() executes in tests. + withGitHubProjectsCredentials: vi.fn((_creds: unknown, fn: () => unknown) => fn()), +})); + +function makeStatusChangePayload( + projectNodeId: string, + contentNodeId: string, + toStatus: { id: string; name: string }, + fromStatus?: { id: string; name: string }, +) { + return { + action: 'edited', + projects_v2_item: { + id: 123456, + node_id: contentNodeId, + project_node_id: projectNodeId, + content_node_id: contentNodeId, + content_type: 'Issue', + }, + changes: { + field_value: { + field_node_id: 'PVTSSF_field', + field_type: 'single_select', + field_name: 'Status', + from: fromStatus ?? null, + to: toStatus, + }, + }, + sender: { login: 'human-user' }, + }; +} + +describe('GitHubProjectsRouterAdapter', () => { + const adapter = new GitHubProjectsRouterAdapter(); + + describe('parseWebhook', () => { + it('parses a status-change webhook payload', async () => { + const payload = makeStatusChangePayload( + 'PVT_project123', + 'PVTI_item456', + { id: 'PVTSSF_inprogress', name: 'In Progress' }, + { id: 'PVTSSF_todo', name: 'Todo' }, + ); + + const event = await adapter.parseWebhook(payload); + expect(event).toBeTruthy(); + expect(event?.projectIdentifier).toBe('PVT_project123'); + expect(event?.workItemId).toBe('PVTI_item456'); + expect(event?.eventType).toBe('projects_v2_item/edited'); + expect(event?.isCommentEvent).toBe(false); + expect(event?.statusChange).toEqual({ + from: 'Todo', + to: 'In Progress', + fieldId: 'PVTSSF_field', + fieldName: 'Status', + }); + }); + + it('returns null for non-status field changes', async () => { + const payload = { + action: 'edited', + projects_v2_item: { + id: 1, + node_id: 'PVTI_item', + project_node_id: 'PVT_project', + content_node_id: 'PVTI_item', + content_type: 'Issue', + }, + changes: { + field_value: { + field_node_id: 'field_priority', + field_type: 'single_select', + field_name: 'Priority', + from: null, + to: { id: 'priority_high', name: 'High' }, + }, + }, + }; + + const event = await adapter.parseWebhook(payload); + expect(event).toBeNull(); + }); + + it('returns null when required fields are missing', async () => { + const event = await adapter.parseWebhook({ action: 'edited' }); + expect(event).toBeNull(); + }); + + it('forwards an edited event when field_name is absent (GitHub often omits it)', async () => { + // GitHub's projects_v2_item.edited webhook does not reliably send + // field_name; the router must forward the event so the trigger can + // confirm the Status field authoritatively. + const payload = { + action: 'edited', + projects_v2_item: { + id: 1, + node_id: 'PVTI_item', + project_node_id: 'PVT_project', + content_node_id: 'PVTI_item', + content_type: 'Issue', + }, + changes: { field_value: { field_node_id: 'PVTSSF_status', field_type: 'single_select' } }, + }; + + const event = await adapter.parseWebhook(payload); + expect(event).toBeTruthy(); + expect(event?.projectIdentifier).toBe('PVT_project'); + }); + + it('returns null for an edited event with no field-value change', async () => { + const event = await adapter.parseWebhook({ + action: 'edited', + projects_v2_item: { + id: 1, + node_id: 'PVTI_item', + project_node_id: 'PVT_project', + content_node_id: 'PVTI_item', + content_type: 'Issue', + }, + changes: {}, + }); + expect(event).toBeNull(); + }); + }); + + describe('isProcessableEvent', () => { + it('accepts projects_v2_item events', async () => { + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_p', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + expect(adapter.isProcessableEvent(event)).toBe(true); + }); + }); + + describe('dispatchWithCredentials', () => { + it('returns null when project credentials are missing', async () => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [], + }); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue(null); + + const project = { id: 'proj-1' } as RouterProjectConfig; + const registry = { dispatch: vi.fn() } as unknown as TriggerRegistry; + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_p', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.dispatchWithCredentials(event, {}, project, registry); + expect(result).toBeNull(); + }); + }); + + describe('isSelfAuthored', () => { + // Maps the GitHub project node id (PVT_…) → CASCADE project id, so viewer + // resolution keys on the CASCADE id, not the node id. Regression guard for + // the loop-prevention bug where isSelfAuthored passed the node id to + // credential resolution and always returned false. + function stubProjectLookup(nodeId: string, cascadeId: string) { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [{ id: cascadeId, githubProjects: { projectId: nodeId } }], + fullProjects: [], + } as unknown as Awaited>); + } + + it('resolves the viewer via the CASCADE project id (not the GitHub node id)', async () => { + stubProjectLookup('PVT_project123', 'cascade-proj'); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue({ + token: 'ghp_x', + }); + vi.mocked(client.getViewer).mockResolvedValue({ login: 'cascade-bot' } as never); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + const payload = { sender: { login: 'cascade-bot' } }; + + const result = await adapter.isSelfAuthored(event, payload); + + expect(result).toBe(true); + // The credential lookup must receive the CASCADE id, never the PVT_ node id. + expect(credentials.resolveGitHubProjectsCredentials).toHaveBeenCalledWith('cascade-proj'); + expect(credentials.resolveGitHubProjectsCredentials).not.toHaveBeenCalledWith( + 'PVT_project123', + ); + }); + + it('returns false when the sender is a different login', async () => { + stubProjectLookup('PVT_project123', 'cascade-proj'); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue({ + token: 'ghp_x', + }); + vi.mocked(client.getViewer).mockResolvedValue({ login: 'cascade-bot' } as never); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.isSelfAuthored(event, { sender: { login: 'human-user' } }); + expect(result).toBe(false); + }); + + it('returns false when no CASCADE project matches the node id', async () => { + stubProjectLookup('PVT_other', 'cascade-proj'); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.isSelfAuthored(event, { sender: { login: 'cascade-bot' } }); + expect(result).toBe(false); + }); + }); + + describe('buildJob', () => { + it('builds a github-projects job', async () => { + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_p', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + const project = { id: 'proj-1' } as RouterProjectConfig; + const result: TriggerResult = { + shouldDispatch: true, + agentType: 'implementation', + workItemId: 'PVTI_i', + }; + + const job = adapter.buildJob(event, {}, project, result, { + commentId: 'comment-1', + message: 'ack', + }); + + expect(job.type).toBe('github-projects'); + expect(job.projectId).toBe('proj-1'); + expect(job.workItemId).toBe('PVTI_i'); + expect(job.ackCommentId).toBe('comment-1'); + }); + }); +}); diff --git a/tests/unit/triggers/github-projects-status-changed.test.ts b/tests/unit/triggers/github-projects-status-changed.test.ts new file mode 100644 index 000000000..36a1cee99 --- /dev/null +++ b/tests/unit/triggers/github-projects-status-changed.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); +vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +// The capacity gate is fail-closed with no PM-provider scope (the case in unit +// tests). Mock it to not block so the trigger-logic assertions run. +vi.mock('../../../src/triggers/shared/pipeline-capacity-gate.js', () => ({ + shouldBlockForPipelineCapacity: vi.fn().mockResolvedValue(false), +})); + +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + +const mockGetGitHubProjectsConfig = vi.fn(); +vi.mock('../../../src/pm/config.js', () => ({ + getGitHubProjectsConfig: (...args: unknown[]) => mockGetGitHubProjectsConfig(...args), +})); + +const { mockGetProjectItem } = vi.hoisted(() => ({ mockGetProjectItem: vi.fn() })); +vi.mock('../../../src/github-projects/client.js', () => ({ + getProjectItem: mockGetProjectItem, +})); + +import { GitHubProjectsStatusChangedTrigger } from '../../../src/triggers/github-projects/status-changed.js'; +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseConfig = { + projectId: 'PVT_project', + owner: 'octocat', + ownerType: 'user' as const, + statuses: { todo: 'opt-todo', done: 'opt-done' }, +}; + +const mockProject = { + id: 'proj-ghp', + orgId: 'org-1', + name: 'GHP Project', + pm: { type: 'github-projects' as const }, + githubProjects: baseConfig, +} as TriggerContext['project']; + +/** Builds a webhook ctx. `fieldName`/`toId` are optional webhook hints. */ +function buildCtx( + overrides: { + source?: TriggerContext['source']; + action?: string; + fieldNodeId?: string | null; + fieldName?: string; + noFieldValue?: boolean; + } = {}, +): TriggerContext { + const fieldValue = overrides.noFieldValue + ? undefined + : { + field_node_id: + overrides.fieldNodeId === null ? undefined : (overrides.fieldNodeId ?? 'PVTSSF_status'), + ...(overrides.fieldName ? { field_name: overrides.fieldName } : {}), + }; + + return { + project: mockProject, + source: overrides.source ?? 'github-projects', + payload: { + action: overrides.action ?? 'edited', + projects_v2_item: { + node_id: 'PVTI_item', + project_node_id: 'PVT_project', + content_node_id: 'I_content', + content_type: 'Issue', + }, + ...(fieldValue ? { changes: { field_value: fieldValue } } : { changes: {} }), + }, + }; +} + +/** Mocks the authoritative getProjectItem read with a given Status option ID. */ +function mockCurrentStatus(optionId: string | undefined, statusFieldId = 'PVTSSF_status') { + mockGetProjectItem.mockResolvedValue({ + id: 'PVTI_item', + project: { id: 'PVT_project', number: 1 }, + content: { id: 'I_content', type: 'issue', title: 't', body: '', url: '', state: 'OPEN' }, + fieldValues: { + nodes: + optionId === undefined + ? [] + : [ + { + id: 'value-node', + name: 'Some Status', + optionId, + field: { id: statusFieldId, name: 'Status' }, + }, + ], + }, + }); +} + +function mockTriggerConfig(enabled: boolean, parameters: Record = {}) { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled, parameters }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GitHubProjectsStatusChangedTrigger', () => { + let trigger: GitHubProjectsStatusChangedTrigger; + + beforeEach(() => { + vi.clearAllMocks(); + mockTriggerConfig(true); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + mockGetGitHubProjectsConfig.mockReturnValue(baseConfig); + trigger = new GitHubProjectsStatusChangedTrigger(); + }); + + describe('matches', () => { + it('matches an edited event with a field-value change and no field_name hint', () => { + expect(trigger.matches(buildCtx())).toBe(true); + }); + + it('matches when the field_name hint is Status', () => { + expect(trigger.matches(buildCtx({ fieldName: 'Status' }))).toBe(true); + }); + + it('does not match a non-github-projects source', () => { + expect(trigger.matches(buildCtx({ source: 'jira' }))).toBe(false); + }); + + it('does not match non-edited actions', () => { + expect(trigger.matches(buildCtx({ action: 'created' }))).toBe(false); + }); + + it('does not match edits with no field_value change', () => { + expect(trigger.matches(buildCtx({ noFieldValue: true }))).toBe(false); + }); + + it('skips early when the field_name hint is a non-Status field', () => { + expect(trigger.matches(buildCtx({ fieldName: 'Priority' }))).toBe(false); + }); + }); + + describe('handle', () => { + it('dispatches the mapped agent using the authoritative option ID (todo → implementation)', async () => { + mockCurrentStatus('opt-todo'); + + const result = await trigger.handle(buildCtx()); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('I_content'); + expect(result?.agentInput.githubProjectsItemId).toBe('PVTI_item'); + }); + + it('returns null when the current status maps to no agent', async () => { + mockCurrentStatus('opt-done'); // done → agentType null + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('returns null when the current status is not in the configured mapping', async () => { + mockCurrentStatus('opt-unknown'); + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('returns null when the changed field is not the Status field (avoids spurious re-trigger)', async () => { + // Item currently sits in "todo", but the edit touched a different field. + mockCurrentStatus('opt-todo'); + const result = await trigger.handle(buildCtx({ fieldNodeId: 'PVTSSF_priority' })); + expect(result).toBeNull(); + }); + + it('confirms Status via the field_name hint when field_node_id is absent', async () => { + mockCurrentStatus('opt-todo'); + const result = await trigger.handle(buildCtx({ fieldNodeId: null, fieldName: 'Status' })); + expect(result?.agentType).toBe('implementation'); + }); + + it('returns null when the item has no Status field value (status cleared)', async () => { + mockCurrentStatus(undefined); + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('ignores empty {} field-value nodes the real API returns for non-single-select fields (NEW-2 regression)', async () => { + // getProjectItem selects fieldValues(first:100) but only spreads the + // ...on ProjectV2ItemFieldSingleSelectValue fragment. Every other field + // value (Title text — present on every item, dates, numbers, etc.) + // comes back as an empty {} node with no `field`. The predicate must + // guard `n.field?.name` or it throws TypeError on real data. + mockGetProjectItem.mockResolvedValue({ + id: 'PVTI_item', + project: { id: 'PVT_project', number: 1 }, + content: { id: 'I_content', type: 'issue', title: 't', body: '', url: '', state: 'OPEN' }, + fieldValues: { + nodes: [ + {}, // Title (text) value — no field in the projection + {}, // an iteration/date value — no field either + { + id: 'value-node', + name: 'Some Status', + optionId: 'opt-todo', + field: { id: 'PVTSSF_status', name: 'Status' }, + }, + ], + }, + }); + + const result = await trigger.handle(buildCtx()); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('I_content'); + }); + + it('returns null when the project has no status configuration', async () => { + mockGetGitHubProjectsConfig.mockReturnValue({ ...baseConfig, statuses: undefined }); + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + expect(mockGetProjectItem).not.toHaveBeenCalled(); + }); + + it('returns null when the trigger is disabled for the resolved agent', async () => { + mockCurrentStatus('opt-todo'); + mockTriggerConfig(false); + + const result = await trigger.handle(buildCtx()); + + expect(result).toBeNull(); + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'proj-ghp', + 'implementation', + 'pm:status-changed', + 'github-projects-status-changed', + ); + }); + + it('dispatches a custom workflow status when configured', async () => { + mockGetGitHubProjectsConfig.mockReturnValue({ + ...baseConfig, + statuses: { ...baseConfig.statuses, prd: 'opt-prd' }, + }); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + mockCurrentStatus('opt-prd'); + + const result = await trigger.handle(buildCtx()); + expect(result?.agentType).toBe('prd'); + }); + }); +}); diff --git a/tests/unit/web/github-projects-webhook-step.test.ts b/tests/unit/web/github-projects-webhook-step.test.ts new file mode 100644 index 000000000..797264860 --- /dev/null +++ b/tests/unit/web/github-projects-webhook-step.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for GitHubProjectsWebhookAdapter (issue #1 fix). + * + * GitHub Projects webhooks are set up manually, but the story differs by owner + * type. The prior copy claimed as a blanket platform fact that "CASCADE cannot + * create them programmatically" — false for org-owned projects, where + * `projects_v2_item` is a valid org-hook event creatable via the GitHub API. + * The adapter now scopes its banner + instructions to `state.githubProjectsOwnerType`: + * + * - organization → org-settings instructions, no "cannot create programmatically" claim + * - user → the genuine limitation (no webhook API/settings for user-owned projects) + */ + +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock ProjectSecretField — it uses `useQueryClient` which pulls React from +// web/node_modules (a different instance than the root-aliased React the test +// env uses), causing a null-context crash during SSR. The stub renders a +// deterministic `
` preserving the props we assert on. +vi.mock('../../../web/src/components/projects/project-secret-field.js', () => ({ + ProjectSecretField: (props: { + projectId: string; + envVarKey: string; + label: string; + description?: string; + placeholder?: string; + }) => + createElement( + 'div', + { + 'data-component': 'ProjectSecretField', + 'data-env-var-key': props.envVarKey, + 'data-project-id': props.projectId, + }, + createElement('label', null, props.label), + createElement('input', { type: 'password', placeholder: props.placeholder ?? '' }), + ), +})); + +import { + GitHubProjectsWebhookAdapter, + normalizeGitHubProjectsActiveWebhooks, +} from '../../../web/src/components/projects/pm-providers/github-projects/webhook-step.js'; +import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +function makeState(ownerType: 'user' | 'organization'): WizardState { + return { githubProjectsOwnerType: ownerType } as WizardState; +} + +function makeProviderHooks(overrides: Record = {}): Record { + return { + webhookUrl: 'https://router.example.com/github-projects/webhook', + projectIdForSecret: 'proj-123', + webhookSecretCredential: undefined, + callbackBaseUrl: 'https://router.example.com', + activeGithubProjectsWebhooks: [], + webhooksLoading: false, + createGithubProjectsWebhook: () => {}, + createLoading: false, + createError: undefined, + deleteGithubProjectsWebhook: () => {}, + deleteLoading: false, + ...overrides, + }; +} + +function render( + ownerType: 'user' | 'organization', + hookOverrides: Record = {}, +): string { + return renderToStaticMarkup( + createElement(GitHubProjectsWebhookAdapter, { + state: makeState(ownerType), + dispatch: () => {}, + providerHooks: makeProviderHooks(hookOverrides), + }), + ); +} + +describe('GitHubProjectsWebhookAdapter', () => { + it('renders the shared WebhookUrlDisplayStep with the webhook URL', () => { + const html = render('organization'); + expect(html).toContain('data-step-component="webhook-url-display"'); + expect(html).toContain('https://router.example.com/github-projects/webhook'); + }); + + it('renders a ProjectSecretField bound to GITHUB_WEBHOOK_SECRET', () => { + const html = render('organization'); + expect(html).toContain('data-env-var-key="GITHUB_WEBHOOK_SECRET"'); + expect(html).toContain('Webhook Signing Secret'); + }); + + it('does not render the ProjectSecretField when projectIdForSecret is empty', () => { + const html = render('organization', { projectIdForSecret: '' }); + expect(html).not.toContain('Webhook Signing Secret'); + }); + + it('shows an owner-appropriate info banner title', () => { + // Org owners can create programmatically, so the banner is not "Manual … Required". + expect(render('organization')).toContain('Webhook Setup'); + expect(render('organization')).not.toContain('Manual Webhook Setup Required'); + expect(render('user')).toContain('Manual Webhook Setup Required'); + }); + + it('never claims CASCADE cannot create webhooks programmatically (the removed false blanket claim)', () => { + expect(render('organization')).not.toContain('cannot create them programmatically'); + expect(render('user')).not.toContain('cannot create them programmatically'); + }); + + it('for org owners: points at organization settings and omits the user-only limitation', () => { + const html = render('organization'); + expect(html).toContain('organization'); + // Org webhooks are creatable via the API — the copy must acknowledge that + // rather than assert a platform prohibition. + expect(html).toContain('created via the GitHub API'); + expect(html).not.toContain('no create-webhook API'); + }); + + it('for user owners: states the genuine no-webhook-API limitation for user-owned projects', () => { + const html = render('user'); + expect(html).toContain('no create-webhook API'); + expect(html).toContain('projects_v2_item'); + }); + + it('for org owners: renders programmatic Create button + active-webhooks list', () => { + const html = render('organization'); + expect(html).toContain('data-action="create-webhook"'); + expect(html).toContain('data-section="active-webhooks"'); + }); + + it('for org owners: renders a delete button for each active webhook', () => { + const html = render('organization', { + activeGithubProjectsWebhooks: [ + { id: '42', url: 'https://router.example.com/github-projects/webhook', active: true }, + ], + }); + expect(html).toContain('data-action="delete-webhook"'); + expect(html).toContain('data-webhook-id="42"'); + }); + + it('for user owners: does NOT render the programmatic Create/list UI (no webhook API)', () => { + const html = render('user'); + expect(html).not.toContain('data-action="create-webhook"'); + expect(html).not.toContain('data-section="active-webhooks"'); + }); + + it('surfaces a create error when present (org owners)', () => { + const html = render('organization', { createError: 'admin:org_hook scope required' }); + expect(html).toContain('admin:org_hook scope required'); + }); +}); + +describe('normalizeGitHubProjectsActiveWebhooks', () => { + it('keeps only CASCADE github-projects hooks and drops unrelated org hooks', () => { + const active = normalizeGitHubProjectsActiveWebhooks({ + githubProjects: [ + { id: 1, active: true, config: { url: 'https://r.example.com/github-projects/webhook' } }, + { id: 2, active: false, config: { url: 'https://other.example.com/some/thing' } }, + { id: 3, active: true, config: {} }, + ], + }); + expect(active).toEqual([ + { id: '1', url: 'https://r.example.com/github-projects/webhook', active: true }, + ]); + }); + + it('returns [] for missing data', () => { + expect(normalizeGitHubProjectsActiveWebhooks(undefined)).toEqual([]); + }); +}); diff --git a/web/src/components/projects/pm-providers/github-projects/auth.ts b/web/src/components/projects/pm-providers/github-projects/auth.ts new file mode 100644 index 000000000..d855656fb --- /dev/null +++ b/web/src/components/projects/pm-providers/github-projects/auth.ts @@ -0,0 +1,16 @@ +import type { ProviderAuthMetadata, ProviderCredentialPersistenceMapping } from '../types.js'; + +export const githubProjectsAuthMetadata: ProviderAuthMetadata = { + rawCredentials: [{ role: 'token', stateField: 'githubProjectsToken' }], + storedCredentials: { fallbackWhenStateFieldEmpty: 'githubProjectsToken' }, + missingCredentialsMessage: 'Enter your GitHub Personal Access Token before verifying', +}; + +export const githubProjectsCredentialPersistence: readonly ProviderCredentialPersistenceMapping[] = + [ + { + envVarKey: 'GITHUB_TOKEN', + stateField: 'githubProjectsToken', + label: 'GitHub Personal Access Token', + }, + ]; diff --git a/web/src/components/projects/pm-providers/github-projects/hooks.ts b/web/src/components/projects/pm-providers/github-projects/hooks.ts new file mode 100644 index 000000000..4b87abd90 --- /dev/null +++ b/web/src/components/projects/pm-providers/github-projects/hooks.ts @@ -0,0 +1,232 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { Dispatch } from 'react'; +import { useEffect, useMemo } from 'react'; +import { trpcClient } from '@/lib/trpc.js'; +import type { + GitHubProjectsProjectOption, + GitHubProjectsStatusOption, + WizardAction, + WizardState, +} from '../../pm-wizard-state.js'; +import type { ProviderAuthMetadata } from '../types.js'; + +/** + * Fetches the current GitHub user to populate the owner selection. + * Uses the 'currentUser' discovery capability which returns the authenticated viewer. + */ +async function fetchCurrentUser( + token: string | undefined, + projectId: string, + hasStoredCredentials: boolean, +): Promise<{ login: string; type: 'user' } | null> { + // In edit mode with stored credentials but no token, use project-scoped discovery + if (!token && hasStoredCredentials) { + try { + const result = (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'github-projects', + capability: 'currentUser', + args: {}, + projectId, + })) as { id: string; name: string; displayName: string }; + return { login: result.displayName || result.name, type: 'user' }; + } catch { + return null; + } + } + + // Otherwise use credential-based discovery + if (!token) return null; + try { + const result = (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'github-projects', + capability: 'currentUser', + args: {}, + credentials: { token }, + })) as { id: string; name: string; displayName: string }; + return { login: result.displayName || result.name, type: 'user' }; + } catch { + return null; + } +} + +export function useGitHubProjectsDiscovery( + state: WizardState, + dispatch: Dispatch, + advanceToStep: (step: number) => void, + projectId: string, +) { + const githubProjectsProjectsMutation = useMutation({ + mutationFn: async () => { + if (state.isEditing && state.hasStoredCredentials && !state.githubProjectsToken) { + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'github-projects', + capability: 'projects', + args: { containerId: `${state.githubProjectsOwner}:${state.githubProjectsOwnerType}` }, + projectId, + })) as Array<{ id: string; name: string; url: string }>; + } + if (!state.githubProjectsToken) { + throw new Error('Enter your GitHub token before fetching projects'); + } + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'github-projects', + capability: 'projects', + args: { containerId: `${state.githubProjectsOwner}:${state.githubProjectsOwnerType}` }, + credentials: { token: state.githubProjectsToken }, + })) as Array<{ id: string; name: string; url: string }>; + }, + onSuccess: (projects) => + dispatch({ + type: 'SET_GITHUB_PROJECTS_PROJECTS', + projects: projects as GitHubProjectsProjectOption[], + }), + }); + + const githubProjectsStatusesMutation = useMutation({ + mutationFn: async (projectIdArg: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.githubProjectsToken) { + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'github-projects', + capability: 'states', + args: { containerId: projectIdArg }, + projectId, + })) as Array<{ id: string; name: string; category: string; color?: string }>; + } + if (!state.githubProjectsToken) { + throw new Error('Enter your GitHub token before fetching statuses'); + } + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'github-projects', + capability: 'states', + args: { containerId: projectIdArg }, + credentials: { token: state.githubProjectsToken }, + })) as Array<{ id: string; name: string; category: string; color?: string }>; + }, + onSuccess: (statuses) => { + dispatch({ + type: 'SET_GITHUB_PROJECTS_STATUS_OPTIONS', + options: statuses as GitHubProjectsStatusOption[], + }); + advanceToStep(4); + }, + }); + + const handleProjectSelect = (projectIdArg: string) => { + dispatch({ type: 'SET_GITHUB_PROJECTS_PROJECT_ID', id: projectIdArg }); + if (projectIdArg) { + githubProjectsStatusesMutation.mutate(projectIdArg); + } + }; + + // Auto-fetch projects when verification succeeds or owner is selected. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification/owner change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'github-projects') return; + if ( + state.githubProjectsOwner && + state.githubProjectsProjects.length === 0 && + !githubProjectsProjectsMutation.isPending + ) { + githubProjectsProjectsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult, state.githubProjectsOwner]); + + // In edit mode, auto-fetch projects and statuses. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds + useEffect(() => { + if (!state.isEditing || state.provider !== 'github-projects') return; + const canFetch = state.githubProjectsToken ? true : state.hasStoredCredentials; + if ( + state.githubProjectsOwner && + state.githubProjectsProjects.length === 0 && + canFetch && + !githubProjectsProjectsMutation.isPending + ) { + githubProjectsProjectsMutation.mutate(); + } + if ( + state.githubProjectsProjectId && + state.githubProjectsStatusOptions.length === 0 && + canFetch && + !githubProjectsStatusesMutation.isPending + ) { + githubProjectsStatusesMutation.mutate(state.githubProjectsProjectId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.githubProjectsProjectId, state.hasStoredCredentials]); + + return { + githubProjectsProjectsMutation, + githubProjectsStatusesMutation, + handleProjectSelect, + }; +} + +export function useGitHubProjectsOwnerManagement( + state: WizardState, + dispatch: Dispatch, + projectId: string, +) { + const { data: currentUser } = useQuery({ + queryKey: ['github-projects', 'currentUser', state.githubProjectsToken, projectId], + queryFn: () => + fetchCurrentUser(state.githubProjectsToken, projectId, state.hasStoredCredentials), + enabled: + state.provider === 'github-projects' && + (Boolean(state.githubProjectsToken) || state.hasStoredCredentials), + }); + + const ownerOptions = useMemo(() => { + if (currentUser) { + return [{ login: currentUser.login, type: currentUser.type }]; + } + // Fallback: if we have an owner already set in state, use that + if (state.githubProjectsOwner) { + return [{ login: state.githubProjectsOwner, type: state.githubProjectsOwnerType }]; + } + return []; + }, [currentUser, state.githubProjectsOwner, state.githubProjectsOwnerType]); + + const setOwner = (login: string, ownerType: 'user' | 'organization') => { + dispatch({ type: 'SET_GITHUB_PROJECTS_OWNER', login, ownerType }); + }; + + return { + ownerOptions, + setOwner, + }; +} + +/** + * Hook for label creation in GitHub Projects. + * + * @remarks + * This is intentionally a no-op. GitHub Projects v2 does not support programmatic + * label creation through the minimal integration. Labels in GitHub are managed + * at the repository level, not the project level, and require different permissions. + * This stub maintains API compatibility with other PM providers (Trello, JIRA, Linear) + * that do support label creation. + * + * @param _providerId - Unused. The provider identifier ('github-projects'). + * @param _auth - Unused. Provider authentication metadata. + * @param _state - Unused. Current wizard state. + * @param _dispatch - Unused. State dispatch function. + * @param _projectId - Unused. The project ID. + * @returns Stub mutations that perform no operations. + */ +export function useGitHubProjectsLabelCreation( + _providerId: string, + _auth: ProviderAuthMetadata, + _state: WizardState, + _dispatch: Dispatch, + _projectId: string, +) { + // Label creation is not supported for GitHub Projects in the minimal integration. + // Labels in GitHub are repository-scoped and require different permissions than + // project-scoped operations. This stub maintains API compatibility with other providers. + return { + createLabelMutation: { mutate: () => {}, isPending: false }, + createMissingLabelsMutation: { mutate: () => {}, isPending: false }, + }; +} diff --git a/web/src/components/projects/pm-providers/github-projects/index.ts b/web/src/components/projects/pm-providers/github-projects/index.ts new file mode 100644 index 000000000..3cf748b65 --- /dev/null +++ b/web/src/components/projects/pm-providers/github-projects/index.ts @@ -0,0 +1,12 @@ +/** + * GitHub Projects frontend provider barrel. + * Side-effect: registers the wizard definition with the global registry. + */ + +import { registerProviderWizard } from '../registry.js'; +import { githubProjectsProviderWizard } from './wizard.js'; + +registerProviderWizard(githubProjectsProviderWizard); + +export * from './state.js'; +export { githubProjectsProviderWizard }; diff --git a/web/src/components/projects/pm-providers/github-projects/state.ts b/web/src/components/projects/pm-providers/github-projects/state.ts new file mode 100644 index 000000000..fc8b51b8a --- /dev/null +++ b/web/src/components/projects/pm-providers/github-projects/state.ts @@ -0,0 +1,119 @@ +export interface GitHubProjectsOwnerOption { + login: string; + type: 'user' | 'organization'; +} + +export interface GitHubProjectsProjectOption { + id: string; + title: string; + url: string; +} + +export interface GitHubProjectsStatusOption { + id: string; + name: string; + color?: string; +} + +export interface GitHubProjectsWizardStateSlice { + githubProjectsToken: string; + githubProjectsOwner: string; + githubProjectsOwnerType: 'user' | 'organization'; + githubProjectsOwners: GitHubProjectsOwnerOption[]; + githubProjectsProjectId: string; + githubProjectsProjects: GitHubProjectsProjectOption[]; + githubProjectsStatusOptions: GitHubProjectsStatusOption[]; + githubProjectsStatusMappings: Record; +} + +interface VerificationState { + verificationResult: { provider: string; display: string } | null; + verifyError: string | null; +} + +export type GitHubProjectsWizardAction = + | { type: 'SET_GITHUB_PROJECTS_TOKEN'; value: string } + | { type: 'SET_GITHUB_PROJECTS_OWNER'; login: string; ownerType: 'user' | 'organization' } + | { type: 'SET_GITHUB_PROJECTS_OWNERS'; owners: GitHubProjectsOwnerOption[] } + | { type: 'SET_GITHUB_PROJECTS_PROJECT_ID'; id: string } + | { type: 'SET_GITHUB_PROJECTS_PROJECTS'; projects: GitHubProjectsProjectOption[] } + | { type: 'SET_GITHUB_PROJECTS_STATUS_OPTIONS'; options: GitHubProjectsStatusOption[] } + | { type: 'SET_GITHUB_PROJECTS_STATUS_MAPPING'; key: string; value: string } + | { type: 'RESET_GITHUB_PROJECTS_PROJECT_STATE' }; + +export function createInitialGitHubProjectsState(): GitHubProjectsWizardStateSlice { + return { + githubProjectsToken: '', + githubProjectsOwner: '', + githubProjectsOwnerType: 'user', + githubProjectsOwners: [], + githubProjectsProjectId: '', + githubProjectsProjects: [], + githubProjectsStatusOptions: [], + githubProjectsStatusMappings: {}, + }; +} + +export function isGitHubProjectsWizardAction(action: { + type: string; +}): action is GitHubProjectsWizardAction { + return action.type.includes('GITHUB_PROJECTS'); +} + +export function githubProjectsWizardReducer< + T extends GitHubProjectsWizardStateSlice & VerificationState, +>(state: T, action: GitHubProjectsWizardAction): T { + switch (action.type) { + case 'SET_GITHUB_PROJECTS_TOKEN': + return { + ...state, + githubProjectsToken: action.value, + verificationResult: null, + verifyError: null, + }; + case 'SET_GITHUB_PROJECTS_OWNER': + return { + ...state, + githubProjectsOwner: action.login, + githubProjectsOwnerType: action.ownerType, + ...resetGitHubProjectsProjectState(), + }; + case 'SET_GITHUB_PROJECTS_OWNERS': + return { ...state, githubProjectsOwners: action.owners }; + case 'SET_GITHUB_PROJECTS_PROJECT_ID': + return { + ...state, + githubProjectsProjectId: action.id, + githubProjectsStatusMappings: {}, + }; + case 'SET_GITHUB_PROJECTS_PROJECTS': + return { ...state, githubProjectsProjects: action.projects }; + case 'SET_GITHUB_PROJECTS_STATUS_OPTIONS': + return { ...state, githubProjectsStatusOptions: action.options }; + case 'SET_GITHUB_PROJECTS_STATUS_MAPPING': + return { + ...state, + githubProjectsStatusMappings: { + ...state.githubProjectsStatusMappings, + [action.key]: action.value, + }, + }; + case 'RESET_GITHUB_PROJECTS_PROJECT_STATE': + return { ...state, ...resetGitHubProjectsProjectState() }; + } +} + +export function resetGitHubProjectsProjectState(): Pick< + GitHubProjectsWizardStateSlice, + | 'githubProjectsProjectId' + | 'githubProjectsProjects' + | 'githubProjectsStatusOptions' + | 'githubProjectsStatusMappings' +> { + return { + githubProjectsProjectId: '', + githubProjectsProjects: [], + githubProjectsStatusOptions: [], + githubProjectsStatusMappings: {}, + }; +} diff --git a/web/src/components/projects/pm-providers/github-projects/webhook-step.tsx b/web/src/components/projects/pm-providers/github-projects/webhook-step.tsx new file mode 100644 index 000000000..900b5d206 --- /dev/null +++ b/web/src/components/projects/pm-providers/github-projects/webhook-step.tsx @@ -0,0 +1,319 @@ +/** + * GitHub Projects webhook step adapter. + * + * The setup story differs by owner type: + * + * - **organization** — `projects_v2_item` is a valid org-hook event, so the + * webhook can be created **programmatically** (`POST /orgs/{org}/hooks`) — the + * step renders a "Create Webhook" button + active-webhooks list + delete + * (mirroring Trello/JIRA), with manual Organization Settings → Webhooks + * instructions as a fallback. + * - **user** — user-owned Projects have no webhook settings page and no + * create-webhook API; events can only reach CASCADE via an org-owned project + * or a GitHub App subscribed to `projects_v2_item`. Manual setup only. + * + * All tRPC wiring (webhooks.list/create/delete with githubProjectsOnly:true) + * lives in the wizard's `useProviderHooks`; this component renders what it gets. + */ + +import { Info, Loader2, Trash2 } from 'lucide-react'; +import { createElement, Fragment, type ReactElement } from 'react'; +import { Button } from '@/components/ui/button.js'; +import type { DataProps } from '@/lib/data-props.js'; +import { type ProjectCredentialMeta, ProjectSecretField } from '../../project-secret-field.js'; +import { WebhookUrlDisplayStep } from '../steps/webhook-url-display.js'; +import type { ProviderWizardStepProps } from '../types.js'; + +export interface ActiveWebhook { + readonly id: string; + readonly url: string; + readonly active: boolean; +} + +export interface GitHubProjectsWebhookListData { + readonly githubProjects?: ReadonlyArray<{ + readonly id: string | number; + readonly active?: boolean; + readonly config?: { readonly url?: string }; + }>; +} + +/** + * Normalize the `webhooks.list` payload's github-projects org hooks for the UI. + * Org webhooks can include hooks from other integrations, so we show only + * CASCADE's own (`…/github-projects/webhook`) — the delete path matches the same + * callback URL, so unrelated org hooks are neither listed nor deletable here. + */ +export function normalizeGitHubProjectsActiveWebhooks( + webhooksData: GitHubProjectsWebhookListData | undefined, +): ActiveWebhook[] { + return (webhooksData?.githubProjects ?? []) + .filter((webhook) => (webhook.config?.url ?? '').endsWith('/github-projects/webhook')) + .map((webhook) => ({ + id: String(webhook.id), + url: webhook.config?.url ?? '', + active: webhook.active ?? true, + })); +} + +interface GitHubProjectsWebhookProviderHooks { + readonly webhookUrl: string; + readonly projectIdForSecret: string; + readonly webhookSecretCredential: ProjectCredentialMeta | undefined; + // Org-owned programmatic webhook management (undefined for user-owned). + readonly callbackBaseUrl?: string; + readonly activeGithubProjectsWebhooks?: ReadonlyArray; + readonly webhooksLoading?: boolean; + readonly createGithubProjectsWebhook?: () => void; + readonly createLoading?: boolean; + readonly createError?: string | undefined; + readonly deleteGithubProjectsWebhook?: (callbackBaseUrl: string) => void; + readonly deleteLoading?: boolean; +} + +function asGitHubProjectsWebhookHooks( + providerHooks: Record | undefined, +): GitHubProjectsWebhookProviderHooks { + return (providerHooks ?? {}) as unknown as GitHubProjectsWebhookProviderHooks; +} + +/** Body copy for the info banner, scoped to owner type (see file header). */ +function bannerBody(isOrg: boolean): string { + return isOrg + ? 'Organization webhooks can be created via the GitHub API — use the "Create Webhook" button ' + + 'below, or add it manually in your organization settings and enable the Projects v2 events.' + : "User-owned GitHub Projects have no webhook settings page and no create-webhook API. To receive Projects v2 events, move the project under an organization (Organization Settings → Webhooks) or use a GitHub App subscribed to 'projects_v2_item'."; +} + +/** The shared WebhookUrlDisplayStep's inline instruction line. */ +function urlDisplayInstructions(isOrg: boolean): string { + return isOrg + ? 'Click "Create Webhook" to register automatically, or configure this URL in your GitHub organization settings.' + : 'Configure this webhook URL in the organization or GitHub App that receives your project events.'; +} + +/** Owner-aware "where to add the webhook" first step; the rest are shared. */ +function locationStep(isOrg: boolean): ReactElement { + return isOrg + ? createElement( + 'li', + { key: 'loc' }, + 'Go to your GitHub ', + createElement('strong', null, 'organization'), + ' Settings → Webhooks.', + ) + : createElement( + 'li', + { key: 'loc' }, + 'User-owned projects have no webhook settings — receive events through the ', + createElement('strong', null, 'organization'), + ' that owns the project (Organization Settings → Webhooks) or a GitHub App subscribed to ', + createElement('code', null, 'projects_v2_item'), + '.', + ); +} + +function instructionSteps(isOrg: boolean): ReactElement[] { + return [ + locationStep(isOrg), + createElement('li', { key: 'add' }, 'Click "Add webhook" and enter the URL above.'), + createElement( + 'li', + { key: 'content-type' }, + 'Set Content type to ', + createElement('code', null, 'application/json'), + '.', + ), + createElement( + 'li', + { key: 'events' }, + 'Select "Let me select individual events" and enable ', + createElement('strong', null, 'Projects v2'), + ' events.', + ), + createElement( + 'li', + { key: 'secret' }, + 'If you set a secret in GitHub, paste it into the field above so CASCADE can verify webhook authenticity.', + ), + ]; +} + +function infoBanner(isOrg: boolean): ReactElement { + return createElement( + 'div', + { + className: + 'rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-900/50 dark:bg-blue-900/20', + 'data-section': 'info-banner', + }, + createElement( + 'div', + { className: 'flex items-start gap-2' }, + createElement(Info, { + className: 'h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5', + }), + createElement( + 'div', + { className: 'space-y-1' }, + createElement( + 'p', + { className: 'text-sm font-medium text-blue-700 dark:text-blue-300' }, + isOrg ? 'Webhook Setup' : 'Manual Webhook Setup Required', + ), + createElement( + 'p', + { className: 'text-xs text-blue-600 dark:text-blue-400' }, + bannerBody(isOrg), + ), + ), + ), + ); +} + +/** Active-webhooks list (org-owned only). */ +function activeWebhookList(h: GitHubProjectsWebhookProviderHooks): ReactElement { + const active = h.activeGithubProjectsWebhooks ?? []; + return createElement( + 'div', + { className: 'space-y-2', 'data-section': 'active-webhooks' }, + h.webhooksLoading + ? createElement( + 'p', + { + 'data-state': 'loading', + className: 'flex items-center gap-2 text-sm text-muted-foreground', + }, + createElement(Loader2, { className: 'h-4 w-4 animate-spin' }), + 'Loading webhooks…', + ) + : active.length === 0 + ? createElement( + 'p', + { className: 'text-sm text-amber-600 dark:text-amber-400' }, + 'No GitHub Projects webhooks configured for this organization.', + ) + : createElement( + 'ul', + { className: 'space-y-1' }, + ...active.map((wh) => + createElement( + 'li', + { + key: wh.id, + className: 'flex items-center justify-between rounded-md border px-3 py-2', + 'data-webhook-id': wh.id, + }, + createElement( + 'div', + { className: 'flex items-center gap-2 text-sm' }, + createElement('span', { + className: `inline-block h-2 w-2 rounded-full ${wh.active ? 'bg-green-500 dark:bg-green-400' : 'bg-amber-500 dark:bg-amber-400'}`, + 'data-active': wh.active ? 'true' : 'false', + }), + createElement('code', { className: 'font-mono text-xs break-all' }, wh.url), + ), + createElement( + Button, + { + type: 'button', + variant: 'ghost', + size: 'icon-sm', + 'data-action': 'delete-webhook', + 'data-webhook-id': wh.id, + disabled: h.deleteLoading, + onClick: () => { + // Strip the trailing /github-projects/webhook to recover the base URL. + const base = wh.url.replace(/\/github-projects\/webhook$/, ''); + h.deleteGithubProjectsWebhook?.(base); + }, + title: 'Delete webhook', + } as React.ComponentProps & DataProps, + createElement(Trash2, { className: 'h-4 w-4' }), + ), + ), + ), + ), + ); +} + +/** "Create Webhook" button (org-owned only). */ +function createButton(h: GitHubProjectsWebhookProviderHooks): ReactElement { + const createDisabled = !h.callbackBaseUrl || h.createLoading; + return createElement( + 'div', + { className: 'space-y-2' }, + createElement( + Button, + { + type: 'button', + variant: 'default', + 'data-action': 'create-webhook', + disabled: createDisabled, + onClick: () => h.createGithubProjectsWebhook?.(), + } as React.ComponentProps & DataProps, + h.createLoading ? createElement(Loader2, { className: 'h-4 w-4 animate-spin' }) : null, + h.createLoading ? 'Creating…' : 'Create Webhook', + ), + h.createError + ? createElement( + 'p', + { className: 'text-sm text-destructive', 'data-state': 'error' }, + h.createError, + ) + : null, + ); +} + +export function GitHubProjectsWebhookAdapter({ + state, + providerHooks, +}: ProviderWizardStepProps): ReactElement { + const h = asGitHubProjectsWebhookHooks(providerHooks); + const isOrg = state.githubProjectsOwnerType === 'organization'; + + return createElement( + Fragment, + null, + infoBanner(isOrg), + WebhookUrlDisplayStep({ + step: { + kind: 'webhook-url-display', + id: 'github-projects-webhook', + config: { instructions: urlDisplayInstructions(isOrg) }, + }, + providerId: 'github-projects', + webhookUrl: h.webhookUrl, + }), + // Programmatic create/list/delete — organization-owned projects only. + isOrg ? activeWebhookList(h) : null, + isOrg ? createButton(h) : null, + h.projectIdForSecret + ? createElement(ProjectSecretField, { + projectId: h.projectIdForSecret, + envVarKey: 'GITHUB_WEBHOOK_SECRET', + label: 'Webhook Signing Secret (optional)', + description: + 'Paste the signing secret from your GitHub webhook. CASCADE verifies HMAC-SHA256 on every incoming GitHub Projects webhook request when this is set; verification is skipped if left blank.', + placeholder: 'ghp_...', + credential: h.webhookSecretCredential, + }) + : null, + createElement( + 'div', + { className: 'space-y-2' }, + createElement( + 'p', + { className: 'text-xs text-muted-foreground font-medium' }, + isOrg ? 'Manual setup (alternative):' : 'Setup instructions:', + ), + createElement( + 'ol', + { + className: 'list-decimal list-inside space-y-1 text-xs text-muted-foreground pl-1', + }, + ...instructionSteps(isOrg), + ), + ), + ); +} diff --git a/web/src/components/projects/pm-providers/github-projects/wizard.ts b/web/src/components/projects/pm-providers/github-projects/wizard.ts new file mode 100644 index 000000000..8b7e996ec --- /dev/null +++ b/web/src/components/projects/pm-providers/github-projects/wizard.ts @@ -0,0 +1,348 @@ +/** + * GitHub Projects ProviderWizardDefinition. + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import type { ProjectCredentialMeta } from '../../project-secret-field.js'; +import { buildMissingStatusTriggerConfigs } from '../save-trigger-configs.js'; +import { ContainerPickStep } from '../steps/container-pick.js'; +import { CredentialsStep } from '../steps/credentials.js'; +import { ProjectScopeStep } from '../steps/project-scope.js'; +import { StatusMappingStep } from '../steps/status-mapping.js'; +import type { ProviderWizardDefinition, ProviderWizardStepProps } from '../types.js'; +import { githubProjectsAuthMetadata, githubProjectsCredentialPersistence } from './auth.js'; +import { + useGitHubProjectsDiscovery, + useGitHubProjectsLabelCreation, + useGitHubProjectsOwnerManagement, +} from './hooks.js'; +import { + type ActiveWebhook, + GitHubProjectsWebhookAdapter, + normalizeGitHubProjectsActiveWebhooks, +} from './webhook-step.js'; + +export const GITHUB_PROJECTS_STATUS_SLOTS = [ + { key: 'backlog', label: 'Backlog' }, + { key: 'todo', label: 'Todo' }, + { key: 'inProgress', label: 'In Progress' }, + { key: 'inReview', label: 'In Review' }, + { key: 'done', label: 'Done' }, + { key: 'merged', label: 'Merged' }, + { key: 'alerts', label: 'Alerts' }, + { key: 'friction', label: 'Friction' }, +] as const; + +const GITHUB_PROJECTS_CREDENTIAL_ROLES = [{ role: 'token', label: 'GitHub Personal Access Token' }]; + +function isCredentialsComplete(state: { + githubProjectsToken: string; + verificationResult: unknown; + isEditing: boolean; + hasStoredCredentials: boolean; +}): boolean { + if (state.isEditing && state.hasStoredCredentials) return true; + return Boolean(state.githubProjectsToken && state.verificationResult); +} + +function areRequiredStepsDone( + state: Parameters[0] & { + githubProjectsProjectId: string; + githubProjectsStatusMappings: Record; + }, +): boolean { + return ( + isCredentialsComplete(state) && + Boolean(state.githubProjectsProjectId) && + Object.keys(state.githubProjectsStatusMappings).length > 0 + ); +} + +interface GitHubProjectsProviderHooks { + readonly projectOptions: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly projectsLoading: boolean; + readonly projectsError: string | undefined; + readonly onProjectSelect: (projectId: string) => void; + readonly statusOptionsLoading: boolean; + readonly providerStates: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly webhookUrl: string; + readonly projectIdForSecret: string; + readonly webhookSecretCredential: ProjectCredentialMeta | undefined; + readonly workflowStatuses: ReadonlyArray<{ readonly key: string; readonly label: string }>; + // Org-owned programmatic webhook management (consumed by GitHubProjectsWebhookAdapter). + readonly callbackBaseUrl: string; + readonly activeGithubProjectsWebhooks: ReadonlyArray; + readonly webhooksLoading: boolean; + readonly createGithubProjectsWebhook: () => void; + readonly createLoading: boolean; + readonly createError: string | undefined; + readonly deleteGithubProjectsWebhook: (callbackBaseUrl: string) => void; + readonly deleteLoading: boolean; +} + +function asGitHubProjectsHooks( + providerHooks: Record | undefined, +): GitHubProjectsProviderHooks { + return (providerHooks ?? {}) as unknown as GitHubProjectsProviderHooks; +} + +function GitHubProjectsCredentialsAdapter({ + state, + dispatch, +}: ProviderWizardStepProps): ReactElement { + return CredentialsStep({ + step: { kind: 'credentials', id: 'github-projects-credentials' }, + providerId: 'github-projects', + credentialRoles: GITHUB_PROJECTS_CREDENTIAL_ROLES, + values: { token: state.githubProjectsToken }, + onChange: (role, value) => { + if (role === 'token') dispatch({ type: 'SET_GITHUB_PROJECTS_TOKEN', value }); + }, + }); +} + +function GitHubProjectsScopeAdapter({ + state, + dispatch, + providerHooks, +}: ProviderWizardStepProps): ReactElement { + const h = providerHooks as { projectIdForSecret: string } | undefined; + const { ownerOptions, setOwner } = useGitHubProjectsOwnerManagement( + state, + dispatch, + h?.projectIdForSecret ?? '', + ); + const selectedOwner = ownerOptions.find( + (o) => o.login === state.githubProjectsOwner && o.type === state.githubProjectsOwnerType, + ); + return ProjectScopeStep({ + step: { kind: 'project-scope', id: 'github-projects-scope' }, + providerId: 'github-projects', + projects: ownerOptions.map((o) => ({ + id: `${o.login}:${o.type}`, + name: `${o.login} (${o.type})`, + })), + selectedProjectId: selectedOwner ? `${selectedOwner.login}:${selectedOwner.type}` : null, + onSelect: (v) => { + if (!v) return; + const [login, ownerType] = v.split(':') as [string, 'user' | 'organization']; + setOwner(login, ownerType); + }, + loading: false, + }); +} + +function GitHubProjectsContainerPickAdapter({ + state, + dispatch, + providerHooks, +}: ProviderWizardStepProps): ReactElement { + const h = asGitHubProjectsHooks(providerHooks); + return ContainerPickStep({ + step: { kind: 'container-pick', id: 'github-projects-selection' }, + providerId: 'github-projects', + label: 'Select Project', + options: h.projectOptions, + selectedId: state.githubProjectsProjectId || null, + onSelect: (id) => { + if (id) h.onProjectSelect(id); + else dispatch({ type: 'SET_GITHUB_PROJECTS_PROJECT_ID', id: '' }); + }, + loading: h.projectsLoading, + error: h.projectsError, + searchable: true, + }); +} + +function GitHubProjectsStatusMappingAdapter({ + state, + dispatch, + providerHooks, +}: ProviderWizardStepProps): ReactElement { + const h = asGitHubProjectsHooks(providerHooks); + return StatusMappingStep({ + step: { kind: 'status-mapping', id: 'github-projects-statuses' }, + providerId: 'github-projects', + cascadeStatuses: + h.workflowStatuses.length > 0 ? h.workflowStatuses : GITHUB_PROJECTS_STATUS_SLOTS, + providerStates: h.providerStates, + mappings: state.githubProjectsStatusMappings, + onMappingChange: (key, value) => + dispatch({ type: 'SET_GITHUB_PROJECTS_STATUS_MAPPING', key, value }), + loading: h.statusOptionsLoading, + }); +} + +export const githubProjectsProviderWizard: ProviderWizardDefinition = { + id: 'github-projects', + label: 'GitHub Projects', + auth: githubProjectsAuthMetadata, + formatVerificationDisplay: (me) => me.name || me.login, + credentialPersistence: githubProjectsCredentialPersistence, + + steps: [ + { + id: 'github-projects-credentials', + title: 'GitHub credentials', + Component: GitHubProjectsCredentialsAdapter, + isComplete: isCredentialsComplete, + }, + { + id: 'github-projects-scope', + title: 'Owner', + Component: GitHubProjectsScopeAdapter, + isComplete: (state) => Boolean(state.githubProjectsOwner), + }, + { + id: 'github-projects-selection', + title: 'Project', + Component: GitHubProjectsContainerPickAdapter, + isComplete: (state) => Boolean(state.githubProjectsProjectId), + }, + { + id: 'github-projects-statuses', + title: 'Status mapping', + Component: GitHubProjectsStatusMappingAdapter, + isComplete: (state) => Object.keys(state.githubProjectsStatusMappings).length > 0, + }, + { + id: 'github-projects-webhook', + title: 'Webhook', + Component: GitHubProjectsWebhookAdapter, + isComplete: (state) => areRequiredStepsDone(state), + }, + ], + + buildIntegrationConfig: (state) => ({ + projectId: state.githubProjectsProjectId, + owner: state.githubProjectsOwner, + ownerType: state.githubProjectsOwnerType, + statuses: state.githubProjectsStatusMappings, + }), + + buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => + buildMissingStatusTriggerConfigs({ + statusMappings: state.githubProjectsStatusMappings, + workflowStatuses, + existingConfigs, + }), + + buildEditState: (initialConfig, configuredKeys) => { + const config = initialConfig as { + projectId?: string; + owner?: string; + ownerType?: 'user' | 'organization'; + statuses?: Record; + }; + return { + provider: 'github-projects', + githubProjectsProjectId: config.projectId ?? '', + githubProjectsOwner: config.owner ?? '', + githubProjectsOwnerType: config.ownerType ?? 'user', + ...(config.statuses ? { githubProjectsStatusMappings: config.statuses } : {}), + hasStoredCredentials: configuredKeys.has('GITHUB_TOKEN'), + }; + }, + + isSetupComplete: (state) => { + if (!state.githubProjectsProjectId) return false; + if (Object.keys(state.githubProjectsStatusMappings).length === 0) return false; + return isCredentialsComplete(state); + }, + + useProviderHooks: ({ providerId, auth, state, dispatch, projectId, advanceToStep }) => { + const discovery = useGitHubProjectsDiscovery(state, dispatch, advanceToStep, projectId ?? ''); + const labels = useGitHubProjectsLabelCreation( + providerId, + auth, + state, + dispatch, + projectId ?? '', + ); + const credentialsQuery = useQuery( + trpc.projects.credentials.list.queryOptions({ projectId: projectId ?? '' }), + ); + const workflowStatusesQuery = useQuery(trpc.workflowStatuses.list.queryOptions()); + const webhookSecretCredential = credentialsQuery.data?.find( + (c) => c.envVarKey === 'GITHUB_WEBHOOK_SECRET', + ); + + const routerOrigin = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + const webhookUrl = routerOrigin ? `${routerOrigin}/github-projects/webhook` : ''; + + // Programmatic org-webhook management (mirrors Trello/JIRA). The backend + // no-ops for user-owned projects, so it's safe to always wire the hooks. + const queryClient = useQueryClient(); + const callbackBaseUrl = routerOrigin; + const webhooksListOptions = trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' }); + const webhooksQuery = useQuery({ + ...webhooksListOptions, + enabled: state.githubProjectsOwnerType === 'organization' && Boolean(projectId), + }); + const activeGithubProjectsWebhooks = normalizeGitHubProjectsActiveWebhooks(webhooksQuery.data); + // Carry the just-entered token so create works before it is persisted. + const oneTimeTokens = state.githubProjectsToken + ? { githubProjectsToken: state.githubProjectsToken } + : undefined; + const invalidateWebhooks = () => + queryClient.invalidateQueries({ queryKey: webhooksListOptions.queryKey }); + const createWebhookMutation = useMutation({ + mutationFn: () => + trpcClient.webhooks.create.mutate({ + projectId: projectId ?? '', + callbackBaseUrl, + githubProjectsOnly: true, + oneTimeTokens, + }), + onSuccess: invalidateWebhooks, + }); + const deleteWebhookMutation = useMutation({ + mutationFn: (deleteBaseUrl: string) => + trpcClient.webhooks.delete.mutate({ + projectId: projectId ?? '', + callbackBaseUrl: deleteBaseUrl, + githubProjectsOnly: true, + oneTimeTokens, + }), + onSuccess: invalidateWebhooks, + }); + + return { + projectOptions: state.githubProjectsProjects.map((p) => ({ id: p.id, name: p.title })), + projectsLoading: discovery.githubProjectsProjectsMutation.isPending, + projectsError: discovery.githubProjectsProjectsMutation.isError + ? (discovery.githubProjectsProjectsMutation.error as Error).message + : undefined, + onProjectSelect: discovery.handleProjectSelect, + statusOptionsLoading: discovery.githubProjectsStatusesMutation.isPending, + providerStates: state.githubProjectsStatusOptions.map((s) => ({ + id: s.id, + name: s.name, + })), + webhookUrl, + projectIdForSecret: projectId ?? '', + webhookSecretCredential, + workflowStatuses: + workflowStatusesQuery.data?.map((status) => ({ + key: status.key, + label: status.label, + })) ?? GITHUB_PROJECTS_STATUS_SLOTS, + callbackBaseUrl, + activeGithubProjectsWebhooks, + webhooksLoading: webhooksQuery.isLoading, + createGithubProjectsWebhook: () => createWebhookMutation.mutate(), + createLoading: createWebhookMutation.isPending, + createError: createWebhookMutation.isError + ? (createWebhookMutation.error as Error).message + : undefined, + deleteGithubProjectsWebhook: (baseUrl: string) => deleteWebhookMutation.mutate(baseUrl), + deleteLoading: deleteWebhookMutation.isPending, + ...labels, + } satisfies GitHubProjectsProviderHooks & Record; + }, +}; diff --git a/web/src/components/projects/pm-providers/index.ts b/web/src/components/projects/pm-providers/index.ts index 78278ef0b..eafa293be 100644 --- a/web/src/components/projects/pm-providers/index.ts +++ b/web/src/components/projects/pm-providers/index.ts @@ -22,3 +22,4 @@ import './trello/index.js'; import './jira/index.js'; import './linear/index.js'; +import './github-projects/index.js'; diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 04a53e6d8..35f675b59 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -3,6 +3,14 @@ * Has zero imports from other pm-wizard files to avoid circular dependencies. */ import type { Reducer } from 'react'; +import { + createInitialGitHubProjectsState, + type GitHubProjectsProjectOption, + type GitHubProjectsStatusOption, + type GitHubProjectsWizardAction, + githubProjectsWizardReducer, + isGitHubProjectsWizardAction, +} from './pm-providers/github-projects/state.js'; import { createInitialJiraState, INITIAL_JIRA_LABELS, @@ -33,6 +41,8 @@ import { } from './pm-providers/trello/state.js'; export type { + GitHubProjectsProjectOption, + GitHubProjectsStatusOption, JiraProjectDetails, JiraProjectOption, LinearProjectOption, @@ -73,6 +83,9 @@ export interface WizardState { jiraBaseUrl: string; jiraAuthType: JiraWizardAuthType; linearApiKey: string; + githubProjectsToken: string; + githubProjectsOwner: string; + githubProjectsOwnerType: 'user' | 'organization'; verificationResult: { provider: Provider; display: string } | null; verifyError: string | null; // Step 3: Board/Project @@ -84,6 +97,10 @@ export interface WizardState { linearTeams: LinearTeamOption[]; linearProjectId: string; linearProjects: LinearProjectOption[]; + githubProjectsOwners: Array<{ login: string; type: 'user' | 'organization' }>; + githubProjectsProjects: GitHubProjectsProjectOption[]; + githubProjectsProjectId: string; + githubProjectsStatusOptions: GitHubProjectsStatusOption[]; // Step 4: Field mapping trelloBoardDetails: TrelloBoardDetails | null; jiraProjectDetails: JiraProjectDetails | null; @@ -100,6 +117,8 @@ export interface WizardState { // Linear mappings linearStatusMappings: Record; linearLabels: Record; + // GitHub Projects mappings + githubProjectsStatusMappings: Record; // Editing mode isEditing: boolean; hasStoredCredentials: boolean; // true in edit mode when provider credentials exist in project_credentials @@ -121,7 +140,8 @@ export type WizardAction = | { type: 'INIT_EDIT'; state: Partial } | TrelloWizardAction | JiraWizardAction - | LinearWizardAction; + | LinearWizardAction + | GitHubProjectsWizardAction; // ============================================================================ // Initial state and constants @@ -135,6 +155,7 @@ export function createInitialState(): WizardState { ...createInitialTrelloState(), ...createInitialJiraState(), ...createInitialLinearState(), + ...createInitialGitHubProjectsState(), isEditing: false, hasStoredCredentials: false, }; @@ -167,6 +188,7 @@ export const wizardReducer: Reducer = (state, action) if (isTrelloWizardAction(action)) return trelloWizardReducer(state, action); if (isJiraWizardAction(action)) return jiraWizardReducer(state, action); if (isLinearWizardAction(action)) return linearWizardReducer(state, action); + if (isGitHubProjectsWizardAction(action)) return githubProjectsWizardReducer(state, action); return state; } }; @@ -197,5 +219,6 @@ export function shouldUseStoredCredentials(state: WizardState): boolean { if (!state.isEditing || !state.hasStoredCredentials) return false; if (state.provider === 'trello') return !state.trelloApiKey; if (state.provider === 'jira') return !state.jiraApiToken; + if (state.provider === 'github-projects') return !state.githubProjectsToken; return !state.linearApiKey; } From ac29abf227f36705e1254c93729b510588a83eca Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 17 Jul 2026 09:47:55 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(github-projects):=20address=20PR=20revi?= =?UTF-8?q?ew=20=E2=80=94=20blank=20project=20picker,=20work-item=20URL,?= =?UTF-8?q?=20and=20follow-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 `//projects/?item_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 --- docs/architecture/06-integration-layer.md | 1 + src/agents/shared/promptContext.ts | 5 +- .../pm/github-projects/config-schema.ts | 4 +- .../pm/github-projects/manifest.ts | 12 +++- src/pm/github-projects/adapter.ts | 72 +++++++++++++------ .../unit/integrations/pm-conformance.test.ts | 3 + tests/unit/pm/github-projects/adapter.test.ts | 47 ++++++++++++ .../pm-providers/github-projects/hooks.ts | 6 +- .../pm-providers/github-projects/state.ts | 2 +- .../pm-providers/github-projects/wizard.ts | 4 +- 10 files changed, 123 insertions(+), 33 deletions(-) diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md index 2be8f0f93..826315bfa 100644 --- a/docs/architecture/06-integration-layer.md +++ b/docs/architecture/06-integration-layer.md @@ -160,6 +160,7 @@ Each provider declares its credential roles — the mapping from logical role na |--------|----------|-------------| | `getAttachments` / `addAttachment*` | `[]` / no-op | formal attachments unavailable (inline-pasted images **are** delivered via the shared media pipeline) | | `getCustomFieldNumber` / `updateCustomFieldNumber` | `0` / no-op | no cost/budget custom-field tracking (GitHub Projects number fields exist but are not wired — parity with Linear's stub) | + | `getWorkItem` / `listWorkItems` label **reads** | always `labels: []` | label **writes** (`addLabel` / `removeLabel`) work, but reads never surface current labels — label-conditioned logic can't observe GitHub Projects label state | | `linkPR` | no-op | PRs link implicitly by being added to the project | ### GitHub (`src/github/`) diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index cf649fafa..1c5a082b7 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -66,7 +66,10 @@ function getListIds(project: ProjectConfig) { trelloConfig?.labels?.auto ?? jiraConfig?.labels?.auto ?? linearConfig?.labels?.auto ?? - githubProjectsConfig?.labels?.processing, + // GitHub Projects has no dedicated `auto` label; `readyToProcess` + // (cascade-ready) is the closest analog to the other providers' auto + // label. `processing` means "an agent is already working" — the opposite. + githubProjectsConfig?.labels?.readyToProcess, }; } diff --git a/src/integrations/pm/github-projects/config-schema.ts b/src/integrations/pm/github-projects/config-schema.ts index 81368ebc6..1cd5eb0ae 100644 --- a/src/integrations/pm/github-projects/config-schema.ts +++ b/src/integrations/pm/github-projects/config-schema.ts @@ -17,7 +17,9 @@ export const githubProjectsConfigSchema = z /** * Mapping from CASCADE status keys (todo/inProgress/done/etc.) to - * GitHub Projects Status single-select option node IDs (PVTSSF_xxx). + * GitHub Projects Status single-select *option* IDs. Option IDs are short + * opaque hashes (e.g. `47fc9ee4`), NOT the `PVTSSF_…`-prefixed value — + * that prefix identifies the single-select *field*, not its options. */ statuses: z.record(z.string(), z.string()), diff --git a/src/integrations/pm/github-projects/manifest.ts b/src/integrations/pm/github-projects/manifest.ts index 7ab50cfa1..7ff53c3b7 100644 --- a/src/integrations/pm/github-projects/manifest.ts +++ b/src/integrations/pm/github-projects/manifest.ts @@ -167,15 +167,21 @@ export const githubProjectsManifest: PMProviderManifest = { ], }, + // Opt into the behavioral conformance harness's full lifecycle scenario, + // giving github-projects parity with Trello/JIRA/Linear. The fixture keyed + // by 'github-projects' lives in the test-only LIFECYCLE_FIXTURES registry. + lifecycle: { enabled: true, fixtureKey: 'github-projects' }, + configSchema: githubProjectsConfigSchema, configFixture: { projectId: 'PVT_xxx', owner: 'username', ownerType: 'user', + // Status *option* IDs are short opaque hashes, not the `PVTSSF_…` field ID. statuses: { - todo: 'PVTSSF_xxx', - inProgress: 'PVTSSF_yyy', - done: 'PVTSSF_zzz', + todo: '47fc9ee4', + inProgress: '98236657', + done: 'f75ad846', }, }, diff --git a/src/pm/github-projects/adapter.ts b/src/pm/github-projects/adapter.ts index 35700ace1..64a81ab13 100644 --- a/src/pm/github-projects/adapter.ts +++ b/src/pm/github-projects/adapter.ts @@ -20,6 +20,7 @@ import { resolveProjectItemId, updateComment, } from '../../github-projects/client.js'; +import type { GitHubProjectItem } from '../../github-projects/types.js'; import { logger } from '../../utils/logging.js'; import { parseRepoFullName } from '../../utils/repo.js'; import { withDescriptionMutationLock } from '../_shared/description-mutation-lock.js'; @@ -48,22 +49,6 @@ import type { WorkItemComment, } from '../types.js'; -const CASCADE_STATUS_KEYS = new Set([ - 'backlog', - 'todo', - 'inProgress', - 'inReview', - 'done', - 'merged', - 'cancelled', - 'canceled', - 'splitting', - 'planning', - 'debug', - 'friction', - 'alerts', -]); - function resolveGitHubProjectsStatusFilter( status: string | undefined, configStatuses: GitHubProjectsConfig['statuses'] | undefined, @@ -71,8 +56,13 @@ function resolveGitHubProjectsStatusFilter( if (!status) return undefined; const mapped = configStatuses?.[status]; if (mapped) return mapped; - if (CASCADE_STATUS_KEYS.has(status)) return null; - return status.startsWith('PVTSSF_') ? status : null; + // Any status without a configured mapping lists nothing (`null`) — a known + // CASCADE key with no mapping, or an unknown/custom key. GitHub Projects + // Status *option* IDs are short opaque hashes with no stable prefix (the + // `PVTSSF_` prefix identifies the single-select *field*, not its options), so + // a raw option ID can't be distinguished from an unmapped key here — and every + // caller passes a CASCADE status key, so raw-option-id passthrough is unneeded. + return null; } export class GitHubProjectsPMProvider implements PMProvider { @@ -90,6 +80,29 @@ export class GitHubProjectsPMProvider implements PMProvider { private repoFullName?: string, ) {} + /** + * In-flight de-duplication of full-board fetches, keyed by project node ID. + * + * GitHub Projects v2 has no server-side field filter, so `listWorkItems({status})` + * must page the entire board. The pipeline-capacity gate fires three concurrent + * `listWorkItems` calls (todo/inProgress/inReview) per dispatch — without + * coalescing that is three full board paginations. Memoizing the in-flight + * `listAllProjectItems` promise collapses the concurrent burst into a single + * pagination, then clears the entry the moment it settles, so separate + * (non-concurrent) capacity checks always re-fetch and never observe a stale board. + */ + private readonly inFlightListAll = new Map>(); + + private listAllProjectItemsCoalesced(projectId: string): Promise { + const inFlight = this.inFlightListAll.get(projectId); + if (inFlight) return inFlight; + const promise = listAllProjectItems(projectId).finally(() => { + this.inFlightListAll.delete(projectId); + }); + this.inFlightListAll.set(projectId, promise); + return promise; + } + async getWorkItem(id: string): Promise { // `id` is the content (Issue/PR) node ID used across the github-projects // path — resolve the content node directly and read its Status for this @@ -260,14 +273,16 @@ export class GitHubProjectsPMProvider implements PMProvider { // Maps a CASCADE status key to the GitHub Status *option* ID: // - a string → keep only items whose Status field value has that optionId - // - null → known CASCADE key with no configured mapping → nothing to list + // - null → status has no configured mapping → nothing to list // - undefined → no status filter → list every item const statusOptionId = resolveGitHubProjectsStatusFilter(filter?.status, this.config.statuses); if (statusOptionId === null) return []; // GitHub Projects v2 exposes no server-side field filter, so we fetch the - // project's items and filter by Status option ID client-side. - const items = await listAllProjectItems(projectId); + // project's items and filter by Status option ID client-side. The fetch is + // coalesced so the capacity gate's concurrent todo/inProgress/inReview burst + // pages the board once instead of three times (see listAllProjectItemsCoalesced). + const items = await this.listAllProjectItemsCoalesced(projectId); const result: WorkItem[] = []; for (const item of items) { @@ -502,8 +517,19 @@ export class GitHubProjectsPMProvider implements PMProvider { logger.debug('[GitHubProjects] linkPR is a no-op; PRs are linked by being in the project'); } - getWorkItemUrl(id: string): string { - return `https://github.com/${this.config.owner}/projects/${this.config.projectId}?pane=issue&item_id=${id}`; + getWorkItemUrl(_id: string): string { + // The work-item identity carried across the github-projects path is the + // opaque *content* (Issue/PR) node ID, which cannot be turned into an + // item-specific URL synchronously; the project's numeric `number` (needed + // for a `/projects/` deep link) is not persisted in config either. + // Every WorkItem-producing method (getWorkItem / listWorkItems / + // createWorkItem) already carries the accurate Issue/PR `content.url`, and + // every fallback-style caller prefers it (`workItem.url || getWorkItemUrl`). + // So this fallback returns a correctly-shaped, resolving owner Projects URL + // (with the required `users`/`orgs` segment) instead of the previous + // non-resolving `github.com//projects/` string. + const ownerSegment = this.config.ownerType === 'organization' ? 'orgs' : 'users'; + return `https://github.com/${ownerSegment}/${this.config.owner}/projects`; } async getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }> { diff --git a/tests/unit/integrations/pm-conformance.test.ts b/tests/unit/integrations/pm-conformance.test.ts index ef2db5a95..fc7966d7f 100644 --- a/tests/unit/integrations/pm-conformance.test.ts +++ b/tests/unit/integrations/pm-conformance.test.ts @@ -19,6 +19,7 @@ import { createFakePMProvider, runLifecycleScenario, } from '../../helpers/fakePMProvider.js'; +import { githubProjectsLifecycleFixture } from '../../helpers/githubProjectsLifecycleFixture.js'; import { jiraLifecycleFixture } from '../../helpers/jiraLifecycleFixture.js'; import { linearLifecycleFixture } from '../../helpers/linearLifecycleFixture.js'; import { registerTestProvider } from '../../helpers/testPMProvider.js'; @@ -40,6 +41,7 @@ const LIFECYCLE_FIXTURES: Record< trello: trelloLifecycleFixture, jira: jiraLifecycleFixture, linear: linearLifecycleFixture, + 'github-projects': githubProjectsLifecycleFixture, }; // Import every real PM provider so the harness exercises each of them @@ -47,6 +49,7 @@ const LIFECYCLE_FIXTURES: Record< import '../../../src/integrations/pm/trello/index.js'; import '../../../src/integrations/pm/jira/index.js'; import '../../../src/integrations/pm/linear/index.js'; +import '../../../src/integrations/pm/github-projects/index.js'; // describe.each evaluates at collection time, before beforeAll. Register // the TestProvider + FakePMProvider at module load so the iteration sees diff --git a/tests/unit/pm/github-projects/adapter.test.ts b/tests/unit/pm/github-projects/adapter.test.ts index 56f16f203..68e784482 100644 --- a/tests/unit/pm/github-projects/adapter.test.ts +++ b/tests/unit/pm/github-projects/adapter.test.ts @@ -278,6 +278,36 @@ describe('GitHubProjectsPMProvider', () => { expect(items).toHaveLength(1); expect(items[0].id).toBe('I_1'); }); + + it('coalesces a concurrent capacity-gate burst into a single board pagination', async () => { + mockClient.listAllProjectItems.mockResolvedValue([ + makeProjectItem({ statusName: 'Todo', statusOptionId: 'opt-todo' }), + makeProjectItem({ statusName: 'In Progress', statusOptionId: 'opt-inprogress' }), + ]); + + // Mirror `isActivePipelineOverCapacity`: three concurrent status queries. + const [todo, inProgress, inReview] = await Promise.all([ + provider.listWorkItems(undefined, { status: 'todo' }), + provider.listWorkItems(undefined, { status: 'inProgress' }), + provider.listWorkItems(undefined, { status: 'inReview' }), + ]); + + // A single pagination served both mapped concurrent calls (down from 3). + expect(mockClient.listAllProjectItems).toHaveBeenCalledTimes(1); + expect(todo).toHaveLength(1); + expect(inProgress).toHaveLength(1); + // 'inReview' is unmapped in config.statuses → resolves to null → [] with no fetch. + expect(inReview).toHaveLength(0); + }); + + it('re-fetches on a later non-concurrent call (in-flight coalescing, not a stale cache)', async () => { + mockClient.listAllProjectItems.mockResolvedValue([ + makeProjectItem({ statusOptionId: 'opt-todo' }), + ]); + await provider.listWorkItems(undefined, { status: 'todo' }); + await provider.listWorkItems(undefined, { status: 'todo' }); + expect(mockClient.listAllProjectItems).toHaveBeenCalledTimes(2); + }); }); describe('getWorkItemComments', () => { @@ -562,4 +592,21 @@ describe('GitHubProjectsPMProvider', () => { expect(user.name).toBe('octocat'); }); }); + + describe('getWorkItemUrl', () => { + it('returns a resolving user-scoped Projects URL (correct users/ segment, no PVT_ node id)', () => { + // The content node ID can't be turned into an item-specific URL + // synchronously; the fallback must at least resolve and be well-shaped. + expect(provider.getWorkItemUrl('I_content')).toBe( + 'https://github.com/users/octocat/projects', + ); + }); + + it('uses the orgs/ segment for organization-owned projects', () => { + const orgProvider = new GitHubProjectsPMProvider({ ...config, ownerType: 'organization' }); + expect(orgProvider.getWorkItemUrl('I_content')).toBe( + 'https://github.com/orgs/octocat/projects', + ); + }); + }); }); diff --git a/web/src/components/projects/pm-providers/github-projects/hooks.ts b/web/src/components/projects/pm-providers/github-projects/hooks.ts index 4b87abd90..c889c2888 100644 --- a/web/src/components/projects/pm-providers/github-projects/hooks.ts +++ b/web/src/components/projects/pm-providers/github-projects/hooks.ts @@ -3,7 +3,6 @@ import type { Dispatch } from 'react'; import { useEffect, useMemo } from 'react'; import { trpcClient } from '@/lib/trpc.js'; import type { - GitHubProjectsProjectOption, GitHubProjectsStatusOption, WizardAction, WizardState, @@ -78,7 +77,10 @@ export function useGitHubProjectsDiscovery( onSuccess: (projects) => dispatch({ type: 'SET_GITHUB_PROJECTS_PROJECTS', - projects: projects as GitHubProjectsProjectOption[], + // Discovery returns `{ id, name, url }` — structurally identical to + // GitHubProjectsProjectOption, so no cast is needed (the `name` field + // now lines up; the previous `as` cast masked a `title` mismatch). + projects, }), }); diff --git a/web/src/components/projects/pm-providers/github-projects/state.ts b/web/src/components/projects/pm-providers/github-projects/state.ts index fc8b51b8a..f8101f737 100644 --- a/web/src/components/projects/pm-providers/github-projects/state.ts +++ b/web/src/components/projects/pm-providers/github-projects/state.ts @@ -5,7 +5,7 @@ export interface GitHubProjectsOwnerOption { export interface GitHubProjectsProjectOption { id: string; - title: string; + name: string; url: string; } diff --git a/web/src/components/projects/pm-providers/github-projects/wizard.ts b/web/src/components/projects/pm-providers/github-projects/wizard.ts index 8b7e996ec..e2e95726a 100644 --- a/web/src/components/projects/pm-providers/github-projects/wizard.ts +++ b/web/src/components/projects/pm-providers/github-projects/wizard.ts @@ -180,7 +180,7 @@ export const githubProjectsProviderWizard: ProviderWizardDefinition = { id: 'github-projects', label: 'GitHub Projects', auth: githubProjectsAuthMetadata, - formatVerificationDisplay: (me) => me.name || me.login, + formatVerificationDisplay: (me) => me.displayName || me.name, credentialPersistence: githubProjectsCredentialPersistence, steps: [ @@ -313,7 +313,7 @@ export const githubProjectsProviderWizard: ProviderWizardDefinition = { }); return { - projectOptions: state.githubProjectsProjects.map((p) => ({ id: p.id, name: p.title })), + projectOptions: state.githubProjectsProjects.map((p) => ({ id: p.id, name: p.name })), projectsLoading: discovery.githubProjectsProjectsMutation.isPending, projectsError: discovery.githubProjectsProjectsMutation.isError ? (discovery.githubProjectsProjectsMutation.error as Error).message From 3551734be2fdba1b5bbc14ec0a54ba0f9d922ca5 Mon Sep 17 00:00:00 2001 From: Grzegorz Aniol Date: Fri, 17 Jul 2026 13:58:06 +0000 Subject: [PATCH 3/3] test: raise unit coverage for GitHub Projects PM provider (MNG-1050) Codecov patch coverage on the GitHub Projects PM provider commit (cdc017a0) 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 Claude-Session: https://claude.ai/code/session_01LSnWeF6spNgBtcD2HffjWm --- tests/unit/api/routers/webhooks.test.ts | 299 +++++++++++- .../routers/webhooks/github-projects.test.ts | 319 +++++++++++++ tests/unit/cli/credential-scoping.test.ts | 93 ++++ .../unit/db/repositories/configMapper.test.ts | 43 ++ .../db/repositories/configRepository.test.ts | 85 ++++ .../gadgets/pm/core/reportFriction.test.ts | 313 ++++++++++++ tests/unit/pm/config-alert-accessors.test.ts | 87 ++++ tests/unit/pm/config-friction-slot.test.ts | 44 ++ tests/unit/pm/github-projects/adapter.test.ts | 76 +++ tests/unit/pm/github-projects/client.test.ts | 446 ++++++++++++++++++ .../pm/github-projects/integration.test.ts | 236 +++++++++ tests/unit/router/ackMessageGenerator.test.ts | 58 +++ .../router/adapters/github-projects.test.ts | 341 ++++++++++++- tests/unit/router/config.test.ts | 37 ++ tests/unit/router/platformClients.test.ts | 28 ++ .../platformClients/github-projects.test.ts | 154 ++++++ .../unit/router/resolveWebhookSecret.test.ts | 38 ++ tests/unit/router/webhook-signature.test.ts | 117 +++++ .../github-projects-webhook-handler.test.ts | 60 +++ tests/unit/webhook/webhookParsers.test.ts | 60 +++ tests/unit/worker-entry.test.ts | 128 +++++ 21 files changed, 3059 insertions(+), 3 deletions(-) create mode 100644 tests/unit/api/routers/webhooks/github-projects.test.ts create mode 100644 tests/unit/router/platformClients/github-projects.test.ts create mode 100644 tests/unit/triggers/github-projects-webhook-handler.test.ts diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index db505721c..4bdb71e16 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -16,6 +16,9 @@ const { mockListWebhooks, mockCreateWebhook, mockDeleteWebhook, + mockOrgListWebhooks, + mockOrgCreateWebhook, + mockOrgDeleteWebhook, mockFetch, } = vi.hoisted(() => ({ mockFindProjectByIdFromDb: vi.fn(), @@ -24,6 +27,9 @@ const { mockListWebhooks: vi.fn(), mockCreateWebhook: vi.fn(), mockDeleteWebhook: vi.fn(), + mockOrgListWebhooks: vi.fn(), + mockOrgCreateWebhook: vi.fn(), + mockOrgDeleteWebhook: vi.fn(), mockFetch: vi.fn(), })); @@ -68,7 +74,7 @@ vi.mock('../../../../src/jira/api-host.js', () => ({ // Mock global fetch for Trello API calls vi.stubGlobal('fetch', mockFetch); -// Mock Octokit for GitHub API calls +// Mock Octokit for GitHub API calls (repo-scoped) and GitHub Projects (org-scoped) vi.mock('@octokit/rest', () => ({ Octokit: vi.fn(() => ({ repos: { @@ -76,6 +82,11 @@ vi.mock('@octokit/rest', () => ({ createWebhook: mockCreateWebhook, deleteWebhook: mockDeleteWebhook, }, + orgs: { + listWebhooks: mockOrgListWebhooks, + createWebhook: mockOrgCreateWebhook, + deleteWebhook: mockOrgDeleteWebhook, + }, })), })); @@ -132,6 +143,43 @@ const mockSentryProject = { }, }; +const mockGithubProjectsProject = { + id: 'gh-projects-project', + orgId: 'org-1', + repo: 'owner/gh-projects-repo', + pm: { type: 'github-projects' }, + githubProjects: { + projectId: 'PVT_kwabc', + owner: 'acme-org', + ownerType: 'organization' as const, + statuses: { todo: 'opt-1' }, + }, +}; + +function setupGithubProjectsProjectContext(opts?: { + ownerType?: 'user' | 'organization'; + noOwner?: boolean; + noToken?: boolean; +}) { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectByIdFromDb.mockResolvedValue({ + ...mockGithubProjectsProject, + githubProjects: { + ...mockGithubProjectsProject.githubProjects, + owner: opts?.noOwner ? undefined : mockGithubProjectsProject.githubProjects.owner, + ownerType: opts?.ownerType ?? mockGithubProjectsProject.githubProjects.ownerType, + }, + }); + mockGetIntegrationByProjectAndCategory.mockResolvedValue(null); + const creds: Record = {}; + if (!opts?.noToken) { + creds.GITHUB_TOKEN = 'ghp_projects_test'; + } + mockGetAllProjectCredentials.mockResolvedValue(creds); +} + function setupJiraProjectContext() { mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); @@ -679,6 +727,45 @@ describe('webhooksRouter', () => { expect(result.labelsEnsured).toEqual([]); }); + it('returns duplicate message when a JIRA webhook already exists at the callback URL', async () => { + setupJiraProjectContext(); + + // Fetch calls in order: + // 1. jiraListWebhooks (router duplicate check) - returns a match, so + // jiraCreateWebhook (and its own internal dedup list) never runs. + // 2. jiraEnsureLabels search (returns no issues, so no further label calls) + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + values: [ + { + id: 200, + name: 'cascade-webhook', + url: 'http://example.com/jira/webhook', + events: [], + enabled: true, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ issues: [] }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'jira-project', + callbackBaseUrl: 'http://example.com', + jiraOnly: true, + }); + + expect(result.jira).toBe('Already exists: 200'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + it('surfaces the create actionable error when the router-level dedup list is denied (scoped token)', async () => { setupJiraProjectContext(); @@ -863,6 +950,216 @@ describe('webhooksRouter', () => { expect(mockListWebhooks).toHaveBeenCalled(); expect(result.github).toEqual([]); }); + + it('deletes a matching JIRA webhook', async () => { + setupJiraProjectContext(); + + mockFetch + // jiraListWebhooks + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + values: [ + { + id: 300, + name: 'cascade-webhook', + url: 'http://example.com/jira/webhook', + events: [], + enabled: true, + }, + ], + }), + }) + // jiraDeleteWebhook + .mockResolvedValueOnce({ ok: true }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.delete({ + projectId: 'jira-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.jira).toEqual([300]); + }); + + it('does not delete JIRA webhooks with a non-matching URL', async () => { + setupJiraProjectContext(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + values: [ + { + id: 301, + name: 'other-webhook', + url: 'http://other.example.com/jira/webhook', + events: [], + enabled: true, + }, + ], + }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.delete({ + projectId: 'jira-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.jira).toEqual([]); + // Only the list call — no DELETE issued for the non-matching webhook. + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('GitHub Projects webhooks', () => { + describe('create', () => { + it('skips when ownerType is not organization', async () => { + setupGithubProjectsProjectContext({ ownerType: 'user' }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.githubProjects).toBeUndefined(); + expect(mockOrgListWebhooks).not.toHaveBeenCalled(); + expect(mockOrgCreateWebhook).not.toHaveBeenCalled(); + }); + + it('skips when the GitHub Projects token is not configured', async () => { + setupGithubProjectsProjectContext({ noToken: true }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.githubProjects).toBeUndefined(); + expect(mockOrgListWebhooks).not.toHaveBeenCalled(); + expect(mockOrgCreateWebhook).not.toHaveBeenCalled(); + }); + + it('skips when another provider-only flag is set', async () => { + setupGithubProjectsProjectContext(); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + trelloOnly: true, + }); + + expect(result.githubProjects).toBeUndefined(); + expect(mockOrgListWebhooks).not.toHaveBeenCalled(); + }); + + it('returns duplicate message when a GitHub Projects webhook already exists', async () => { + setupGithubProjectsProjectContext(); + + mockOrgListWebhooks.mockResolvedValue({ + data: [ + { + id: 400, + name: 'web', + active: true, + events: ['projects_v2_item'], + config: { url: 'http://example.com/github-projects/webhook' }, + }, + ], + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + githubProjectsOnly: true, + }); + + expect(result.githubProjects).toBe('Already exists: 400'); + expect(mockOrgCreateWebhook).not.toHaveBeenCalled(); + }); + + it('creates the org webhook on success', async () => { + setupGithubProjectsProjectContext(); + + mockOrgListWebhooks.mockResolvedValue({ data: [] }); + mockOrgCreateWebhook.mockResolvedValue({ + data: { + id: 401, + name: 'web', + active: true, + events: ['projects_v2_item'], + config: { url: 'http://example.com/github-projects/webhook' }, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + githubProjectsOnly: true, + }); + + expect(result.githubProjects).toMatchObject({ id: 401 }); + expect(mockOrgCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ org: 'acme-org' }), + ); + }); + }); + + describe('delete', () => { + it('skips when the GitHub Projects token is not configured', async () => { + setupGithubProjectsProjectContext({ noToken: true }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.delete({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.githubProjects).toEqual([]); + expect(mockOrgListWebhooks).not.toHaveBeenCalled(); + }); + + it('deletes matching org webhooks', async () => { + setupGithubProjectsProjectContext(); + + mockOrgListWebhooks.mockResolvedValue({ + data: [ + { + id: 402, + name: 'web', + active: true, + events: ['projects_v2_item'], + config: { url: 'http://example.com/github-projects/webhook' }, + }, + { + id: 403, + name: 'web', + active: true, + events: ['projects_v2_item'], + config: { url: 'http://other.example.com/github-projects/webhook' }, + }, + ], + }); + mockOrgDeleteWebhook.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.delete({ + projectId: 'gh-projects-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.githubProjects).toEqual([402]); + expect(mockOrgDeleteWebhook).toHaveBeenCalledWith({ org: 'acme-org', hook_id: 402 }); + expect(mockOrgDeleteWebhook).toHaveBeenCalledTimes(1); + }); + }); }); describe('per-provider errors', () => { diff --git a/tests/unit/api/routers/webhooks/github-projects.test.ts b/tests/unit/api/routers/webhooks/github-projects.test.ts new file mode 100644 index 000000000..c547c3864 --- /dev/null +++ b/tests/unit/api/routers/webhooks/github-projects.test.ts @@ -0,0 +1,319 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockOctokitCtor, mockListWebhooks, mockCreateWebhook, mockDeleteWebhook, mockLoggerWarn } = + vi.hoisted(() => ({ + mockOctokitCtor: vi.fn(), + mockListWebhooks: vi.fn(), + mockCreateWebhook: vi.fn(), + mockDeleteWebhook: vi.fn(), + mockLoggerWarn: vi.fn(), + })); + +vi.mock('@octokit/rest', () => ({ + Octokit: mockOctokitCtor, +})); + +vi.mock('../../../../../src/utils/logging.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: mockLoggerWarn, error: vi.fn() }, +})); + +import { + GITHUB_PROJECTS_WEBHOOK_EVENTS, + githubProjectsCreateWebhook, + githubProjectsDeleteWebhook, + githubProjectsListWebhooks, +} from '../../../../../src/api/routers/webhooks/github-projects.js'; +import type { ProjectContext } from '../../../../../src/api/routers/webhooks/types.js'; + +const CALLBACK = 'https://cascade.example.com/github-projects/webhook'; + +function orgCtx(overrides: Partial = {}): ProjectContext { + return { + projectId: 'proj-1', + orgId: 'org-1', + pmType: 'github-projects', + trelloApiKey: '', + trelloToken: '', + githubToken: '', + githubProjectsOwner: 'acme-org', + githubProjectsOwnerType: 'organization', + githubProjectsToken: 'ghp_projects_test', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockOctokitCtor.mockImplementation(() => ({ + orgs: { + listWebhooks: mockListWebhooks, + createWebhook: mockCreateWebhook, + deleteWebhook: mockDeleteWebhook, + }, + })); +}); + +describe('webhooks/github-projects', () => { + describe('githubProjectsListWebhooks', () => { + it('returns [] without instantiating Octokit when pmType is not github-projects', async () => { + const result = await githubProjectsListWebhooks(orgCtx({ pmType: 'trello' })); + + expect(result).toEqual([]); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('returns [] without instantiating Octokit when ownerType is user', async () => { + const result = await githubProjectsListWebhooks(orgCtx({ githubProjectsOwnerType: 'user' })); + + expect(result).toEqual([]); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('returns [] without instantiating Octokit when owner is missing', async () => { + const result = await githubProjectsListWebhooks(orgCtx({ githubProjectsOwner: undefined })); + + expect(result).toEqual([]); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('returns [] without instantiating Octokit when token is missing', async () => { + const result = await githubProjectsListWebhooks(orgCtx({ githubProjectsToken: undefined })); + + expect(result).toEqual([]); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('returns the org webhooks from Octokit on success', async () => { + const webhooks = [ + { + id: 1, + name: 'web', + active: true, + events: ['projects_v2_item'], + config: { url: CALLBACK }, + }, + ]; + mockListWebhooks.mockResolvedValue({ data: webhooks }); + + const result = await githubProjectsListWebhooks(orgCtx()); + + expect(mockListWebhooks).toHaveBeenCalledWith({ org: 'acme-org' }); + expect(result).toEqual(webhooks); + }); + + it('catches Octokit errors, logs a warning, and returns []', async () => { + mockListWebhooks.mockRejectedValue(new Error('boom')); + + const result = await githubProjectsListWebhooks(orgCtx()); + + expect(result).toEqual([]); + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[GitHubProjectsWebhook] Could not list org webhooks (continuing)', + expect.objectContaining({ projectId: 'proj-1', org: 'acme-org' }), + ); + }); + }); + + describe('githubProjectsCreateWebhook', () => { + it('throws BAD_REQUEST when ownerType is not organization', async () => { + const err = await githubProjectsCreateWebhook( + orgCtx({ githubProjectsOwnerType: 'user' }), + CALLBACK, + ).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('BAD_REQUEST'); + expect(err.message).toContain('organization-owned GitHub Projects'); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when ownerType is organization but owner is missing', async () => { + const err = await githubProjectsCreateWebhook( + orgCtx({ githubProjectsOwner: undefined }), + CALLBACK, + ).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('BAD_REQUEST'); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when token is missing', async () => { + const err = await githubProjectsCreateWebhook( + orgCtx({ githubProjectsToken: undefined }), + CALLBACK, + ).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('BAD_REQUEST'); + expect(err.message).toBe('GitHub Projects token not configured'); + expect(mockOctokitCtor).not.toHaveBeenCalled(); + }); + + it('deletes an existing webhook with the same callback URL before creating (dedup)', async () => { + mockListWebhooks.mockResolvedValue({ + data: [{ id: 42, name: 'web', active: true, events: [], config: { url: CALLBACK } }], + }); + mockDeleteWebhook.mockResolvedValue({}); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 99, + name: 'web', + active: true, + events: GITHUB_PROJECTS_WEBHOOK_EVENTS, + config: { url: CALLBACK }, + }, + }); + + const result = await githubProjectsCreateWebhook(orgCtx(), CALLBACK); + + expect(mockDeleteWebhook).toHaveBeenCalledWith({ org: 'acme-org', hook_id: 42 }); + expect(mockCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'acme-org', + events: GITHUB_PROJECTS_WEBHOOK_EVENTS, + }), + ); + expect(result).toMatchObject({ id: 99 }); + }); + + it('still creates the webhook when the dedup delete fails (error swallowed + warned)', async () => { + mockListWebhooks.mockResolvedValue({ + data: [{ id: 42, name: 'web', active: true, events: [], config: { url: CALLBACK } }], + }); + mockDeleteWebhook.mockRejectedValue(new Error('delete failed')); + mockCreateWebhook.mockResolvedValue({ + data: { id: 100, name: 'web', active: true, events: [], config: { url: CALLBACK } }, + }); + + const result = await githubProjectsCreateWebhook(orgCtx(), CALLBACK); + + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[GitHubProjectsWebhook] Failed to delete existing webhook (continuing)', + expect.objectContaining({ webhookId: 42, projectId: 'proj-1' }), + ); + expect(mockCreateWebhook).toHaveBeenCalled(); + expect(result).toMatchObject({ id: 100 }); + }); + + it('includes the secret in the webhook config when ctx.webhookSecret is set', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { id: 1, name: 'web', active: true, events: [], config: { url: CALLBACK } }, + }); + + await githubProjectsCreateWebhook(orgCtx({ webhookSecret: 'shh-secret' }), CALLBACK); + + expect(mockCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + url: CALLBACK, + content_type: 'json', + secret: 'shh-secret', + }), + }), + ); + }); + + it('omits the secret from the webhook config when ctx.webhookSecret is not set', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { id: 1, name: 'web', active: true, events: [], config: { url: CALLBACK } }, + }); + + await githubProjectsCreateWebhook(orgCtx(), CALLBACK); + + expect(mockCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.not.objectContaining({ secret: expect.anything() }), + }), + ); + }); + + it('returns the created webhook data on success', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + const created = { + id: 55, + name: 'web', + active: true, + events: GITHUB_PROJECTS_WEBHOOK_EVENTS, + config: { url: CALLBACK }, + }; + mockCreateWebhook.mockResolvedValue({ data: created }); + + const result = await githubProjectsCreateWebhook(orgCtx(), CALLBACK); + + expect(result).toEqual(created); + }); + + it('throws FORBIDDEN with the admin:org_hook scope message on a 403', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockRejectedValue(Object.assign(new Error('Forbidden'), { status: 403 })); + + const err = await githubProjectsCreateWebhook(orgCtx(), CALLBACK).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('FORBIDDEN'); + expect(err.message).toContain('HTTP 403'); + expect(err.message).toContain('admin:org_hook'); + expect(err.message).toContain('acme-org'); + }); + + it('throws FORBIDDEN with the admin:org_hook scope message on a 404', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 })); + + const err = await githubProjectsCreateWebhook(orgCtx(), CALLBACK).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('FORBIDDEN'); + expect(err.message).toContain('HTTP 404'); + expect(err.message).toContain('admin:org_hook'); + }); + + it('throws FORBIDDEN with the generic message for a non-403/404 error', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockRejectedValue(Object.assign(new Error('boom'), { status: 500 })); + + const err = await githubProjectsCreateWebhook(orgCtx(), CALLBACK).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('FORBIDDEN'); + expect(err.message).toContain('GitHub webhook operation failed for organization "acme-org"'); + expect(err.message).not.toContain('admin:org_hook'); + expect(err.message).toContain('boom'); + }); + }); + + describe('githubProjectsDeleteWebhook', () => { + it('no-ops without instantiating Octokit when ownerType is not organization', async () => { + await githubProjectsDeleteWebhook(orgCtx({ githubProjectsOwnerType: 'user' }), 7); + + expect(mockOctokitCtor).not.toHaveBeenCalled(); + expect(mockDeleteWebhook).not.toHaveBeenCalled(); + }); + + it('no-ops without instantiating Octokit when owner is missing', async () => { + await githubProjectsDeleteWebhook(orgCtx({ githubProjectsOwner: undefined }), 7); + + expect(mockOctokitCtor).not.toHaveBeenCalled(); + expect(mockDeleteWebhook).not.toHaveBeenCalled(); + }); + + it('no-ops without instantiating Octokit when token is missing', async () => { + await githubProjectsDeleteWebhook(orgCtx({ githubProjectsToken: undefined }), 7); + + expect(mockOctokitCtor).not.toHaveBeenCalled(); + expect(mockDeleteWebhook).not.toHaveBeenCalled(); + }); + + it('calls orgs.deleteWebhook with the org and hook id on the happy path', async () => { + mockDeleteWebhook.mockResolvedValue({}); + + await githubProjectsDeleteWebhook(orgCtx(), 42); + + expect(mockDeleteWebhook).toHaveBeenCalledWith({ org: 'acme-org', hook_id: 42 }); + }); + }); +}); diff --git a/tests/unit/cli/credential-scoping.test.ts b/tests/unit/cli/credential-scoping.test.ts index 78ea0d9b3..aa10af249 100644 --- a/tests/unit/cli/credential-scoping.test.ts +++ b/tests/unit/cli/credential-scoping.test.ts @@ -33,6 +33,10 @@ vi.mock('../../../src/linear/client.js', () => ({ linearClient: {}, })); +vi.mock('../../../src/github-projects/client.js', () => ({ + withGitHubProjectsCredentials: vi.fn((_creds: { token: string }, fn: () => unknown) => fn()), +})); + vi.mock('../../../src/sentry/integration.js', () => ({ getSentryIntegrationConfig: vi.fn().mockResolvedValue(null), hasAlertingIntegration: vi.fn().mockResolvedValue(false), @@ -58,6 +62,7 @@ import '../../../src/sentry/register.js'; import { CredentialScopedCommand, resolveJiraBaseUrl } from '../../../src/cli/base.js'; import { withGitHubToken } from '../../../src/github/client.js'; +import { withGitHubProjectsCredentials } from '../../../src/github-projects/client.js'; import { withJiraCredentials } from '../../../src/jira/client.js'; import { withLinearCredentials } from '../../../src/linear/client.js'; import { getPMProvider } from '../../../src/pm/context.js'; @@ -108,8 +113,14 @@ describe('CredentialScopedCommand', () => { delete process.env.CASCADE_JIRA_PROJECT_KEY; delete process.env.CASCADE_JIRA_STATUSES; delete process.env.CASCADE_JIRA_AUTH_TYPE; + delete process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID; + delete process.env.CASCADE_GITHUB_PROJECTS_OWNER; + delete process.env.CASCADE_GITHUB_PROJECTS_OWNER_TYPE; + delete process.env.CASCADE_GITHUB_PROJECTS_STATUSES; + delete process.env.CASCADE_GITHUB_PROJECTS_LABELS; vi.mocked(withJiraCredentials).mockClear(); vi.mocked(withLinearCredentials).mockClear(); + vi.mocked(withGitHubProjectsCredentials).mockClear(); }); afterEach(() => { @@ -340,4 +351,86 @@ describe('CredentialScopedCommand', () => { authType: 'basic', }); }); + + // GitHub Projects scope — mirrors the Trello/JIRA/Linear pattern. GitHub + // Projects reuses GITHUB_TOKEN but only establishes its dedicated + // AsyncLocalStorage scope when CASCADE_PM_TYPE=github-projects, since the + // same token is also used (unscoped) by the SCM `withGitHubToken` wrapper. + + it('wraps execute() with withGitHubProjectsCredentials when CASCADE_PM_TYPE=github-projects and GITHUB_TOKEN is set', async () => { + process.env.GITHUB_TOKEN = 'ghp_test123'; + process.env.CASCADE_PM_TYPE = 'github-projects'; + process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID = 'PVT_test'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER = 'acme'; + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(cmd.executeCalled).toBe(true); + expect(withGitHubProjectsCredentials).toHaveBeenCalledWith( + { token: 'ghp_test123' }, + expect.any(Function), + ); + // Also establishes the plain SCM scope with the same underlying token. + expect(withGitHubToken).toHaveBeenCalledWith('ghp_test123', expect.any(Function)); + }); + + it('does not wrap with withGitHubProjectsCredentials when GITHUB_TOKEN is set but CASCADE_PM_TYPE is not github-projects', async () => { + process.env.GITHUB_TOKEN = 'ghp_test123'; + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(cmd.executeCalled).toBe(true); + expect(withGitHubProjectsCredentials).not.toHaveBeenCalled(); + }); + + it('does not wrap with withGitHubProjectsCredentials when CASCADE_PM_TYPE=github-projects but GITHUB_TOKEN is unset', async () => { + process.env.CASCADE_PM_TYPE = 'github-projects'; + process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID = 'PVT_test'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER = 'acme'; + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(cmd.executeCalled).toBe(true); + expect(withGitHubProjectsCredentials).not.toHaveBeenCalled(); + }); + + it('synthesises GitHub Projects config from env vars for scoped PM commands', async () => { + process.env.CASCADE_PM_TYPE = 'github-projects'; + process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID = 'PVT_kwABC'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER = 'acme-org'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER_TYPE = 'organization'; + process.env.CASCADE_GITHUB_PROJECTS_STATUSES = JSON.stringify({ todo: 'Todo' }); + process.env.CASCADE_GITHUB_PROJECTS_LABELS = JSON.stringify({ auto: 'label-auto' }); + + const cmd = new InspectPMProviderCommand([], {} as never); + await cmd.run(); + + expect(cmd.providerConfig).toEqual({ + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + labels: { auto: 'label-auto' }, + }); + }); + + it('defaults ownerType to "user" and omits labels when not set in env', async () => { + process.env.CASCADE_PM_TYPE = 'github-projects'; + process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID = 'PVT_kwABC'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER = 'someuser'; + // CASCADE_GITHUB_PROJECTS_OWNER_TYPE and CASCADE_GITHUB_PROJECTS_LABELS intentionally unset. + + const cmd = new InspectPMProviderCommand([], {} as never); + await cmd.run(); + + expect(cmd.providerConfig).toEqual({ + projectId: 'PVT_kwABC', + owner: 'someuser', + ownerType: 'user', + statuses: {}, + }); + }); }); diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 8d54b1daa..7b7101caf 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -82,6 +82,21 @@ const linearIntegrationRow: IntegrationRow = { config: linearConfig, }; +const githubProjectsConfig = { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization' as const, + statuses: { todo: 'Todo', inProgress: 'In Progress' }, + labels: { processing: 'label-processing', readyToProcess: 'label-ready' }, +}; + +const githubProjectsIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'pm', + provider: 'github-projects', + config: githubProjectsConfig, +}; + // --------------------------------------------------------------------------- // orUndefined // --------------------------------------------------------------------------- @@ -423,6 +438,13 @@ describe('extractIntegrationConfigs', () => { expect(result.jiraConfig).toBeUndefined(); }); + it('extracts github-projects config from integration rows', () => { + const result = extractIntegrationConfigs([githubProjectsIntegrationRow]); + expect(result.githubProjectsConfig).toEqual(githubProjectsConfig); + expect(result.trelloConfig).toBeUndefined(); + expect(result.linearConfig).toBeUndefined(); + }); + it('handles empty integration list', () => { const result = extractIntegrationConfigs([]); expect(result.trelloConfig).toBeUndefined(); @@ -564,6 +586,27 @@ describe('mapProjectRow', () => { expect(result.linear).toBeUndefined(); }); + it('sets pm.type to github-projects when githubProjectsConfig is provided', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, githubProjectsConfig })); + expect(result.pm.type).toBe('github-projects'); + }); + + it('builds github-projects config with projectId, owner, ownerType, statuses, and labels', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, githubProjectsConfig })); + expect(result.githubProjects).toEqual({ + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo', inProgress: 'In Progress' }, + labels: { processing: 'label-processing', readyToProcess: 'label-ready' }, + }); + }); + + it('does not include githubProjects field when githubProjectsConfig is not provided', () => { + const result = mapProjectRow(makeInput()); + expect(result.githubProjects).toBeUndefined(); + }); + it('omits agentEngine when neither row.agentEngine nor agent overrides are set', () => { const result = mapProjectRow(makeInput()); expect(result.agentEngine).toBeUndefined(); diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index 0914a0e5a..a89802b00 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -5,9 +5,11 @@ vi.mock('../../../../src/db/client.js', () => mockDbClientModule); import { findProjectByBoardIdFromDb, + findProjectByGitHubProjectsProjectIdFromDb, findProjectByIdFromDb, findProjectByLinearTeamIdFromDb, findProjectByRepoFromDb, + findProjectWithConfigByGitHubProjectsProjectId, loadConfigFromDb, } from '../../../../src/db/repositories/configRepository.js'; @@ -84,6 +86,22 @@ const linearIntegration = { updatedAt: new Date(), }; +const githubProjectsIntegration = { + id: 5, + projectId: 'proj1', + category: 'pm' as const, + provider: 'github-projects' as const, + config: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo', inProgress: 'In Progress' }, + }, + triggers: {}, + createdAt: new Date(), + updatedAt: new Date(), +}; + const projectAgentConfig = { id: 2, projectId: 'proj1', @@ -567,4 +585,71 @@ describe('configRepository', () => { expect(result).toBeUndefined(); }); }); + + describe('GitHub Projects integration', () => { + it('loads config with GitHub Projects integration from project_integrations', async () => { + const mockDb = createSequentialMockDb([[projectRow], [], [githubProjectsIntegration]]); + mockGetDb.mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.projects).toHaveLength(1); + const proj = config.projects[0]; + expect(proj.pm?.type).toBe('github-projects'); + expect(proj.githubProjects?.projectId).toBe('PVT_kwABC'); + expect(proj.githubProjects?.owner).toBe('acme-org'); + expect(proj.githubProjects?.ownerType).toBe('organization'); + expect(proj.githubProjects?.statuses).toEqual({ todo: 'Todo', inProgress: 'In Progress' }); + }); + }); + + describe('findProjectByGitHubProjectsProjectIdFromDb', () => { + it('returns project found via integrations projectId subquery', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], // subquery finds project + [], + [githubProjectsIntegration], + ]); + mockGetDb.mockReturnValue(mockDb as never); + + const result = await findProjectByGitHubProjectsProjectIdFromDb('PVT_kwABC'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('proj1'); + expect(result?.githubProjects?.projectId).toBe('PVT_kwABC'); + expect(result?.pm?.type).toBe('github-projects'); + }); + + it('returns undefined when no project has matching GitHub Projects project ID', async () => { + const mockDb = createSequentialMockDb([[]]); + mockGetDb.mockReturnValue(mockDb as never); + + const result = await findProjectByGitHubProjectsProjectIdFromDb('nonexistent-project'); + + expect(result).toBeUndefined(); + }); + }); + + describe('findProjectWithConfigByGitHubProjectsProjectId', () => { + it('returns project and org-scoped config found via integrations projectId subquery', async () => { + const mockDb = createSequentialMockDb([[projectRow], [], [githubProjectsIntegration]]); + mockGetDb.mockReturnValue(mockDb as never); + + const result = await findProjectWithConfigByGitHubProjectsProjectId('PVT_kwABC'); + + expect(result).toBeDefined(); + expect(result?.project.id).toBe('proj1'); + expect(result?.project.githubProjects?.projectId).toBe('PVT_kwABC'); + expect(result?.config.projects).toHaveLength(1); + }); + + it('returns undefined when no project has matching GitHub Projects project ID', async () => { + const mockDb = createSequentialMockDb([[]]); + mockGetDb.mockReturnValue(mockDb as never); + + const result = await findProjectWithConfigByGitHubProjectsProjectId('nonexistent-project'); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/gadgets/pm/core/reportFriction.test.ts b/tests/unit/gadgets/pm/core/reportFriction.test.ts index ec70eacae..8b3ca94d5 100644 --- a/tests/unit/gadgets/pm/core/reportFriction.test.ts +++ b/tests/unit/gadgets/pm/core/reportFriction.test.ts @@ -64,6 +64,22 @@ beforeEach(() => { delete process.env.JIRA_BASE_URL; delete process.env.CASCADE_JIRA_STATUSES; delete process.env.CASCADE_JIRA_AUTH_TYPE; + // Clear Linear env-synthesis vars. + delete process.env.CASCADE_LINEAR_TEAM_ID; + delete process.env.CASCADE_LINEAR_PROJECT_ID; + delete process.env.CASCADE_LINEAR_STATUSES; + // Clear GitHub Projects env-synthesis vars. + delete process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID; + delete process.env.CASCADE_GITHUB_PROJECTS_OWNER; + delete process.env.CASCADE_GITHUB_PROJECTS_OWNER_TYPE; + delete process.env.CASCADE_GITHUB_PROJECTS_STATUSES; + delete process.env.CASCADE_GITHUB_PROJECTS_LABELS; + // Clear Trello env-synthesis vars. + delete process.env.CASCADE_TRELLO_BOARD_ID; + delete process.env.CASCADE_TRELLO_LISTS; + delete process.env.CASCADE_TRELLO_LABELS; + delete process.env.CASCADE_REPO_OWNER; + delete process.env.CASCADE_REPO_NAME; }); describe('reportFriction', () => { @@ -377,4 +393,301 @@ describe('reportFriction', () => { ); rmSync(path, { force: true }); }); + + it('env-synthesized LINEAR config carries teamId, projectId, and parsed statuses (MNG-1050)', async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'linear-project'; + process.env.CASCADE_PM_TYPE = 'linear'; + process.env.CASCADE_LINEAR_TEAM_ID = 'team-1'; + process.env.CASCADE_LINEAR_PROJECT_ID = 'proj-1'; + process.env.CASCADE_LINEAR_STATUSES = JSON.stringify({ friction: 'state-uuid-1' }); + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'LIN-1', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'Linear env synthesis', + details: 'The synthesized project must carry Linear connection details.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + linear: { + teamId: 'team-1', + projectId: 'proj-1', + statuses: { friction: 'state-uuid-1' }, + }, + }), + }), + ); + rmSync(path, { force: true }); + }); + + it('env-synthesized LINEAR config omits projectId when CASCADE_LINEAR_PROJECT_ID is unset', async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'linear-project'; + process.env.CASCADE_PM_TYPE = 'linear'; + process.env.CASCADE_LINEAR_TEAM_ID = 'team-2'; + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'LIN-2', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'Linear env synthesis without project scope', + details: 'CASCADE_LINEAR_PROJECT_ID absent must not add a projectId key.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + linear: { teamId: 'team-2', statuses: {} }, + }), + }), + ); + const call = mockMaterializeFrictionReport.mock.calls[0][0] as { + project: { linear: Record }; + }; + expect(call.project.linear).not.toHaveProperty('projectId'); + rmSync(path, { force: true }); + }); + + it('env-synthesized GITHUB PROJECTS config carries projectId, owner, ownerType, statuses, and labels (MNG-1050)', async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'gh-projects-project'; + process.env.CASCADE_PM_TYPE = 'github-projects'; + process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID = 'PVT_1'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER = 'acme-org'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER_TYPE = 'organization'; + process.env.CASCADE_GITHUB_PROJECTS_STATUSES = JSON.stringify({ friction: 'Friction' }); + process.env.CASCADE_GITHUB_PROJECTS_LABELS = JSON.stringify({ 'cascade-friction': 'label-1' }); + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'gh-item-1', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'GitHub Projects env synthesis', + details: 'The synthesized project must carry GitHub Projects connection details.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + githubProjects: { + projectId: 'PVT_1', + owner: 'acme-org', + ownerType: 'organization', + statuses: { friction: 'Friction' }, + labels: { 'cascade-friction': 'label-1' }, + }, + }), + }), + ); + rmSync(path, { force: true }); + }); + + it('env-synthesized GITHUB PROJECTS config defaults ownerType to user and omits labels when unset', async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'gh-projects-project'; + process.env.CASCADE_PM_TYPE = 'github-projects'; + process.env.CASCADE_GITHUB_PROJECTS_PROJECT_ID = 'PVT_2'; + process.env.CASCADE_GITHUB_PROJECTS_OWNER = 'octocat'; + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'gh-item-2', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'GitHub Projects env synthesis without labels', + details: 'CASCADE_GITHUB_PROJECTS_LABELS absent must not add a labels key.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + githubProjects: { + projectId: 'PVT_2', + owner: 'octocat', + ownerType: 'user', + statuses: {}, + }, + }), + }), + ); + const call = mockMaterializeFrictionReport.mock.calls[0][0] as { + project: { githubProjects: Record }; + }; + expect(call.project.githubProjects).not.toHaveProperty('labels'); + rmSync(path, { force: true }); + }); + + it('env-synthesized TRELLO config is the switch default when CASCADE_PM_TYPE is unset', async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'trello-project'; + // CASCADE_PM_TYPE intentionally unset — projectFromEnv() falls through the + // switch's `default:` branch straight to trelloFromEnv(). + process.env.CASCADE_REPO_OWNER = 'acme'; + process.env.CASCADE_REPO_NAME = 'widgets'; + process.env.CASCADE_TRELLO_BOARD_ID = 'board-99'; + process.env.CASCADE_TRELLO_LISTS = JSON.stringify({ friction: 'list-friction-99' }); + process.env.CASCADE_TRELLO_LABELS = JSON.stringify({ 'cascade-friction': 'label-99' }); + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'card-99', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'Trello env synthesis via switch default', + details: 'No CASCADE_PM_TYPE set — must fall through to the Trello default branch.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + repo: 'acme/widgets', + trello: { + boardId: 'board-99', + lists: { friction: 'list-friction-99' }, + labels: { 'cascade-friction': 'label-99' }, + }, + }), + }), + ); + rmSync(path, { force: true }); + }); + + it('parseJsonRecord returns {} when the env var parses to a non-object JSON value', async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'linear-project'; + process.env.CASCADE_PM_TYPE = 'linear'; + process.env.CASCADE_LINEAR_TEAM_ID = 'team-3'; + // A JSON array is valid JSON but not a plain object — parseJsonRecord's + // object/array guard must fall through to the {} branch instead of + // passing an array through as "statuses". + process.env.CASCADE_LINEAR_STATUSES = JSON.stringify(['friction']); + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'LIN-3', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'Non-object JSON env value', + details: 'A JSON array value must not be treated as a status record.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + linear: expect.objectContaining({ statuses: {} }), + }), + }), + ); + rmSync(path, { force: true }); + }); + + it('carries CASCADE_PR_NUMBER from process.env when set to a valid integer string', async () => { + const path = sidecarPath(); + process.env.CASCADE_PR_NUMBER = '482'; + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'card-pr-1', + }); + + await reportFriction({ + project, + sidecarPath: path, + summary: 'PR number from env', + details: 'process.env.CASCADE_PR_NUMBER must be parsed into report.context.pr.number.', + category: 'tooling', + severity: 'low', + }); + + const event = JSON.parse(readFileSync(path, 'utf-8').trim().split('\n')[0]); + expect(event.report.context.pr.number).toBe(482); + rmSync(path, { force: true }); + }); + + it('falls back to undefined PR number when CASCADE_PR_NUMBER is not a safe integer', async () => { + const path = sidecarPath(); + process.env.CASCADE_PR_NUMBER = 'not-a-number'; + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'card-pr-2', + }); + + await reportFriction({ + project, + sidecarPath: path, + summary: 'Invalid PR number from env', + details: + 'A non-numeric CASCADE_PR_NUMBER must not crash and must yield an undefined PR number.', + category: 'tooling', + severity: 'low', + }); + + const event = JSON.parse(readFileSync(path, 'utf-8').trim().split('\n')[0]); + expect(event.report.context.pr.number).toBeUndefined(); + rmSync(path, { force: true }); + }); + + it('falls back to the default sidecar path when no override is provided anywhere', async () => { + const defaultPath = join(process.cwd(), '.cascade', 'friction-reports.jsonl'); + rmSync(defaultPath, { force: true }); + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'card-default-path', + }); + + try { + // No params.sidecarPath, no FRICTION_SIDECAR_ENV_VAR, and a SessionState + // with no frictionSidecarPath configured — must fall through to the + // module's DEFAULT_FRICTION_SIDECAR_PATH constant. + const result = await reportFriction({ + project, + summary: 'No sidecar override anywhere', + details: 'Must resolve to the default .cascade/friction-reports.jsonl path.', + category: 'tooling', + severity: 'low', + }); + + expect(result.status).toBe('filed'); + const events = readFileSync(defaultPath, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events.map((event) => event.event)).toEqual(['queued', 'filed']); + } finally { + rmSync(defaultPath, { force: true }); + } + }); }); diff --git a/tests/unit/pm/config-alert-accessors.test.ts b/tests/unit/pm/config-alert-accessors.test.ts index 554287044..7f42d71bb 100644 --- a/tests/unit/pm/config-alert-accessors.test.ts +++ b/tests/unit/pm/config-alert-accessors.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getAlertLabelId, getAlertsContainerId, + getAlertsStatusDestination, getAlertsStatusKey, } from '../../../src/pm/config.js'; import type { ProjectConfig } from '../../../src/types/index.js'; @@ -46,6 +47,20 @@ function makeLinearProject(overrides: Record = {}): ProjectConf } as unknown as ProjectConfig; } +function makeGitHubProjectsProject(overrides: Record = {}): ProjectConfig { + return { + id: 'p1', + pm: { type: 'github-projects' }, + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo', alerts: 'Triage' }, + }, + ...overrides, + } as unknown as ProjectConfig; +} + describe('getAlertsContainerId', () => { it('returns Trello list ID from project.trello.lists.alerts', () => { expect(getAlertsContainerId(makeTrelloProject())).toBe('list-alerts'); @@ -59,6 +74,22 @@ describe('getAlertsContainerId', () => { expect(getAlertsContainerId(makeLinearProject())).toBe('team-1'); }); + it('returns GitHub Projects project ID for GitHub Projects projects', () => { + expect(getAlertsContainerId(makeGitHubProjectsProject())).toBe('PVT_kwABC'); + }); + + it('returns undefined for GitHub Projects projects when statuses.alerts is not configured', () => { + const project = makeGitHubProjectsProject({ + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + }, + }); + expect(getAlertsContainerId(project)).toBeUndefined(); + }); + it('returns undefined when no PM config is present', () => { const project = { id: 'p1', pm: undefined } as unknown as ProjectConfig; expect(getAlertsContainerId(project)).toBeUndefined(); @@ -108,6 +139,10 @@ describe('getAlertLabelId', () => { expect(getAlertLabelId(makeLinearProject())).toBe('label-uuid'); }); + it('returns undefined for GitHub Projects projects (no cascade-alert label support)', () => { + expect(getAlertLabelId(makeGitHubProjectsProject())).toBeUndefined(); + }); + it('returns undefined when label slot is not configured', () => { const p1 = makeTrelloProject({ trello: { boardId: 'b1', lists: {}, labels: {} } }); expect(getAlertLabelId(p1)).toBeUndefined(); @@ -135,8 +170,60 @@ describe('getAlertsStatusKey', () => { expect(getAlertsStatusKey(makeTrelloProject())).toBe('alerts'); }); + it('returns "alerts" when statuses.alerts is configured (GitHub Projects)', () => { + expect(getAlertsStatusKey(makeGitHubProjectsProject())).toBe('alerts'); + }); + it('returns undefined when alerts slot is not configured', () => { const p = makeTrelloProject({ trello: { boardId: 'b1', lists: { todo: 'l1' }, labels: {} } }); expect(getAlertsStatusKey(p)).toBeUndefined(); }); + + it('returns undefined for GitHub Projects projects when statuses.alerts is not configured', () => { + const p = makeGitHubProjectsProject({ + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + }, + }); + expect(getAlertsStatusKey(p)).toBeUndefined(); + }); +}); + +describe('getAlertsStatusDestination', () => { + it('returns Trello alerts list ID', () => { + expect(getAlertsStatusDestination(makeTrelloProject())).toBe('list-alerts'); + }); + + it('returns JIRA statuses.alerts value', () => { + expect(getAlertsStatusDestination(makeJiraProject())).toBe('In Triage'); + }); + + it('returns Linear statuses.alerts value', () => { + expect(getAlertsStatusDestination(makeLinearProject())).toBe('state-triage'); + }); + + it('returns GitHub Projects statuses.alerts value', () => { + expect(getAlertsStatusDestination(makeGitHubProjectsProject())).toBe('Triage'); + }); + + it('returns undefined when the alerts slot is not configured', () => { + const project = makeGitHubProjectsProject({ + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + }, + }); + expect(getAlertsStatusDestination(project)).toBeUndefined(); + }); + + it('returns undefined for unknown PM provider types', () => { + expect( + getAlertsStatusDestination({ id: 'p1', pm: undefined } as unknown as ProjectConfig), + ).toBeUndefined(); + }); }); diff --git a/tests/unit/pm/config-friction-slot.test.ts b/tests/unit/pm/config-friction-slot.test.ts index 7fad08fa3..84c80ef93 100644 --- a/tests/unit/pm/config-friction-slot.test.ts +++ b/tests/unit/pm/config-friction-slot.test.ts @@ -48,6 +48,20 @@ function makeLinearProject(overrides: Record = {}): ProjectConf } as unknown as ProjectConfig; } +function makeGitHubProjectsProject(overrides: Record = {}): ProjectConfig { + return { + id: 'p1', + pm: { type: 'github-projects' }, + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo', friction: 'Friction' }, + }, + ...overrides, + } as unknown as ProjectConfig; +} + describe('getFrictionContainerId', () => { it('returns Trello list ID from project.trello.lists.friction', () => { expect(getFrictionContainerId(makeTrelloProject())).toBe('list-friction'); @@ -76,6 +90,20 @@ describe('getFrictionContainerId', () => { expect(getFrictionContainerId(project)).toBeUndefined(); }); + it('returns GitHub Projects project ID only when statuses.friction is configured', () => { + expect(getFrictionContainerId(makeGitHubProjectsProject())).toBe('PVT_kwABC'); + + const project = makeGitHubProjectsProject({ + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + }, + }); + expect(getFrictionContainerId(project)).toBeUndefined(); + }); + it('returns undefined when no PM config or Trello friction list is present', () => { expect( getFrictionContainerId({ id: 'p1', pm: undefined } as unknown as ProjectConfig), @@ -93,6 +121,7 @@ describe('getFrictionStatusDestination', () => { expect(getFrictionStatusDestination(makeTrelloProject())).toBe('list-friction'); expect(getFrictionStatusDestination(makeJiraProject())).toBe('Friction'); expect(getFrictionStatusDestination(makeLinearProject())).toBe('state-friction'); + expect(getFrictionStatusDestination(makeGitHubProjectsProject())).toBe('Friction'); }); it('returns undefined when friction is unconfigured', () => { @@ -110,10 +139,25 @@ describe('getFrictionStatusDestination', () => { const linearProject = makeLinearProject({ linear: { teamId: 'team-1', statuses: { todo: 'state-todo' } }, }); + const githubProjectsProject = makeGitHubProjectsProject({ + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + }, + }); expect(getFrictionStatusDestination(trelloProject)).toBeUndefined(); expect(getFrictionStatusDestination(jiraProject)).toBeUndefined(); expect(getFrictionStatusDestination(linearProject)).toBeUndefined(); + expect(getFrictionStatusDestination(githubProjectsProject)).toBeUndefined(); + }); + + it('returns undefined for unknown PM provider types', () => { + expect( + getFrictionStatusDestination({ id: 'p1', pm: undefined } as unknown as ProjectConfig), + ).toBeUndefined(); }); }); diff --git a/tests/unit/pm/github-projects/adapter.test.ts b/tests/unit/pm/github-projects/adapter.test.ts index 68e784482..37ec3c112 100644 --- a/tests/unit/pm/github-projects/adapter.test.ts +++ b/tests/unit/pm/github-projects/adapter.test.ts @@ -33,6 +33,7 @@ vi.mock('../../../../src/utils/logging.js', () => ({ import { hashChecklistItemId } from '../../../../src/pm/_shared/inline-checklist.js'; import type { GitHubProjectsConfig } from '../../../../src/pm/config.js'; import { GitHubProjectsPMProvider } from '../../../../src/pm/github-projects/adapter.js'; +import { logger } from '../../../../src/utils/logging.js'; const config: GitHubProjectsConfig = { projectId: 'PVT_project', @@ -609,4 +610,79 @@ describe('GitHubProjectsPMProvider', () => { ); }); }); + + describe('updateComment', () => { + it('delegates directly to the client updateComment call', async () => { + await provider.updateComment('I_1', 'IC_1', 'edited text'); + + expect(mockClient.updateComment).toHaveBeenCalledWith('IC_1', 'edited text'); + }); + }); + + describe('addChecklistItem — checklist section not found', () => { + it('throws when the parsed checklist ID no longer matches a section in the body', async () => { + const { buildChecklistId } = await import('../../../../src/pm/_shared/inline-checklist.js'); + const checklistId = buildChecklistId('I_1', 'Missing Section'); + mockClient.getContentNode.mockResolvedValue( + makeContentNode({ body: '### Some Other Section\n- [ ] unrelated' }), + ); + + await expect(provider.addChecklistItem(checklistId, 'new item')).rejects.toThrow( + `Checklist not found in description: ${checklistId}`, + ); + }); + }); + + describe('getAttachments', () => { + it('returns an empty list (inline pastes are handled by extractMarkdownImages, not attachments)', async () => { + await expect(provider.getAttachments('I_1')).resolves.toEqual([]); + }); + }); + + describe('addAttachment', () => { + it('logs a not-implemented warning and does not throw', async () => { + await expect( + provider.addAttachment('I_1', 'https://example.com/a.png', 'a.png'), + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith('[GitHubProjects] addAttachment not implemented'); + }); + }); + + describe('addAttachmentFile', () => { + it('logs a not-implemented warning and does not throw', async () => { + await expect( + provider.addAttachmentFile('I_1', Buffer.from('data'), 'a.png', 'image/png'), + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + '[GitHubProjects] addAttachmentFile not implemented', + ); + }); + }); + + describe('getCustomFieldNumber', () => { + it('returns 0 (custom fields are not implemented)', async () => { + await expect(provider.getCustomFieldNumber('I_1', 'field-1')).resolves.toBe(0); + }); + }); + + describe('updateCustomFieldNumber', () => { + it('logs a not-implemented warning with the field ID and does not throw', async () => { + await expect(provider.updateCustomFieldNumber('I_1', 'field-1', 42)).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + '[GitHubProjects] updateCustomFieldNumber not implemented', + { fieldId: 'field-1' }, + ); + }); + }); + + describe('linkPR', () => { + it('is a no-op that logs at debug level', async () => { + await expect( + provider.linkPR('I_1', 'https://github.com/octocat/repo/pull/1', 'A PR'), + ).resolves.toBeUndefined(); + expect(logger.debug).toHaveBeenCalledWith( + '[GitHubProjects] linkPR is a no-op; PRs are linked by being in the project', + ); + }); + }); }); diff --git a/tests/unit/pm/github-projects/client.test.ts b/tests/unit/pm/github-projects/client.test.ts index 89e3bde70..61c508e9d 100644 --- a/tests/unit/pm/github-projects/client.test.ts +++ b/tests/unit/pm/github-projects/client.test.ts @@ -5,18 +5,31 @@ vi.mock('../../../../src/utils/logging.js', () => ({ })); import { + addCommentToIssue, addContentToProject, addLabelsToContent, createRepositoryIssue, + deleteComment, downloadImage, getContentNode, + getGitHubProjectsCredentials, getIssueComments, + getOrganizationProjects, + getProject, + getProjectFields, getProjectItem, getRepositoryId, + getStatusField, + getUserProjects, + getViewer, listAllProjectItems, + moveProjectItemToStatus, removeLabelsFromContent, resolveContentRepoLabelId, resolveProjectItemId, + resolveStatusOptionName, + updateComment, + updateProjectItemField, withGitHubProjectsCredentials, } from '../../../../src/github-projects/client.js'; import { logger } from '../../../../src/utils/logging.js'; @@ -429,4 +442,437 @@ describe('github-projects client', () => { expect(result).toBeNull(); }); }); + + describe('getGitHubProjectsCredentials', () => { + it('throws when called outside withGitHubProjectsCredentials scope', () => { + expect(() => getGitHubProjectsCredentials()).toThrow( + /No GitHub Projects credentials in scope\. Wrap the call with withGitHubProjectsCredentials\(\)\./, + ); + }); + }); + + describe('githubGraphQL — error branches', () => { + it('throws with the response body on a non-ok HTTP status', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'internal server error', + } as unknown as Response); + + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => getViewer()), + ).rejects.toThrow(/GitHub GraphQL HTTP error 500: internal server error/); + }); + + it('falls back to "" when reading the error body itself fails', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 502, + text: async () => { + throw new Error('stream closed'); + }, + } as unknown as Response); + + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => getViewer()), + ).rejects.toThrow(/GitHub GraphQL HTTP error 502: /); + }); + + it('throws with joined messages when the GraphQL errors array is present', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + errors: [{ message: 'field not found' }, { message: 'not authorized' }], + }), + text: async () => '', + } as unknown as Response); + + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => getViewer()), + ).rejects.toThrow(/GitHub GraphQL error: field not found; not authorized/); + }); + + it('throws when the response has neither errors nor data', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + text: async () => '', + } as unknown as Response); + + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => getViewer()), + ).rejects.toThrow(/GitHub GraphQL returned no data/); + }); + }); + + describe('getProject / getProjectFields', () => { + it('getProject returns the node with its fields', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + id: 'PVT_project', + number: 3, + title: 'Roadmap', + url: 'https://github.com/orgs/o/projects/3', + fields: { + nodes: [ + { id: 'F_1', name: 'Title' }, + { + id: 'F_2', + name: 'Status', + options: [{ id: 'opt-1', name: 'Todo', color: 'GREEN' }], + }, + ], + }, + }, + }), + ); + + const project = await withGitHubProjectsCredentials({ token: 't' }, () => + getProject('PVT_project'), + ); + + expect(project.title).toBe('Roadmap'); + expect(project.fields?.nodes).toHaveLength(2); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as { body: string }).body).variables).toEqual({ + projectId: 'PVT_project', + }); + }); + + it('getProjectFields returns the fields.nodes array', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + id: 'PVT_project', + number: 3, + title: 'Roadmap', + url: 'u', + fields: { nodes: [{ id: 'F_1', name: 'Status', options: [] }] }, + }, + }), + ); + + const fields = await withGitHubProjectsCredentials({ token: 't' }, () => + getProjectFields('PVT_project'), + ); + + expect(fields).toEqual([{ id: 'F_1', name: 'Status', options: [] }]); + }); + + it('getProjectFields falls back to [] when the project has no fields connection', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ node: { id: 'PVT_project', number: 3, title: 't', url: 'u' } }), + ); + + const fields = await withGitHubProjectsCredentials({ token: 't' }, () => + getProjectFields('PVT_project'), + ); + + expect(fields).toEqual([]); + }); + }); + + describe('listAllProjectItems — additional edge cases', () => { + it('returns [] without calling fetch when maxItems is 0', async () => { + const items = await withGitHubProjectsCredentials({ token: 't' }, () => + listAllProjectItems('PVT_project', { maxItems: 0 }), + ); + + expect(items).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('stops paginating when hasNextPage is true but endCursor is null', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + node: { + items: { + nodes: [ + { + id: 'PVTI_1', + content: { + __typename: 'Issue', + id: 'content-1', + number: 1, + title: 't', + body: '', + url: 'u', + state: 'OPEN', + }, + fieldValues: { nodes: [] }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: null }, + }, + }, + }), + ); + + const items = await withGitHubProjectsCredentials({ token: 't' }, () => + listAllProjectItems('PVT_project'), + ); + + expect(items).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateProjectItemField', () => { + it('posts updateProjectV2ItemFieldValue with the singleSelectOptionId value', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ updateProjectV2ItemFieldValue: { projectV2Item: { id: 'PVTI_1' } } }), + ); + + await withGitHubProjectsCredentials({ token: 't' }, () => + updateProjectItemField('PVT_project', 'PVTI_1', 'F_status', 'opt-done'), + ); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('updateProjectV2ItemFieldValue'); + expect(parsed.variables).toEqual({ + projectId: 'PVT_project', + itemId: 'PVTI_1', + fieldId: 'F_status', + optionId: 'opt-done', + }); + }); + }); + + describe('comment mutations', () => { + it('addCommentToIssue returns the new comment node id', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ addComment: { commentEdge: { node: { id: 'IC_new' } } } }), + ); + + const id = await withGitHubProjectsCredentials({ token: 't' }, () => + addCommentToIssue('I_1', 'hello'), + ); + + expect(id).toBe('IC_new'); + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('addComment'); + expect(parsed.variables).toEqual({ subjectId: 'I_1', body: 'hello' }); + }); + + it('updateComment posts updateIssueComment with the new body', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ updateIssueComment: { issueComment: { id: 'IC_1' } } }), + ); + + await withGitHubProjectsCredentials({ token: 't' }, () => updateComment('IC_1', 'edited')); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('updateIssueComment'); + expect(parsed.variables).toEqual({ commentId: 'IC_1', body: 'edited' }); + }); + + it('deleteComment posts deleteIssueComment with the comment id', async () => { + fetchMock.mockResolvedValue(graphqlResponse({ deleteIssueComment: {} })); + + await withGitHubProjectsCredentials({ token: 't' }, () => deleteComment('IC_1')); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = JSON.parse((init as { body: string }).body); + expect(parsed.query).toContain('deleteIssueComment'); + expect(parsed.variables).toEqual({ commentId: 'IC_1' }); + }); + }); + + describe('discovery queries', () => { + it('getUserProjects returns the user projectsV2 nodes', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + user: { + projectsV2: { + nodes: [{ id: 'PVT_1', number: 1, title: 'Personal', url: 'u' }], + }, + }, + }), + ); + + const projects = await withGitHubProjectsCredentials({ token: 't' }, () => + getUserProjects('octocat'), + ); + + expect(projects).toEqual([{ id: 'PVT_1', number: 1, title: 'Personal', url: 'u' }]); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as { body: string }).body).variables).toEqual({ login: 'octocat' }); + }); + + it('getOrganizationProjects returns the organization projectsV2 nodes', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ + organization: { + projectsV2: { + nodes: [{ id: 'PVT_2', number: 2, title: 'Org Board', url: 'u' }], + }, + }, + }), + ); + + const projects = await withGitHubProjectsCredentials({ token: 't' }, () => + getOrganizationProjects('acme'), + ); + + expect(projects).toEqual([{ id: 'PVT_2', number: 2, title: 'Org Board', url: 'u' }]); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as { body: string }).body).variables).toEqual({ org: 'acme' }); + }); + + it('getViewer returns the viewer identity', async () => { + fetchMock.mockResolvedValue( + graphqlResponse({ viewer: { id: 'U_1', login: 'octocat', name: 'The Octocat' } }), + ); + + const viewer = await withGitHubProjectsCredentials({ token: 't' }, () => getViewer()); + + expect(viewer).toEqual({ id: 'U_1', login: 'octocat', name: 'The Octocat' }); + }); + }); + + describe('status field helpers', () => { + function projectWithFields(fields: Array<{ id: string; name: string; options?: unknown }>) { + return graphqlResponse({ + node: { id: 'PVT_project', number: 1, title: 't', url: 'u', fields: { nodes: fields } }, + }); + } + + it('getStatusField returns the Status field id + options when present', async () => { + fetchMock.mockResolvedValue( + projectWithFields([ + { id: 'F_1', name: 'Title' }, + { + id: 'F_status', + name: 'Status', + options: [ + { id: 'opt-todo', name: 'Todo' }, + { id: 'opt-done', name: 'Done' }, + ], + }, + ]), + ); + + const statusField = await withGitHubProjectsCredentials({ token: 't' }, () => + getStatusField('PVT_project'), + ); + + expect(statusField).toEqual({ + id: 'F_status', + options: [ + { id: 'opt-todo', name: 'Todo' }, + { id: 'opt-done', name: 'Done' }, + ], + }); + }); + + it('getStatusField returns null when there is no Status field', async () => { + fetchMock.mockResolvedValue(projectWithFields([{ id: 'F_1', name: 'Title' }])); + + const statusField = await withGitHubProjectsCredentials({ token: 't' }, () => + getStatusField('PVT_project'), + ); + + expect(statusField).toBeNull(); + }); + + it('getStatusField returns null when the Status field has no options', async () => { + fetchMock.mockResolvedValue(projectWithFields([{ id: 'F_status', name: 'Status' }])); + + const statusField = await withGitHubProjectsCredentials({ token: 't' }, () => + getStatusField('PVT_project'), + ); + + expect(statusField).toBeNull(); + }); + + it('resolveStatusOptionName returns the matching option name', async () => { + fetchMock.mockResolvedValue( + projectWithFields([ + { id: 'F_status', name: 'Status', options: [{ id: 'opt-done', name: 'Done' }] }, + ]), + ); + + const name = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveStatusOptionName('PVT_project', 'opt-done'), + ); + + expect(name).toBe('Done'); + }); + + it('resolveStatusOptionName returns null when the option id is not found', async () => { + fetchMock.mockResolvedValue( + projectWithFields([ + { id: 'F_status', name: 'Status', options: [{ id: 'opt-done', name: 'Done' }] }, + ]), + ); + + const name = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveStatusOptionName('PVT_project', 'opt-missing'), + ); + + expect(name).toBeNull(); + }); + + it('resolveStatusOptionName returns null when there is no Status field at all', async () => { + fetchMock.mockResolvedValue(projectWithFields([{ id: 'F_1', name: 'Title' }])); + + const name = await withGitHubProjectsCredentials({ token: 't' }, () => + resolveStatusOptionName('PVT_project', 'opt-done'), + ); + + expect(name).toBeNull(); + }); + + it('moveProjectItemToStatus throws when the project has no Status field', async () => { + fetchMock.mockResolvedValue(projectWithFields([{ id: 'F_1', name: 'Title' }])); + + await expect( + withGitHubProjectsCredentials({ token: 't' }, () => + moveProjectItemToStatus('PVT_project', 'PVTI_1', 'opt-done'), + ), + ).rejects.toThrow(/Project PVT_project does not have a Status field/); + }); + + it('moveProjectItemToStatus resolves the Status field then writes the option and logs', async () => { + fetchMock + .mockResolvedValueOnce( + projectWithFields([ + { + id: 'F_status', + name: 'Status', + options: [{ id: 'opt-done', name: 'Done' }], + }, + ]), + ) + .mockResolvedValueOnce( + graphqlResponse({ updateProjectV2ItemFieldValue: { projectV2Item: { id: 'PVTI_1' } } }), + ); + + await withGitHubProjectsCredentials({ token: 't' }, () => + moveProjectItemToStatus('PVT_project', 'PVTI_1', 'opt-done'), + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondInit] = fetchMock.mock.calls[1]; + const parsed = JSON.parse((secondInit as { body: string }).body); + expect(parsed.variables).toEqual({ + projectId: 'PVT_project', + itemId: 'PVTI_1', + fieldId: 'F_status', + optionId: 'opt-done', + }); + expect(logger.debug).toHaveBeenCalledWith( + '[GitHubProjects] Moved item to status', + expect.objectContaining({ + projectId: 'PVT_project', + itemId: 'PVTI_1', + statusOptionId: 'opt-done', + }), + ); + }); + }); }); diff --git a/tests/unit/pm/github-projects/integration.test.ts b/tests/unit/pm/github-projects/integration.test.ts index f7882050d..e819f54b3 100644 --- a/tests/unit/pm/github-projects/integration.test.ts +++ b/tests/unit/pm/github-projects/integration.test.ts @@ -28,6 +28,17 @@ vi.mock('../../../../src/github-projects/client.js', () => ({ deleteComment: vi.fn(), })); +const { mockLoggerWarn } = vi.hoisted(() => ({ mockLoggerWarn: vi.fn() })); +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { warn: mockLoggerWarn, debug: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +import { + addCommentToIssue, + deleteComment, + getViewer, + withGitHubProjectsCredentials, +} from '../../../../src/github-projects/client.js'; import { GitHubProjectsIntegration } from '../../../../src/pm/github-projects/integration.js'; import type { ProjectConfig } from '../../../../src/types/index.js'; @@ -145,4 +156,229 @@ describe('GitHubProjectsIntegration', () => { expect(integration.extractWorkItemId('no url here')).toBeNull(); }); }); + + describe('hasIntegration', () => { + it('returns false when the PM provider is not github-projects', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + expect(mockGetIntegrationCredentialOrNull).not.toHaveBeenCalled(); + }); + + it('returns true when the provider is github-projects and the required token is present', async () => { + mockGetIntegrationProvider.mockResolvedValue('github-projects'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('ghp_token'); + + const result = await integration.hasIntegration('proj-1'); + + // Only 'token' is required; 'webhook_secret' is optional and must not gate readiness. + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledTimes(1); + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith( + 'proj-1', + 'pm', + 'github-projects', + 'token', + ); + expect(result).toBe(true); + }); + + it('returns false when the required token credential is missing', async () => { + mockGetIntegrationProvider.mockResolvedValue('github-projects'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce(null); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + }); + + describe('withCredentials', () => { + it('fetches the token credential and scopes the callback via withGitHubProjectsCredentials', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('ghp_token'); + const fn = vi.fn().mockResolvedValue('done'); + + const result = await integration.withCredentials('proj-1', fn); + + expect(mockGetIntegrationCredential).toHaveBeenCalledWith( + 'proj-1', + 'pm', + 'github-projects', + 'token', + ); + expect(withGitHubProjectsCredentials).toHaveBeenCalledWith({ token: 'ghp_token' }, fn); + expect(result).toBe('done'); + }); + }); + + describe('isSelfAuthored', () => { + it('returns false for non-projects_v2_item event types', async () => { + const result = await integration.isSelfAuthored( + { eventType: 'issue_comment.created', projectIdentifier: 'PVT_project', raw: {} }, + 'proj-1', + ); + expect(result).toBe(false); + expect(mockGetIntegrationCredential).not.toHaveBeenCalled(); + }); + + it('returns false when the webhook payload has no sender', async () => { + const result = await integration.isSelfAuthored( + { + eventType: 'projects_v2_item.edited', + projectIdentifier: 'PVT_project', + raw: {}, + }, + 'proj-1', + ); + expect(result).toBe(false); + }); + + it('returns false when the sender has no login', async () => { + const result = await integration.isSelfAuthored( + { + eventType: 'projects_v2_item.edited', + projectIdentifier: 'PVT_project', + raw: { sender: {} }, + }, + 'proj-1', + ); + expect(result).toBe(false); + }); + + it('returns true when the webhook sender matches the authenticated viewer', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('ghp_token'); + vi.mocked(getViewer).mockResolvedValueOnce({ + id: 'U_bot', + login: 'cascade-bot', + name: 'Cascade Bot', + }); + + const result = await integration.isSelfAuthored( + { + eventType: 'projects_v2_item.edited', + projectIdentifier: 'PVT_project', + raw: { sender: { login: 'cascade-bot' } }, + }, + 'proj-1', + ); + + expect(result).toBe(true); + }); + + it('returns false when the webhook sender does not match the authenticated viewer', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('ghp_token'); + vi.mocked(getViewer).mockResolvedValueOnce({ + id: 'U_bot', + login: 'cascade-bot', + name: 'Cascade Bot', + }); + + const result = await integration.isSelfAuthored( + { + eventType: 'projects_v2_item.edited', + projectIdentifier: 'PVT_project', + raw: { sender: { login: 'a-human' } }, + }, + 'proj-1', + ); + + expect(result).toBe(false); + }); + + it('returns false when resolving credentials or the viewer throws', async () => { + mockGetIntegrationCredential.mockRejectedValueOnce(new Error('no credential')); + + const result = await integration.isSelfAuthored( + { + eventType: 'projects_v2_item.edited', + projectIdentifier: 'PVT_project', + raw: { sender: { login: 'cascade-bot' } }, + }, + 'proj-1', + ); + + expect(result).toBe(false); + }); + }); + + describe('postAckComment', () => { + it('posts the comment and returns the comment ID', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('ghp_token'); + vi.mocked(addCommentToIssue).mockResolvedValueOnce('comment-1'); + + const result = await integration.postAckComment('proj-1', 'I_content', 'On it'); + + expect(addCommentToIssue).toHaveBeenCalledWith('I_content', 'On it'); + expect(result).toBe('comment-1'); + }); + + it('returns null and logs a warning when posting fails', async () => { + mockGetIntegrationCredential.mockRejectedValueOnce(new Error('boom')); + + const result = await integration.postAckComment('proj-1', 'I_content', 'On it'); + + expect(result).toBeNull(); + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[GitHubProjects] Failed to post ack comment', + expect.objectContaining({ projectId: 'proj-1', workItemId: 'I_content' }), + ); + }); + }); + + describe('deleteAckComment', () => { + it('deletes the comment', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('ghp_token'); + vi.mocked(deleteComment).mockResolvedValueOnce(undefined); + + await integration.deleteAckComment('proj-1', 'I_content', 'comment-1'); + + expect(deleteComment).toHaveBeenCalledWith('comment-1'); + }); + + it('swallows the error and logs a warning when deletion fails', async () => { + mockGetIntegrationCredential.mockRejectedValueOnce(new Error('boom')); + + await expect( + integration.deleteAckComment('proj-1', 'I_content', 'comment-1'), + ).resolves.toBeUndefined(); + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[GitHubProjects] Failed to delete ack comment', + expect.objectContaining({ projectId: 'proj-1', commentId: 'comment-1' }), + ); + }); + }); + + describe('sendReaction', () => { + it('is a no-op', async () => { + await expect( + integration.sendReaction('proj-1', { + eventType: 'projects_v2_item.edited', + projectIdentifier: 'PVT_project', + raw: {}, + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('lookupProject', () => { + it('returns the project + config when a matching GitHub Projects project is found', async () => { + const project = projectWithConfig; + const config = { version: 1, agents: [] }; + mockLoadProjectConfigByGitHubProjectsProjectId.mockResolvedValueOnce({ project, config }); + + const result = await integration.lookupProject('PVT_project'); + + expect(mockLoadProjectConfigByGitHubProjectsProjectId).toHaveBeenCalledWith('PVT_project'); + expect(result).toEqual({ project, config }); + }); + + it('returns null when no project matches the given identifier', async () => { + mockLoadProjectConfigByGitHubProjectsProjectId.mockResolvedValueOnce(undefined); + + const result = await integration.lookupProject('unknown'); + + expect(result).toBeNull(); + }); + }); }); diff --git a/tests/unit/router/ackMessageGenerator.test.ts b/tests/unit/router/ackMessageGenerator.test.ts index eddc22cba..c16d7cb1f 100644 --- a/tests/unit/router/ackMessageGenerator.test.ts +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -46,6 +46,7 @@ vi.mock('../../../src/config/agentMessages.js', () => ({ import { getOrgCredential, loadConfig } from '../../../src/config/provider.js'; import { extractGitHubContext, + extractGitHubProjectsContext, extractJiraContext, extractTrelloContext, generateAckMessage, @@ -258,6 +259,63 @@ describe('extractJiraContext', () => { }); }); +describe('extractGitHubProjectsContext', () => { + it('extracts item content type and field change', () => { + const payload = { + projects_v2_item: { content_type: 'Issue' }, + changes: { + field_value: { + field_name: 'Status', + to: { name: 'In Progress' }, + }, + }, + }; + const result = extractGitHubProjectsContext(payload); + expect(result).toContain('Item: Issue'); + expect(result).toContain('Field: Status'); + expect(result).toContain('New value: In Progress'); + }); + + it('extracts only item content type when changes are absent', () => { + const payload = { projects_v2_item: { content_type: 'PullRequest' } }; + const result = extractGitHubProjectsContext(payload); + expect(result).toBe('Item: PullRequest'); + }); + + it('extracts only field change when projects_v2_item is absent', () => { + const payload = { + changes: { field_value: { field_name: 'Status', to: { name: 'Done' } } }, + }; + const result = extractGitHubProjectsContext(payload); + expect(result).toBe('Field: Status\nNew value: Done'); + }); + + it('returns empty string for null payload', () => { + expect(extractGitHubProjectsContext(null)).toBe(''); + }); + + it('returns empty string for payload without projects_v2_item or changes', () => { + expect(extractGitHubProjectsContext({})).toBe(''); + }); + + it('omits field name when field_value has no field_name', () => { + const payload = { + projects_v2_item: { content_type: 'Issue' }, + changes: { field_value: { to: { name: 'Done' } } }, + }; + const result = extractGitHubProjectsContext(payload); + expect(result).toBe('Item: Issue\nNew value: Done'); + }); + + it('truncates long context', () => { + const longName = 'D'.repeat(600); + const payload = { projects_v2_item: { content_type: longName } }; + const result = extractGitHubProjectsContext(payload); + expect(result.length).toBeLessThanOrEqual(501); + expect(result.endsWith('…')).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // generateAckMessage // --------------------------------------------------------------------------- diff --git a/tests/unit/router/adapters/github-projects.test.ts b/tests/unit/router/adapters/github-projects.test.ts index 547879baf..350586d33 100644 --- a/tests/unit/router/adapters/github-projects.test.ts +++ b/tests/unit/router/adapters/github-projects.test.ts @@ -2,14 +2,17 @@ * Unit tests for GitHubProjectsRouterAdapter. */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as client from '../../../../src/github-projects/client.js'; +import * as ackMessageGenerator from '../../../../src/router/ackMessageGenerator.js'; +import * as sharedAdapter from '../../../../src/router/adapters/_shared.js'; import { GitHubProjectsRouterAdapter } from '../../../../src/router/adapters/github-projects.js'; import type { RouterProjectConfig } from '../../../../src/router/config.js'; import * as config from '../../../../src/router/config.js'; import * as credentials from '../../../../src/router/platformClients/credentials.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; import type { TriggerResult } from '../../../../src/types/index.js'; +import * as runLink from '../../../../src/utils/runLink.js'; vi.mock('../../../../src/router/platformClients/credentials.js', () => ({ resolveGitHubProjectsCredentials: vi.fn(), @@ -21,10 +24,27 @@ vi.mock('../../../../src/router/config.js', () => ({ vi.mock('../../../../src/github-projects/client.js', () => ({ getViewer: vi.fn(), + addCommentToIssue: vi.fn(), // Run the scoped fn directly so getViewer() executes in tests. withGitHubProjectsCredentials: vi.fn((_creds: unknown, fn: () => unknown) => fn()), })); +// Spec 017 / plan 2: PM router adapters wrap dispatch in `withPMScopeForDispatch`. +// Mock as passthrough so dispatch tests don't pull the real PM manifest registry. +vi.mock('../../../../src/router/adapters/_shared.js', () => ({ + withPMScopeForDispatch: vi.fn().mockImplementation((_p: unknown, fn: () => unknown) => fn()), +})); + +vi.mock('../../../../src/router/ackMessageGenerator.js', () => ({ + extractGitHubProjectsContext: vi.fn().mockReturnValue('Item: Issue'), + generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), +})); + +vi.mock('../../../../src/utils/runLink.js', () => ({ + buildWorkItemRunsLink: vi.fn().mockReturnValue(null), + getDashboardUrl: vi.fn().mockReturnValue(null), +})); + function makeStatusChangePayload( projectNodeId: string, contentNodeId: string, @@ -144,6 +164,48 @@ describe('GitHubProjectsRouterAdapter', () => { }); expect(event).toBeNull(); }); + + it('returns null when project_node_id is missing', async () => { + const event = await adapter.parseWebhook({ + action: 'edited', + projects_v2_item: { + id: 1, + node_id: 'PVTI_item', + project_node_id: '', + content_node_id: 'PVTI_item', + content_type: 'Issue', + }, + changes: { + field_value: { + field_node_id: 'f', + field_name: 'Status', + to: { id: 'x', name: 'Done' }, + }, + }, + }); + expect(event).toBeNull(); + }); + + it('returns null when content_node_id is missing', async () => { + const event = await adapter.parseWebhook({ + action: 'edited', + projects_v2_item: { + id: 1, + node_id: 'PVTI_item', + project_node_id: 'PVT_project', + content_node_id: '', + content_type: 'Issue', + }, + changes: { + field_value: { + field_node_id: 'f', + field_name: 'Status', + to: { id: 'x', name: 'Done' }, + }, + }, + }); + expect(event).toBeNull(); + }); }); describe('isProcessableEvent', () => { @@ -154,10 +216,63 @@ describe('GitHubProjectsRouterAdapter', () => { if (!event) throw new Error('expected event'); expect(adapter.isProcessableEvent(event)).toBe(true); }); + + it('rejects events from other webhook types', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'x', + eventType: 'issue/opened', + isCommentEvent: false, + }), + ).toBe(false); + }); + }); + + describe('sendReaction', () => { + it('is a no-op — GitHub Projects item webhooks have no reaction support', () => { + expect(() => + adapter.sendReaction( + { projectIdentifier: 'x', eventType: 'projects_v2_item/edited', isCommentEvent: false }, + {}, + ), + ).not.toThrow(); + }); + }); + + describe('resolveProject', () => { + it('returns the matching project config by GitHub Projects node id', async () => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [{ id: 'cascade-proj', githubProjects: { projectId: 'PVT_abc' } }], + fullProjects: [], + } as unknown as Awaited>); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_abc', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const project = await adapter.resolveProject(event); + expect(project?.id).toBe('cascade-proj'); + }); + + it('returns null when no project matches the node id', async () => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [{ id: 'cascade-proj', githubProjects: { projectId: 'PVT_other' } }], + fullProjects: [], + } as unknown as Awaited>); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_abc', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const project = await adapter.resolveProject(event); + expect(project).toBeNull(); + }); }); describe('dispatchWithCredentials', () => { - it('returns null when project credentials are missing', async () => { + it('returns null when no full project config is found (no credential lookup)', async () => { vi.mocked(config.loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [], @@ -173,6 +288,66 @@ describe('GitHubProjectsRouterAdapter', () => { const result = await adapter.dispatchWithCredentials(event, {}, project, registry); expect(result).toBeNull(); + expect(credentials.resolveGitHubProjectsCredentials).not.toHaveBeenCalled(); + }); + + it('returns null when GitHub Projects credentials are missing for a resolved full project', async () => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' } as never], + }); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue(null); + + const project = { id: 'proj-1' } as RouterProjectConfig; + const registry = { dispatch: vi.fn() } as unknown as TriggerRegistry; + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_p', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.dispatchWithCredentials(event, {}, project, registry); + expect(result).toBeNull(); + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches through PM scope and credential scope on the happy path', async () => { + const fullProject = { id: 'proj-1', repo: 'owner/repo' }; + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [fullProject as never], + }); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue({ + token: 'ghp_x', + }); + const triggerResult: TriggerResult = { + shouldDispatch: true, + agentType: 'implementation', + workItemId: 'PVTI_i', + }; + const dispatch = vi.fn().mockResolvedValue(triggerResult); + const registry = { dispatch } as unknown as TriggerRegistry; + + const project = { id: 'proj-1' } as RouterProjectConfig; + const payload = makeStatusChangePayload('PVT_p', 'PVTI_i', { id: 's', name: 'Todo' }); + const event = await adapter.parseWebhook(payload); + if (!event) throw new Error('expected event'); + + const result = await adapter.dispatchWithCredentials(event, payload, project, registry); + + expect(result).toEqual(triggerResult); + expect(dispatch).toHaveBeenCalledWith({ + project: fullProject, + source: 'github-projects', + payload, + }); + expect(sharedAdapter.withPMScopeForDispatch).toHaveBeenCalledWith( + fullProject, + expect.any(Function), + ); + expect(client.withGitHubProjectsCredentials).toHaveBeenCalledWith( + { token: 'ghp_x' }, + expect.any(Function), + ); }); }); @@ -238,6 +413,168 @@ describe('GitHubProjectsRouterAdapter', () => { const result = await adapter.isSelfAuthored(event, { sender: { login: 'cascade-bot' } }); expect(result).toBe(false); }); + + it('returns false immediately when the event has no projectId', async () => { + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.isSelfAuthored( + { ...event, projectId: '' }, + { sender: { login: 'cascade-bot' } }, + ); + expect(result).toBe(false); + expect(config.loadProjectConfig).not.toHaveBeenCalled(); + }); + + it('returns false when the payload has no sender', async () => { + stubProjectLookup('PVT_project123', 'cascade-proj'); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.isSelfAuthored(event, {}); + expect(result).toBe(false); + }); + + it('returns false when credentials cannot be resolved for the viewer lookup', async () => { + stubProjectLookup('PVT_project123', 'cascade-proj'); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue(null); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.isSelfAuthored(event, { sender: { login: 'cascade-bot' } }); + expect(result).toBe(false); + }); + + it('returns false when the viewer lookup throws', async () => { + stubProjectLookup('PVT_project123', 'cascade-proj'); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue({ + token: 'ghp_x', + }); + vi.mocked(client.getViewer).mockRejectedValue(new Error('GraphQL error')); + + const event = await adapter.parseWebhook( + makeStatusChangePayload('PVT_project123', 'PVTI_i', { id: 's', name: 'Todo' }), + ); + if (!event) throw new Error('expected event'); + + const result = await adapter.isSelfAuthored(event, { sender: { login: 'cascade-bot' } }); + expect(result).toBe(false); + }); + }); + + describe('postAck', () => { + const baseProject = { id: 'proj-1' } as RouterProjectConfig; + const baseEvent = { + projectIdentifier: 'PVT_p', + eventType: 'projects_v2_item/edited', + workItemId: 'PVTI_i', + isCommentEvent: false, + }; + + beforeEach(() => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1' } as never], + }); + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue({ + token: 'ghp_x', + }); + }); + + it('returns undefined when the event has no workItemId', async () => { + const ackResult = await adapter.postAck( + { ...baseEvent, workItemId: undefined }, + {}, + baseProject, + 'implementation', + ); + expect(ackResult).toBeUndefined(); + expect(client.addCommentToIssue).not.toHaveBeenCalled(); + }); + + it('posts an ack comment and returns commentId + message', async () => { + vi.mocked(client.addCommentToIssue).mockResolvedValue('comment-1'); + + const ackResult = await adapter.postAck(baseEvent, {}, baseProject, 'implementation'); + + expect(ackResult?.commentId).toBe('comment-1'); + expect(ackResult?.message).toBe('Starting implementation...'); + expect(client.addCommentToIssue).toHaveBeenCalledWith('PVTI_i', 'Starting implementation...'); + }); + + it('skips the ack when PM posting is disabled for the resolved update channel', async () => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [ + { id: 'proj-1', agentUpdateChannels: { implementation: 'scm-only' } } as never, + ], + }); + + const ackResult = await adapter.postAck(baseEvent, {}, baseProject, 'implementation'); + + expect(ackResult).toBeUndefined(); + expect(client.addCommentToIssue).not.toHaveBeenCalled(); + }); + + it('appends a run-link footer when runLinksEnabled and a dashboard URL is available', async () => { + vi.mocked(config.loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', runLinksEnabled: true } as never], + }); + vi.mocked(runLink.getDashboardUrl).mockReturnValue('https://dashboard.example.com'); + vi.mocked(runLink.buildWorkItemRunsLink).mockReturnValue( + '\n[View runs](https://dashboard.example.com/runs)', + ); + vi.mocked(client.addCommentToIssue).mockResolvedValue('comment-2'); + + const ackResult = await adapter.postAck(baseEvent, {}, baseProject, 'implementation'); + + expect(runLink.buildWorkItemRunsLink).toHaveBeenCalledWith({ + dashboardUrl: 'https://dashboard.example.com', + projectId: 'proj-1', + workItemId: 'PVTI_i', + }); + expect(ackResult?.message).toContain('[View runs]'); + }); + + it('returns undefined when GitHub Projects credentials cannot be resolved', async () => { + vi.mocked(credentials.resolveGitHubProjectsCredentials).mockResolvedValue(null); + + const ackResult = await adapter.postAck(baseEvent, {}, baseProject, 'implementation'); + + expect(ackResult).toBeUndefined(); + expect(client.addCommentToIssue).not.toHaveBeenCalled(); + }); + + it('catches errors from addCommentToIssue and returns undefined', async () => { + vi.mocked(client.addCommentToIssue).mockRejectedValue(new Error('GraphQL failure')); + + const ackResult = await adapter.postAck(baseEvent, {}, baseProject, 'implementation'); + + expect(ackResult).toBeUndefined(); + }); + + it('uses extractGitHubProjectsContext + generateAckMessage to build the ack message', async () => { + vi.mocked(client.addCommentToIssue).mockResolvedValue('comment-3'); + const payload = { projects_v2_item: { content_type: 'Issue' } }; + + await adapter.postAck(baseEvent, payload, baseProject, 'implementation'); + + expect(ackMessageGenerator.extractGitHubProjectsContext).toHaveBeenCalledWith(payload); + expect(ackMessageGenerator.generateAckMessage).toHaveBeenCalledWith( + 'implementation', + 'Item: Issue', + 'proj-1', + ); + }); }); describe('buildJob', () => { diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts index 67cf7a761..2851d843c 100644 --- a/tests/unit/router/config.test.ts +++ b/tests/unit/router/config.test.ts @@ -151,6 +151,43 @@ describe('loadProjectConfig', () => { }); }); + it('maps github-projects project config correctly', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p5', + name: 'GitHub Projects project', + repo: 'owner/gh-projects-repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + pm: { type: 'github-projects' }, + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + statuses: { todo: 'Todo' }, + }, + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]).toMatchObject({ + id: 'p5', + repo: 'owner/gh-projects-repo', + pmType: 'github-projects', + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization', + }, + }); + }); + it('leaves pmType undefined when pm is not set (SCM-only project)', async () => { mockLoadConfig.mockResolvedValueOnce({ projects: [ diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index 9524af3ce..8233766df 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -35,6 +35,7 @@ import { JiraPlatformClient, LinearPlatformClient, resolveGitHubHeaders, + resolveGitHubProjectsCredentials, resolveJiraCredentials, resolveTrelloCredentials, TrelloPlatformClient, @@ -131,6 +132,33 @@ describe('resolveTrelloCredentials', () => { }); }); +// --------------------------------------------------------------------------- +// resolveGitHubProjectsCredentials +// --------------------------------------------------------------------------- + +describe('resolveGitHubProjectsCredentials', () => { + it('returns token on success', async () => { + mockGetIntegrationCredential.mockImplementation( + async (_projectId, category, _provider, role) => { + if (category === 'pm' && role === 'token') return 'ghp_test123'; + throw new Error(`Credential '${category}/${role}' not found`); + }, + ); + + const result = await resolveGitHubProjectsCredentials('proj1'); + + expect(result).toEqual({ token: 'ghp_test123' }); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveGitHubProjectsCredentials('proj1'); + + expect(result).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // resolveJiraCredentials // --------------------------------------------------------------------------- diff --git a/tests/unit/router/platformClients/github-projects.test.ts b/tests/unit/router/platformClients/github-projects.test.ts new file mode 100644 index 000000000..524abe82e --- /dev/null +++ b/tests/unit/router/platformClients/github-projects.test.ts @@ -0,0 +1,154 @@ +/** + * Unit tests for GitHubProjectsPlatformClient. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../src/router/platformClients/credentials.js', () => ({ + resolveGitHubProjectsCredentials: vi.fn(), +})); + +vi.mock('../../../../src/github-projects/client.js', () => ({ + addCommentToIssue: vi.fn(), + deleteComment: vi.fn(), + updateComment: vi.fn(), + withGitHubProjectsCredentials: vi.fn((_creds: unknown, fn: () => unknown) => fn()), +})); + +import * as client from '../../../../src/github-projects/client.js'; +import * as credentials from '../../../../src/router/platformClients/credentials.js'; +import { GitHubProjectsPlatformClient } from '../../../../src/router/platformClients/github-projects.js'; +import { logger } from '../../../../src/utils/logging.js'; + +const mockLogger = vi.mocked(logger); +const mockResolveCredentials = vi.mocked(credentials.resolveGitHubProjectsCredentials); + +beforeEach(() => { + mockResolveCredentials.mockResolvedValue({ token: 'ghp_test' }); +}); + +describe('GitHubProjectsPlatformClient', () => { + describe('postComment', () => { + it('posts a comment and returns the comment id', async () => { + vi.mocked(client.addCommentToIssue).mockResolvedValue('comment-1'); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + const result = await platformClient.postComment('PVTI_item', 'hello'); + + expect(result).toBe('comment-1'); + expect(client.addCommentToIssue).toHaveBeenCalledWith('PVTI_item', 'hello'); + expect(client.withGitHubProjectsCredentials).toHaveBeenCalledWith( + { token: 'ghp_test' }, + expect.any(Function), + ); + }); + + it('returns null and logs a warning when credentials are missing', async () => { + mockResolveCredentials.mockResolvedValue(null); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + const result = await platformClient.postComment('PVTI_item', 'hello'); + + expect(result).toBeNull(); + expect(client.addCommentToIssue).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Missing GitHub Projects credentials'), + ); + }); + + it('returns null and logs a warning when the underlying call throws', async () => { + vi.mocked(client.addCommentToIssue).mockRejectedValue(new Error('GraphQL error')); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + const result = await platformClient.postComment('PVTI_item', 'hello'); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to post GitHub Projects comment'), + expect.stringContaining('GraphQL error'), + ); + }); + }); + + describe('deleteComment', () => { + it('deletes the comment via the client', async () => { + vi.mocked(client.deleteComment).mockResolvedValue(undefined); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.deleteComment('PVTI_item', 'comment-1'); + + expect(client.deleteComment).toHaveBeenCalledWith('comment-1'); + }); + + it('silently returns when credentials are missing', async () => { + mockResolveCredentials.mockResolvedValue(null); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.deleteComment('PVTI_item', 'comment-1'); + + expect(client.deleteComment).not.toHaveBeenCalled(); + }); + + it('catches errors from the client and logs a warning', async () => { + vi.mocked(client.deleteComment).mockRejectedValue(new Error('not found')); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.deleteComment('PVTI_item', 'comment-1'); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to delete GitHub Projects comment'), + expect.stringContaining('not found'), + ); + }); + + it('coerces a numeric commentId to a string', async () => { + vi.mocked(client.deleteComment).mockResolvedValue(undefined); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.deleteComment('PVTI_item', 42); + + expect(client.deleteComment).toHaveBeenCalledWith('42'); + }); + }); + + describe('updateComment', () => { + it('updates the comment via the client', async () => { + vi.mocked(client.updateComment).mockResolvedValue(undefined); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.updateComment('PVTI_item', 'comment-1', 'edited message'); + + expect(client.updateComment).toHaveBeenCalledWith('comment-1', 'edited message'); + }); + + it('silently returns when credentials are missing', async () => { + mockResolveCredentials.mockResolvedValue(null); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.updateComment('PVTI_item', 'comment-1', 'edited message'); + + expect(client.updateComment).not.toHaveBeenCalled(); + }); + + it('catches errors from the client and logs a warning', async () => { + vi.mocked(client.updateComment).mockRejectedValue(new Error('rate limited')); + + const platformClient = new GitHubProjectsPlatformClient('proj1'); + await platformClient.updateComment('PVTI_item', 'comment-1', 'edited message'); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update GitHub Projects comment'), + expect.stringContaining('rate limited'), + ); + }); + }); +}); diff --git a/tests/unit/router/resolveWebhookSecret.test.ts b/tests/unit/router/resolveWebhookSecret.test.ts index dbba5ee35..0bf90f167 100644 --- a/tests/unit/router/resolveWebhookSecret.test.ts +++ b/tests/unit/router/resolveWebhookSecret.test.ts @@ -13,6 +13,23 @@ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ })); const { resolveWebhookSecret } = await import('../../../src/router/platformClients/credentials.js'); +const { registerCredentialRoles } = await import('../../../src/config/integrationRoles.js'); + +// github-projects self-registers its credential roles at module load time +// (src/pm/github-projects/integration.ts) rather than via the static +// PROVIDER_CREDENTIAL_ROLES map. Mirror that registration here so the +// 'github-projects' branch of resolveWebhookSecret resolves a real envVarKey +// without needing to import the full integration module (and its DB/client +// transitive imports) into this isolated unit test. +registerCredentialRoles('github-projects', 'pm', [ + { role: 'token', label: 'Personal Access Token', envVarKey: 'GITHUB_TOKEN' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'GITHUB_WEBHOOK_SECRET', + optional: true, + }, +]); describe('resolveWebhookSecret', () => { beforeEach(() => { @@ -63,6 +80,27 @@ describe('resolveWebhookSecret', () => { expect(resolveSpy).toHaveBeenCalledWith('proj', 'SENTRY_WEBHOOK_SECRET'); }); + it("returns GITHUB_WEBHOOK_SECRET for provider='github-projects'", async () => { + // Note: this envVarKey is literally identical to the `github` (SCM) provider's + // webhook_secret role above — see src/pm/github-projects/integration.ts. Since + // credential rows are keyed only by (projectId, envVarKey) with no + // category/provider disambiguation (src/db/repositories/credentialsRepository.ts), + // a project with both GitHub SCM and GitHub Projects PM configured shares a + // single webhook-secret credential row between the two integrations. + resolveSpy.mockImplementation(async (_, key) => + key === 'GITHUB_WEBHOOK_SECRET' ? 'gh-projects-secret' : null, + ); + const got = await resolveWebhookSecret('proj', 'github-projects'); + expect(got).toBe('gh-projects-secret'); + expect(resolveSpy).toHaveBeenCalledWith('proj', 'GITHUB_WEBHOOK_SECRET'); + }); + + it("returns null for provider='github-projects' when no secret is configured", async () => { + resolveSpy.mockImplementation(async () => null); + const got = await resolveWebhookSecret('proj', 'github-projects'); + expect(got).toBeNull(); + }); + it("returns TRELLO_API_SECRET for provider='trello' (Trello HMAC uses api_secret)", async () => { resolveSpy.mockImplementation(async (_, key) => key === 'TRELLO_API_SECRET' ? 'trello-api-secret' : null, diff --git a/tests/unit/router/webhook-signature.test.ts b/tests/unit/router/webhook-signature.test.ts index 9fec705e7..729000798 100644 --- a/tests/unit/router/webhook-signature.test.ts +++ b/tests/unit/router/webhook-signature.test.ts @@ -124,9 +124,11 @@ import { resolveWebhookSecret } from '../../../src/router/platformClients/creden import { buildTrelloCallbackUrl, createWebhookVerifier, + extractGitHubProjectsProjectId, extractJiraProjectKey, extractLinearTeamId, extractTrelloBoardId, + verifyGitHubProjectsWebhookSignature, verifyGitHubWebhookSignature, verifyJiraWebhookSignature, verifyLinearWebhookSignature, @@ -191,10 +193,22 @@ const LINEAR_PROJECT = { }, }; +const GITHUB_PROJECTS_PROJECT = { + id: 'proj-github-projects', + repo: 'owner/repo', + pmType: 'github-projects' as const, + githubProjects: { + projectId: 'PVT_kwABC', + owner: 'acme-org', + ownerType: 'organization' as const, + }, +}; + const GITHUB_SECRET = 'my-github-webhook-secret'; const TRELLO_SECRET = 'my-trello-api-secret'; const JIRA_SECRET = 'my-jira-webhook-secret'; const LINEAR_SECRET = 'my-linear-webhook-secret'; +const GITHUB_PROJECTS_SECRET = 'my-github-projects-webhook-secret'; const TRELLO_CALLBACK_URL = 'https://example.com/trello/webhook'; // --------------------------------------------------------------------------- @@ -557,6 +571,109 @@ describe('verifyLinearWebhookSignature — direct function tests', () => { }); }); +// --------------------------------------------------------------------------- +// Unit tests: extractGitHubProjectsProjectId +// --------------------------------------------------------------------------- + +describe('extractGitHubProjectsProjectId', () => { + it('extracts project node ID from projects_v2_item.project_node_id', () => { + const body = JSON.stringify({ + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_kwABC' }, + }); + expect(extractGitHubProjectsProjectId(body)).toBe('PVT_kwABC'); + }); + + it('returns undefined when projects_v2_item is missing', () => { + const body = JSON.stringify({ action: 'edited' }); + expect(extractGitHubProjectsProjectId(body)).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(extractGitHubProjectsProjectId('not json')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests: verifyGitHubProjectsWebhookSignature (function directly) +// --------------------------------------------------------------------------- + +describe('verifyGitHubProjectsWebhookSignature — direct function tests', () => { + beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [GITHUB_PROJECTS_PROJECT] }); + vi.mocked(resolveWebhookSecret).mockResolvedValue(GITHUB_PROJECTS_SECRET); + }); + + function makeContext(headers: Record = {}) { + return { + req: { + header: (name: string) => headers[name.toLowerCase()] ?? headers[name], + }, + } as unknown as import('hono').Context; + } + + it('returns { valid: true } when signature is correct', async () => { + const body = JSON.stringify({ + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_kwABC' }, + }); + const sig = githubSignature(body, GITHUB_PROJECTS_SECRET); + const result = await verifyGitHubProjectsWebhookSignature( + makeContext({ 'X-Hub-Signature-256': sig }), + body, + ); + expect(result).toEqual({ valid: true, reason: 'Signature valid' }); + }); + + it('returns { valid: false } when signature is wrong', async () => { + const body = JSON.stringify({ + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_kwABC' }, + }); + const badSig = githubSignature(body, 'wrong-secret'); + const result = await verifyGitHubProjectsWebhookSignature( + makeContext({ 'X-Hub-Signature-256': badSig }), + body, + ); + expect(result).toEqual({ valid: false, reason: 'GitHub Projects signature mismatch' }); + }); + + it('returns { valid: false, reason: "Missing signature header" } when header absent but secret configured', async () => { + const body = JSON.stringify({ + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_kwABC' }, + }); + const result = await verifyGitHubProjectsWebhookSignature(makeContext({}), body); + expect(result).toEqual({ valid: false, reason: 'Missing signature header' }); + }); + + it('returns null (skip) when no secret configured', async () => { + vi.mocked(resolveWebhookSecret).mockResolvedValue(null); + const body = JSON.stringify({ + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_kwABC' }, + }); + const result = await verifyGitHubProjectsWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); + + it('returns null (skip) when project not found for project node ID', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [] }); + const body = JSON.stringify({ + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_unknown' }, + }); + const result = await verifyGitHubProjectsWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); + + it('returns null (skip) when project node ID is missing from payload', async () => { + const body = JSON.stringify({ action: 'edited' }); + const result = await verifyGitHubProjectsWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Integration tests: end-to-end via Hono app (mirrors src/router/index.ts wiring) // --------------------------------------------------------------------------- diff --git a/tests/unit/triggers/github-projects-webhook-handler.test.ts b/tests/unit/triggers/github-projects-webhook-handler.test.ts new file mode 100644 index 000000000..a6368d0f8 --- /dev/null +++ b/tests/unit/triggers/github-projects-webhook-handler.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { mockGet, mockProcessPMWebhook } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockProcessPMWebhook: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../src/pm/index.js', () => ({ + pmRegistry: { get: mockGet }, +})); + +vi.mock('../../../src/pm/webhook-handler.js', () => ({ + processPMWebhook: mockProcessPMWebhook, +})); + +import { processGitHubProjectsWebhook } from '../../../src/triggers/github-projects/webhook-handler.js'; + +describe('processGitHubProjectsWebhook', () => { + it('resolves the github-projects integration from the registry and delegates to processPMWebhook', async () => { + const fakeIntegration = { type: 'github-projects' }; + mockGet.mockReturnValue(fakeIntegration); + const payload = { action: 'edited' }; + const registry = { + dispatch: vi.fn(), + } as unknown as import('../../../src/triggers/registry.js').TriggerRegistry; + const triggerResult = { agentType: 'implementation' } as never; + + await processGitHubProjectsWebhook(payload, registry, 'ack-1', triggerResult, 'proj-1'); + + expect(mockGet).toHaveBeenCalledWith('github-projects'); + expect(mockProcessPMWebhook).toHaveBeenCalledWith( + fakeIntegration, + payload, + registry, + 'ack-1', + triggerResult, + 'proj-1', + ); + }); + + it('forwards undefined optional args through to processPMWebhook', async () => { + const fakeIntegration = { type: 'github-projects' }; + mockGet.mockReturnValue(fakeIntegration); + const payload = { action: 'created' }; + const registry = { + dispatch: vi.fn(), + } as unknown as import('../../../src/triggers/registry.js').TriggerRegistry; + + await processGitHubProjectsWebhook(payload, registry); + + expect(mockProcessPMWebhook).toHaveBeenCalledWith( + fakeIntegration, + payload, + registry, + undefined, + undefined, + undefined, + ); + }); +}); diff --git a/tests/unit/webhook/webhookParsers.test.ts b/tests/unit/webhook/webhookParsers.test.ts index ee1b4b3d9..489d98a5d 100644 --- a/tests/unit/webhook/webhookParsers.test.ts +++ b/tests/unit/webhook/webhookParsers.test.ts @@ -20,6 +20,7 @@ vi.mock('../../../src/utils/index.js', () => ({ import { parseGitHubPayload, + parseGitHubProjectsPayload, parseJiraPayload, parseTrelloPayload, } from '../../../src/webhook/webhookParsers.js'; @@ -216,3 +217,62 @@ describe('parseJiraPayload', () => { ); }); }); + +describe('parseGitHubProjectsPayload', () => { + it('extracts eventType as projects_v2_item/', async () => { + const payload = { + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_1' }, + }; + const ctx = makeHonoContext(payload); + + const result = await parseGitHubProjectsPayload(ctx as never); + + expect(result.ok).toBe(true); + expect(result.eventType).toBe('projects_v2_item/edited'); + expect(result.payload).toEqual(payload); + }); + + it('defaults eventType to "unknown" when action is missing', async () => { + const payload = { projects_v2_item: { node_id: 'PVTI_1' } }; + const ctx = makeHonoContext(payload); + + const result = await parseGitHubProjectsPayload(ctx as never); + + expect(result.ok).toBe(true); + expect(result.eventType).toBe('unknown'); + }); + + it('returns ok=false and error string on parse failure', async () => { + const ctx = { + req: { + text: vi.fn().mockResolvedValue('not valid json {{{'), + header: vi.fn(), + }, + }; + + const result = await parseGitHubProjectsPayload(ctx as never); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('logs info with action, eventType, and project node ID', async () => { + const payload = { + action: 'edited', + projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_abc' }, + }; + const ctx = makeHonoContext(payload); + + await parseGitHubProjectsPayload(ctx as never); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Received GitHub Projects webhook', + expect.objectContaining({ + action: 'edited', + eventType: 'projects_v2_item/edited', + projectId: 'PVT_abc', + }), + ); + }); +}); diff --git a/tests/unit/worker-entry.test.ts b/tests/unit/worker-entry.test.ts index 3232c24a8..fbb90773c 100644 --- a/tests/unit/worker-entry.test.ts +++ b/tests/unit/worker-entry.test.ts @@ -44,6 +44,10 @@ vi.mock('../../src/triggers/linear/webhook-handler.js', () => ({ processLinearWebhook: vi.fn().mockResolvedValue(undefined), })); +vi.mock('../../src/triggers/github-projects/webhook-handler.js', () => ({ + processGitHubProjectsWebhook: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/router/pm-ack-dispatch.js', () => ({ dispatchPMAck: vi.fn(), })); @@ -59,6 +63,7 @@ vi.mock('../../src/router/ackMessageGenerator.js', () => ({ extractTrelloContext: vi.fn().mockReturnValue(''), extractJiraContext: vi.fn().mockReturnValue(''), extractLinearContext: vi.fn().mockReturnValue(''), + extractGitHubProjectsContext: vi.fn().mockReturnValue(''), generateAckMessage: vi.fn().mockResolvedValue('🔨 Generated ack message'), })); @@ -108,6 +113,7 @@ import { BootFailureError } from '../../src/agents/shared/bootFailureError.js'; import { loadProjectConfigById } from '../../src/config/provider.js'; import { getRunById, markDebugAnalysisFailed } from '../../src/db/repositories/runsRepository.js'; import { + extractGitHubProjectsContext, extractJiraContext, extractLinearContext, extractTrelloContext, @@ -116,6 +122,7 @@ import { import { readOffloadedJobData } from '../../src/router/job-data-offload.js'; import { dispatchPMAck } from '../../src/router/pm-ack-dispatch.js'; import { captureException, flush } from '../../src/sentry.js'; +import { processGitHubProjectsWebhook } from '../../src/triggers/github-projects/webhook-handler.js'; import { processGitHubWebhook, processJiraWebhook } from '../../src/triggers/index.js'; import { processLinearWebhook } from '../../src/triggers/linear/webhook-handler.js'; import { processSentryWebhook } from '../../src/triggers/sentry/webhook-handler.js'; @@ -126,6 +133,7 @@ import { type DebugAnalysisJobData, dispatchJob, type GitHubJobData, + type GitHubProjectsJobData, type JiraJobData, type LinearJobData, type ManualRunJobData, @@ -380,6 +388,36 @@ describe('dispatchJob routing', () => { expect(dispatchPMAck).not.toHaveBeenCalled(); }); + it('routes github-projects job to processGitHubProjectsWebhook with payload, registry, ackCommentId, triggerResult', async () => { + const mockRegistry = {}; + const jobPayload = { action: 'edited', projects_v2_item: { id: 'PVTI_1' } }; + const triggerResult = { matched: true, agentType: 'implementation' } as never; + + const jobData: GitHubProjectsJobData = { + type: 'github-projects', + source: 'github-projects', + payload: jobPayload, + projectId: 'proj-1', + workItemId: 'gh-item-1', + eventType: 'projects_v2_item/edited', + receivedAt: '2024-01-01T00:00:00Z', + ackCommentId: 'gh-comment-789', + triggerResult, + }; + + await dispatchJob('job-github-projects-1', jobData, mockRegistry as never); + + expect(processGitHubProjectsWebhook).toHaveBeenCalledWith( + jobPayload, + mockRegistry, + 'gh-comment-789', + triggerResult, + 'proj-1', + ); + // Without pendingAck, the deferred-ack path is NOT taken + expect(dispatchPMAck).not.toHaveBeenCalled(); + }); + it('handles unknown job type by calling captureException with worker_unknown_job tag', async () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?) => { throw new Error(`process.exit(${code})`); @@ -411,6 +449,7 @@ describe('dispatchJob - deferred ack (pendingAck=true)', () => { vi.mocked(extractJiraContext).mockReset().mockReturnValue(''); vi.mocked(extractLinearContext).mockReset().mockReturnValue(''); vi.mocked(extractTrelloContext).mockReset().mockReturnValue(''); + vi.mocked(extractGitHubProjectsContext).mockReset().mockReturnValue(''); vi.mocked(generateAckMessage).mockReset().mockResolvedValue('🔨 Generated ack'); }); @@ -660,6 +699,71 @@ describe('dispatchJob - deferred ack (pendingAck=true)', () => { ); }); + it('github-projects pendingAck: extracts context, generates ack, posts via dispatchPMAck, passes new commentId to processGitHubProjectsWebhook', async () => { + vi.mocked(extractGitHubProjectsContext).mockReturnValueOnce('Item: PVTI_1 — Fix flaky test'); + vi.mocked(generateAckMessage).mockResolvedValueOnce('🔨 Fixing the flaky test'); + vi.mocked(dispatchPMAck).mockResolvedValueOnce({ + commentId: 'gh-deferred-1', + message: '🔨 Fixing the flaky test', + }); + + const jobData: GitHubProjectsJobData = { + type: 'github-projects', + source: 'github-projects', + payload: { action: 'edited', projects_v2_item: { id: 'PVTI_1' } }, + projectId: 'proj-1', + workItemId: 'PVTI_1', + eventType: 'projects_v2_item/edited', + receivedAt: '2024-01-01T00:00:00Z', + pendingAck: true, + ackContextHint: 'Fix flaky test', + triggerResult: { agentType: 'implementation' } as never, + }; + + await dispatchJob('job-github-projects-deferred', jobData, {} as never); + + expect(extractGitHubProjectsContext).toHaveBeenCalledWith(jobData.payload); + expect(generateAckMessage).toHaveBeenCalledWith( + 'implementation', + 'Item: PVTI_1 — Fix flaky test', + 'proj-1', + ); + expect(dispatchPMAck).toHaveBeenCalledWith({ + projectId: 'proj-1', + workItemId: 'PVTI_1', + pmType: 'github-projects', + message: '🔨 Fixing the flaky test', + agentType: 'implementation', + }); + expect(processGitHubProjectsWebhook).toHaveBeenCalledWith( + jobData.payload, + expect.anything(), + 'gh-deferred-1', + jobData.triggerResult, + 'proj-1', + ); + }); + + it('github-projects pendingAck without workItemId: skips deferred ack entirely', async () => { + const jobData: GitHubProjectsJobData = { + type: 'github-projects', + source: 'github-projects', + payload: {}, + projectId: 'proj-1', + // workItemId is missing + eventType: 'projects_v2_item/created', + receivedAt: '2024-01-01T00:00:00Z', + pendingAck: true, + triggerResult: { agentType: 'implementation' } as never, + }; + + await dispatchJob('job-github-projects-no-id', jobData, {} as never); + + // Without workItemId, the deferred-ack branch is skipped + expect(dispatchPMAck).not.toHaveBeenCalled(); + expect(processGitHubProjectsWebhook).toHaveBeenCalled(); + }); + it('linear pendingAck without workItemId: skips deferred ack entirely', async () => { const jobData: LinearJobData = { type: 'linear', @@ -1235,4 +1339,28 @@ describe('main() - environment variable validation', () => { ); expect(flush).toHaveBeenCalled(); }); + + // ── defensive fallback: mismatched key, no inline value ───────────────────── + // + // main()'s upfront check only requires JOB_DATA *or* JOB_DATA_REDIS_KEY to be + // present — it does not know whether a present key actually names this job. + // If a stale baked key (see the two tests above) doesn't match JOB_ID *and* + // there's no fresh inline JOB_DATA to fall back on, resolveRawJobData() has no + // channel left to read from. This is the defensive branch that turns that into + // a clear, grep-able exit instead of undefined behavior. + it('exits 1 with worker_env tag when JOB_DATA_REDIS_KEY is stale (mismatched) and no inline JOB_DATA fallback exists', async () => { + process.env.JOB_ID = 'job-no-fallback'; + process.env.JOB_TYPE = 'linear'; + process.env.JOB_DATA_REDIS_KEY = 'cascade:jobdata:some-other-job'; + // JOB_DATA intentionally absent — no channel names this job's payload. + + await expect(main()).rejects.toThrow('process.exit(1)'); + + expect(readOffloadedJobData).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'JOB_DATA could not be resolved from env or Redis' }), + expect.objectContaining({ tags: { source: 'worker_env' } }), + ); + expect(flush).toHaveBeenCalled(); + }); });