diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad5bf93..ef007bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable user-visible changes to CASCADE are documented here. The format is l ### Added +- **Linear PM — optional Project scope.** Operators can now narrow a Linear-backed CASCADE project to a specific Linear Project (initiative) in the PM wizard's "Board / Project Selection" step. When set, CASCADE only responds to issues that belong to that Linear Project; webhooks for issues outside the scope are silently dropped by the router (with a structured `logger.info` entry), outbound listings are scoped to the project, and newly-created issues (including checklist sub-issues) inherit the project. Leave the new selector empty to preserve existing team-wide behavior. Because Linear's data model requires every issue to belong to a team and scopes workflow states per team, status mappings stay team-scoped. For cross-team Linear Projects, CASCADE responds to the **intersection** of the configured team and project only (sibling-team issues in the same project are ignored). No migration required — existing Linear integrations are unaffected. (Spec [005](docs/specs/005-linear-project-scope.md.done).) - **Linear status mapping — full parity with Trello and JIRA.** The Linear PM wizard's Field Mapping step now exposes all eight CASCADE stages that drive agent dispatch (backlog, splitting, planning, todo, inProgress, inReview, done, merged) in lifecycle order, instead of only four. An operator can now map a Linear workflow state to any of `splitting`, `planning`, `todo`, or `merged` and have the corresponding agent (splitting, planning, implementation, backlog-manager) dispatch on issue transitions — previously these four stages were unreachable from Linear because the wizard had no slot to save them. Existing Linear integrations upgrade in place: the four new slots render as "not set" on next wizard visit; pre-existing mappings are untouched. No migration required. The normalized `ProjectPMConfig.statuses` type widens to declare the full nine-stage vocabulary (including `debug`, reserved for a future trigger), so providers can no longer silently drift from the trigger layer's dispatch map. (Spec [003](docs/specs/003-linear-status-mapping-parity.md), plan [1/1](docs/plans/003-linear-status-mapping-parity/1-status-parity.md).) - **Linear wizard — inline webhook signing-secret field and accurate events list.** The Webhooks step of the Linear PM wizard now renders a `ProjectSecretField` bound to `LINEAR_WEBHOOK_SECRET` directly beneath the webhook URL, so operators can paste Linear's signing secret in place instead of navigating to the Credentials tab. The "Enable events" instructions now list the three event families CASCADE actually consumes — `Issues` (status transitions), `Comments` (bot @mentions), and `Issue Labels` ("Ready to Process") — each with a one-line rationale tracing back to the registered trigger handlers. (Spec [002](docs/specs/002-linear-webhook-setup-ux.md), plan [2/2](docs/plans/002-linear-webhook-setup-ux/2-wizard-webhooks-step.md).) diff --git a/docs/plans/005-linear-project-scope/1-scope-config-and-outbound.md.done b/docs/plans/005-linear-project-scope/1-scope-config-and-outbound.md.done new file mode 100644 index 00000000..64bbb54f --- /dev/null +++ b/docs/plans/005-linear-project-scope/1-scope-config-and-outbound.md.done @@ -0,0 +1,200 @@ +--- +id: 005 +slug: linear-project-scope +plan: 1 +plan_slug: scope-config-and-outbound +level: plan +parent_spec: docs/specs/005-linear-project-scope.md +depends_on: [] +status: done +--- + +# 005/1: Foundation — `LinearConfig.projectId`, client filter, provider outbound scope + +> Part 1 of 3 in the 005-linear-project-scope plan. See [parent spec](../../specs/005-linear-project-scope.md). + +## Summary + +This plan introduces the configuration shape and the outbound (code → Linear) half of the feature. It adds an optional `projectId` field to `LinearConfig`, threads it through `LinearIntegration.createProvider`, extends `linearClient.listIssues()` and `linearClient.createIssue()` to honor a project filter / project assignment, and updates `LinearPMProvider.listWorkItems()`, `LinearPMProvider.createWorkItem()`, and the checklist sub-issue creation path in `LinearPMProvider.addChecklistItem()` so every outbound write/read respects the configured project scope when one is set. + +No operator-visible behavior changes from this plan alone. No existing project has a `projectId` in its `LinearConfig` (the wizard doesn't write one until plan 3), and the code treats absence of `projectId` exactly as today. This plan ships a **dormant** enhancement that unlocks plans 2 and 3 and is reviewable in isolation via unit tests that pass a `LinearConfig` with `projectId` set and assert the shape of outbound Linear API calls. + +**Components delivered:** +- `src/pm/config.ts` — `LinearConfig.projectId?: string` field. +- `src/linear/types.ts` — `LinearCreateIssueInput.projectId?: string` passthrough. +- `src/linear/client.ts` — `listIssues` accepts `projectId`; `createIssue` already accepts `input.projectId` via the GraphQL `IssueCreateInput`, confirm end-to-end. +- `src/pm/linear/adapter.ts` — `LinearPMProvider` reads `config.projectId`, applies it in `listWorkItems`, `createWorkItem`, and `addChecklistItem` (for sub-issues). +- `src/pm/linear/integration.ts` — no logic change beyond continuing to pass the full `LinearConfig` to `LinearPMProvider` constructor (already happens). Add a smoke test asserting this. +- `tests/unit/pm/linear-adapter.test.ts` — new file. +- `tests/unit/linear/client.test.ts` — extend (if exists) or add. +- `tests/unit/pm/linear-integration.test.ts` — extend. + +**Deferred to later plans in this spec:** +- Webhook-side inbound scope filter (plan 2). +- tRPC `linearProjects` discovery endpoint (plan 2). +- Wizard UI project selector, save payload change, webhook info panel copy (plan 3). +- All operator-visible documentation (README, CHANGELOG) except the plan-internal CHANGELOG stub (plan 3). + +--- + +## Spec ACs satisfied by this plan + +- **Spec AC #8** (listing operations return only issues in the configured project when a project scope is set, and all team issues when it is not) — **full** at the provider layer. Triggers and gadgets that call `LinearPMProvider.listWorkItems()` inherit this for free. +- **Spec AC #9** (new issues, including checklist sub-issues, are placed into the configured Linear Project when a scope is set, and into no project when it is not) — **full** at the provider layer. All issue-creation paths through `LinearPMProvider` respect `config.projectId`. +- **Spec AC #10** (existing Linear integration without project scope works end-to-end with no behavior change) — **partial (this plan provides the outbound half — `LinearConfig` without `projectId` behaves exactly as before; plan 2 provides the router-side half; plan 3 preserves wizard save-payload compatibility)**. + +--- + +## Depends On + +- None. This plan can ship first. + +--- + +## Detailed Task List (TDD) + +### 1. `LinearConfig.projectId` field + +**Tests first** (`tests/unit/pm/linear-integration.test.ts`, extend existing): +- `getLinearConfig — returns config with projectId when set`: given a `ProjectConfig` whose `pm.type = 'linear'` and `project.linear = { teamId: 'T1', projectId: 'P1', statuses: {} }`, `getLinearConfig(project)?.projectId === 'P1'`. +- `getLinearConfig — returns config without projectId when absent`: given the same shape minus `projectId`, `getLinearConfig(project)?.projectId === undefined`. +- `LinearIntegration.createProvider — forwards projectId from LinearConfig to LinearPMProvider`: construct a provider from a config with `projectId` set, assert `(provider as any).config.projectId === 'P1'`. (Use an explicit field-read test — do NOT mock the provider constructor.) + +**Implementation** (`src/pm/config.ts`): +- Extend the `LinearConfig` interface to add `projectId?: string`. Place it directly after `teamId` for readability. +- No runtime change — TypeScript-only. + +**Implementation** (`src/pm/linear/integration.ts`): +- No code change required. `createProvider` already passes the full `LinearConfig` to `LinearPMProvider`'s constructor. Verify via the added test above. + +### 2. `linearClient.listIssues()` accepts `projectId` filter + +**Tests first** (`tests/unit/linear/client.test.ts`, extend or create): +- `listIssues — includes project.id.eq when projectId passed`: stub the GraphQL transport, call `linearClient.listIssues({ teamId: 'T1', projectId: 'P1' })`, assert the query variables contain `filter.project.id.eq === 'P1'` and `filter.team.id.eq === 'T1'`. +- `listIssues — omits project filter when projectId absent`: call `linearClient.listIssues({ teamId: 'T1' })`, assert `filter.project` is not present in the variables. +- `listIssues — sends no filter at all when neither teamId nor projectId passed`: call `linearClient.listIssues({})`, assert `filter` is `undefined` in variables. + +**Implementation** (`src/linear/client.ts`): +- Extend the `filter` parameter type of `listIssues` to include `projectId?: string`. +- In the body, when `filter?.projectId` is set, add `filterObj.project = { id: { eq: filter.projectId } }` alongside the existing team/assignee/state guards. + +### 3. `linearClient.createIssue()` passes `projectId` through + +**Tests first** (`tests/unit/linear/client.test.ts`, extend): +- `createIssue — forwards projectId in input`: stub GraphQL transport, call `linearClient.createIssue({ teamId: 'T1', projectId: 'P1', title: 't' })`, assert the mutation's `input` variable contains `projectId: 'P1'`. +- `createIssue — omits projectId when not supplied`: call `linearClient.createIssue({ teamId: 'T1', title: 't' })`, assert the `input` variable does not contain `projectId`. + +**Implementation** (`src/linear/types.ts`): +- Extend `LinearCreateIssueInput` to include `projectId?: string`. Document with a one-line comment: `/** Linear project (initiative) ID — when set, the new issue is placed into this project. */`. + +**Implementation** (`src/linear/client.ts`): +- No code change in `createIssue`. It already passes the `input` object through to the GraphQL mutation. The type extension is sufficient. + +### 4. `LinearPMProvider.listWorkItems()` applies project filter + +**Tests first** (`tests/unit/pm/linear-adapter.test.ts`, NEW FILE): +- `listWorkItems — passes projectId when configured`: instantiate `new LinearPMProvider({ teamId: 'T1', projectId: 'P1', statuses: {} })`, stub `linearClient.listIssues`, call `provider.listWorkItems('T1')`, assert the stub was called with `{ teamId: 'T1', projectId: 'P1', ... }`. +- `listWorkItems — omits projectId when not configured`: same but with `projectId` absent; assert the stub call has no `projectId` key. +- `listWorkItems — still applies status filter when both are set`: with `filter = { status: 'backlog' }`, assert the stub receives both `projectId: 'P1'` and the resolved `stateId`. + +**Implementation** (`src/pm/linear/adapter.ts`): +- In `listWorkItems`, after the existing `teamId` resolution and before/with the `filter.status` handling, pass `projectId: this.config.projectId` through to `linearClient.listIssues()`. Spread cleanly so the absent case produces no extra key. + +### 5. `LinearPMProvider.createWorkItem()` + `addChecklistItem()` set `projectId` + +**Tests first** (`tests/unit/pm/linear-adapter.test.ts`, same file): +- `createWorkItem — sets projectId when configured`: with config `{ teamId: 'T1', projectId: 'P1', statuses: {} }`, stub `linearClient.createIssue`, call `provider.createWorkItem({ title: 'x' })`, assert the stub call contains `projectId: 'P1'`. +- `createWorkItem — omits projectId when not configured`: same with `projectId` absent; assert the stub call has no `projectId` key. +- `addChecklistItem — sub-issue inherits projectId when configured`: with `projectId` in config, call `provider.addChecklistItem('subtasks-parent-123', 'child', false)`, assert `linearClient.createIssue` was called with `projectId: 'P1'` (and `parentId: 'parent-123'`). +- `addChecklistItem — omits projectId for sub-issue when not configured`: same with `projectId` absent; assert no `projectId` in the stub call. + +**Implementation** (`src/pm/linear/adapter.ts`): +- In `createWorkItem`, add `...(this.config.projectId ? { projectId: this.config.projectId } : {})` to the `linearClient.createIssue({...})` call. +- In `addChecklistItem`, same spread added to the `linearClient.createIssue({...})` call that constructs the sub-issue. + +--- + +## Test Plan + +### Unit tests +- [ ] `tests/unit/pm/linear-integration.test.ts`: 3 tests covering the `LinearConfig.projectId` field + `createProvider` pass-through. +- [ ] `tests/unit/linear/client.test.ts`: 5 tests covering `listIssues` filter behavior and `createIssue` input pass-through (create new file if one does not exist). +- [ ] `tests/unit/pm/linear-adapter.test.ts` (NEW): 6 tests covering `listWorkItems`, `createWorkItem`, `addChecklistItem` project-scope behavior. + +### Integration tests +- None in this plan. The feature is testable at the unit level with stubbed `linearClient` calls; adding a live-Linear integration test is disproportionate to the risk. + +### Acceptance tests +- [x] AC #1: `LinearConfig.projectId` type compiles and is optional. +- [x] AC #2: `linearClient.listIssues()` filter variables include `project.id.eq` exactly when `projectId` is supplied. +- [x] AC #3: `linearClient.createIssue()` mutation input includes `projectId` exactly when supplied. +- [x] AC #4: `LinearPMProvider.listWorkItems()` propagates `config.projectId` to the client. +- [x] AC #5: `LinearPMProvider.createWorkItem()` sets `projectId` on new issues when `config.projectId` is set. +- [x] AC #6: `LinearPMProvider.addChecklistItem()` sets `projectId` on sub-issues when `config.projectId` is set. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. `LinearConfig` exposes an optional `projectId: string` field; existing code paths that don't reference it compile and run unchanged. +2. `linearClient.listIssues()` includes `project.id.eq` in the GraphQL filter variables exactly when a `projectId` is passed; absent otherwise. +3. `linearClient.createIssue()` passes `projectId` through to the GraphQL mutation input exactly when provided. +4. `LinearPMProvider.listWorkItems()` propagates `config.projectId` to `linearClient.listIssues()` when set, omits it when not. +5. `LinearPMProvider.createWorkItem()` assigns new issues to the configured project when `config.projectId` is set, and to no project when not. +6. `LinearPMProvider.addChecklistItem()` assigns newly-created sub-issues to the configured project when `config.projectId` is set. +7. When `config.projectId` is not set, every outbound call has the exact same shape as before this plan (verify via snapshot-style assertions on the stub calls). +8. All new/modified code has corresponding tests. +9. `npm run build` passes. +10. `npm test` passes (unit projects). +11. `npm run lint` passes. +12. `npm run typecheck` passes. + +**Partial-state criterion:** +- The `LinearConfig.projectId` field is readable throughout the outbound code path, but no operator-facing surface writes it yet. Existing installations with no `projectId` in their config see zero behavior change. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `CHANGELOG.md` | Add an entry under an Unreleased heading: "Linear PM: `LinearConfig.projectId` added as optional field; when set, outbound list/create operations scope to the Linear Project. No operator-facing surface writes this yet (see later plans). Existing installations: no behavior change." | + +No other doc updates in this plan. Integration README + wizard README-like copy lands with plan 3 (the operator-facing ship). + +--- + +## Out of Scope (this plan) + +- **Webhook-side inbound scope filter** — deferred to plan 2. +- **tRPC `linearProjects` discovery endpoint** — deferred to plan 2. +- **Wizard UI project selector** — deferred to plan 3. +- **Save payload change in the wizard** — deferred to plan 3. +- **`LinearWebhookInfoPanel` copy update** — deferred to plan 3. +- **Integration README update** — deferred to plan 3. +- Linear **Initiatives** as a CASCADE scope selector (spec-level out of scope). +- **Multi-team project scoping** (spec-level out of scope). +- **"No project" as an explicit filter** (spec-level out of scope). +- **Project-level label configuration** (spec-level out of scope). +- **Workspace-level labels** as an alternative to team labels (spec-level out of scope). +- **Migration tooling** (spec-level out of scope — change is purely additive). +- Any change to **webhook signature verification, ack comment, or reaction** behavior (spec-level out of scope). +- Any change to **status mapping** configuration (spec-level out of scope). + +--- + +## Progress + + +- [x] AC #1 +- [x] AC #2 +- [x] AC #3 +- [x] AC #4 +- [x] AC #5 +- [x] AC #6 +- [x] AC #7 +- [x] AC #8 +- [x] AC #9 +- [x] AC #10 +- [x] AC #11 +- [x] AC #12 diff --git a/docs/plans/005-linear-project-scope/2-webhook-scope-filter-and-discovery.md.done b/docs/plans/005-linear-project-scope/2-webhook-scope-filter-and-discovery.md.done new file mode 100644 index 00000000..377cf163 --- /dev/null +++ b/docs/plans/005-linear-project-scope/2-webhook-scope-filter-and-discovery.md.done @@ -0,0 +1,206 @@ +--- +id: 005 +slug: linear-project-scope +plan: 2 +plan_slug: webhook-scope-filter-and-discovery +level: plan +parent_spec: docs/specs/005-linear-project-scope.md +depends_on: [1-scope-config-and-outbound.md] +status: done +--- + +# 005/2: Webhook scope filter + `linearProjects` discovery endpoint + +> Part 2 of 3 in the 005-linear-project-scope plan. See [parent spec](../../specs/005-linear-project-scope.md). + +## Summary + +This plan adds the inbound half of the feature: the `LinearRouterAdapter.parseWebhook()` drop path that silently ignores events whose issue is outside the configured project scope, and the tRPC discovery endpoint the wizard will call in plan 3 to populate its project dropdown. + +After this plan, a CASCADE project with a `projectId` manually set in its `project_integrations.config` JSONB will start ignoring webhook events for issues that don't belong to that project — with a distinct log entry — while a CASCADE project without `projectId` continues to behave exactly as today. The behavior is verifiable via unit tests and by curl/database smoke tests; no UI is required. Plan 3 wires the wizard so operators can actually save a `projectId`. + +**Components delivered:** +- `src/router/adapters/linear.ts` — `parseWebhook` reads `linearConfig.projectId` from the resolved project and drops events whose issue `projectId` does not match. Log line makes the drop reason obvious. +- `src/api/routers/integrationsDiscovery.ts` — two new procedures: `linearProjects` (raw credentials) and `linearProjectsByProject` (stored-credential variant), mirroring the existing `linearTeams` / `linearTeamsByProject` pattern. Both accept a `teamId` input and return `{ id: string; name: string; icon?: string; color?: string }[]`. +- `src/linear/client.ts` — add `getTeamProjects(teamId): Promise` GraphQL method. +- `src/linear/types.ts` — add `LinearProject` type. +- `tests/unit/router/adapters/linear.test.ts` — extend. +- `tests/unit/api/routers/integrationsDiscovery.test.ts` — extend. +- `tests/unit/linear/client.test.ts` — extend (from plan 1). + +**Deferred to later plans in this spec:** +- Wizard UI project selector, state reducer updates, save payload change (plan 3). +- `LinearWebhookInfoPanel` copy update (plan 3). +- Operator-facing documentation: integration README, CHANGELOG user-facing notes (plan 3). + +--- + +## Spec ACs satisfied by this plan + +- **Spec AC #4** (webhook events for issues whose current Linear Project matches the configured scope are processed as today) — **full** at the router filter. +- **Spec AC #5** (webhook events for out-of-scope issues — including issues with no project — are silently dropped; no agent invoked, no ack, no reaction; a log entry records the drop and its reason) — **full**. +- **Spec AC #6** (when no project is configured, all team webhook events are processed as today) — **full** at the router filter; the tests assert the backwards-compatible path. +- **Spec AC #7** (cross-team project intersection — CASCADE responds only to issues in **both** the configured team and configured project) — **full**. Because the team-match is already enforced by the existing `data.teamId` lookup in `parseWebhook`, and the new project-match filter runs against the issue's `projectId`, the two conditions compose into the intersection. +- **Spec AC #10** (existing integrations with no project scope work unchanged) — **partial (this plan provides the router-side half; plan 1 provided the outbound half; plan 3 preserves wizard compatibility)**. +- **Spec AC #12** (clearing and re-selecting a project takes effect on the next inbound event with no stale state) — **partial (this plan provides the backend "re-read config on every event" guarantee via the existing `loadProjectConfig()` call in `parseWebhook`; plan 3 provides the UI that lets the operator actually clear/change the scope)**. + +Additional (not strictly AC-mapped but required for plan 3): +- tRPC `linearProjects` / `linearProjectsByProject` — enables the plan 3 UI to load the dropdown options. + +--- + +## Depends On + +- Plan 1 (`scope-config-and-outbound`) — provides the `LinearConfig.projectId` field that this plan reads in `parseWebhook`. + +--- + +## Detailed Task List (TDD) + +### 1. `linearClient.getTeamProjects()` + +**Tests first** (`tests/unit/linear/client.test.ts`, extend): +- `getTeamProjects — returns mapped projects`: stub the GraphQL transport to return two project nodes, call `linearClient.getTeamProjects('T1')`, assert the result is an array of `{ id, name, icon?, color? }` with the expected fields. +- `getTeamProjects — returns empty array when team has no projects`: stub returning `{ team: { projects: { nodes: [] } } }`, assert empty array. +- `getTeamProjects — sends the teamId as the `id` variable`: stub transport, assert the GraphQL variables contain `{ id: 'T1' }`. + +**Implementation** (`src/linear/types.ts`): +- Add a `LinearProject` interface: `{ id: string; name: string; icon?: string | null; color?: string | null }`. + +**Implementation** (`src/linear/client.ts`): +- Add a new method under the `// ===== Discovery =====` region (alongside `getTeams`, `getTeamWorkflowStates`, `getTeamLabels`): + ``` + async getTeamProjects(teamId: string): Promise { + // GraphQL: query GetTeamProjects($id: String!) { team(id: $id) { projects { nodes { id name icon color } } } } + } + ``` +- Introduce a `PROJECT_FIELDS` fragment near the other field-set constants for readability (`id name icon color` — nothing more in v1; Linear project statuses and labels are out of scope here). + +### 2. `linearProjects` / `linearProjectsByProject` tRPC procedures + +**Tests first** (`tests/unit/api/routers/integrationsDiscovery.test.ts`, extend): +- `linearProjects — returns team projects when api key + teamId are valid`: call the procedure with `{ apiKey: 'k', teamId: 'T1' }`, stub `linearClient.getTeamProjects`, assert response matches the stub payload. +- `linearProjects — throws INVALID_ARGUMENT when teamId is missing or empty`: zod validation rejects empty string. +- `linearProjectsByProject — resolves api key from stored credentials and returns projects`: existing fixture with a Linear PM integration; assert the procedure looks up the key via the credentials repository and calls `linearClient.getTeamProjects` within `withLinearCredentials`. +- `linearProjectsByProject — returns PRECONDITION_FAILED when no PM integration exists` (mirror `linearTeamsByProject` semantics). +- `linearProjectsByProject — returns PRECONDITION_FAILED when provider is not linear`. + +**Implementation** (`src/api/routers/integrationsDiscovery.ts`): +- Model on the adjacent `linearTeams` and `linearTeamsByProject` procedures. Input schemas: + - `linearProjects`: `linearCredsInput.extend({ teamId: z.string().min(1) })` + - `linearProjectsByProject`: `z.object({ projectId: z.string(), teamId: z.string().min(1) })` +- Both procedures call `linearClient.getTeamProjects(input.teamId)` inside `withLinearCredentials({ apiKey }, ...)`, wrapped with `wrapIntegrationCall('Failed to fetch Linear projects', ...)`. +- Return shape: the raw `LinearProject[]` (the TRPC serializer handles the rest; front-end treats it as searchable options). + +### 3. `LinearRouterAdapter.parseWebhook()` project-scope filter + +**Tests first** (`tests/unit/router/adapters/linear.test.ts`, extend): +- Fixture: a router project config with `{ linear: { teamId: 'T1', projectId: 'P1', statuses: {} } }`. +- Fixture: a router project config with `{ linear: { teamId: 'T1', statuses: {} } }` (no project scope). +- `parseWebhook — Issue event with matching teamId and projectId is processed`: payload `{ type: 'Issue', action: 'update', data: { teamId: 'T1', id: 'i1', identifier: 'ENG-1', projectId: 'P1' } }`, `projectId: 'P1'` in config, expect a non-null `ParsedWebhookEvent`. +- `parseWebhook — Issue event with matching teamId but different projectId is dropped`: payload `data.projectId = 'P2'`, `config.projectId = 'P1'`, expect `null` and a log entry whose message contains "project scope" and whose meta contains `{ configuredProjectId: 'P1', issueProjectId: 'P2', issueId: 'i1' }`. +- `parseWebhook — Issue event with no projectId in payload is dropped when config.projectId is set`: payload `data.projectId` undefined, `config.projectId = 'P1'`, expect `null` and a log entry citing "issue has no project". +- `parseWebhook — Issue event is processed when config has no projectId (current behavior)`: `config.projectId` undefined regardless of `data.projectId`, expect non-null event. +- `parseWebhook — Comment event inspects issue.projectId`: payload `{ type: 'Comment', action: 'create', data: { issueId: 'i1', issue: { teamId: 'T1', projectId: 'P1' } } }` processed when `config.projectId === 'P1'`, dropped when `config.projectId === 'P2'`. +- `parseWebhook — IssueLabel event inspects issue.projectId`: mirror the Comment case for IssueLabel payloads (Linear sends `data.issue.projectId` for label events too). +- `parseWebhook — cross-team intersection: Issue event in matching project but mismatched teamId is dropped by the existing teamId lookup`: `data.teamId = 'T2'`, `config.teamId = 'T1'`, expect `null` (existing behavior unchanged — the project filter runs *after* the team-match, so sibling-team issues in the same project never survive). + +**Implementation** (`src/router/adapters/linear.ts`): +- In `parseWebhook`, after the existing project lookup (`const project = config.projects.find((proj) => proj.linear?.teamId === teamId);`) and before the `isCommentEvent` branch, inspect `project.linear?.projectId`. If it is set, compute the payload's `issueProjectId`: + - For `Issue` / `IssueLabel` events: `data.projectId` (string | undefined). + - For `Comment` events: `(data.issue as Record)?.projectId` (string | undefined). +- If `project.linear.projectId !== issueProjectId`, log at `info` level with `{ reason: 'project scope mismatch' | 'issue has no project', configuredProjectId, issueProjectId, issueId, teamId, projectId: project.id, eventType: '${action}/${type}' }` and return `null`. +- If `project.linear.projectId` is unset/empty, skip the filter entirely and preserve today's behavior. +- Place the filter **before** building the `ParsedWebhookEvent` return value so no downstream step (self-authored check, reaction, dispatch, ack) fires for out-of-scope events. + +### 4. Log contract verification + +**Tests first** (same file): +- `parseWebhook drop — log message matches agreed contract`: spy on `logger.info`, assert it was called with a message starting with `'LinearRouterAdapter: dropping event'` (exact wording is a convention, not a spec requirement, but pin it in tests so future changes are intentional). +- `parseWebhook drop — log meta is useful for debugging`: assert the meta object contains the required fields listed above. + +**Implementation:** +- Match the message text pinned by the test. No separate code change. + +--- + +## Test Plan + +### Unit tests +- [ ] `tests/unit/linear/client.test.ts`: 3 tests for `getTeamProjects`. +- [ ] `tests/unit/api/routers/integrationsDiscovery.test.ts`: 5 tests across `linearProjects` + `linearProjectsByProject`. +- [ ] `tests/unit/router/adapters/linear.test.ts`: 8 tests covering the project-scope filter across Issue / Comment / IssueLabel events, match / mismatch / missing-projectId / no-scope-configured / cross-team. + +### Integration tests +- None. The router filter is pure logic driven by the payload and the project config; no live Linear or DB fixture adds coverage. + +### Acceptance tests +- [x] AC #1 (plan-AC): `parseWebhook` returns the same event shape as before when no project scope is configured. +- [x] AC #2 (plan-AC): `parseWebhook` returns `null` and logs a drop entry for Issue/Comment/IssueLabel events whose issue is outside the configured project. +- [x] AC #3 (plan-AC): The cross-team intersection is enforced because the teamId lookup runs before the project filter. +- [x] AC #4 (plan-AC): `linearProjects` / `linearProjectsByProject` tRPC procedures return a list of `{ id, name, icon?, color? }` for the given team. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. `linearClient.getTeamProjects(teamId)` returns an array of `{ id, name, icon?, color? }` for the given team via GraphQL. +2. The tRPC router exposes `linearProjects` (raw credentials + teamId) and `linearProjectsByProject` (stored credentials + teamId) procedures that both return the team's projects. +3. `LinearRouterAdapter.parseWebhook()` returns `null` for any Issue, Comment, or IssueLabel webhook whose issue's `projectId` does not equal the configured `LinearConfig.projectId` — including events for issues with no project. +4. When `LinearConfig.projectId` is unset, `LinearRouterAdapter.parseWebhook()` produces exactly the same output it did before this plan (snapshot-equivalent for all existing test fixtures). +5. Drop events produce a log entry at `info` level whose message identifies the drop and whose meta includes `configuredProjectId`, `issueProjectId`, `issueId`, `teamId`, `projectId` (CASCADE project), and `eventType`. +6. The cross-team-project intersection holds: an issue whose `teamId` does not match `config.teamId` is dropped by the existing team lookup, so project-scoped filtering never sees sibling-team events. +7. `linearProjectsByProject` returns `PRECONDITION_FAILED` when the project has no PM integration configured, and when the integration is not `linear`. +8. All new/modified code has corresponding tests. +9. `npm run build` passes. +10. `npm test` passes. +11. `npm run lint` passes. +12. `npm run typecheck` passes. + +**Partial-state criterion:** +- No wizard change yet — operators cannot save a `projectId` via the UI. A `projectId` can only be set by direct DB edit for smoke-testing. Plan 3 wires this properly. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `CHANGELOG.md` | Add a line under Unreleased: "Linear PM: router drops webhook events whose issue is outside the configured Linear Project scope; `linearProjects` tRPC discovery endpoint added for the wizard." | + +No README updates in this plan. The integration README + `CLAUDE.md` update + operator-facing wizard/webhook-panel copy all land together in plan 3. + +--- + +## Out of Scope (this plan) + +- **Wizard UI project selector** — deferred to plan 3. +- **Save payload change in the wizard** — deferred to plan 3. +- **`LinearWebhookInfoPanel` copy update** — deferred to plan 3. +- **Integration README update** — deferred to plan 3. +- Linear **Initiatives** as a CASCADE scope selector (spec-level out of scope). +- **Multi-team project scoping** (spec-level out of scope). +- **"No project" as an explicit filter** (spec-level out of scope). +- **Project-level label configuration** (spec-level out of scope). +- **Workspace-level labels** as an alternative to team labels (spec-level out of scope). +- **Migration tooling** (spec-level out of scope — change is purely additive). +- Any change to **webhook signature verification, ack comment, or reaction** behavior beyond the scope-filter gating (spec-level out of scope). +- Any change to **status mapping** configuration (spec-level out of scope). + +--- + +## Progress + + +- [x] AC #1 +- [x] AC #2 +- [x] AC #3 +- [x] AC #4 +- [x] AC #5 +- [x] AC #6 +- [x] AC #7 +- [x] AC #8 +- [x] AC #9 +- [x] AC #10 +- [x] AC #11 +- [x] AC #12 diff --git a/docs/plans/005-linear-project-scope/3-wizard-ui.md.done b/docs/plans/005-linear-project-scope/3-wizard-ui.md.done new file mode 100644 index 00000000..d870b41d --- /dev/null +++ b/docs/plans/005-linear-project-scope/3-wizard-ui.md.done @@ -0,0 +1,233 @@ +--- +id: 005 +slug: linear-project-scope +plan: 3 +plan_slug: wizard-ui +level: plan +parent_spec: docs/specs/005-linear-project-scope.md +depends_on: [1-scope-config-and-outbound.md, 2-webhook-scope-filter-and-discovery.md] +status: done +--- + +# 005/3: PM wizard UI — optional Linear Project selector + webhook panel copy + docs + +> Part 3 of 3 in the 005-linear-project-scope plan. See [parent spec](../../specs/005-linear-project-scope.md). + +## Summary + +This plan closes the loop: it wires the PM wizard so operators can select, change, or clear a Linear Project inside the existing "Board / Project Selection" step, persists the selection to `LinearConfig.projectId`, updates the `LinearWebhookInfoPanel` copy to clarify that project filtering happens on CASCADE's side, and ships the operator-facing documentation (integration README, CHANGELOG). + +After this plan, the feature is end-to-end live: the operator picks a Linear team (as today), optionally narrows to a Linear Project from a searchable dropdown populated by the `linearProjects` tRPC endpoint (shipped in plan 2), and saves. The router immediately starts filtering webhook events to that project (shipped in plan 2), and outbound provider operations start creating/listing issues within that project (shipped in plan 1). + +**Components delivered:** +- `web/src/components/projects/pm-wizard-state.ts` — `linearProjectId: string`, `linearProjects: LinearProjectOption[]` state fields; `SET_LINEAR_PROJECTS`, `SET_LINEAR_PROJECT_ID` action cases; `SET_LINEAR_TEAM_ID` case cleared to also reset `linearProjectId` and `linearProjects` (a new team invalidates the project list); `buildEditState` hydrates `linearProjectId` from stored config. +- `web/src/components/projects/pm-wizard-hooks.ts` — new `linearProjectsMutation` that calls `integrationsDiscovery.linearProjects` / `linearProjectsByProject`, fires after team selection (same effect-trigger pattern as `linearDetailsMutation`). +- `web/src/components/projects/pm-wizard-linear-steps.tsx` — extend `LinearTeamStep` to render the new Project selector under the Team selector; disabled until a team is chosen; shows a helper: "Optional — leave empty to process all issues in this team." +- `web/src/components/projects/pm-wizard.tsx` — save payload includes `projectId: state.linearProjectId || undefined` (omit when empty to keep storage minimal). +- `web/src/components/projects/pm-wizard-common-steps.tsx` — `LinearWebhookInfoPanel` gets a new short note: "If you also set a Linear Project scope in the next step, CASCADE will apply that filter on its side — no change is needed to your Linear webhook config." +- `src/integrations/README.md` — update the "Linear — operator setup" paragraph to mention the optional project scope lives in the "Board / Project Selection" step. +- `CHANGELOG.md` — operator-facing release note. +- `tests/unit/web/pm-wizard-state.test.ts` (NEW or extend existing) — state reducer coverage. +- `tests/unit/web/linear-team-step.test.ts` (NEW or extend existing) — component coverage for the project selector. +- `tests/unit/web/linear-webhook-info-panel.test.ts` — extend for the new copy. +- `tests/unit/web/pm-wizard.test.ts` — extend for the save payload. + +**Deferred to later plans in this spec:** +- None — this is the last plan. + +--- + +## Spec ACs satisfied by this plan + +- **Spec AC #1** (selecting a Team enables an optional "Linear Project" selector populated with that team's projects; leaving it empty preserves full-team scope) — **full**. +- **Spec AC #2** (reopening the wizard shows the previously chosen project pre-selected) — **full** via `buildEditState` hydration. +- **Spec AC #3** (clearing the selection and saving reverts to full-team scope with no residual filter) — **full** — the save payload omits `projectId` when empty. +- **Spec AC #10** (existing Linear integrations without project scope work unchanged) — **partial (this plan provides the wizard-compatibility half — rehydrating a config without `projectId` leaves the new state field empty; plans 1 + 2 provide the code-level halves)**. +- **Spec AC #11** (the wizard step that explains Linear webhook setup includes copy clarifying that project filtering is performed by CASCADE, not by Linear webhook config) — **full**. +- **Spec AC #12** (clearing and re-selecting a different project takes effect on the next inbound event) — **full (plan 2 guarantees the router re-reads config on every event; this plan guarantees the UI persists the new value correctly)**. + +--- + +## Depends On + +- Plan 1 (`scope-config-and-outbound`) — provides the `LinearConfig.projectId` field that the save payload writes and the provider reads. +- Plan 2 (`webhook-scope-filter-and-discovery`) — provides the `linearProjects` / `linearProjectsByProject` tRPC endpoints that the wizard mutation calls, plus the router-side filter that gives saved values runtime effect. + +--- + +## Detailed Task List (TDD) + +### 1. Wizard state reducer: `linearProjectId`, `linearProjects` + +**Tests first** (`tests/unit/web/pm-wizard-state.test.ts`): +- `createInitialState — linearProjectId defaults to '' and linearProjects defaults to []`. +- `SET_LINEAR_PROJECTS — replaces the list`. +- `SET_LINEAR_PROJECT_ID — sets the chosen id; empty string clears it`. +- `SET_LINEAR_TEAM_ID — resets linearProjectId AND linearProjects when team changes`: given a state with a project already selected, dispatch a new team id; assert both `linearProjectId === ''` and `linearProjects === []`. (The existing reducer already resets `linearStatusMappings`; extend the same case.) +- `buildEditState — hydrates linearProjectId from initialConfig.projectId when present`: given `initialConfig = { teamId: 'T1', projectId: 'P1', statuses: {} }`, assert the produced state has `linearProjectId === 'P1'`. +- `buildEditState — hydrates linearProjectId to '' when initialConfig has no projectId`. + +**Implementation** (`web/src/components/projects/pm-wizard-state.ts`): +- Extend `WizardState` with `linearProjectId: string` and `linearProjects: LinearProjectOption[]` (a new local type `{ id: string; name: string; icon?: string | null; color?: string | null }`). +- Extend `WizardAction` with `SET_LINEAR_PROJECTS` and `SET_LINEAR_PROJECT_ID` variants. +- `createInitialState`: initialize both to `''` and `[]`. +- Reducer: handle the two new actions. In the existing `SET_LINEAR_TEAM_ID` case, also reset `linearProjectId: ''` and `linearProjects: []` (the existing `linearStatusMappings: {}` reset is the template — add alongside). +- `buildEditState`: read `initialConfig.projectId` and hydrate. + +### 2. Wizard hook: `linearProjectsMutation` + +**Tests first** (`tests/unit/web/pm-wizard-hooks.test.ts`, new or extend): +- `linearProjectsMutation — calls linearProjectsByProject when editing with stored credentials`. +- `linearProjectsMutation — calls linearProjects with raw apiKey when creating`. +- `linearProjectsMutation — fires automatically after team selection (non-editing)`. +- `linearProjectsMutation — fires automatically on mount when editing with a stored team`. + +**Implementation** (`web/src/components/projects/pm-wizard-hooks.ts`): +- Inside `useLinearDiscovery`, add a `linearProjectsMutation` that mirrors `linearDetailsMutation`. It takes `teamId: string` as argument. When `state.isEditing && state.hasStoredCredentials && !state.linearApiKey`, calls `trpcClient.integrationsDiscovery.linearProjectsByProject.mutate({ projectId, teamId })`. Otherwise calls `.linearProjects.mutate({ apiKey: state.linearApiKey, teamId })`. +- On success, dispatch `{ type: 'SET_LINEAR_PROJECTS', projects: result }`. +- Trigger the mutation from the same effect branches that trigger `linearDetailsMutation`: right after a team is selected, and on the editing-mount branch when a team is already in state. +- Return `linearProjectsMutation` from the hook. Update `pm-wizard.tsx` to destructure it. + +### 3. `LinearTeamStep` — render the project selector + +**Tests first** (`tests/unit/web/linear-team-step.test.ts`, new or extend): +- `renders an optional Project selector below the Team selector`. +- `Project selector is disabled when no team is selected`. +- `Project selector is populated from state.linearProjects`. +- `selecting a project dispatches SET_LINEAR_PROJECT_ID with the id`. +- `clearing the project selection dispatches SET_LINEAR_PROJECT_ID with ''`. +- `helper text contains "Optional" and clarifies leaving it empty processes all team issues`. + +**Implementation** (`web/src/components/projects/pm-wizard-linear-steps.tsx`): +- Extend the `LinearTeamStep` prop signature to include `dispatch` (to dispatch `SET_LINEAR_PROJECT_ID`) and `linearProjectsMutation` (loading-state indicator + retry handler). +- Render a second `SearchableSelect` below the team `SearchableSelect`: + - `Label`: "Linear Project (optional)" + - `options`: `state.linearProjects.map((p) => ({ label: p.name, value: p.id }))` + - `value`: `state.linearProjectId` + - `onChange`: `(v) => dispatch({ type: 'SET_LINEAR_PROJECT_ID', value: v })` + - Disabled when `!state.linearTeamId`. + - `clearable`: show a clear button / allow setting value to `''`. + - Helper text below: "Optional — leave empty to process all issues in this team. When set, CASCADE only responds to issues that belong to this project." +- `pm-wizard.tsx`: update the `LinearTeamStep` invocation to pass `dispatch` and `linearProjectsMutation`. + +### 4. Save payload includes `projectId` + +**Tests first** (`tests/unit/web/pm-wizard.test.ts`, extend): +- `save — omits projectId when linearProjectId is empty`: render the wizard with a fresh state, fill team only, click save, assert the `projects.integrations.upsert` call receives a config **without** a `projectId` key. +- `save — includes projectId when linearProjectId is set`: fill team + project, save, assert the config contains `projectId: 'P1'`. +- `save — reverts to no project scope when a previously-saved projectId is cleared`: start in edit mode with `initialConfig.projectId = 'P1'`, dispatch `SET_LINEAR_PROJECT_ID` with `''`, save, assert the config has **no** `projectId` key. + +**Implementation** (`web/src/components/projects/pm-wizard-hooks.ts`, in the save mutation): +- In the Linear branch of the save payload builder, add `...(state.linearProjectId ? { projectId: state.linearProjectId } : {})`. Use the spread-when-truthy pattern already established in the adjacent `labels` / `customFields` spreads. + +### 5. `LinearWebhookInfoPanel` copy update + +**Tests first** (`tests/unit/web/linear-webhook-info-panel.test.ts`, extend): +- `renders the existing setup instructions unchanged` (regression). +- `renders a short note explaining project-scope filtering happens in CASCADE, not in Linear webhook config`: assert the panel's text contains the phrase "project scope" (lower-case 'p') and the phrase "CASCADE applies" (or the exact agreed wording below). + +**Implementation** (`web/src/components/projects/pm-wizard-common-steps.tsx`): +- In `LinearWebhookInfoPanel`, add a paragraph at the end of the explainer (or a small callout) with wording along the lines of: "If you also scope your CASCADE project to a specific Linear Project in the next step, the filter is applied by CASCADE after receiving each webhook — your Linear webhook configuration stays the same." +- Keep the tone consistent with the surrounding copy. + +### 6. Documentation + +**Tests first:** +- No automated test — documentation is reviewed manually. The verification step reads the rendered markdown. + +**Implementation** (`src/integrations/README.md`): +- In the "Linear — operator setup" paragraph (search for `### Linear — operator setup`), append one sentence: "CASCADE projects can optionally scope to a specific Linear **Project** (initiative) in the 'Board / Project Selection' wizard step; when set, webhooks for issues outside that project are dropped by CASCADE (Linear webhooks themselves remain team-scoped)." + +**Implementation** (`CHANGELOG.md`): +- Under Unreleased, add: "**Linear PM — optional Project scope.** Operators can now narrow a Linear-backed CASCADE project to a specific Linear Project. Leave the new wizard field empty to preserve existing team-wide behavior." + +--- + +## Test Plan + +### Unit tests +- [ ] `tests/unit/web/pm-wizard-state.test.ts`: 6 reducer tests for `linearProjectId` / `linearProjects` / team-reset / edit-state hydration. +- [ ] `tests/unit/web/pm-wizard-hooks.test.ts` (new or extend): 4 tests for the `linearProjectsMutation`. +- [ ] `tests/unit/web/linear-team-step.test.ts` (new or extend): 6 component tests for rendering, disabled state, selection, clearing, helper text. +- [ ] `tests/unit/web/linear-webhook-info-panel.test.ts`: 2 tests (regression + new copy). +- [ ] `tests/unit/web/pm-wizard.test.ts`: 3 tests for save payload inclusion/omission/clearing. + +### Integration tests +- None. The end-to-end path (UI save → DB → router drop) is exercised by the combination of this plan's unit tests and plan 2's router tests. + +### Acceptance tests +- [x] AC #1 (plan-AC): Operator can select, change, and clear a Linear Project in the wizard. +- [x] AC #2 (plan-AC): Opening an existing project with a saved `projectId` pre-populates the selector. +- [x] AC #3 (plan-AC): Saving with an empty selector writes no `projectId` to the DB. +- [x] AC #4 (plan-AC): The webhook info panel explains where filtering happens. + +### Manual smoke test (post-merge) +- [ ] Configure a fresh Linear integration without a project scope → confirm today's behavior. +- [ ] Add a project scope, trigger a Linear webhook for an issue **outside** the project → observe the drop log entry, no agent invocation, no ack comment. +- [ ] Trigger a webhook for an issue **inside** the scope → observe normal processing. +- [ ] Clear the project scope, re-save → confirm full-team processing resumes on next event. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. In the PM wizard, after selecting a Linear Team, an optional "Linear Project" selector appears under it, populated from the `linearProjects` / `linearProjectsByProject` tRPC endpoint. +2. The Project selector is disabled until a team is chosen. +3. Selecting a project updates the wizard state; clearing it (selecting empty) also updates the state. +4. Changing the team resets both `linearProjectId` and `linearProjects` in the reducer (the new team's projects must be re-fetched). +5. Opening the wizard for a project whose stored config has a `projectId` hydrates the selector with that value. +6. Saving the wizard writes `projectId` to `LinearConfig` when the selector has a value; **omits** `projectId` entirely when the selector is empty. +7. `LinearWebhookInfoPanel` contains a short note clarifying that project-scope filtering happens on CASCADE's side, not in Linear's webhook config. +8. `src/integrations/README.md` Linear operator-setup paragraph mentions the new optional project scope. +9. `CHANGELOG.md` Unreleased section has an operator-facing entry for the feature. +10. All new/modified code has corresponding tests. +11. `npm run build` passes. +12. `npm test` passes. +13. `npm run lint` passes. +14. `npm run typecheck` passes. +15. Manual smoke test checklist (above) has been executed and each item confirmed passing before the PR is marked ready. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `src/integrations/README.md` | Append one sentence to the "Linear — operator setup" paragraph noting the optional project scope in the wizard's "Board / Project Selection" step. | +| `CHANGELOG.md` | Add an Unreleased entry for the operator-facing feature: "Linear PM — optional Project scope." | + +No `CLAUDE.md` change in v1. No Linear-specific section exists there today and the change is fully represented by the README + CHANGELOG updates. If future debugging shows operators hitting footguns (e.g. misunderstanding cross-team intersection), a short "Linear project scope" note can be added in a follow-up. + +--- + +## Out of Scope (this plan) + +- Linear **Initiatives** as a CASCADE scope selector (spec-level out of scope). +- **Multi-team project scoping** (spec-level out of scope). +- **"No project" as an explicit filter** (spec-level out of scope). +- **Project-level label configuration** (spec-level out of scope). +- **Workspace-level labels** as an alternative to team labels (spec-level out of scope). +- **Migration tooling** for existing integrations (spec-level out of scope — the change is purely additive). +- Any change to **webhook signature verification, ack comment, or reaction** behavior beyond scope-filter gating (spec-level out of scope). +- Any change to **status mapping** configuration (spec-level out of scope). + +--- + +## Progress + + +- [x] AC #1 +- [x] AC #2 +- [x] AC #3 +- [x] AC #4 +- [x] AC #5 +- [x] AC #6 +- [x] AC #7 +- [x] AC #8 +- [x] AC #9 +- [x] AC #10 +- [x] AC #11 +- [x] AC #12 +- [x] AC #13 +- [x] AC #14 +- [ ] AC #15 (deferred: manual smoke test requires a live Linear workspace + webhook delivery; not executable in the /implement harness. Operator to execute post-merge per the checklist under Test Plan > Manual smoke test.) diff --git a/docs/plans/005-linear-project-scope/_coverage.md b/docs/plans/005-linear-project-scope/_coverage.md new file mode 100644 index 00000000..207e11ee --- /dev/null +++ b/docs/plans/005-linear-project-scope/_coverage.md @@ -0,0 +1,61 @@ +# Coverage map for spec 005-linear-project-scope + +Auto-generated by /plan. Tracks which plans satisfy which spec ACs. + +## Spec ACs + +| # | Spec AC (short) | Satisfied by | Status | +|---|---|---|---| +| 1 | Wizard shows optional Linear Project selector after team selection | plan 3 (wizard-ui) | full | +| 2 | Reopening the wizard pre-selects the saved project | plan 3 (wizard-ui) | full | +| 3 | Clearing and saving reverts to full-team scope | plan 3 (wizard-ui) | full | +| 4 | Webhook events for in-scope issues processed as today | plan 2 (webhook-scope-filter-and-discovery) | full | +| 5 | Out-of-scope webhook events silently dropped with a log entry | plan 2 | full | +| 6 | No project configured → all team events processed as today | plan 2 | full | +| 7 | Cross-team project intersection (team ∩ project only) | plan 2 | full | +| 8 | Issue-listing operations scoped to the configured project | plan 1 (scope-config-and-outbound) | full | +| 9 | Newly-created issues (incl. sub-issues) placed into configured project | plan 1 | full | +| 10 | Existing integrations without project scope unchanged end-to-end | plan 1 + plan 2 + plan 3 | partial chain (all three must ship) | +| 11 | Webhook info panel explains CASCADE-side filtering | plan 3 | full | +| 12 | Clearing and re-selecting project takes effect on next event | plan 2 + plan 3 | partial chain (plan 2 re-reads config on each event; plan 3 persists the new value) | + +## Coverage summary + +- **12 spec ACs** mapped to **3 plans** +- **10 plans** (AC rows) with full-coverage ACs (testable in isolation) +- **2 spec ACs** require partial-chain coverage (AC #10 spans all three plans; AC #12 spans plans 2 + 3) +- **0 spec ACs** without coverage + +## Plan dependency graph + +``` +1-scope-config-and-outbound ──→ 2-webhook-scope-filter-and-discovery ──→ 3-wizard-ui +``` + +Linear chain, no branching, no cycles. + +## Plan summary + +| Plan | Slug | Role | Ships value | +|---|---|---|---| +| 1 | `scope-config-and-outbound` | Foundation | Config field + client/provider honor `projectId`. Dormant (no UI writes it yet). | +| 2 | `webhook-scope-filter-and-discovery` | Backend behavior + tRPC | Router drops out-of-scope webhooks; tRPC endpoint feeds the wizard. Curl/DB-testable. | +| 3 | `wizard-ui` | UI + docs | Operator-facing selector, save payload, info panel copy, README + CHANGELOG. End-to-end live. | + +## Documentation impact distribution + +| Spec-level doc | Plan(s) that own it | +|---|---| +| `src/integrations/README.md` | plan 3 | +| `CLAUDE.md` | — (not needed in v1; deferred per plan 3 note) | +| PM wizard copy (Linear steps) | plan 3 | +| PM wizard info panel (Linear webhook setup) | plan 3 | +| `CHANGELOG.md` | plan 1 (internal foundation note), plan 2 (backend behavior note), plan 3 (operator-facing release note) | + +## Test distribution + +| Plan | Test files touched | Approx. new tests | +|---|---|---| +| 1 | `tests/unit/pm/linear-integration.test.ts`, `tests/unit/linear/client.test.ts`, `tests/unit/pm/linear-adapter.test.ts` (new) | ~14 | +| 2 | `tests/unit/linear/client.test.ts`, `tests/unit/api/routers/integrationsDiscovery.test.ts`, `tests/unit/router/adapters/linear.test.ts` | ~16 | +| 3 | `tests/unit/web/pm-wizard-state.test.ts`, `tests/unit/web/pm-wizard-hooks.test.ts`, `tests/unit/web/linear-team-step.test.ts`, `tests/unit/web/linear-webhook-info-panel.test.ts`, `tests/unit/web/pm-wizard.test.ts` | ~21 | diff --git a/docs/specs/005-linear-project-scope.md.done b/docs/specs/005-linear-project-scope.md.done new file mode 100644 index 00000000..d2fe2ca7 --- /dev/null +++ b/docs/specs/005-linear-project-scope.md.done @@ -0,0 +1,149 @@ +--- +id: 005 +slug: linear-project-scope +level: spec +title: Linear PM integration — optional Project scope +created: 2026-04-15 +status: done +--- + +# 005: Linear PM integration — optional Project scope + +## Problem & Motivation + +Today, when an operator connects a CASCADE project to Linear, the only scoping knob in the PM wizard is **Select Team** (see the "Board / Project Selection" step). That means CASCADE responds to every issue in the selected Linear team — there is no way to say "only work within this specific Linear Project (initiative) inside the team." + +Linear users routinely structure work as **Projects within a team** — one project per feature, epic, or initiative. A team often contains many projects running in parallel, some of which should be automated by CASCADE and some of which should absolutely not be (e.g. a separate project run by a different sub-team, or an exploratory project where automation isn't desired). The current team-only scope forces operators into an all-or-nothing choice that doesn't match how they actually organize work in Linear. + +The fix is to let operators optionally narrow a CASCADE project's scope to a specific **Linear Project** inside the selected team. When a project is chosen, CASCADE only sees, lists, and acts on issues that belong to that Linear Project. When no project is chosen, behavior is unchanged from today (full-team scope). This brings the integration into line with the Trello ("Select board") and JIRA ("Select project") flows, where scope is narrower than the whole tenant. + +--- + +## Goals + +- Operators can optionally select a Linear Project when configuring a Linear-backed CASCADE project. +- CASCADE honors the project scope across all inbound (webhook) and outbound (list, create, transition) operations. +- Operators who don't select a project see no behavior change (fully backwards compatible). +- The wizard clearly explains that webhook setup in Linear is unchanged — project filtering happens on CASCADE's side. +- New issues CASCADE creates (e.g. sub-issues for checklists) inherit the configured Linear Project so children stay in-scope. + +--- + +## Non-goals + +- **Linear Initiatives as a scope** (the level above Projects). Not supported in v1. +- **Multi-team projects.** A Linear Project can span multiple teams, but v1 only handles the **intersection of the selected team and the selected project**. Issues in the chosen project that live in a *different* team are ignored. +- **Status mapping per project.** Linear's workflow states are defined per team, not per project. Status mappings stay team-scoped. No "override statuses for this project" feature. +- **"Not in any project" filtering.** v1 has two modes: no project selected (full team) or one project selected (that project only). There is no "only issues without a project" mode. +- **Workspace-level labels** as a scope source. Label config stays team-scoped as today. +- **Project labels** (Linear's separate concept where labels are applied to the *project* entity, not issues in it). Out of scope. +- **Linear-side webhook reconfiguration.** Linear webhooks cannot be scoped to a project at their end; CASCADE continues to assume a team-or-org-scoped webhook and filters in code. + +--- + +## Constraints + +- **Linear data model constraint:** Every Linear issue belongs to exactly one team, identifiers like `ENG-123` derive from the team key. Projects are an optional grouping. Workflow states are team-scoped. None of this can be worked around. +- **Linear webhook constraint:** Webhooks can only be configured for a single team or "all public teams" — never for a specific project. Project-level filtering must happen in the consumer. +- **Backwards compatibility:** Every existing Linear-connected CASCADE project must continue to work with zero operator action. "No project selected" is a valid, supported state forever. +- **CASCADE architectural alignment:** The solution must fit the existing PM integration abstraction (`PMIntegration`, `PMProvider`, `ProjectPMConfig`, `RouterPlatformAdapter`) without adding a new category or shared-layer branch for Linear. +- **Stateless project-membership evaluation:** CASCADE must not maintain a cached list of "issues currently in the scoped project." Project membership is determined per-event from the webhook payload / API response. + +--- + +## User stories / Requirements + +1. **As an operator configuring Linear**, I can select a Team (required) and optionally narrow the scope to a Linear Project within that team. +2. **As an operator**, when I don't select a project, CASCADE behaves exactly as it does today — full-team scope. +3. **As an operator**, the project dropdown shows me only projects that are accessible to the selected team, and I can search by name. +4. **As an operator**, I can change or clear the project selection later and have the new scope take effect on subsequent events. +5. **As an operator**, the wizard tells me that my Linear webhook setup doesn't change when I add a project scope — the filter is applied on CASCADE's side. +6. **As CASCADE**, when a webhook arrives for an issue whose current project doesn't match the configured scope, I ignore the event and record a log entry explaining why. +7. **As CASCADE**, when I list issues (e.g. to back a trigger or an agent query), I scope the listing to the configured Linear Project. +8. **As CASCADE**, when I create a new issue (including sub-issues for checklist items), the new issue is placed into the configured Linear Project. +9. **As CASCADE**, when the operator has configured a project scope but a webhook event's issue has no project or belongs to a different one, my acknowledgment and triage behavior does not fire — the event is silently dropped at the scope filter layer, not surfaced to agents. +10. **As an operator with an existing Linear integration (no project scope)**, I see no change in behavior and no forced migration. + +--- + +## Research Notes + +- **Linear's conceptual model:** Issues belong to a team (mandatory). Projects are optional groupings that can span teams, but each issue still has exactly one team. Workflow states are defined per team; labels exist at team or workspace level. [Linear docs: Concepts](https://linear.app/docs/conceptual-model), [Teams](https://linear.app/docs/teams), [Projects](https://linear.app/docs/projects). +- **Project statuses are not issue statuses.** Linear added "Custom statuses for projects" (Backlog / Planned / In Progress / Completed / Canceled) in 2024, but these describe the *project initiative* and are updated manually — they do not drive issue status transitions. Integrations automate *issue* status, not project status. [Custom statuses for projects](https://linear.app/changelog/2024-03-19-custom-statuses-for-projects), [Project status docs](https://linear.app/docs/project-status). +- **Webhook scoping:** Linear webhooks can be configured for a single team (`teamId`) or all public teams (`allPublicTeams: true`). There is no `projectId` option. [Linear webhooks](https://linear.app/developers/webhooks). +- **GraphQL filtering by project is supported.** Issues can be filtered by nested project relationships using the standard filter DSL. [Linear GraphQL filtering](https://linear.app/developers/filtering). +- **Cross-team projects are native.** Introduced in 2020, a project can have multiple associated teams; the UI surfaces per-team tabs. CASCADE's v1 intersection model is the simplest valid subset of this behavior. [Cross-team projects changelog](https://linear.app/changelog/2020-03-27). +- **Labels** live at team or workspace level; sub-teams inherit from parents. [Labels docs](https://linear.app/docs/labels), [Sub-teams](https://linear.app/docs/sub-teams). + +--- + +## Open Source Decisions + +| Tool | Solves | Decision | Reason | +|------|--------|----------|--------| +| [`@linear/sdk`](https://github.com/linear/linear-sdk) (official SDK) | GraphQL wrapper | **Skip** | CASCADE already has a hand-written Linear GraphQL client in the codebase; continue extending it rather than introducing a second dependency path. | +| [Linear Zapier integration](https://github.com/linear/linear-zapier) reference | Implementation patterns for team/project scoping | **Reference only** | Read it to sanity-check our scope filtering, do not import. | + +No new runtime dependencies. This change is pure feature work on top of the existing Linear client and PM integration layer. + +--- + +## Strategic decisions + +1. **Team is required, Project is optional (additive narrowing).** Team stays the primary scope (it's mandatory in Linear's model for issue creation, status mapping, and label config). Project, if set, is an additional filter. Rejected: making Project the primary selector — it doesn't fit Linear's model because statuses/labels are team-scoped and issues must have a team. + +2. **Cross-team projects are handled by intersection in v1.** If a chosen Linear Project spans the configured team and one or more sibling teams, CASCADE responds only to the Team ∩ Project intersection. Sibling-team issues inside the same project are ignored. Multi-team-in-project support is a future spec. Reason: keeps status mapping single-team and avoids compounding the config surface. + +3. **Project filtering happens in CASCADE, not in Linear.** Webhooks remain team- or org-scoped at Linear's end; CASCADE inspects each incoming event's payload and drops events whose issue is outside the scoped project. Reason: Linear does not expose project-level webhook scoping, so there is no alternative. + +4. **Project membership is evaluated per-event and stateless.** Each webhook or API response is judged against the currently-configured scope using the issue's current `projectId` at the time of the event. No persistent "tracked issues" set. Reason: matches how the team-only filter works today, keeps the runtime simple, and naturally handles issues being added/removed from the project. + +5. **New issues CASCADE creates inherit the configured project.** Sub-issues (used for checklists), agent-created issues, and any other creation path set `projectId` alongside `teamId`. Reason: otherwise children would fall outside the scope and webhook events for them would be silently dropped — confusing and broken. + +6. **Backwards compatibility is free.** The project scope is nothing more than an optional field on the Linear PM config. Absence = existing behavior, presence = scoped behavior. No migration, no env-var toggle, no feature flag. Reason: every existing Linear integration must keep working unchanged. + +7. **Wizard surfaces the project selector in the existing "Board / Project Selection" step.** No new step. The project dropdown appears below the team dropdown, disabled until a team is chosen, with copy that makes the optional nature obvious. Reason: matches the existing Trello/JIRA flow and avoids wizard sprawl. + +8. **Wizard copy explicitly clarifies the webhook setup is unchanged.** The existing `LinearWebhookInfoPanel` gets a short note explaining that project filtering is applied by CASCADE after the webhook fires, so operators don't try to find a (non-existent) project-level webhook option in Linear. Reason: pre-empt a predictable support question. + +--- + +## Acceptance Criteria (outcome-level) + +1. In the PM wizard, selecting a Team enables an optional "Linear Project" selector populated with that team's projects; leaving it empty preserves today's full-team scope. +2. When a project is selected and saved, reopening the wizard shows the previously chosen project pre-selected. +3. When a project is cleared (set back to empty) and saved, the integration reverts to full-team scope with no residual project filter on any subsequent operation. +4. Webhook events for issues whose current Linear Project matches the configured scope are processed as they are today. +5. Webhook events for issues whose current Linear Project does **not** match the configured scope (including issues with no project) are silently dropped at the scope filter; no agent is invoked, no acknowledgment comment is posted, no reaction is sent. A log entry records that the event was dropped and why. +6. When the configured scope is "no project" (i.e. full-team), all webhook events for the configured team are processed exactly as today, regardless of any project the issue may or may not belong to. +7. For cross-team Linear Projects, CASCADE responds only to issues that belong to **both** the configured team and the configured project; issues in the same project but a different team are dropped as in (5). +8. Issue-listing operations triggered by CASCADE (e.g. to enumerate work items) return only issues in the configured project when a project scope is set, and all team issues when it is not. +9. Issues CASCADE creates — including sub-issues created for checklist items — are placed into the configured Linear Project when a project scope is set, and into no project when it is not. +10. Configuring a Linear integration without a project scope works end-to-end with no behavior change from the current release, for both new and existing projects. +11. The wizard step that explains Linear webhook setup includes copy clarifying that project filtering is performed by CASCADE (not by Linear webhook config), so operators do not look for a Linear-side project-scoping option that does not exist. +12. Clearing and re-selecting a different project results in the new scope taking effect on the next inbound event, with no stale state from the previous project influencing routing. + +--- + +## Documentation Impact (high-level) + +- **Integration architecture README** — Linear operator setup section: mention the new optional project scope and where it lives in the wizard. +- **Top-level CLAUDE.md** — update any Linear-related invariants if relevant (e.g. the fact that project scope is a valid optional config now). +- **PM wizard copy** (Linear steps) — the "Board / Project Selection" step needs to surface the new Project selector and explain the opt-in, optional nature. Per-file edits belong in the plan. +- **PM wizard info panel** (Linear webhook setup) — add the clarifying sentence about where project filtering happens (in CASCADE, not in Linear's webhook config). Per-file edits belong in the plan. +- **CHANGELOG** — add an entry noting the optional Linear Project scope. + +Per-file granular documentation impact is deferred to the downstream plans. + +--- + +## Out of Scope + +- Linear **Initiatives** as a CASCADE scope selector. +- **Multi-team project scoping** (responding to issues across all teams in a project with per-team status maps). +- **"No project" as an explicit filter** (issues not belonging to any project as a first-class scope). +- **Project-level label configuration** (using Linear's "Project labels" concept). +- **Workspace-level labels** as an alternative to team labels. +- **Migration tooling** to move existing Linear integrations from team-only to team+project scope (not needed — the change is purely additive and opt-in). +- Any change to the **webhook signature verification, ack comment, or reaction** behavior beyond the project-scope filter gating whether those fire. +- Any change to how **status mappings** are configured (they stay team-scoped). diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 36c79c04..ce2ef823 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -652,4 +652,63 @@ export const integrationsDiscoveryRouter = router({ ), ); }), + + /** + * Fetch Linear projects scoped to a team using raw API key credentials. + * Returns the list of Linear Projects accessible to the given team. + */ + linearProjects: protectedProcedure + .input(linearCredsInput.extend({ teamId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.linearProjects called', { + orgId: ctx.effectiveOrgId, + teamId: input.teamId, + }); + return withLinearCreds(input, 'Failed to fetch Linear projects', (creds) => + withLinearCredentials(creds, () => linearClient.getTeamProjects(input.teamId)), + ); + }), + + /** + * Fetch Linear projects scoped to a team using stored project credentials. + * Resolves the API key from stored credentials and returns the team's projects. + */ + linearProjectsByProject: protectedProcedure + .input(z.object({ projectId: z.string(), teamId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.linearProjectsByProject called', { + orgId: ctx.effectiveOrgId, + projectId: input.projectId, + teamId: input.teamId, + }); + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + const integration = await getIntegrationByProjectAndCategory(input.projectId, 'pm'); + if (!integration) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'No PM integration configured for this project yet', + }); + } + if (integration.provider !== 'linear') { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Project is configured with a different PM provider', + }); + } + const apiKey = await getIntegrationCredentialOrNull( + input.projectId, + 'pm', + 'linear', + 'api_key', + ); + if (!apiKey) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Linear credentials not configured', + }); + } + return wrapIntegrationCall('Failed to fetch Linear projects', () => + withLinearCredentials({ apiKey }, () => linearClient.getTeamProjects(input.teamId)), + ); + }), }); diff --git a/src/integrations/README.md b/src/integrations/README.md index e05617e1..3a0ee806 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -463,5 +463,7 @@ Before submitting a new integration: ### Linear — operator setup Linear webhooks are configured **manually in Linear** (CASCADE cannot create them programmatically). The authoritative setup instructions — including the three event families CASCADE consumes (**Issues**, **Comments**, **Issue Labels**) and the inline signing-secret input — live in the dashboard PM wizard at `web/src/components/projects/pm-wizard-common-steps.tsx` (`LinearWebhookInfoPanel`). Any changes to the trigger handlers in `src/triggers/linear/` should be reflected there. + +A CASCADE project can optionally scope to a specific Linear **Project** (initiative) in the "Board / Project Selection" wizard step. When set, webhooks for issues outside that project are dropped by the router (Linear webhooks themselves remain team-scoped; the filter is applied by CASCADE). Leaving the selector empty preserves full-team behavior. | `github` | scm | `src/github/scm-integration.ts` | `src/router/adapters/github.ts` | `src/triggers/github/` | | `sentry` | alerting | `src/sentry/alerting-integration.ts` | `src/router/adapters/sentry.ts` | `src/triggers/sentry/` | diff --git a/src/linear/client.ts b/src/linear/client.ts index 86161781..e95366fe 100644 --- a/src/linear/client.ts +++ b/src/linear/client.ts @@ -17,6 +17,7 @@ import type { LinearCredentials, LinearIssue, LinearLabel, + LinearProject, LinearReaction, LinearTeam, LinearUpdateIssueInput, @@ -248,6 +249,13 @@ const TEAM_FIELDS = ` description `; +const PROJECT_FIELDS = ` + id + name + icon + color +`; + const ISSUE_FIELDS = ` id identifier @@ -295,6 +303,7 @@ export const linearClient = { async listIssues(filter?: { teamId?: string; + projectId?: string; assigneeId?: string; stateId?: string; first?: number; @@ -303,6 +312,7 @@ export const linearClient = { const filterObj: Record = {}; if (filter?.teamId) filterObj.team = { id: { eq: filter.teamId } }; + if (filter?.projectId) filterObj.project = { id: { eq: filter.projectId } }; if (filter?.assigneeId) filterObj.assignee = { id: { eq: filter.assigneeId } }; if (filter?.stateId) filterObj.state = { id: { eq: filter.stateId } }; @@ -650,6 +660,36 @@ export const linearClient = { ).map(mapLabel); }, + async getTeamProjects(teamId: string, first = 250): Promise { + logger.debug('Fetching Linear team projects', { teamId, first }); + const data = await linearGraphQL<{ + team: { projects: { nodes: unknown[] } } | null; + }>( + `query GetTeamProjects($id: String!, $first: Int) { + team(id: $id) { + projects(first: $first) { + nodes { + ${PROJECT_FIELDS} + } + } + } + }`, + { id: teamId, first }, + ); + const nodes = (data.team?.projects.nodes ?? []) as Array<{ + id?: string; + name?: string; + icon?: string | null; + color?: string | null; + }>; + return nodes.map((n) => ({ + id: n.id ?? '', + name: n.name ?? '', + icon: n.icon ?? null, + color: n.color ?? null, + })); + }, + // ===== User ===== async getMe(): Promise { diff --git a/src/linear/types.ts b/src/linear/types.ts index 806e852e..13cc39dd 100644 --- a/src/linear/types.ts +++ b/src/linear/types.ts @@ -32,6 +32,13 @@ export interface LinearWorkflowState { color: string; } +export interface LinearProject { + id: string; + name: string; + icon: string | null; + color: string | null; +} + export interface LinearIssue { id: string; identifier: string; @@ -80,6 +87,8 @@ export interface LinearCreateIssueInput { title: string; description?: string; teamId: string; + /** Linear project (initiative) ID — when set, the new issue is placed into this project. */ + projectId?: string; parentId?: string; assigneeId?: string; stateId?: string; diff --git a/src/pm/config.ts b/src/pm/config.ts index 048b15a1..d1df369d 100644 --- a/src/pm/config.ts +++ b/src/pm/config.ts @@ -56,6 +56,8 @@ export function getJiraConfig(project: ProjectConfig): JiraConfig | undefined { /** Linear-specific configuration (from project_integrations JSONB) */ export interface LinearConfig { teamId: string; + /** Optional Linear Project (initiative) ID that narrows scope within the team. */ + projectId?: string; statuses: Record; labels?: { processing?: string; diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index fb7c1e61..c271edfc 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -82,6 +82,7 @@ export class LinearPMProvider implements PMProvider { const teamId = config.containerId || this.config.teamId; const issue = await linearClient.createIssue({ teamId, + ...(this.config.projectId ? { projectId: this.config.projectId } : {}), title: config.title, description: config.description, ...(config.labels?.length @@ -121,6 +122,7 @@ export class LinearPMProvider implements PMProvider { const teamId = containerId || this.config.teamId; const issues = await linearClient.listIssues({ teamId, + ...(this.config.projectId ? { projectId: this.config.projectId } : {}), ...(filter?.status ? { stateId: this.config.statuses?.[filter.status] ?? filter.status, @@ -217,6 +219,7 @@ export class LinearPMProvider implements PMProvider { await linearClient.createIssue({ teamId: this.config.teamId, + ...(this.config.projectId ? { projectId: this.config.projectId } : {}), title: name, description, parentId, diff --git a/src/router/adapters/linear.ts b/src/router/adapters/linear.ts index 2f4cdd08..63410353 100644 --- a/src/router/adapters/linear.ts +++ b/src/router/adapters/linear.ts @@ -78,10 +78,34 @@ export class LinearRouterAdapter implements RouterPlatformAdapter { const workItemId = isCommentEvent ? (data.issueId as string | undefined) : (data.id as string | undefined); + const eventType = `${p.action}/${p.type}`; + + // Optional project-scope filter: when the CASCADE project has been narrowed + // to a specific Linear Project, drop webhook events whose issue is not in + // that project. Linear cannot scope webhooks to a project, so the filter + // runs here, after team-match. + const configuredProjectId = project.linear?.projectId; + if (configuredProjectId) { + const issueProjectId = isCommentEvent + ? ((data.issue as Record | undefined)?.projectId as string | undefined) + : (data.projectId as string | undefined); + if (issueProjectId !== configuredProjectId) { + logger.info('LinearRouterAdapter: dropping event outside project scope', { + reason: issueProjectId ? 'project scope mismatch' : 'issue has no project', + configuredProjectId, + issueProjectId, + issueId: workItemId, + teamId, + projectId: project.id, + eventType, + }); + return null; + } + } return { projectIdentifier: teamId, - eventType: `${p.action}/${p.type}`, + eventType, workItemId, isCommentEvent, projectId: project.id, diff --git a/src/router/config.ts b/src/router/config.ts index c20e5a23..2fc5dd6b 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -18,6 +18,7 @@ export interface RouterProjectConfig { }; linear?: { teamId: string; + projectId?: string; }; } @@ -108,6 +109,7 @@ export async function loadProjectConfig(): Promise<{ ...(linearConfig && { linear: { teamId: linearConfig.teamId, + ...(linearConfig.projectId ? { projectId: linearConfig.projectId } : {}), }, }), }; diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 7fb0e563..747d5257 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -19,6 +19,7 @@ const { mockLinearGetTeams, mockLinearGetTeamWorkflowStates, mockLinearGetTeamLabels, + mockLinearGetTeamProjects, mockGetAuthenticated, mockVerifyProjectOrgAccess, mockGetIntegrationCredentialOrNull, @@ -41,6 +42,7 @@ const { mockLinearGetTeams: vi.fn(), mockLinearGetTeamWorkflowStates: vi.fn(), mockLinearGetTeamLabels: vi.fn(), + mockLinearGetTeamProjects: vi.fn(), mockGetAuthenticated: vi.fn(), mockVerifyProjectOrgAccess: vi.fn(), mockGetIntegrationCredentialOrNull: vi.fn(), @@ -88,6 +90,7 @@ vi.mock('../../../../src/linear/client.js', () => ({ getTeams: mockLinearGetTeams, getTeamWorkflowStates: mockLinearGetTeamWorkflowStates, getTeamLabels: mockLinearGetTeamLabels, + getTeamProjects: mockLinearGetTeamProjects, }, })); @@ -1278,6 +1281,114 @@ describe('integrationsDiscoveryRouter', () => { }); }); + // ── linearProjects ──────────────────────────────────────────────────── + + describe('linearProjects', () => { + const linearCredsInput = { apiKey: 'lin_api_test' }; + + it('returns team projects on success', async () => { + const projects = [ + { id: 'P1', name: 'Alpha', icon: 'rocket', color: '#ff0000' }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ]; + mockLinearGetTeamProjects.mockResolvedValue(projects); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.linearProjects({ ...linearCredsInput, teamId: 'T1' }); + + expect(result).toEqual(projects); + expect(mockLinearGetTeamProjects).toHaveBeenCalledWith('T1'); + }); + + it('rejects empty teamId', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.linearProjects({ ...linearCredsInput, teamId: '' })).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + mockLinearGetTeamProjects.mockRejectedValue(new Error('Network error')); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearProjects({ ...linearCredsInput, teamId: 'T1' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + + // ── linearProjectsByProject ─────────────────────────────────────────── + + describe('linearProjectsByProject', () => { + beforeEach(() => { + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ + id: 1, + projectId: 'proj-1', + category: 'pm', + provider: 'linear', + config: { teamId: 'team-1' }, + triggers: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + + it('returns projects using stored project credentials', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('stored-api-key'); + const projects = [{ id: 'P1', name: 'Alpha', icon: null, color: null }]; + mockLinearGetTeamProjects.mockResolvedValue(projects); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.linearProjectsByProject({ + projectId: 'proj-1', + teamId: 'team-1', + }); + + expect(mockVerifyProjectOrgAccess).toHaveBeenCalledWith('proj-1', mockUser.orgId); + expect(mockLinearGetTeamProjects).toHaveBeenCalledWith('team-1'); + expect(result).toEqual(projects); + }); + + it('throws NOT_FOUND when no PM integration exists', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce(null); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearProjectsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when provider is not linear', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 2, + projectId: 'proj-1', + category: 'pm', + provider: 'jira', + config: {}, + triggers: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearProjectsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when apiKey credential is missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue(null); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearProjectsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('wraps Linear API failure in BAD_REQUEST', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('stored-api-key'); + mockLinearGetTeamProjects.mockRejectedValue(new Error('API error')); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearProjectsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + // ── verifySentry ───────────────────────────────────────────────────── describe('verifySentry', () => { diff --git a/tests/unit/linear/client.test.ts b/tests/unit/linear/client.test.ts new file mode 100644 index 00000000..28ab89e1 --- /dev/null +++ b/tests/unit/linear/client.test.ts @@ -0,0 +1,165 @@ +/** + * linearClient — unit tests for project/team scoping in listIssues and createIssue. + * + * Stubs global fetch to capture outgoing GraphQL variables so we can assert + * the shape of the filter and mutation inputs without hitting the real API. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { linearClient, withLinearCredentials } from '../../../src/linear/client.js'; + +interface CapturedRequest { + url: string; + body: { query: string; variables?: Record }; +} + +function stubFetch(responseData: unknown): { calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(init?.body as string) as CapturedRequest['body']; + calls.push({ url: String(url), body }); + return new Response(JSON.stringify({ data: responseData }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + // biome-ignore lint/suspicious/noExplicitAny: test stub + globalThis.fetch = fetchMock as any; + return { calls }; +} + +const ISSUE_NODE = { + id: 'i1', + identifier: 'TEAM-1', + title: 't', + description: '', + url: 'https://linear.app/x/issue/TEAM-1', + state: { id: 's', name: 'Todo', type: 'unstarted', color: '#fff' }, + labels: { nodes: [] }, + team: { id: 'T1', key: 'TEAM', name: 'Team' }, + assignee: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('linearClient.listIssues — project scoping', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('includes project.id.eq when projectId passed', async () => { + const { calls } = stubFetch({ issues: { nodes: [ISSUE_NODE] } }); + await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.listIssues({ teamId: 'T1', projectId: 'P1' }), + ); + const vars = calls[0].body.variables as { filter: Record }; + expect(vars.filter).toEqual( + expect.objectContaining({ + team: { id: { eq: 'T1' } }, + project: { id: { eq: 'P1' } }, + }), + ); + }); + + it('omits project filter when projectId absent', async () => { + const { calls } = stubFetch({ issues: { nodes: [] } }); + await withLinearCredentials({ apiKey: 'k' }, () => linearClient.listIssues({ teamId: 'T1' })); + const vars = calls[0].body.variables as { filter: Record }; + expect(vars.filter).toEqual({ team: { id: { eq: 'T1' } } }); + expect(vars.filter).not.toHaveProperty('project'); + }); + + it('sends filter undefined when no filter keys supplied', async () => { + const { calls } = stubFetch({ issues: { nodes: [] } }); + await withLinearCredentials({ apiKey: 'k' }, () => linearClient.listIssues({})); + const vars = calls[0].body.variables as { filter: Record | undefined }; + expect(vars.filter).toBeUndefined(); + }); +}); + +describe('linearClient.createIssue — projectId passthrough', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('forwards projectId in mutation input when supplied', async () => { + const { calls } = stubFetch({ issueCreate: { issue: ISSUE_NODE } }); + await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.createIssue({ teamId: 'T1', projectId: 'P1', title: 't' }), + ); + const vars = calls[0].body.variables as { input: Record }; + expect(vars.input).toEqual( + expect.objectContaining({ + teamId: 'T1', + projectId: 'P1', + title: 't', + }), + ); + }); + + it('omits projectId when not supplied', async () => { + const { calls } = stubFetch({ issueCreate: { issue: ISSUE_NODE } }); + await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.createIssue({ teamId: 'T1', title: 't' }), + ); + const vars = calls[0].body.variables as { input: Record }; + expect(vars.input).not.toHaveProperty('projectId'); + expect(vars.input).toEqual(expect.objectContaining({ teamId: 'T1', title: 't' })); + }); +}); + +describe('linearClient.getTeamProjects', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('returns mapped projects', async () => { + const { calls } = stubFetch({ + team: { + projects: { + nodes: [ + { id: 'P1', name: 'Alpha', icon: 'rocket', color: '#ff0000' }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }, + }, + }); + const projects = await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.getTeamProjects('T1'), + ); + expect(projects).toEqual([ + { id: 'P1', name: 'Alpha', icon: 'rocket', color: '#ff0000' }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ]); + expect(calls[0].body.variables).toEqual({ id: 'T1', first: 250 }); + }); + + it('returns empty array when team has no projects', async () => { + stubFetch({ team: { projects: { nodes: [] } } }); + const projects = await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.getTeamProjects('T1'), + ); + expect(projects).toEqual([]); + }); + + it('sends teamId + default first=250 as GraphQL variables', async () => { + const { calls } = stubFetch({ team: { projects: { nodes: [] } } }); + await withLinearCredentials({ apiKey: 'k' }, () => linearClient.getTeamProjects('T42')); + expect(calls[0].body.variables).toEqual({ id: 'T42', first: 250 }); + }); + + it('accepts a custom first argument for pagination', async () => { + const { calls } = stubFetch({ team: { projects: { nodes: [] } } }); + await withLinearCredentials({ apiKey: 'k' }, () => linearClient.getTeamProjects('T1', 50)); + expect(calls[0].body.variables).toEqual({ id: 'T1', first: 50 }); + }); +}); diff --git a/tests/unit/pm/linear-adapter.test.ts b/tests/unit/pm/linear-adapter.test.ts new file mode 100644 index 00000000..5f367fb2 --- /dev/null +++ b/tests/unit/pm/linear-adapter.test.ts @@ -0,0 +1,122 @@ +/** + * LinearPMProvider — unit tests for project-scope propagation. + * + * Verifies listWorkItems, createWorkItem, and addChecklistItem honor + * LinearConfig.projectId when set, and preserve current behavior when not. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { linearClient } from '../../../src/linear/client.js'; +import type { LinearConfig } from '../../../src/pm/config.js'; +import { LinearPMProvider } from '../../../src/pm/linear/adapter.js'; + +const ISSUE = { + id: 'i1', + identifier: 'TEAM-1', + title: 't', + description: '', + url: 'https://linear.app/x/issue/TEAM-1', + state: { id: 's', name: 'Todo', type: 'unstarted', color: '#fff' }, + labels: [], + team: { id: 'T1', key: 'TEAM', name: 'Team' }, + assignee: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +function configOf(overrides: Partial = {}): LinearConfig { + return { + teamId: 'T1', + statuses: {}, + ...overrides, + }; +} + +describe('LinearPMProvider.listWorkItems — project scope', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes projectId to linearClient.listIssues when configured', async () => { + const spy = vi.spyOn(linearClient, 'listIssues').mockResolvedValue([]); + const provider = new LinearPMProvider(configOf({ projectId: 'P1' })); + await provider.listWorkItems('T1'); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ teamId: 'T1', projectId: 'P1' })); + }); + + it('omits projectId when not configured', async () => { + const spy = vi.spyOn(linearClient, 'listIssues').mockResolvedValue([]); + const provider = new LinearPMProvider(configOf()); + await provider.listWorkItems('T1'); + const call = spy.mock.calls[0][0] ?? {}; + expect(call).not.toHaveProperty('projectId'); + expect(call).toMatchObject({ teamId: 'T1' }); + }); + + it('passes projectId alongside status filter when both are configured', async () => { + const spy = vi.spyOn(linearClient, 'listIssues').mockResolvedValue([]); + const provider = new LinearPMProvider( + configOf({ projectId: 'P1', statuses: { backlog: 'S-BL' } }), + ); + await provider.listWorkItems('T1', { status: 'backlog' }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'T1', projectId: 'P1', stateId: 'S-BL' }), + ); + }); +}); + +describe('LinearPMProvider.createWorkItem — project scope', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets projectId on new issue when configured', async () => { + // biome-ignore lint/suspicious/noExplicitAny: test stub + const spy = vi.spyOn(linearClient, 'createIssue').mockResolvedValue(ISSUE as any); + const provider = new LinearPMProvider(configOf({ projectId: 'P1' })); + await provider.createWorkItem({ title: 'x' }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'T1', projectId: 'P1', title: 'x' }), + ); + }); + + it('omits projectId on new issue when not configured', async () => { + // biome-ignore lint/suspicious/noExplicitAny: test stub + const spy = vi.spyOn(linearClient, 'createIssue').mockResolvedValue(ISSUE as any); + const provider = new LinearPMProvider(configOf()); + await provider.createWorkItem({ title: 'x' }); + const call = spy.mock.calls[0][0]; + expect(call).not.toHaveProperty('projectId'); + }); +}); + +describe('LinearPMProvider.addChecklistItem — project scope for sub-issues', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sub-issue inherits projectId when configured', async () => { + // biome-ignore lint/suspicious/noExplicitAny: test stub + const spy = vi.spyOn(linearClient, 'createIssue').mockResolvedValue(ISSUE as any); + const provider = new LinearPMProvider(configOf({ projectId: 'P1' })); + await provider.addChecklistItem('subtasks-parent-123', 'child'); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 'T1', + projectId: 'P1', + parentId: 'parent-123', + title: 'child', + }), + ); + }); + + it('sub-issue omits projectId when not configured', async () => { + // biome-ignore lint/suspicious/noExplicitAny: test stub + const spy = vi.spyOn(linearClient, 'createIssue').mockResolvedValue(ISSUE as any); + const provider = new LinearPMProvider(configOf()); + await provider.addChecklistItem('subtasks-parent-123', 'child'); + const call = spy.mock.calls[0][0]; + expect(call).not.toHaveProperty('projectId'); + expect(call).toMatchObject({ teamId: 'T1', parentId: 'parent-123', title: 'child' }); + }); +}); diff --git a/tests/unit/pm/linear-integration.test.ts b/tests/unit/pm/linear-integration.test.ts index ccf7be5f..f4699fdf 100644 --- a/tests/unit/pm/linear-integration.test.ts +++ b/tests/unit/pm/linear-integration.test.ts @@ -7,10 +7,14 @@ */ import { describe, expect, it } from 'vitest'; +import { getLinearConfig } from '../../../src/pm/config.js'; import { LinearIntegration } from '../../../src/pm/linear/integration.js'; import type { ProjectConfig } from '../../../src/types/index.js'; -function makeProject(statuses: Record): ProjectConfig { +function makeProject( + statuses: Record, + overrides?: { projectId?: string }, +): ProjectConfig { return { id: 'test', name: 'test', @@ -19,6 +23,7 @@ function makeProject(statuses: Record): ProjectConfig { linear: { teamId: 'team-1', statuses, + ...(overrides?.projectId !== undefined ? { projectId: overrides.projectId } : {}), }, } as unknown as ProjectConfig; } @@ -68,3 +73,33 @@ describe('LinearIntegration.resolveLifecycleConfig', () => { expect(cfg.statuses.inProgress).toBe('s-ip'); }); }); + +describe('LinearConfig.projectId', () => { + it('getLinearConfig — returns config with projectId when set', () => { + const project = makeProject({ inProgress: 's-ip' }, { projectId: 'P1' }); + expect(getLinearConfig(project)?.projectId).toBe('P1'); + }); + + it('getLinearConfig — returns config without projectId when absent', () => { + const project = makeProject({ inProgress: 's-ip' }); + expect(getLinearConfig(project)?.projectId).toBeUndefined(); + }); + + it('LinearIntegration.createProvider — forwards projectId from LinearConfig to LinearPMProvider', () => { + const integration = new LinearIntegration(); + const project = makeProject({ inProgress: 's-ip' }, { projectId: 'P1' }); + const provider = integration.createProvider(project) as unknown as { + config: { projectId?: string }; + }; + expect(provider.config.projectId).toBe('P1'); + }); + + it('LinearIntegration.createProvider — provider has undefined projectId when config has none', () => { + const integration = new LinearIntegration(); + const project = makeProject({ inProgress: 's-ip' }); + const provider = integration.createProvider(project) as unknown as { + config: { projectId?: string }; + }; + expect(provider.config.projectId).toBeUndefined(); + }); +}); diff --git a/tests/unit/router/adapters/linear.test.ts b/tests/unit/router/adapters/linear.test.ts index e9db5e21..2d371a51 100644 --- a/tests/unit/router/adapters/linear.test.ts +++ b/tests/unit/router/adapters/linear.test.ts @@ -42,8 +42,11 @@ import type { RouterProjectConfig } from '../../../../src/router/config.js'; import { loadProjectConfig } from '../../../../src/router/config.js'; import { resolveLinearCredentials } from '../../../../src/router/platformClients/index.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; +import { logger } from '../../../../src/utils/logging.js'; import { buildWorkItemRunsLink, getDashboardUrl } from '../../../../src/utils/runLink.js'; +const mockLoggerInfo = vi.mocked(logger.info); + const mockProject: RouterProjectConfig = { id: 'p1', repo: 'owner/repo', @@ -151,6 +154,170 @@ describe('LinearRouterAdapter', () => { }); }); + describe('parseWebhook — project scope filter', () => { + const scopedProject: RouterProjectConfig = { + id: 'p1', + repo: 'owner/repo', + pmType: 'linear', + linear: { + teamId: 'team-abc-123', + projectId: 'P1', + }, + }; + + beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [scopedProject], + fullProjects: [{ id: 'p1' } as never], + }); + mockLoggerInfo.mockClear(); + }); + + it('Issue event — processed when data.projectId matches configured projectId', async () => { + const result = await adapter.parseWebhook({ + ...baseLinearPayload, + data: { ...baseLinearPayload.data, projectId: 'P1' }, + }); + expect(result).not.toBeNull(); + expect(result?.workItemId).toBe('issue-abc'); + }); + + it('Issue event — dropped when data.projectId does not match configured projectId', async () => { + const result = await adapter.parseWebhook({ + ...baseLinearPayload, + data: { ...baseLinearPayload.data, projectId: 'P2' }, + }); + expect(result).toBeNull(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.stringMatching(/LinearRouterAdapter: dropping event/), + expect.objectContaining({ + reason: 'project scope mismatch', + configuredProjectId: 'P1', + issueProjectId: 'P2', + issueId: 'issue-abc', + teamId: 'team-abc-123', + projectId: 'p1', + eventType: 'create/Issue', + }), + ); + }); + + it('Issue event — dropped when issue has no project and config has projectId', async () => { + const result = await adapter.parseWebhook(baseLinearPayload); + expect(result).toBeNull(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.stringMatching(/LinearRouterAdapter: dropping event/), + expect.objectContaining({ + reason: 'issue has no project', + configuredProjectId: 'P1', + issueProjectId: undefined, + }), + ); + }); + + it('Issue event — processed regardless of projectId when config.projectId is unset', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1' } as never], + }); + const resultWithProject = await adapter.parseWebhook({ + ...baseLinearPayload, + data: { ...baseLinearPayload.data, projectId: 'P-whatever' }, + }); + expect(resultWithProject).not.toBeNull(); + const resultWithoutProject = await adapter.parseWebhook(baseLinearPayload); + expect(resultWithoutProject).not.toBeNull(); + expect(mockLoggerInfo).not.toHaveBeenCalled(); + }); + + it('Comment event — processed when data.issue.projectId matches configured projectId', async () => { + const payload = { + action: 'create', + type: 'Comment', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'comment-xyz', + body: 'ok', + issueId: 'issue-abc', + teamId: 'team-abc-123', + issue: { id: 'issue-abc', teamId: 'team-abc-123', projectId: 'P1' }, + }, + url: 'https://linear.app/issue', + }; + const result = await adapter.parseWebhook(payload); + expect(result).not.toBeNull(); + expect(result?.isCommentEvent).toBe(true); + }); + + it('Comment event — dropped when data.issue.projectId differs from configured projectId', async () => { + const payload = { + action: 'create', + type: 'Comment', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'comment-xyz', + body: 'ok', + issueId: 'issue-abc', + teamId: 'team-abc-123', + issue: { id: 'issue-abc', teamId: 'team-abc-123', projectId: 'P2' }, + }, + url: 'https://linear.app/issue', + }; + const result = await adapter.parseWebhook(payload); + expect(result).toBeNull(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.stringMatching(/LinearRouterAdapter: dropping event/), + expect.objectContaining({ + reason: 'project scope mismatch', + configuredProjectId: 'P1', + issueProjectId: 'P2', + eventType: 'create/Comment', + }), + ); + }); + + it('IssueLabel event — inspects data.projectId and drops on mismatch', async () => { + const payload = { + action: 'create', + type: 'IssueLabel', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'label-link-1', + teamId: 'team-abc-123', + projectId: 'P2', + issueId: 'issue-abc', + }, + url: 'https://linear.app/issue', + }; + const result = await adapter.parseWebhook(payload); + expect(result).toBeNull(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.stringMatching(/LinearRouterAdapter: dropping event/), + expect.objectContaining({ + reason: 'project scope mismatch', + eventType: 'create/IssueLabel', + }), + ); + }); + + it('cross-team intersection — Issue in matching project but wrong team is dropped by existing teamId lookup', async () => { + const result = await adapter.parseWebhook({ + ...baseLinearPayload, + data: { ...baseLinearPayload.data, teamId: 'team-different', projectId: 'P1' }, + }); + expect(result).toBeNull(); + // Dropped by the existing "no project found for teamId" branch, not the new filter. + // No project-scope-specific log entry should fire: + expect(mockLoggerInfo).not.toHaveBeenCalledWith( + expect.stringMatching(/LinearRouterAdapter: dropping event/), + expect.any(Object), + ); + }); + }); + describe('isProcessableEvent', () => { it('returns true for Issue events', () => { expect( diff --git a/tests/unit/web/linear-team-step.test.ts b/tests/unit/web/linear-team-step.test.ts new file mode 100644 index 00000000..c48fa547 --- /dev/null +++ b/tests/unit/web/linear-team-step.test.ts @@ -0,0 +1,118 @@ +/** + * SSR tests for LinearTeamStep — verify team + project selector rendering + * and the new optional project-scope selector behavior. + */ + +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { LinearTeamStep } from '../../../web/src/components/projects/pm-wizard-linear-steps.js'; +import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +function makeState(overrides: Partial = {}): WizardState { + return { + provider: 'linear', + linearApiKey: 'lin_api_test', + linearTeamId: '', + linearTeams: [ + { id: 'team-1', name: 'Engineering', key: 'ENG' }, + { id: 'team-2', name: 'Design', key: 'DES' }, + ], + linearTeamDetails: null, + linearStatusMappings: {}, + linearLabels: {}, + linearProjectId: '', + linearProjects: [], + isEditing: false, + hasStoredCredentials: false, + ...overrides, + } as unknown as WizardState; +} + +function pendingMutation(): { + isPending: boolean; + isError: boolean; + error: null; + mutate: () => void; +} { + return { isPending: false, isError: false, error: null, mutate: vi.fn() }; +} + +function render(extra: Partial = {}): string { + const state = makeState(extra); + return renderToStaticMarkup( + createElement(LinearTeamStep, { + state, + onTeamSelect: () => {}, + dispatch: () => {}, + // biome-ignore lint/suspicious/noExplicitAny: test stub for tanstack mutation object + linearTeamsMutation: pendingMutation() as any, + // biome-ignore lint/suspicious/noExplicitAny: test stub for tanstack mutation object + linearDetailsMutation: pendingMutation() as any, + // biome-ignore lint/suspicious/noExplicitAny: test stub for tanstack mutation object + linearProjectsMutation: pendingMutation() as any, + }), + ); +} + +describe('LinearTeamStep — project selector', () => { + it('does not render the Linear Project selector when no team is selected', () => { + const html = render({ linearTeamId: '' }); + expect(html).not.toContain('Linear Project'); + }); + + it('renders the Linear Project selector when a team is selected', () => { + const html = render({ + linearTeamId: 'team-1', + linearProjects: [ + { id: 'P1', name: 'Alpha', icon: null, color: null }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }); + expect(html).toContain('Linear Project'); + }); + + it('populates the selector options from state.linearProjects', () => { + const html = render({ + linearTeamId: 'team-1', + linearProjects: [ + { id: 'P1', name: 'Alpha', icon: null, color: null }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }); + expect(html).toContain('Alpha'); + expect(html).toContain('Beta'); + expect(html).toContain('value="P1"'); + expect(html).toContain('value="P2"'); + }); + + it('pre-selects the stored projectId when set', () => { + const html = render({ + linearTeamId: 'team-1', + linearProjectId: 'P2', + linearProjects: [ + { id: 'P1', name: 'Alpha', icon: null, color: null }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }); + // Native