From 9d57cea77f251f6cb6520b9e0e1ece4a97a48fc0 Mon Sep 17 00:00:00 2001 From: Paul Poulose Date: Sat, 19 Sep 2026 13:40:54 -0400 Subject: [PATCH 01/10] feat(T5.3): implement kt_sync_to_linear (real Linear GraphQL sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the shipped T5.2 GitHub sync: track_external_links is the authoritative idempotency key, a durable 'pending' creation-intent is committed before the outbound mutation, crash recovery adopts via the hidden marker and never auto-recreates, and the client is injectable so CI never calls Linear. No new migration — the T5.2 table and tracks.last_linear_sync_at already generalize. Linear-specific (frontier panel 2026-09-19, unanimous auto_lookup_with_config_override): - GraphQL client (raw fetch, no SDK; Authorization: , no Bearer; 200-with-errors handled; ambiguous flag for create safety). - Workflow-state resolver: a done track's issue moves to a completed state (team's lowest-position completed, or optional done_state_id); a non-done track's issue is never moved backward unless a valid open_state_id is configured. Two optional adapter-config fields added. - Error prefixes LINEAR_AUTH_FAILED / NOT_FOUND / RATE_LIMITED / TIMEOUT / STATE_CONFIG / UNKNOWN_ERROR; kt_get_track exposes linear_issue_url. Removes the now-dead sync-shared stub. Offline unit + integration tests (fake LinearClient, no network). Docs: ROADMAP T5.3 + PRD 4.14 updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01278w3fed8ZcXjHpKrFHWDw --- docs/PRD.md | 14 +- docs/ROADMAP.md | 34 +- src/db/queries/track-external-links.ts | 16 + src/db/queries/tracks.ts | 12 + src/linear/linear-client.ts | 386 +++++++++++++++++++++ src/mcp/tools/get-track.ts | 10 +- src/mcp/tools/issue-payload.ts | 4 +- src/mcp/tools/linear-payload.ts | 195 +++++++++++ src/mcp/tools/register-project.ts | 8 + src/mcp/tools/sync-shared.ts | 62 ---- src/mcp/tools/sync-to-linear.ts | 391 ++++++++++++++++++++- src/schemas/tools.ts | 8 + tests/integration/sync-to-linear.test.ts | 417 ++++++++++++++++++++--- tests/unit/linear-client.test.ts | 303 ++++++++++++++++ tests/unit/linear-payload.test.ts | 203 +++++++++++ 15 files changed, 1940 insertions(+), 123 deletions(-) create mode 100644 src/linear/linear-client.ts create mode 100644 src/mcp/tools/linear-payload.ts delete mode 100644 src/mcp/tools/sync-shared.ts create mode 100644 tests/unit/linear-client.test.ts create mode 100644 tests/unit/linear-payload.test.ts diff --git a/docs/PRD.md b/docs/PRD.md index 067b37b..ed72c2c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -448,18 +448,20 @@ There is no `advisory_notice` field echoed on every response and no `blocking_su ### 4.14 `kt_sync_to_linear` -**Description:** Identical shape and semantics to `kt_sync_to_github` (§4.13), mirrored for Linear. Also registered with its real input schema; in this build it ships the same T2.14 stub slice — project/track validation plus the missing-adapter `CONFLICT` precondition — with the operational Linear API path deferred to T5.3. +**Description:** Identical shape and semantics to `kt_sync_to_github` (§4.13), mirrored for Linear. As of T5.3 it ships the full operational Linear sync path — create/update a linked Linear Issue for a track, idempotently, over the Linear GraphQL API — with the same durable creation-intent and hidden-marker crash recovery as §4.13. The one Linear-specific difference is workflow state: Linear has no open/closed, so a done track's issue is moved to a `completed` workflow state (the team's lowest-position `completed` state, or an optional configured `done_state_id`), and a non-done track's issue is never auto-moved backward unless a valid `open_state_id` is configured (both optional fields on the Linear adapter config). **Inputs:** `project_id` (required), `track_id` (required). -**Output:** `{ ok: true }` on success; `{ ok: false, error: ": " }` on an operational failure. Prefixes: `LINEAR_AUTH_FAILED`, `LINEAR_NOT_FOUND`, `LINEAR_TIMEOUT` (exceeds `KNOTRACK_LINEAR_SYNC_TIMEOUT_MS`, default 8000ms), `LINEAR_UNKNOWN_ERROR`. +**Output:** `{ ok: true }` on success; `{ ok: false, error: ": " }` on an operational failure. Prefixes: `LINEAR_AUTH_FAILED`, `LINEAR_NOT_FOUND`, `LINEAR_RATE_LIMITED`, `LINEAR_TIMEOUT` (exceeds `KNOTRACK_LINEAR_SYNC_TIMEOUT_MS`, default 8000ms), `LINEAR_STATE_CONFIG` (an invalid `done_state_id`/`open_state_id`, or a done track whose team has no `completed` workflow state and no `done_state_id` configured), `LINEAR_UNKNOWN_ERROR`. **Business rules / edge cases (mirrors §4.13 exactly, Linear-specific specifics only):** -- `CONFLICT` for missing Linear credentials (no row in `adapters` for `(project_id, "linear")`); everything about the Linear API call itself is a non-error `{ok: false, ...}` result. +- `CONFLICT` for missing Linear credentials (no row in `adapters` for `(project_id, "linear")`) or a Linear adapter with no `team_id`; everything about the Linear API call itself is a non-error `{ok: false, ...}` result. +- Workflow-state mapping: a done track's Issue is moved to a `completed` state (the configured `done_state_id`, else the team's lowest-position `completed` state); an invalid override, or a done track whose team has no `completed` state and no `done_state_id`, returns `{ok:false, error:"LINEAR_STATE_CONFIG: ..."}`. A non-done track's Issue is never moved backward unless a valid `open_state_id` is configured and (on update) the Issue is currently in a `completed`/`canceled` state. **Acceptance criteria:** - **Given** a project without Linear credentials configured, **when** `kt_sync_to_linear` is called, **then** the call fails with `CONFLICT`. -- **Given** this build, **when** `kt_sync_to_linear` is called for an existing project and track with no Linear adapter configured, **then** the missing-adapter `CONFLICT` (§4.13's shape, Linear-specific) is the shipped behavior and no HTTP call is made; the operational Linear API path is not yet implemented (T5.3). An adapter configured via `kt_register_project` (which provisions adapters from inline credentials) yields an `INTERNAL_ERROR` (`linear sync is not available in this build`), never a false success. +- **Given** a configured Linear adapter, **when** `kt_sync_to_linear` is called for a track with no existing link, **then** a Linear Issue is created, the link is recorded, and `kt_get_track`'s `track.linear_issue_url` returns the Issue URL; a second call with unchanged content is a no-op and a call after the track changes updates the same Issue. A done track's Issue is set to a `completed` workflow state. +- **Given** a `pending` link left by a crashed prior create, **when** `kt_sync_to_linear` is called again, **then** it recovers via the hidden marker (adopting the existing Issue if found) rather than creating a duplicate. *Verified offline (fake `LinearClient`, no network in CI); the real-workspace run is the one remaining acceptance gate (docs/ROADMAP.md T5.3).* --- @@ -524,7 +526,7 @@ Because KnoTrack collects no central telemetry (§5.6), every metric below is so 2. **Drift trend, not drift count.** The ratio of `SEQUENCE_SKIP` flags (§4.11) that get resolved, or simply stop recurring as work catches up to its declared order, versus flags that persist open across repeated checks. A tool that's working sees this trend toward resolved, not toward a silently accumulating pile of open flags nobody looks at. 3. **`kt_get_next_steps` actually consulted before work starts.** Correlating ordinary HTTP/application access logs for `kt_get_next_steps` calls against the timestamps of subsequent Item status changes and Events — if items are consistently touched shortly after a next-steps call recommended them, the advisory loop is being used as intended. Note this is derived from access logs, not from the Event table, because `kt_get_next_steps` is deliberately side-effect-free (§4.5) and creates no Event of its own. 4. **Rendered-roadmap freshness, if the caller chooses to persist it.** `kt_render_roadmap` (§4.12) has no notion of a target file — it returns rendered content as a string — so freshness is only measurable if and however the calling agent chooses to persist that string (e.g. committing it into the repo as `ROADMAP.md`). Where an installer adopts that convention, the time elapsed between a DB state change and the next commit of the rendered content is still a meaningful trust signal for the snapshot; where they don't, this metric simply doesn't apply to that installation. -5. **Adapter usage where configured.** For a project with GitHub or Linear credentials configured (a row exists in `adapters` for that project/type — §5.3), the frequency of successful sync calls relative to manual, un-synced Item creation — if a project has an adapter configured but the sync tools are never exercised (for a Linear adapter, note §4.14 is still a stub in this build), that's a signal the adapter isn't earning its complexity for that installer. +5. **Adapter usage where configured.** For a project with GitHub or Linear credentials configured (a row exists in `adapters` for that project/type — §5.3), the frequency of successful sync calls relative to manual, un-synced Item creation — if a project has an adapter configured but the sync tools are never exercised, that's a signal the adapter isn't earning its complexity for that installer. 6. **Time-to-first-registered-project per deploy path (maintainer-run, manual QA).** Not automated telemetry — a manual checklist the maintainers run themselves against each of the three deploy targets in §5.5, timing from `git clone` to a successful first `kt_register_project` call. Target: under 30 minutes on each path, re-verified whenever a deploy guide or dependency changes. As of 2026-09-07 this has only actually been run for Railway (well under target — see `docs/ROADMAP.md` T3/T4); Render+Supabase and Fly.io have a draft runbook (`docs/deploy/client-verification-runbook.md`) but it hasn't actually been run end-to-end yet, so this metric doesn't apply to them until `T7.1`/`T7.2` close. --- @@ -534,7 +536,7 @@ Because KnoTrack collects no central telemetry (§5.6), every metric below is so - **Work dispatch or orchestration of any kind.** No tool in this document assigns, triggers, queues, or executes work against any agent, CI system, or external runner. `kt_get_next_steps` is advisory only (§4.5); this boundary is treated as permanent product identity, not a temporary v1 gap — see §2.2. - **Team/multi-user auth beyond a single shared bearer-token pool.** There is no role-based access control, no SSO/OAuth login flow, no per-device or per-user token identity, and no concept of a restricted or read-only token in v1 — every valid token accepted by an instance (§5.3) has full read/write access to every project it hosts, and the server has no way to tell which configured token a given call used. A small team (persona in §3.2) shares one instance and, in practice, one token per developer drawn from the same pool, all with equal privileges. Formal per-user roles and scoped/read-only tokens are a natural v2 addition once real multi-user usage shows which restrictions are actually wanted. - **Real-time push UI.** `kt_get_project_status` and all other read tools are pull/poll-based only. There is no WebSocket/SSE channel, no live-updating dashboard, and no in-app or external notification (Slack, email, etc.) fired when drift is detected — a human or agent must proactively call a status/drift tool to find out. A future web UI, if built, would poll these same MCP tools rather than requiring a new push mechanism. -- **Automatic conflict resolution for adapter sync, and bidirectional/bulk sync generally.** `kt_sync_to_github` (§4.13) and `kt_sync_to_linear` (§4.14 — the Linear adapter is still a stub in this build) are each a single, scoped, one-way push per call, with no bulk import, no direction choice, and no merge logic for the case where the same piece of work has diverged independently on both sides. +- **Automatic conflict resolution for adapter sync, and bidirectional/bulk sync generally.** `kt_sync_to_github` (§4.13) and `kt_sync_to_linear` (§4.14) are each a single, scoped, one-way push per call, with no bulk import, no direction choice, and no merge logic for the case where the same piece of work has diverged independently on both sides. - **Automatic parsing of arbitrary local roadmap/spec file formats, and importing existing GitHub/Linear issues.** As stated in §1.3, KnoTrack does not itself ingest free-text planning documents from a local folder; the calling agent reads them and populates Tracks/Items via `kt_create_track`/`kt_create_item`. The GitHub and Linear adapters are one-way pushes of existing KnoTrack state outward, not an import path — there is no tool in v1 that pulls GitHub Issues or Linear Issues into KnoTrack Items. - **Centralized, maintainer-run hosting or analytics.** There is no multi-tenant SaaS version of KnoTrack, and no telemetry collection of any kind (§5.6). "Open source, self-hosted" is the whole distribution model for v1, not a stepping stone the PRD assumes will change. - **A standalone chat or web-app interface.** KnoTrack in v1 is purely an MCP tool surface. Whatever conversational interface a user experiences is provided entirely by the calling agent harness (Claude Code, Windsurf, etc.), not by KnoTrack itself. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 59085dc..466bc7a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -953,8 +953,9 @@ this Track. below are both cleared: `T4` went `done` 2026-09-07 and `T2.16` shipped 2026-09-08, so the stale `blocked` no longer holds). `T5.1` is **done + verified 2026-09-19** and `T5.2` is **implemented + offline-verified -2026-09-19** (real-repo dogfood gate pending — see those items); `T5.3` / -`T5.4` remain open. +2026-09-19** (real-repo dogfood gate pending — see those items); `T5.3` is +**implemented + offline-verified 2026-09-19** (real-workspace dogfood gate +pending); `T5.4` remains open. **depends_on:** `T4`, `T2.16` (added 2026-08-29 — `SYNC_DRIFT`'s notion of "the track's most recent change" and any future logic that reads track completion to decide what to push should be built against the @@ -1023,9 +1024,32 @@ corrected track-status model, not the currently-broken one). part of the code/CI deliverable; see the handoff. `migrations/009_track_external_links.sql`. 3. **T5.3 — `kt_sync_to_linear` fully implemented.** Acceptance: given a stored encrypted Linear credential, calling `kt_sync_to_linear` - creates/updates a linked Linear issue for a KnoTrack item and records - the issue URL on the item, verified against one real test Linear - workspace. depends_on: `T2.13`, `T5.1`. + creates/updates a linked Linear Issue for a **track** (track-scoped + `{project_id, track_id}`, mirroring A4.13/T5.2 — no item-level sync + target in v1) and records the issue URL on the track (surfaced on + `kt_get_track`'s `track.linear_issue_url`), verified against one real + test Linear workspace. depends_on: `T2.13`, `T5.1`. + **Status (2026-09-19): implemented + offline-verified.** Mirrors the + T5.2 GitHub design — persisted `track_external_links` link as the + authoritative idempotency key, durable `pending` creation-intent before + the outbound GraphQL mutation, hidden-marker crash recovery that adopts + but never auto-recreates, injectable `LinearClient` (no network in CI), + and no new migration (the T5.2 table and `tracks.last_linear_sync_at` + already generalize). Linear-specific design (frontier panel 2026-09-19, + unanimous `auto_lookup_with_config_override`): a done track's issue is + moved to a `completed` workflow state — the team's lowest-position one, + or the optional `done_state_id` override — while a non-done track's issue + is never auto-moved backward unless a valid `open_state_id` is + configured (two optional fields added to the Linear adapter config). + Operational failures return `{ok:false, error}` with fixed prefixes + (`LINEAR_AUTH_FAILED` / `LINEAR_NOT_FOUND` / `LINEAR_RATE_LIMITED` / + `LINEAR_TIMEOUT` / `LINEAR_STATE_CONFIG` / `LINEAR_UNKNOWN_ERROR`). + Same deliberate v1 bounds as T5.2 (no retries/backoff; no update-path + lock; no auto-recreate on a stale link). Covered by offline unit + + integration tests (`tests/unit/linear-payload.test.ts`, + `tests/unit/linear-client.test.ts`, + `tests/integration/sync-to-linear.test.ts`). The one remaining + acceptance gate is the real-workspace dogfood. 4. **T5.4 — Credential revocation path implemented + unit-tested.** Acceptance: deleting a stored GitHub/Linear credential causes the next sync call to fail with a clear "credential not configured" error diff --git a/src/db/queries/track-external-links.ts b/src/db/queries/track-external-links.ts index 3bf9e27..ca04776 100644 --- a/src/db/queries/track-external-links.ts +++ b/src/db/queries/track-external-links.ts @@ -159,3 +159,19 @@ export async function getGithubIssueUrlForTrack( ); return result.rows[0]?.external_url ?? null; } + +/** The linked Linear issue URL for a track, or null when unlinked (T5.3). + * Sibling of getGithubIssueUrlForTrack; kept adapter-specific for the same + * reason — callers ask for one destination's URL, never "some" URL. */ +export async function getLinearIssueUrlForTrack( + db: Queryable, + trackId: string, +): Promise { + const result = await db.query<{ external_url: string | null }>( + `SELECT external_url FROM track_external_links + WHERE track_id = $1 AND adapter_type = 'linear' AND sync_state = 'linked' + LIMIT 1`, + [trackId], + ); + return result.rows[0]?.external_url ?? null; +} diff --git a/src/db/queries/tracks.ts b/src/db/queries/tracks.ts index ba1544d..673fd93 100644 --- a/src/db/queries/tracks.ts +++ b/src/db/queries/tracks.ts @@ -382,3 +382,15 @@ export async function touchGithubSyncWatermark( // pushed, not the later "now()". await db.query(`UPDATE tracks SET last_github_sync_at = $2 WHERE id = $1`, [trackId, at]); } + +/** Stamps `tracks.last_linear_sync_at` after a successful Linear sync (T5.3). + * Same snapshot-time semantics as the GitHub watermark above: `at` is the + * payload snapshot time, not the completion time, so a change made after the + * snapshot still reads as drift on the next scan. */ +export async function touchLinearSyncWatermark( + db: Queryable, + trackId: string, + at: Date, +): Promise { + await db.query(`UPDATE tracks SET last_linear_sync_at = $2 WHERE id = $1`, [trackId, at]); +} diff --git a/src/linear/linear-client.ts b/src/linear/linear-client.ts new file mode 100644 index 0000000..cd062e3 --- /dev/null +++ b/src/linear/linear-client.ts @@ -0,0 +1,386 @@ +// Linear GraphQL client for kt_sync_to_linear (docs/PRD.md §4.14, T5.3). +// +// Mirrors src/github/github-client.ts by design: a thin native-fetch +// wrapper (no @linear/sdk), one HTTP call per method, a discriminated +// result that NEVER throws for an API-level outcome and NEVER logs or +// echoes the api_key. The frontier panel (2026-09-19) chose the same +// smallest-auditable-dependency approach as GitHub for this self-hosted +// server. +// +// Linear differences handled here: +// - Transport is GraphQL: one endpoint, POST { query, variables }. A +// GraphQL request can return HTTP 200 with a top-level `errors` array +// (the mutation was REJECTED, nothing was written) — that is mapped to +// {ok:false} exactly like an HTTP error, with ambiguous=false because a +// rejected GraphQL mutation is transactional (no partial write). +// - Auth header is the raw api_key ("Authorization: "), NOT +// "Bearer " (that form is only for OAuth access tokens). +// - There is no open/closed; a workflow state (stateId) is resolved by +// the caller and passed in. This client only sets what it is given. +// +// Operational failures map to fixed prefixes +// (LINEAR_AUTH_FAILED / LINEAR_NOT_FOUND / LINEAR_RATE_LIMITED / +// LINEAR_TIMEOUT / LINEAR_UNKNOWN_ERROR). `ambiguous` tells the caller +// whether a create mutation may have reached Linear despite the failure +// (timeout / 5xx / network / malformed-2xx) so it can keep a durable +// `pending` link for marker recovery instead of recreating a duplicate. + +const LINEAR_API = 'https://api.linear.app/graphql'; + +export interface LinearIssuePayload { + title: string; + description: string; +} + +export interface LinearIssueRef { + /** Linear issue UUID — issueUpdate targets this. */ + id: string; + /** Human identifier, e.g. "ENG-123" (informational). */ + identifier: string; + url: string; +} + +export interface LinearWorkflowState { + id: string; + name: string; + /** backlog | unstarted | started | completed | canceled */ + type: string; + position: number; +} + +export type LinearResult = + { ok: true; value: T } | { ok: false; error: string; ambiguous: boolean }; + +export interface LinearClient { + /** issueCreate — creates an issue in `teamId`; sets `stateId` when given. */ + createIssue( + teamId: string, + payload: LinearIssuePayload, + stateId?: string, + ): Promise>; + /** issueUpdate — reconciles title/description (and stateId when given). */ + updateIssue( + issueId: string, + payload: LinearIssuePayload, + stateId?: string, + ): Promise>; + /** Recovery-only: find an existing issue in `teamId` whose description + * carries `marker`. Returns null when none is found. */ + findIssueByMarker(teamId: string, marker: string): Promise>; + /** The team's workflow states, for the state resolver. */ + getWorkflowStates(teamId: string): Promise>; + /** The current workflow-state TYPE of an issue (e.g. 'completed'), used to + * decide whether a reopen should move it backward. Null when the issue or + * its state is absent. */ + getIssueStateType(issueId: string): Promise>; +} + +export interface LinearClientOptions { + timeoutMs: number; + userAgent: string; +} + +export type LinearClientFactory = (apiKey: string, opts: LinearClientOptions) => LinearClient; + +function isRateLimitedMessage(text: string): boolean { + return /ratelimit|rate limit|too many requests/i.test(text); +} + +function isAuthMessage(text: string): boolean { + return /authentication|not authenticated|invalid api key|unauthorized|forbidden/i.test(text); +} + +/** Maps a completed HTTP response (non-2xx) to a prefixed error + ambiguity. + * 4xx means Linear rejected the request, so a create that got one did NOT + * write (ambiguous=false). 5xx may have written then failed to respond, so a + * create that got a 5xx is AMBIGUOUS — the caller keeps its pending link. */ +function mapHttpError( + status: number, + headers: Headers, + bodyText: string, +): { error: string; ambiguous: boolean } { + const detail = bodyText.slice(0, 300).replace(/\s+/g, ' ').trim(); + if (status === 401 || status === 403) + return { error: `LINEAR_AUTH_FAILED: ${detail || 'unauthorized'}`, ambiguous: false }; + if (status === 429 || headers.get('retry-after')) + return { error: `LINEAR_RATE_LIMITED: ${detail || 'rate limit exceeded'}`, ambiguous: false }; + if (status === 404) + return { error: `LINEAR_NOT_FOUND: ${detail || 'not found'}`, ambiguous: false }; + if (status === 400 && isAuthMessage(detail)) + return { error: `LINEAR_AUTH_FAILED: ${detail}`, ambiguous: false }; + if (status === 400 && isRateLimitedMessage(detail)) + return { error: `LINEAR_RATE_LIMITED: ${detail}`, ambiguous: false }; + return { + error: `LINEAR_UNKNOWN_ERROR: HTTP ${status}${detail ? `: ${detail}` : ''}`, + ambiguous: status >= 500, + }; +} + +/** Maps a GraphQL top-level `errors` array (returned with HTTP 200) to a + * prefixed error. A rejected GraphQL request is transactional — nothing was + * written — so ambiguous is always false here. */ +function mapGraphqlErrors(errors: unknown[]): { error: string; ambiguous: boolean } { + const msg = errors + .map((e) => + e && typeof e === 'object' && typeof (e as Record).message === 'string' + ? ((e as Record).message as string) + : '', + ) + .filter(Boolean) + .join('; ') + .slice(0, 300); + const code = errors + .map((e) => { + const ext = + e && typeof e === 'object' ? (e as Record).extensions : undefined; + if (!ext || typeof ext !== 'object') return ''; + const c = (ext as Record).code; + return typeof c === 'string' ? c : ''; + }) + .join(' '); + const haystack = `${msg} ${code}`; + if (isRateLimitedMessage(haystack)) + return { error: `LINEAR_RATE_LIMITED: ${msg || 'rate limited'}`, ambiguous: false }; + if (isAuthMessage(haystack)) + return { error: `LINEAR_AUTH_FAILED: ${msg || 'authentication failed'}`, ambiguous: false }; + return { error: `LINEAR_UNKNOWN_ERROR: ${msg || 'GraphQL error'}`, ambiguous: false }; +} + +function mapThrown(err: unknown): { error: string; ambiguous: boolean } { + if (err instanceof Error && err.name === 'AbortError') { + return { error: 'LINEAR_TIMEOUT: request exceeded the configured timeout', ambiguous: true }; + } + const msg = err instanceof Error ? err.message : String(err); + // A request that never got a response may still have been received and + // acted on by Linear -> ambiguous for the create path. + return { error: `LINEAR_UNKNOWN_ERROR: ${msg}`, ambiguous: true }; +} + +export function createFetchLinearClient(apiKey: string, opts: LinearClientOptions): LinearClient { + const headers: Record = { + // Linear personal API keys are sent as the RAW Authorization value — no + // "Bearer " prefix (that form is for OAuth tokens only). + Authorization: apiKey, + 'Content-Type': 'application/json', + 'User-Agent': opts.userAgent, + }; + + /** One GraphQL round-trip. `isMutation` drives the ambiguity of a + * malformed 2xx body (a write may have landed; a read cannot have). */ + async function send( + query: string, + variables: Record, + isMutation: boolean, + ): Promise>> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs); + try { + const res = await fetch(LINEAR_API, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }); + const text = await res.text(); + if (!res.ok) { + const mapped = mapHttpError(res.status, res.headers, text); + return { ok: false, error: mapped.error, ambiguous: mapped.ambiguous }; + } + let parsed: unknown; + try { + parsed = text.length > 0 ? JSON.parse(text) : {}; + } catch { + return { + ok: false, + error: 'LINEAR_UNKNOWN_ERROR: malformed JSON in Linear response', + // A 2xx mutation may already have written; a read cannot have. + ambiguous: isMutation, + }; + } + const obj = (parsed ?? {}) as Record; + if (Array.isArray(obj.errors) && obj.errors.length > 0) { + const mapped = mapGraphqlErrors(obj.errors); + return { ok: false, error: mapped.error, ambiguous: mapped.ambiguous }; + } + const data = obj.data; + if (data === null || typeof data !== 'object') { + return { + ok: false, + error: 'LINEAR_UNKNOWN_ERROR: Linear response missing data', + ambiguous: isMutation, + }; + } + return { ok: true, value: data as Record }; + } catch (err) { + const mapped = mapThrown(err); + return { ok: false, error: mapped.error, ambiguous: mapped.ambiguous }; + } finally { + clearTimeout(timer); + } + } + + function toRef(node: unknown): LinearIssueRef | null { + if (node === null || typeof node !== 'object') return null; + const rec = node as Record; + const id = rec.id; + const url = rec.url; + const identifier = rec.identifier; + if (typeof id !== 'string' || typeof url !== 'string') return null; + return { id, url, identifier: typeof identifier === 'string' ? identifier : '' }; + } + + function refFromMutation( + data: Record, + field: 'issueCreate' | 'issueUpdate', + ): LinearResult { + const payload = data[field]; + if (payload === null || typeof payload !== 'object') { + return { + ok: false, + error: `LINEAR_UNKNOWN_ERROR: ${field} returned no payload`, + // The mutation reached Linear (2xx, no errors) but the shape is + // unexpected — treat as ambiguous so a create keeps its pending link. + ambiguous: true, + }; + } + const rec = payload as Record; + const ref = toRef(rec.issue); + if (rec.success !== true || !ref) { + return { + ok: false, + error: `LINEAR_UNKNOWN_ERROR: ${field} did not return success/issue`, + ambiguous: true, + }; + } + return { ok: true, value: ref }; + } + + const ISSUE_FIELDS = 'id identifier url'; + + return { + async createIssue(teamId, payload, stateId) { + const input: Record = { + teamId, + title: payload.title, + description: payload.description, + }; + if (stateId) input.stateId = stateId; + const res = await send( + `mutation KtCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } + }`, + { input }, + true, + ); + if (!res.ok) return res; + return refFromMutation(res.value, 'issueCreate'); + }, + + async updateIssue(issueId, payload, stateId) { + const input: Record = { + title: payload.title, + description: payload.description, + }; + if (stateId) input.stateId = stateId; + const res = await send( + `mutation KtUpdate($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } + }`, + { id: issueId, input }, + true, + ); + if (!res.ok) return res; + return refFromMutation(res.value, 'issueUpdate'); + }, + + async findIssueByMarker(teamId, marker) { + // Recovery-only. Linear's `description: { contains }` filter is a + // server-side substring match; we still re-check the exact marker on + // each node (defence in depth) and treat a truncated page as + // unconfirmed so recovery never recreates on a false miss. + const res = await send( + `query KtFind($teamId: ID!, $marker: String!) { + issues( + filter: { team: { id: { eq: $teamId } }, description: { contains: $marker } } + first: 20 + ) { nodes { ${ISSUE_FIELDS} description } pageInfo { hasNextPage } } + }`, + { teamId, marker }, + false, + ); + if (!res.ok) return res; + const issues = (res.value.issues ?? {}) as Record; + const nodes = Array.isArray(issues.nodes) ? (issues.nodes as unknown[]) : []; + for (const node of nodes) { + if (node && typeof node === 'object') { + const rec = node as Record; + if (typeof rec.description === 'string' && rec.description.includes(marker)) { + const ref = toRef(node); + if (ref) return { ok: true, value: ref }; + } + } + } + const pageInfo = (issues.pageInfo ?? {}) as Record; + if (pageInfo.hasNextPage === true) { + return { + ok: false, + error: + 'LINEAR_UNKNOWN_ERROR: issue search returned more results than inspected; could not confirm', + ambiguous: true, + }; + } + return { ok: true, value: null }; + }, + + async getWorkflowStates(teamId) { + const res = await send( + `query KtStates($teamId: String!) { + team(id: $teamId) { states { nodes { id name type position } } } + }`, + { teamId }, + false, + ); + if (!res.ok) return res; + const team = res.value.team; + if (team === null || typeof team !== 'object') { + return { ok: false, error: `LINEAR_NOT_FOUND: team ${teamId} not found`, ambiguous: false }; + } + const states = ((team as Record).states ?? {}) as Record; + const nodes = Array.isArray(states.nodes) ? (states.nodes as unknown[]) : []; + const out: LinearWorkflowState[] = []; + for (const node of nodes) { + if (node && typeof node === 'object') { + const rec = node as Record; + if ( + typeof rec.id === 'string' && + typeof rec.type === 'string' && + typeof rec.position === 'number' + ) { + out.push({ + id: rec.id, + name: typeof rec.name === 'string' ? rec.name : '', + type: rec.type, + position: rec.position, + }); + } + } + } + return { ok: true, value: out }; + }, + + async getIssueStateType(issueId) { + const res = await send( + `query KtIssueState($id: String!) { issue(id: $id) { state { type } } }`, + { id: issueId }, + false, + ); + if (!res.ok) return res; + const issue = res.value.issue; + if (issue === null || typeof issue !== 'object') return { ok: true, value: null }; + const state = (issue as Record).state; + if (state === null || typeof state !== 'object') return { ok: true, value: null }; + const type = (state as Record).type; + return { ok: true, value: typeof type === 'string' ? type : null }; + }, + }; +} diff --git a/src/mcp/tools/get-track.ts b/src/mcp/tools/get-track.ts index 0a1328d..e9db1a8 100644 --- a/src/mcp/tools/get-track.ts +++ b/src/mcp/tools/get-track.ts @@ -6,7 +6,10 @@ import { getTrackInputSchema, type GetTrackInput } from '../../schemas/tools.js' import { findActiveProjectById } from '../../db/queries/projects.js'; import { findTrackById, getDependsOnTrackIds } from '../../db/queries/tracks.js'; import { getItemDependencyEdgesForTrack, listItemsByTrack } from '../../db/queries/items.js'; -import { getGithubIssueUrlForTrack } from '../../db/queries/track-external-links.js'; +import { + getGithubIssueUrlForTrack, + getLinearIssueUrlForTrack, +} from '../../db/queries/track-external-links.js'; import { withReadSnapshot } from '../../db/tx.js'; import { notFound } from '../errors.js'; import { runTool } from '../tool-helpers.js'; @@ -27,6 +30,9 @@ export interface GetTrackOutput extends Record { * This is where "kt_sync_to_github records the issue URL on the track" * (docs/PRD.md §4.13) is observable through the read contract. */ github_issue_url: string | null; + /** T5.3: the URL of the Linear Issue this track is synced to, or null — + * the read-contract equivalent of github_issue_url (docs/PRD.md §4.14). */ + linear_issue_url: string | null; created_at: string; }; items: Array<{ @@ -67,6 +73,7 @@ export async function getTrackService( const items = await listItemsByTrack(client, track.id); const itemEdges = await getItemDependencyEdgesForTrack(client, track.id); const githubIssueUrl = await getGithubIssueUrlForTrack(client, track.id); + const linearIssueUrl = await getLinearIssueUrlForTrack(client, track.id); const dependsOnByItemId = new Map(); for (const edge of itemEdges) { @@ -87,6 +94,7 @@ export async function getTrackService( source_doc_ref: track.source_doc_ref, depends_on_track_ids: dependsOnTrackIds, github_issue_url: githubIssueUrl, + linear_issue_url: linearIssueUrl, created_at: track.created_at.toISOString(), }, items: items.map((item) => ({ diff --git a/src/mcp/tools/issue-payload.ts b/src/mcp/tools/issue-payload.ts index 352b3a7..aae6fe0 100644 --- a/src/mcp/tools/issue-payload.ts +++ b/src/mcp/tools/issue-payload.ts @@ -51,7 +51,7 @@ export function trackMarker(trackId: string): string { return ``; } -function truncate(value: string, max: number): string { +export function truncate(value: string, max: number): string { return value.length <= max ? value : value.slice(0, max); } @@ -65,7 +65,7 @@ function itemCheckbox(status: string): string { * an unrelated issue — nor open a stray hidden HTML comment. HTML-escaping * the delimiters keeps the text readable (GitHub renders `<`/`>` as * literal `<`/`>`) while making the marker syntax impossible to form. */ -function sanitize(value: string): string { +export function sanitize(value: string): string { return value.replace(//g, '-->'); } diff --git a/src/mcp/tools/linear-payload.ts b/src/mcp/tools/linear-payload.ts new file mode 100644 index 0000000..d82aa18 --- /dev/null +++ b/src/mcp/tools/linear-payload.ts @@ -0,0 +1,195 @@ +// Pure track -> Linear Issue payload mapping, content hashing, and workflow- +// state resolution for kt_sync_to_linear (docs/PRD.md §4.14, docs/ROADMAP.md +// T5.3). Side-effect-free and free of any DB/HTTP import (the LinearWorkflowState +// type is imported type-only) so the mapping, the no-op hash, and the state +// resolver are unit-testable in isolation. +// +// KnoTrack owns exactly the issue title, description, and — for a done track — +// its workflow state. The description carries the same hidden recovery marker +// as the GitHub body (reused via issue-payload.ts), used ONLY for crash +// recovery, never as the authoritative create-vs-update key (that is the +// track_external_links row). +import { createHash } from 'node:crypto'; +import { sanitize, truncate, trackMarker } from './issue-payload.js'; +import type { LinearWorkflowState } from '../../linear/linear-client.js'; + +// Linear's issue title limit; we stay under it rather than letting the API +// reject the mutation. +const TITLE_MAX = 255; +// Linear descriptions allow large markdown; bound worst-case body size the +// same way GitHub does so a huge track can't render an unbounded checklist. +const DESC_MAX = 65_536; +const ITEM_RENDER_CAP = 300; + +export interface TrackForLinear { + id: string; + title: string; + /** Derived track status: on_track | pivot_pending | blocked | done. */ + status: string; +} + +export interface ItemForLinear { + id: string; + title: string; + status: string; + sequence_position: number; +} + +export interface LinearPayload { + title: string; + description: string; +} + +/** 'done' when the track is terminal, else 'open'. Folded into the content + * hash so flipping a track's done-ness always forces a re-sync (which lets + * the state resolver move the Linear issue to/from a completed state). */ +export function stateIntentFor(trackStatus: string): 'done' | 'open' { + return trackStatus === 'done' ? 'done' : 'open'; +} + +function itemCheckbox(status: string): string { + return status === 'done' ? '[x]' : '[ ]'; +} + +/** Deterministic mapping — pure function of (track, items). Mirrors the + * GitHub body (issue-payload.ts) but targets Linear's {title, description} + * and omits the open/closed field (Linear state is a separate stateId + * resolved by resolveLinearStateId). */ +export function buildLinearPayload(track: TrackForLinear, items: ItemForLinear[]): LinearPayload { + // Escape FIRST, then enforce the limit (sanitize expands `` into + // longer entities), matching the GitHub title fix. + const title = truncate(sanitize(track.title), TITLE_MAX); + + const ordered = [...items].sort( + (a, b) => a.sequence_position - b.sequence_position || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + + const header = [ + '_Synced from KnoTrack — KnoTrack owns this issue’s title, description, and (when the track is done) its workflow state, and will overwrite manual edits to them._', + '', + `**Track status:** \`${track.status}\``, + '', + '### Items', + '', + ].join('\n'); + + const markerBlock = `\n\n${trackMarker(track.id)}`; + const NOTICE_RESERVE = 64; + const budget = Math.max(0, DESC_MAX - markerBlock.length); + + const lines: string[] = []; + let used = header.length; + let shownCount = 0; + for (const item of ordered) { + if (shownCount >= ITEM_RENDER_CAP) break; + const line = `- ${itemCheckbox(item.status)} ${sanitize(item.title)}\n`; + const reserve = shownCount + 1 < ordered.length ? NOTICE_RESERVE : 0; + if (used + line.length + reserve > budget) break; + lines.push(line); + used += line.length; + shownCount += 1; + } + + const omitted = ordered.length - shownCount; + const checklist = + shownCount > 0 ? lines.join('') : ordered.length === 0 ? '_No items yet._\n' : ''; + const notice = omitted > 0 ? `\n_… ${omitted} more item(s) not shown._` : ''; + + const description = truncate(header + checklist + notice, budget) + markerBlock; + + return { title, description }; +} + +/** sha256 of exactly what KnoTrack pushes to Linear: title, description, and + * the abstract state intent (done|open). Including the intent — not a concrete + * stateId — means a done/undone flip re-syncs while a team's specific state + * UUIDs stay out of the hash (they differ per team and would cause needless + * churn). Detects KnoTrack-side changes only. */ +export function linearPayloadContentHash( + payload: LinearPayload, + stateIntent: 'done' | 'open', +): string { + const canonical = JSON.stringify({ + title: payload.title, + description: payload.description, + state_intent: stateIntent, + }); + return createHash('sha256').update(canonical, 'utf8').digest('hex'); +} + +export type StateResolution = { stateId?: string } | { error: string }; + +function lowestPosition(states: LinearWorkflowState[]): LinearWorkflowState | undefined { + return states.reduce((best, s) => { + if (!best) return s; + if (s.position < best.position) return s; + if (s.position === best.position && s.id < best.id) return s; + return best; + }, undefined); +} + +/** The single shared workflow-state resolver (frontier checklist item 15). + * + * - done track: set the configured `done_state_id`, or (default) the team's + * lowest-position 'completed' state. Fails LINEAR_STATE_CONFIG when the + * override is unknown/not 'completed', or when no completed state exists + * and none is configured. + * - non-done track: NEVER auto-moves the issue (frontier checklist item 2). + * It moves backward only when an explicit, valid `open_state_id` is + * configured AND (on update) the issue is currently in a completed/canceled + * state. On create with `open_state_id` set, new issues are placed there; + * with none set, Linear assigns the team default. + * + * Returns `{}` (no stateId) to mean "leave the state as-is / let Linear + * decide", or `{ error }` for an invalid configuration. */ +export function resolveLinearStateId(args: { + states: LinearWorkflowState[]; + doneStateId?: string; + openStateId?: string; + trackStatus: string; + mode: 'create' | 'update'; + currentStateType?: string | null; +}): StateResolution { + const { states, doneStateId, openStateId, trackStatus, mode, currentStateType } = args; + const byId = new Map(states.map((s) => [s.id, s])); + + if (doneStateId !== undefined) { + const s = byId.get(doneStateId); + if (!s) + return { + error: `LINEAR_STATE_CONFIG: configured done_state_id is not a workflow state of this team`, + }; + if (s.type !== 'completed') + return { + error: `LINEAR_STATE_CONFIG: configured done_state_id is a '${s.type}' state, not 'completed'`, + }; + } + if (openStateId !== undefined) { + const s = byId.get(openStateId); + if (!s) + return { + error: `LINEAR_STATE_CONFIG: configured open_state_id is not a workflow state of this team`, + }; + if (s.type === 'canceled') + return { + error: `LINEAR_STATE_CONFIG: configured open_state_id is a 'canceled' state; choose a non-canceled state to reopen into`, + }; + } + + if (trackStatus === 'done') { + const chosen = doneStateId ?? lowestPosition(states.filter((s) => s.type === 'completed'))?.id; + if (!chosen) + return { + error: `LINEAR_STATE_CONFIG: this team has no 'completed' workflow state; set done_state_id on the Linear adapter`, + }; + return { stateId: chosen }; + } + + // Non-done: never auto-move. Only an explicit open_state_id can set state. + if (openStateId === undefined) return {}; + if (mode === 'create') return { stateId: openStateId }; + // Update: only reopen (move backward) if the issue is currently done/canceled. + if (currentStateType === 'completed' || currentStateType === 'canceled') + return { stateId: openStateId }; + return {}; +} diff --git a/src/mcp/tools/register-project.ts b/src/mcp/tools/register-project.ts index a7abb8e..e1cd4f3 100644 --- a/src/mcp/tools/register-project.ts +++ b/src/mcp/tools/register-project.ts @@ -76,6 +76,14 @@ export async function registerProjectService( encryptedCredential: encrypted, config: { team_id: input.adapters.linear.team_id, + // Optional workflow-state overrides (T5.3); only stored when set, + // so an adapter registered before T5.3 keeps auto-resolution. + ...(input.adapters.linear.done_state_id + ? { done_state_id: input.adapters.linear.done_state_id } + : {}), + ...(input.adapters.linear.open_state_id + ? { open_state_id: input.adapters.linear.open_state_id } + : {}), connected: true, }, }); diff --git a/src/mcp/tools/sync-shared.ts b/src/mcp/tools/sync-shared.ts deleted file mode 100644 index fb44323..0000000 --- a/src/mcp/tools/sync-shared.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Shared precondition path for kt_sync_to_github / kt_sync_to_linear -// (T2.13 / T2.14 stub slice — docs/PRD.md §4.13/§4.14). -import type { Pool } from 'pg'; -import { findActiveProjectById } from '../../db/queries/projects.js'; -import { trackExistsInProject } from '../../db/queries/tracks.js'; -import { adapterConfigured } from '../../db/queries/adapters.js'; -import { conflict, internalError, notFound } from '../errors.js'; - -export interface SyncOutput extends Record { - ok: true; -} - -export interface SyncInput { - project_id: string; - track_id: string; -} - -/** - * Validates the project and track exist (project-scoped track lookup, so - * a `track_id` from another project is a 404, never a cross-project - * leak), then checks the adapter precondition: - * - no adapter row for (project, type) -> CONFLICT " adapter not - * configured" (the caller must configure credentials first; PRD - * §4.13 acceptance). Makes no outbound HTTP call. - * - an adapter row exists -> the real GitHub/Linear push is not built - * in this build, so this returns a clear " sync is not - * available in this build" INTERNAL_ERROR rather than a false success - * or a misleading "not configured". This is a normal, client-reachable - * state: kt_register_project provisions an adapter from inline - * credentials (register-project.ts), so any client that registers a - * project with a GitHub/Linear credential reaches it. The T5.2 / T5.3 - * API path replaces this branch and the success return below. - */ -export async function syncAdapterStub( - pool: Pool, - type: 'github' | 'linear', - input: SyncInput, -): Promise { - const project = await findActiveProjectById(pool, input.project_id); - if (!project) { - throw notFound('project not found', { project_id: input.project_id }); - } - const trackExists = await trackExistsInProject(pool, input.project_id, input.track_id); - if (!trackExists) { - throw notFound('track not found in this project', { - project_id: input.project_id, - track_id: input.track_id, - }); - } - const configured = await adapterConfigured(pool, input.project_id, type); - if (!configured) { - throw conflict(`${type} adapter not configured`, { - project_id: input.project_id, - adapter: type, - }); - } - throw internalError(`${type} sync is not available in this build`, { - project_id: input.project_id, - track_id: input.track_id, - adapter: type, - }); -} diff --git a/src/mcp/tools/sync-to-linear.ts b/src/mcp/tools/sync-to-linear.ts index cd0cd07..0f03b1e 100644 --- a/src/mcp/tools/sync-to-linear.ts +++ b/src/mcp/tools/sync-to-linear.ts @@ -1,17 +1,396 @@ -// kt_sync_to_linear — docs/PRD.md §4.14 (T2.14 stub slice). +// kt_sync_to_linear — docs/PRD.md §4.14, docs/ROADMAP.md T5.3. +// +// Syncs ONE track to a Linear Issue, idempotently. Deliberately a close +// mirror of sync-to-github.ts: the authoritative create-vs-update key is a +// track_external_links row (never a Linear search), idempotency is hardened +// with a durable `pending` creation-intent committed BEFORE the outbound +// mutation, and a crash recovers via the hidden body marker instead of +// blindly recreating. See that file for the shared rationale. +// +// Linear-specific (frontier panel 2026-09-19, unanimous +// auto_lookup_with_config_override): +// - Transport is GraphQL (src/linear/linear-client.ts). +// - `repo_identity` on the link stores the bound Linear team_id. +// - external_id stores the Linear issue UUID (issueUpdate targets it). +// - There is no open/closed: a done track's issue is moved to a completed +// workflow state (configured done_state_id, or the team's lowest-position +// 'completed' state); a non-done track's issue is never auto-moved back +// unless a valid open_state_id is configured. All in resolveLinearStateId. +// +// Failure surfaces, per §4.14: +// - Missing/unusable adapter precondition -> CONFLICT (a thrown KtError). +// - Everything about actually talking to Linear (and an invalid state +// configuration) -> a SUCCESSFUL tool call returning +// { ok: false, error: ": ..." } with a fixed prefix +// (LINEAR_AUTH_FAILED / LINEAR_NOT_FOUND / LINEAR_RATE_LIMITED / +// LINEAR_TIMEOUT / LINEAR_STATE_CONFIG / LINEAR_UNKNOWN_ERROR). +import { randomUUID } from 'node:crypto'; import type { Pool } from 'pg'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Config } from '../../config/env.js'; import { syncToLinearInputSchema, type SyncToLinearInput } from '../../schemas/tools.js'; import { runTool } from '../tool-helpers.js'; -import { syncAdapterStub, type SyncOutput } from './sync-shared.js'; +import { conflict, notFound } from '../errors.js'; +import { findActiveProjectById } from '../../db/queries/projects.js'; +import { findTrackById, touchLinearSyncWatermark } from '../../db/queries/tracks.js'; +import { listItemsByTrack } from '../../db/queries/items.js'; +import { getAdapterForProject } from '../../db/queries/adapters.js'; +import { decryptCredential } from '../../crypto/credential-cipher.js'; +import { trackMarker } from './issue-payload.js'; +import { + buildLinearPayload, + linearPayloadContentHash, + resolveLinearStateId, + stateIntentFor, + type LinearPayload, +} from './linear-payload.js'; +import { withReadSnapshot, withTransaction } from '../../db/tx.js'; +import { + claimPendingLink, + clearPendingLink, + finalizeLinked, + getLinkForUpdate, + updateLinkedContentHash, + type TrackExternalLinkRow, +} from '../../db/queries/track-external-links.js'; +import { + createFetchLinearClient, + type LinearClient, + type LinearClientFactory, +} from '../../linear/linear-client.js'; + +const ADAPTER = 'linear' as const; +const USER_AGENT = 'knotrack-mcp-server'; + +export interface LinearSyncOutput extends Record { + ok: boolean; + error?: string; +} + +export interface SyncToLinearDeps { + linearClientFactory?: LinearClientFactory; +} + +interface StateConfig { + doneStateId?: string; + openStateId?: string; +} export async function syncToLinearService( pool: Pool, - _config: Config, + config: Config, input: SyncToLinearInput, -): Promise { - return syncAdapterStub(pool, 'linear', input); + deps: SyncToLinearDeps = {}, +): Promise { + const clientFactory = deps.linearClientFactory ?? createFetchLinearClient; + + // 1. Project + project-scoped track existence. + const project = await findActiveProjectById(pool, input.project_id); + if (!project) { + throw notFound('project not found', { project_id: input.project_id }); + } + const track = await findTrackById(pool, input.project_id, input.track_id); + if (!track) { + throw notFound('track not found', { + project_id: input.project_id, + track_id: input.track_id, + }); + } + + // 2. Adapter precondition -> CONFLICT (no HTTP). §4.14. + const adapter = await getAdapterForProject(pool, input.project_id, ADAPTER); + if (!adapter) { + throw conflict('linear adapter not configured', { + project_id: input.project_id, + adapter: ADAPTER, + }); + } + const teamRaw = adapter.config['team_id']; + const teamId = typeof teamRaw === 'string' ? teamRaw.trim() : ''; + if (!teamId) { + throw conflict('linear adapter has no team configured', { + project_id: input.project_id, + adapter: ADAPTER, + }); + } + const stateConfig: StateConfig = { + doneStateId: readOptionalString(adapter.config['done_state_id']), + openStateId: readOptionalString(adapter.config['open_state_id']), + }; + + // 3. Decrypt the stored credential (never leaks the key). + const apiKey = decryptCredential(adapter.encrypted_credential, config.encryptionKey); + + // 4. Deterministic payload + no-op hash from ONE consistent snapshot. + const snap = await withReadSnapshot(pool, async (c) => { + const asOf = (await c.query<{ t: Date }>('SELECT now() AS t')).rows[0]!.t; + const t = await findTrackById(c, input.project_id, input.track_id); + return { asOf, track: t, items: t ? await listItemsByTrack(c, t.id) : [] }; + }); + if (!snap.track) { + throw notFound('track not found', { + project_id: input.project_id, + track_id: input.track_id, + }); + } + const syncedAt = snap.asOf; + const payload = buildLinearPayload( + { id: snap.track.id, title: snap.track.title, status: snap.track.status }, + snap.items.map((i) => ({ + id: i.id, + title: i.title, + status: i.status, + sequence_position: i.sequence_position, + })), + ); + const trackStatus = snap.track.status; + const stateIntent = stateIntentFor(trackStatus); + const hash = linearPayloadContentHash(payload, stateIntent); + + const client = clientFactory(apiKey, { + timeoutMs: config.linearSyncTimeoutMs, + userAgent: USER_AGENT, + }); + + // 5. Decide under a row lock; claim a fresh pending slot if none exists. + const decision = await withTransaction(pool, async (c): Promise => { + const existing = await getLinkForUpdate(c, track.id, ADAPTER); + if (existing) { + if (existing.sync_state === 'pending') return { kind: 'pending', row: existing }; + if (existing.repo_identity !== teamId) return { kind: 'team_changed', row: existing }; + if (existing.content_hash === hash) return { kind: 'noop' }; + return { kind: 'update', row: existing }; + } + const operationId = randomUUID(); + const claimed = await claimPendingLink(c, { + trackId: track.id, + adapterType: ADAPTER, + repoIdentity: teamId, + operationId, + }); + if (claimed) return { kind: 'create', operationId }; + return { kind: 'raced' }; + }); + + const ctx: SyncCtx = { + pool, + client, + teamId, + trackId: track.id, + payload, + hash, + syncedAt, + trackStatus, + stateConfig, + }; + + switch (decision.kind) { + case 'noop': + return okSynced(pool, track.id, syncedAt); + case 'team_changed': + return { + ok: false, + error: `LINEAR_UNKNOWN_ERROR: this track is linked to an issue in Linear team ${decision.row.repo_identity}, but the adapter is now configured for team ${teamId}; refusing to update a different team`, + }; + case 'raced': + return { + ok: false, + error: 'LINEAR_UNKNOWN_ERROR: another sync for this track is in progress; retry shortly', + }; + case 'update': + return updateExisting(ctx, decision.row); + case 'create': + return createFresh(ctx, decision.operationId); + case 'pending': + return recoverPending(ctx, decision.row); + } +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +interface SyncCtx { + pool: Pool; + client: LinearClient; + teamId: string; + trackId: string; + payload: LinearPayload; + hash: string; + syncedAt: Date; + trackStatus: string; + stateConfig: StateConfig; +} + +type Decision = + | { kind: 'noop' } + | { kind: 'team_changed'; row: TrackExternalLinkRow } + | { kind: 'raced' } + | { kind: 'update'; row: TrackExternalLinkRow } + | { kind: 'create'; operationId: string } + | { kind: 'pending'; row: TrackExternalLinkRow }; + +async function okSynced(pool: Pool, trackId: string, syncedAt: Date): Promise { + await withTransaction(pool, (c) => touchLinearSyncWatermark(c, trackId, syncedAt)); + return { ok: true }; +} + +/** Resolves the target stateId for a sync, fetching the team's workflow + * states (and, only for a non-done reopen with an override, the issue's + * current state) as needed. Returns { ok:false } with a LINEAR_* error for a + * transport failure or an invalid state configuration. `stateId` undefined + * means "leave the workflow state as-is / let Linear default it". */ +async function resolveState( + ctx: SyncCtx, + mode: 'create' | 'update', + issueId?: string, +): Promise<{ ok: true; stateId?: string } | { ok: false; error: string }> { + const needState = ctx.trackStatus === 'done' || ctx.stateConfig.openStateId !== undefined; + if (!needState) return { ok: true }; + + const statesRes = await ctx.client.getWorkflowStates(ctx.teamId); + if (!statesRes.ok) return { ok: false, error: statesRes.error }; + + let currentStateType: string | null | undefined; + if ( + mode === 'update' && + ctx.trackStatus !== 'done' && + ctx.stateConfig.openStateId !== undefined && + issueId + ) { + const st = await ctx.client.getIssueStateType(issueId); + if (!st.ok) return { ok: false, error: st.error }; + currentStateType = st.value; + } + + const resolved = resolveLinearStateId({ + states: statesRes.value, + doneStateId: ctx.stateConfig.doneStateId, + openStateId: ctx.stateConfig.openStateId, + trackStatus: ctx.trackStatus, + mode, + currentStateType, + }); + if ('error' in resolved) return { ok: false, error: resolved.error }; + return { ok: true, stateId: resolved.stateId }; +} + +async function createFresh(ctx: SyncCtx, operationId: string): Promise { + const state = await resolveState(ctx, 'create'); + if (!state.ok) { + // A definitive config/transport failure before the mutation -> no issue + // was created; clear the pending claim so the next attempt starts clean. + await withTransaction(ctx.pool, (c) => + clearPendingLink(c, { trackId: ctx.trackId, adapterType: ADAPTER, operationId }), + ); + return { ok: false, error: state.error }; + } + + const created = await ctx.client.createIssue(ctx.teamId, ctx.payload, state.stateId); + if (!created.ok) { + if (!created.ambiguous) { + await withTransaction(ctx.pool, (c) => + clearPendingLink(c, { trackId: ctx.trackId, adapterType: ADAPTER, operationId }), + ); + } + return { ok: false, error: created.error }; + } + const ref = created.value; + const finalized = await withTransaction(ctx.pool, (c) => + finalizeLinked(c, { + trackId: ctx.trackId, + adapterType: ADAPTER, + operationId, + externalId: ref.id, + externalUrl: ref.url, + contentHash: ctx.hash, + }), + ); + if (!finalized) { + return { + ok: false, + error: + 'LINEAR_UNKNOWN_ERROR: issue created but its link could not be finalized; reconcile manually', + }; + } + return okSynced(ctx.pool, ctx.trackId, ctx.syncedAt); +} + +async function updateExisting(ctx: SyncCtx, row: TrackExternalLinkRow): Promise { + const issueId = row.external_id; + if (!issueId) { + return { + ok: false, + error: 'LINEAR_UNKNOWN_ERROR: linked row is missing its issue id; reconcile manually', + }; + } + const state = await resolveState(ctx, 'update', issueId); + if (!state.ok) return { ok: false, error: state.error }; + + // As with GitHub, we do NOT hold a DB lock across this network call: the + // decision-transaction row lock is already released. issueUpdate is + // idempotent, there is no duplicate/lost issue, and a momentarily-stale + // content_hash self-corrects on the next sync. + const res = await ctx.client.updateIssue(issueId, ctx.payload, state.stateId); + if (!res.ok) return { ok: false, error: res.error }; + await withTransaction(ctx.pool, (c) => + updateLinkedContentHash(c, { + trackId: ctx.trackId, + adapterType: ADAPTER, + contentHash: ctx.hash, + }), + ); + return okSynced(ctx.pool, ctx.trackId, ctx.syncedAt); +} + +async function recoverPending(ctx: SyncCtx, row: TrackExternalLinkRow): Promise { + if (row.repo_identity !== ctx.teamId) { + return { + ok: false, + error: `LINEAR_UNKNOWN_ERROR: a sync is pending for this track against Linear team ${row.repo_identity}, but the adapter is now configured for team ${ctx.teamId}; resolve the team change before retrying`, + }; + } + const found = await ctx.client.findIssueByMarker(ctx.teamId, trackMarker(ctx.trackId)); + if (!found.ok) return { ok: false, error: found.error }; + + if (found.value) { + // The earlier create DID happen. Reconcile the found issue to current + // content (and state), then adopt it as linked. + const ref = found.value; + const state = await resolveState(ctx, 'update', ref.id); + if (!state.ok) return { ok: false, error: state.error }; + const res = await ctx.client.updateIssue(ref.id, ctx.payload, state.stateId); + if (!res.ok) return { ok: false, error: res.error }; + const finalized = await withTransaction(ctx.pool, (c) => + finalizeLinked(c, { + trackId: ctx.trackId, + adapterType: ADAPTER, + operationId: row.operation_id, + externalId: res.value.id, + externalUrl: res.value.url, + contentHash: ctx.hash, + }), + ); + if (!finalized) { + return { + ok: false, + error: 'LINEAR_UNKNOWN_ERROR: adopted issue could not be finalized; reconcile manually', + }; + } + return okSynced(ctx.pool, ctx.trackId, ctx.syncedAt); + } + + // Marker not found. A pending link only survives an AMBIGUOUS create, so + // the outcome is unknown by construction; per the T5.2 design decision + // (duplicate prevention takes priority over automatic retry when the + // outcome cannot be established), we do NOT auto-recreate. + return { + ok: false, + error: + 'LINEAR_UNKNOWN_ERROR: a previous sync did not complete and no matching issue could be found; its outcome cannot be safely confirmed, so this track is not auto-recreated (that would risk a duplicate). Clear the pending link (or use a relink tool) to resync.', + }; } export function registerSyncToLinearTool( @@ -25,7 +404,7 @@ export function registerSyncToLinearTool( { title: 'Sync to Linear', description: - 'Pushes a track to a linked Linear Issue. Requires a configured Linear adapter; external sync is not available in this build.', + 'Creates or updates a Linear Issue for a track (idempotent). Requires a configured Linear adapter with a team; operational Linear failures return { ok: false, error } rather than throwing.', inputSchema: syncToLinearInputSchema, }, async (rawArgs: unknown) => { diff --git a/src/schemas/tools.ts b/src/schemas/tools.ts index 5f7a9b5..71ed44d 100644 --- a/src/schemas/tools.ts +++ b/src/schemas/tools.ts @@ -23,6 +23,14 @@ const linearAdapterInput = z .object({ api_key: z.string().min(1).max(512), team_id: z.string().min(1).max(200), + // Optional workflow-state overrides (T5.3). When omitted, sync auto-picks + // the team's lowest-position 'completed' state for a done track and never + // moves a non-done track's issue backward. When set, they pin exact Linear + // WorkflowState UUIDs: done_state_id must be a 'completed'-type state; + // open_state_id is the state a reopened (no-longer-done) track's issue is + // moved to and must not be a 'canceled'-type state. + done_state_id: z.string().min(1).max(200).optional(), + open_state_id: z.string().min(1).max(200).optional(), }) .strict(); diff --git a/tests/integration/sync-to-linear.test.ts b/tests/integration/sync-to-linear.test.ts index 9d012d9..0963870 100644 --- a/tests/integration/sync-to-linear.test.ts +++ b/tests/integration/sync-to-linear.test.ts @@ -1,97 +1,432 @@ +import { randomUUID } from 'node:crypto'; import { afterAll, beforeEach, describe, expect, it } from 'vitest'; -import { syncToLinearService } from '../../src/mcp/tools/sync-to-linear.js'; +import { syncToLinearService, type SyncToLinearDeps } from '../../src/mcp/tools/sync-to-linear.js'; +import { getTrackService } from '../../src/mcp/tools/get-track.js'; import { registerProjectService } from '../../src/mcp/tools/register-project.js'; import { createTrackService } from '../../src/mcp/tools/create-track.js'; +import { createItemService } from '../../src/mcp/tools/create-item.js'; +import { updateItemStatusService } from '../../src/mcp/tools/update-item-status.js'; import { upsertAdapter } from '../../src/db/queries/adapters.js'; +import { encryptCredential } from '../../src/crypto/credential-cipher.js'; +import type { + LinearClient, + LinearIssuePayload, + LinearIssueRef, + LinearResult, + LinearWorkflowState, +} from '../../src/linear/linear-client.js'; import { closeTestPool, getTestConfig, getTestPool, truncateAll, UNKNOWN_UUID } from './helpers.js'; const pool = getTestPool(); const config = getTestConfig(); +const TEAM = 'team-1'; -async function makeProjectAndTrack(): Promise<{ project_id: string; track_id: string }> { +const DEFAULT_STATES: LinearWorkflowState[] = [ + { id: 's-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 's-doing', name: 'Doing', type: 'started', position: 2 }, + { id: 's-done', name: 'Done', type: 'completed', position: 3 }, + { id: 's-released', name: 'Released', type: 'completed', position: 5 }, + { id: 's-cancel', name: 'Cancelled', type: 'canceled', position: 6 }, +]; + +// ---- fake Linear client ------------------------------------------------ +interface FakeState { + createCalls: Array<{ teamId: string; stateId?: string; payload: LinearIssuePayload }>; + updateCalls: Array<{ issueId: string; stateId?: string }>; + findCalls: Array<{ teamId: string; marker: string }>; + statesCalls: number; + nextId: number; +} +interface FakeOverrides { + createIssue?: (s: FakeState) => LinearResult; + updateIssue?: (s: FakeState, issueId: string) => LinearResult; + findIssueByMarker?: (s: FakeState) => LinearResult; + getWorkflowStates?: () => LinearResult; + getIssueStateType?: () => LinearResult; +} +function makeFake(overrides: FakeOverrides = {}) { + const state: FakeState = { + createCalls: [], + updateCalls: [], + findCalls: [], + statesCalls: 0, + nextId: 1, + }; + const client: LinearClient = { + createIssue(teamId, payload, stateId) { + state.createCalls.push({ teamId, stateId, payload }); + const r: LinearResult = overrides.createIssue + ? overrides.createIssue(state) + : { + ok: true, + value: { + id: `iss-${state.nextId++}`, + identifier: `ENG-${state.nextId - 1}`, + url: `https://linear.app/x/issue/ENG-${state.nextId - 1}`, + }, + }; + return Promise.resolve(r); + }, + updateIssue(issueId, _payload, stateId) { + state.updateCalls.push({ issueId, stateId }); + const r: LinearResult = overrides.updateIssue + ? overrides.updateIssue(state, issueId) + : { + ok: true, + value: { + id: issueId, + identifier: 'ENG-1', + url: `https://linear.app/x/issue/${issueId}`, + }, + }; + return Promise.resolve(r); + }, + findIssueByMarker(teamId, marker) { + state.findCalls.push({ teamId, marker }); + const r: LinearResult = overrides.findIssueByMarker + ? overrides.findIssueByMarker(state) + : { ok: true, value: null }; + return Promise.resolve(r); + }, + getWorkflowStates() { + state.statesCalls += 1; + const r: LinearResult = overrides.getWorkflowStates + ? overrides.getWorkflowStates() + : { ok: true, value: DEFAULT_STATES }; + return Promise.resolve(r); + }, + getIssueStateType() { + const r: LinearResult = overrides.getIssueStateType + ? overrides.getIssueStateType() + : { ok: true, value: 'completed' }; + return Promise.resolve(r); + }, + }; + const deps: SyncToLinearDeps = { linearClientFactory: () => client }; + return { state, deps }; +} + +async function makeProjectTrack( + opts: { + withLinear?: boolean; + teamId?: string | null; + doneStateId?: string; + openStateId?: string; + } = {}, +) { + const withLinear = opts.withLinear ?? true; + const adapters = withLinear + ? { + linear: { + api_key: 'lin_test_secret', + team_id: opts.teamId ?? TEAM, + done_state_id: opts.doneStateId, + open_state_id: opts.openStateId, + }, + } + : undefined; const { project_id } = await registerProjectService(pool, config, { name: 'P', source_type: 'local', - source_ref: `/tmp/${crypto.randomUUID()}`, - adapters: undefined, + source_ref: `/tmp/${randomUUID()}`, + adapters, }); const { track_id } = await createTrackService(pool, config, { project_id, - title: 'T', + title: 'Auth overhaul', depends_on: [], source_doc_ref: undefined, }); return { project_id, track_id }; } +async function markTrackDone(project_id: string, track_id: string) { + const item = await createItemService(pool, config, { + project_id, + track_id, + title: 'only item', + sequence_position: undefined, + depends_on: [], + }); + await updateItemStatusService(pool, config, { + project_id, + item_id: item.item_id, + status: 'done', + }); +} + +async function linkRow(trackId: string) { + const r = await pool.query( + `SELECT * FROM track_external_links WHERE track_id = $1 AND adapter_type = 'linear'`, + [trackId], + ); + return r.rows[0] as + { sync_state: string; external_id: string | null; external_url: string | null } | undefined; +} + beforeEach(async () => { await truncateAll(); }); - afterAll(async () => { await closeTestPool(); }); -describe('kt_sync_to_linear (T2.14 stub)', () => { - it('negative: CONFLICT (linear-specific) when no linear adapter is configured, even with a github adapter present', async () => { - const { project_id, track_id } = await makeProjectAndTrack(); +describe('kt_sync_to_linear — preconditions (PRD §4.14)', () => { + it('CONFLICT when no linear adapter is configured', async () => { + const { project_id, track_id } = await makeProjectTrack({ withLinear: false }); + await expect(syncToLinearService(pool, config, { project_id, track_id })).rejects.toMatchObject( + { + code: 'CONFLICT', + message: 'linear adapter not configured', + details: { adapter: 'linear' }, + }, + ); + }); + + it('CONFLICT when the linear adapter has no team configured', async () => { + const { project_id, track_id } = await makeProjectTrack({ withLinear: false }); await upsertAdapter(pool, { projectId: project_id, - type: 'github', - encryptedCredential: Buffer.from('github-secret'), + type: 'linear', + encryptedCredential: encryptCredential('lin_test_secret', config.encryptionKey), config: {}, }); await expect(syncToLinearService(pool, config, { project_id, track_id })).rejects.toMatchObject( { code: 'CONFLICT', - message: 'linear adapter not configured', - details: { adapter: 'linear' }, + message: 'linear adapter has no team configured', }, ); }); - it('negative: 404 when the project does not exist', async () => { - const { track_id } = await makeProjectAndTrack(); + it('404 when the project does not exist', async () => { + const { track_id } = await makeProjectTrack(); await expect( syncToLinearService(pool, config, { project_id: UNKNOWN_UUID, track_id }), ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); - it('negative: 404 when the track does not exist in the project', async () => { - const { project_id } = await makeProjectAndTrack(); + it('404 when the track belongs to another project (no cross-project leak)', async () => { + const other = await makeProjectTrack(); + const { project_id } = await makeProjectTrack(); await expect( - syncToLinearService(pool, config, { project_id, track_id: UNKNOWN_UUID }), + syncToLinearService(pool, config, { project_id, track_id: other.track_id }), ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); +}); - it('negative: 404 when the track belongs to a different project (no cross-project leak)', async () => { - const other = await makeProjectAndTrack(); - const { project_id } = await makeProjectAndTrack(); - await expect( - syncToLinearService(pool, config, { project_id, track_id: other.track_id }), - ).rejects.toMatchObject({ code: 'NOT_FOUND' }); +describe('kt_sync_to_linear — create / update / no-op', () => { + it('creates an issue, links it, and surfaces the URL on kt_get_track', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const { state, deps } = makeFake(); + + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res).toEqual({ ok: true }); + expect(state.createCalls).toHaveLength(1); + // non-done track, no override -> no workflow-state lookup, no stateId set + expect(state.statesCalls).toBe(0); + expect(state.createCalls[0]!.stateId).toBeUndefined(); + + const row = await linkRow(track_id); + expect(row?.sync_state).toBe('linked'); + expect(row?.external_id).toBe('iss-1'); + + const track = await getTrackService(pool, config, { project_id, track_id }); + expect(track.track.linear_issue_url).toBe('https://linear.app/x/issue/ENG-1'); + + const wm = await pool.query(`SELECT last_linear_sync_at FROM tracks WHERE id = $1`, [track_id]); + expect(wm.rows[0].last_linear_sync_at).not.toBeNull(); }); - // Reachable through the public kt_register_project path (it provisions - // the adapter from inline credentials), not just fixtures/manual SQL. - it('edge: INTERNAL_ERROR "not available in this build" when a linear adapter is configured via kt_register_project', async () => { - const { project_id } = await registerProjectService(pool, config, { - name: 'P', - source_type: 'local', - source_ref: `/tmp/${crypto.randomUUID()}`, - adapters: { linear: { api_key: 'lin_test_secret', team_id: 'team_123' } }, - }); - const { track_id } = await createTrackService(pool, config, { + it('is a no-op on an unchanged re-sync (no second mutation)', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const { state, deps } = makeFake(); + await syncToLinearService(pool, config, { project_id, track_id }, deps); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res).toEqual({ ok: true }); + expect(state.createCalls).toHaveLength(1); + expect(state.updateCalls).toHaveLength(0); + }); + + it('updates the existing issue when the track content changes', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const { state, deps } = makeFake(); + await syncToLinearService(pool, config, { project_id, track_id }, deps); + await createItemService(pool, config, { project_id, - title: 'T', + track_id, + title: 'New item', + sequence_position: undefined, depends_on: [], - source_doc_ref: undefined, }); - await expect(syncToLinearService(pool, config, { project_id, track_id })).rejects.toMatchObject( - { - code: 'INTERNAL_ERROR', - message: 'linear sync is not available in this build', - }, + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res).toEqual({ ok: true }); + expect(state.createCalls).toHaveLength(1); + expect(state.updateCalls).toHaveLength(1); + expect(state.updateCalls[0]!.issueId).toBe('iss-1'); + }); +}); + +describe('kt_sync_to_linear — workflow-state resolution', () => { + it('sets the lowest-position completed state on create for a done track (auto)', async () => { + const { project_id, track_id } = await makeProjectTrack(); + await markTrackDone(project_id, track_id); + const { state, deps } = makeFake(); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res).toEqual({ ok: true }); + expect(state.statesCalls).toBeGreaterThanOrEqual(1); + // 's-done' (position 3) beats 's-released' (position 5) + expect(state.createCalls[0]!.stateId).toBe('s-done'); + }); + + it('honors a configured done_state_id override', async () => { + const { project_id, track_id } = await makeProjectTrack({ doneStateId: 's-released' }); + await markTrackDone(project_id, track_id); + const { state, deps } = makeFake(); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res).toEqual({ ok: true }); + expect(state.createCalls[0]!.stateId).toBe('s-released'); + }); + + it('fails LINEAR_STATE_CONFIG when done_state_id is not a completed state (and clears pending)', async () => { + const { project_id, track_id } = await makeProjectTrack({ doneStateId: 's-doing' }); + await markTrackDone(project_id, track_id); + const { state, deps } = makeFake(); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/^LINEAR_STATE_CONFIG/); + expect(state.createCalls).toHaveLength(0); + expect(await linkRow(track_id)).toBeUndefined(); // pending cleared + }); + + it('fails LINEAR_STATE_CONFIG when the team has no completed state', async () => { + const { project_id, track_id } = await makeProjectTrack(); + await markTrackDone(project_id, track_id); + const { deps } = makeFake({ + getWorkflowStates: () => ({ + ok: true, + value: DEFAULT_STATES.filter((s) => s.type !== 'completed'), + }), + }); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/no 'completed' workflow state/); + }); +}); + +describe('kt_sync_to_linear — operational failures ({ok:false}, never throw)', () => { + it('passes through a definitive failure and clears the pending claim', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const { deps } = makeFake({ + createIssue: () => ({ + ok: false, + error: 'LINEAR_UNKNOWN_ERROR: bad input', + ambiguous: false, + }), + }); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/^LINEAR_UNKNOWN_ERROR/); + expect(await linkRow(track_id)).toBeUndefined(); + }); + + it('keeps the pending claim after an ambiguous (timeout) failure', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const { deps } = makeFake({ + createIssue: () => ({ ok: false, error: 'LINEAR_TIMEOUT: too slow', ambiguous: true }), + }); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/^LINEAR_TIMEOUT/); + expect((await linkRow(track_id))?.sync_state).toBe('pending'); + }); + + it('refuses to update when the adapter team changed under an existing link', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const first = makeFake(); + await syncToLinearService(pool, config, { project_id, track_id }, first.deps); + await upsertAdapter(pool, { + projectId: project_id, + type: 'linear', + encryptedCredential: encryptCredential('lin_test_secret', config.encryptionKey), + config: { team_id: 'team-OTHER' }, + }); + const { state, deps } = makeFake(); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/refusing to update a different team/); + expect(state.updateCalls).toHaveLength(0); + expect(state.createCalls).toHaveLength(0); + }); +}); + +describe('kt_sync_to_linear — durable creation-intent / recovery', () => { + it('concurrent syncs create exactly one issue (no duplicate)', async () => { + const { project_id, track_id } = await makeProjectTrack(); + const { state, deps } = makeFake(); + const [a, b] = await Promise.all([ + syncToLinearService(pool, config, { project_id, track_id }, deps), + syncToLinearService(pool, config, { project_id, track_id }, deps), + ]); + expect(state.createCalls).toHaveLength(1); + const rows = await pool.query( + `SELECT count(*)::int AS n FROM track_external_links WHERE track_id = $1 AND adapter_type='linear'`, + [track_id], + ); + expect(rows.rows[0].n).toBe(1); + expect([a.ok, b.ok]).toContain(true); + }); + + it('recovers a crashed pending link by adopting the issue the marker finds', async () => { + const { project_id, track_id } = await makeProjectTrack(); + await pool.query( + `INSERT INTO track_external_links (track_id, adapter_type, sync_state, repo_identity, operation_id) + VALUES ($1, 'linear', 'pending', $2, $3)`, + [track_id, TEAM, randomUUID()], + ); + const { state, deps } = makeFake({ + findIssueByMarker: () => ({ + ok: true, + value: { id: 'iss-42', identifier: 'ENG-42', url: 'https://linear.app/x/issue/ENG-42' }, + }), + }); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res).toEqual({ ok: true }); + expect(state.findCalls).toHaveLength(1); + expect(state.createCalls).toHaveLength(0); // adopted, not recreated + const row = await linkRow(track_id); + expect(row?.sync_state).toBe('linked'); + expect(row?.external_id).toBe('iss-42'); + }); + + it('never recreates on a marker miss — surfaces the pending link (no duplicate)', async () => { + const { project_id, track_id } = await makeProjectTrack(); + await pool.query( + `INSERT INTO track_external_links (track_id, adapter_type, sync_state, repo_identity, operation_id, created_at) + VALUES ($1, 'linear', 'pending', $2, $3, now() - interval '2 hours')`, + [track_id, TEAM, randomUUID()], + ); + const { state, deps } = makeFake(); // findIssueByMarker default -> null + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/not auto-recreated/); + expect(state.findCalls).toHaveLength(1); + expect(state.createCalls).toHaveLength(0); + expect((await linkRow(track_id))?.sync_state).toBe('pending'); + }); + + it('refuses recovery when the adapter team changed while a sync was pending', async () => { + const { project_id, track_id } = await makeProjectTrack(); + await pool.query( + `INSERT INTO track_external_links (track_id, adapter_type, sync_state, repo_identity, operation_id, created_at) + VALUES ($1, 'linear', 'pending', 'team-OLD', $2, now() - interval '5 minutes')`, + [track_id, randomUUID()], ); + const { state, deps } = makeFake(); + const res = await syncToLinearService(pool, config, { project_id, track_id }, deps); + expect(res.ok).toBe(false); + expect(String(res.error)).toMatch(/resolve the team change/); + expect(state.findCalls).toHaveLength(0); // never searched the wrong team + expect((await linkRow(track_id))?.sync_state).toBe('pending'); }); }); diff --git a/tests/unit/linear-client.test.ts b/tests/unit/linear-client.test.ts new file mode 100644 index 0000000..ba29e5b --- /dev/null +++ b/tests/unit/linear-client.test.ts @@ -0,0 +1,303 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createFetchLinearClient } from '../../src/linear/linear-client.js'; + +const KEY = 'lin_api_super_secret_value'; +const OPTS = { timeoutMs: 8000, userAgent: 'knotrack-mcp-server' }; +const TEAM = 'team-123'; + +function client() { + return createFetchLinearClient(KEY, OPTS); +} + +interface FakeResponse { + ok: boolean; + status: number; + headers: Headers; + text: () => Promise; +} +function resp(status: number, body: string, headers: Record = {}): FakeResponse { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + text: () => Promise.resolve(body), + }; +} +interface FetchInit { + method: string; + headers: Record; + body: string; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('createFetchLinearClient — request shape', () => { + it('createIssue sends issueCreate with the RAW Authorization header (no Bearer)', async () => { + const fetchMock = vi.fn((_url: string, _init: FetchInit) => + Promise.resolve( + resp( + 200, + JSON.stringify({ + data: { + issueCreate: { success: true, issue: { id: 'i1', identifier: 'ENG-1', url: 'u1' } }, + }, + }), + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }, 's-done'); + expect(res).toEqual({ ok: true, value: { id: 'i1', identifier: 'ENG-1', url: 'u1' } }); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.linear.app/graphql'); + expect(init.method).toBe('POST'); + // RAW key, not "Bearer " + expect(init.headers.Authorization).toBe(KEY); + expect(init.headers.Authorization).not.toContain('Bearer'); + const parsed = JSON.parse(init.body); + expect(parsed.query).toContain('issueCreate'); + expect(parsed.variables.input).toEqual({ + teamId: TEAM, + title: 'T', + description: 'D', + stateId: 's-done', + }); + }); + + it('createIssue omits stateId when none is given', async () => { + const fetchMock = vi.fn((_url: string, _init: FetchInit) => + Promise.resolve( + resp( + 200, + JSON.stringify({ + data: { + issueCreate: { success: true, issue: { id: 'i1', identifier: 'E-1', url: 'u' } }, + }, + }), + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + await client().createIssue(TEAM, { title: 'T', description: 'D' }); + const parsed = JSON.parse(fetchMock.mock.calls[0]![1].body); + expect(parsed.variables.input).not.toHaveProperty('stateId'); + }); + + it('updateIssue sends issueUpdate at the issue id with stateId', async () => { + const fetchMock = vi.fn((_url: string, _init: FetchInit) => + Promise.resolve( + resp( + 200, + JSON.stringify({ + data: { + issueUpdate: { success: true, issue: { id: 'i1', identifier: 'E-1', url: 'u' } }, + }, + }), + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + await client().updateIssue('i1', { title: 'T', description: 'D' }, 's-done'); + const parsed = JSON.parse(fetchMock.mock.calls[0]![1].body); + expect(parsed.query).toContain('issueUpdate'); + expect(parsed.variables).toEqual({ + id: 'i1', + input: { title: 'T', description: 'D', stateId: 's-done' }, + }); + }); +}); + +describe('createFetchLinearClient — error mapping', () => { + it('maps a GraphQL 200-with-errors to LINEAR_UNKNOWN_ERROR, not ambiguous', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve(resp(200, JSON.stringify({ errors: [{ message: 'bad input' }] }))), + ), + ); + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toMatch(/^LINEAR_UNKNOWN_ERROR/); + expect(res.ambiguous).toBe(false); + } + }); + + it('maps a GraphQL rate-limit error to LINEAR_RATE_LIMITED', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + resp( + 200, + JSON.stringify({ + errors: [{ message: 'ratelimited', extensions: { code: 'RATELIMITED' } }], + }), + ), + ), + ), + ); + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/^LINEAR_RATE_LIMITED/); + }); + + it('maps HTTP 401 to LINEAR_AUTH_FAILED (not ambiguous)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(resp(401, 'unauthorized'))), + ); + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toMatch(/^LINEAR_AUTH_FAILED/); + expect(res.ambiguous).toBe(false); + } + }); + + it('maps HTTP 429 to LINEAR_RATE_LIMITED', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(resp(429, 'slow down'))), + ); + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/^LINEAR_RATE_LIMITED/); + }); + + it('treats a 5xx on create as AMBIGUOUS (the write may have landed)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(resp(502, 'bad gateway'))), + ); + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.ambiguous).toBe(true); + }); + + it('maps an aborted (timed-out) request to LINEAR_TIMEOUT, ambiguous', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => { + const e = new Error('aborted'); + e.name = 'AbortError'; + return Promise.reject(e); + }), + ); + const res = await client().createIssue(TEAM, { title: 'T', description: 'D' }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toMatch(/^LINEAR_TIMEOUT/); + expect(res.ambiguous).toBe(true); + } + }); +}); + +describe('createFetchLinearClient — reads', () => { + it('findIssueByMarker returns only a node whose description contains the marker', async () => { + const marker = ''; + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + resp( + 200, + JSON.stringify({ + data: { + issues: { + nodes: [ + { id: 'x', identifier: 'E-1', url: 'ux', description: 'unrelated' }, + { id: 'y', identifier: 'E-2', url: 'uy', description: `has ${marker}` }, + ], + pageInfo: { hasNextPage: false }, + }, + }, + }), + ), + ), + ), + ); + const res = await client().findIssueByMarker(TEAM, marker); + expect(res).toEqual({ ok: true, value: { id: 'y', identifier: 'E-2', url: 'uy' } }); + }); + + it('findIssueByMarker reports an unconfirmed miss when more pages exist', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + resp( + 200, + JSON.stringify({ data: { issues: { nodes: [], pageInfo: { hasNextPage: true } } } }), + ), + ), + ), + ); + const res = await client().findIssueByMarker(TEAM, 'm'); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toMatch(/could not confirm/); + expect(res.ambiguous).toBe(true); + } + }); + + it('getWorkflowStates parses the team states', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + resp( + 200, + JSON.stringify({ + data: { + team: { + states: { + nodes: [ + { id: 's1', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 's2', name: 'Done', type: 'completed', position: 3 }, + ], + }, + }, + }, + }), + ), + ), + ), + ); + const res = await client().getWorkflowStates(TEAM); + expect(res).toEqual({ + ok: true, + value: [ + { id: 's1', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 's2', name: 'Done', type: 'completed', position: 3 }, + ], + }); + }); + + it('getWorkflowStates returns LINEAR_NOT_FOUND for an unknown team', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(resp(200, JSON.stringify({ data: { team: null } })))), + ); + const res = await client().getWorkflowStates(TEAM); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/^LINEAR_NOT_FOUND/); + }); + + it('getIssueStateType returns the state type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + resp(200, JSON.stringify({ data: { issue: { state: { type: 'completed' } } } })), + ), + ), + ); + const res = await client().getIssueStateType('i1'); + expect(res).toEqual({ ok: true, value: 'completed' }); + }); +}); diff --git a/tests/unit/linear-payload.test.ts b/tests/unit/linear-payload.test.ts new file mode 100644 index 0000000..01a8f55 --- /dev/null +++ b/tests/unit/linear-payload.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + buildLinearPayload, + linearPayloadContentHash, + resolveLinearStateId, + stateIntentFor, + type ItemForLinear, +} from '../../src/mcp/tools/linear-payload.js'; +import type { LinearWorkflowState } from '../../src/linear/linear-client.js'; + +const TRACK_ID = '11111111-1111-1111-1111-111111111111'; + +function items(...specs: Array<[string, string, number]>): ItemForLinear[] { + return specs.map(([title, status, sequence_position], i) => ({ + id: `item-${i}`, + title, + status, + sequence_position, + })); +} + +describe('buildLinearPayload', () => { + it('maps title + a checklist in sequence order and embeds the hidden marker', () => { + const payload = buildLinearPayload( + { id: TRACK_ID, title: 'Auth overhaul', status: 'on_track' }, + items(['B second', 'pending', 2], ['A first', 'done', 1]), + ); + expect(payload.title).toBe('Auth overhaul'); + expect(payload.description).toContain('- [x] A first'); + expect(payload.description).toContain('- [ ] B second'); + // ordered by sequence_position: A (1) before B (2) + expect(payload.description.indexOf('A first')).toBeLessThan( + payload.description.indexOf('B second'), + ); + expect(payload.description).toContain(``); + }); + + it('renders a placeholder when the track has no items', () => { + const payload = buildLinearPayload({ id: TRACK_ID, title: 'T', status: 'on_track' }, []); + expect(payload.description).toContain('_No items yet._'); + }); + + it('keeps the title within 255 chars even when escaping delimiters expands it', () => { + const dense = ''.repeat(80); + const payload = buildLinearPayload({ id: TRACK_ID, title: dense, status: 'on_track' }, []); + expect(payload.title.length).toBeLessThanOrEqual(255); + expect(payload.title).not.toContain('`; + const payload = buildLinearPayload( + { id: TRACK_ID, title: 'T', status: 'on_track' }, + items([`sneaky ${fake}`, 'pending', 1]), + ); + // the only real marker is KnoTrack's own; the injected one is escaped + const real = ``; + expect(payload.description.split('