From 75c08424c9656c6250d89a4e425d4ff2c66e77b1 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:41:10 +0200 Subject: [PATCH 1/7] feat(linear): add opt-in --atomic import/sync via SDK commitItemMutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an atomic, crash-resumable, multi-agent-safe bulk-write path to the `linear import` and `linear sync` command/importer surfaces, mirroring the merged pm-github reference implementation. When `--atomic` is passed, the entire batch (creates + updates + closes) is committed as ONE workspace-writer-locked, crash-resumable transaction via the SDK `commitItemMutations` primitive (pm-cli >= 2026.7.20): - Stable item id keyed on the Linear issue `identifier` (e.g. ENG-123), the most stable human-meaningful external key — never the positional fetch index. `deriveAtomicItemId` hashes lowercased team + identifier through `normalizeItemId`. - Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered `BulkItemMutation` plan, so a changed id_prefix or provenance match gets a fresh journal while a reordered retry resumes the same one (`deriveAtomicTransactionId`). - Mutation plan: create+update upsert for new/deterministic-id items (the SAME plan is reused on recovery when the provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. `close` is a separate mutation preserving a reason and is independently reversible (`buildAtomicImportMutations`, `importLinearAtomic`). - Compensation: `createCompensation: "delete"` for creates; the SDK version- restores every update and close. On `AggregateError` / compensation-failed the tracker MAY be partial and the error gives the same-transaction recovery path; on normal SDK compensation no new partial state is expected; on `WorkspaceTransactionInterruptedError` the journal is resumable. - `--atomic --dry-run` shares the atomic preparation/matching path (fetches issues, reports create/update/skip counts) and NEVER commits. The non-atomic `--dry-run` stays fully offline (literal GraphQL request preview) and the non-atomic per-issue `spawnSync` path is byte-compatible (unchanged). - All-skipped non-dry atomic import throws the SAME error shape the legacy path uses for a failed batch (`CommandError`, `Imported 0 issue(s); N failed.`, exitCode GENERIC_FAILURE) BEFORE touching the SDK commit. Export `CommandError`, `EXIT_CODE`, `LinearIssue`, `syncLinearIssues` (+ a `SyncRunDependencies` test-seam) and the atomic helpers so the behavior is unit- and integration-tested. Add `test/atomic.test.ts` (10 tests) covering mid-run failure + reverse compensation, resume idempotence, reordered-retry stability, `--atomic --dry-run` no-commit counts, all-skipped-before-commit, and transaction-id sensitivity to mutation-target changes. Bump peerDependency `@unbrained/pm-cli` to `>=2026.7.20` and devDependency to `^2026.7.20` (the latest published SDK that exports `commitItemMutations`; `^2026.7.21` is not yet on npm). Co-Authored-By: pi-agent --- .agents/pm/history/pm-linear-nvt5.jsonl | 5 + .agents/pm/tasks/pm-linear-nvt5.toon | 15 + index.ts | 547 +++++++++++++++++++++- package-lock.json | 11 +- package.json | 4 +- test/atomic.test.ts | 589 ++++++++++++++++++++++++ 6 files changed, 1141 insertions(+), 30 deletions(-) create mode 100644 .agents/pm/history/pm-linear-nvt5.jsonl create mode 100644 .agents/pm/tasks/pm-linear-nvt5.toon create mode 100644 test/atomic.test.ts diff --git a/.agents/pm/history/pm-linear-nvt5.jsonl b/.agents/pm/history/pm-linear-nvt5.jsonl new file mode 100644 index 0000000..e96cbf0 --- /dev/null +++ b/.agents/pm/history/pm-linear-nvt5.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-07-21T12:39:12.520Z","author":"pi-agent","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-linear-nvt5"},{"op":"add","path":"/metadata/title","value":"Add opt-in --atomic to linear import/sync via SDK commitItemMutations"},{"op":"add","path":"/metadata/description","value":"Atomic, crash-resumable, multi-agent-safe bulk-write path for pm-linear. Mirrors the merged pm-github reference. Companion tracker: pm-cli-website-c9u0 at https://github.com/unbraind/pm-cli-companion (parent epic pm-cli-website-s4wz)."},{"op":"add","path":"/metadata/type","value":"Task"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-07-21T12:39:12.520Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-21T12:39:12.520Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"6374b99c5d7d2d9567e4bf454cb8d439e3b617483e96687073b72adcd7753cbf","message":""} +{"ts":"2026-07-21T12:39:19.418Z","author":"pi-agent","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:39:19.418Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"}],"before_hash":"6374b99c5d7d2d9567e4bf454cb8d439e3b617483e96687073b72adcd7753cbf","after_hash":"44ba9ea0fa5280f68f9fa3cfe890cfcc920eedaff60988fc8d769937e2027935"} +{"ts":"2026-07-21T12:39:19.440Z","author":"pi-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:39:19.440Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"44ba9ea0fa5280f68f9fa3cfe890cfcc920eedaff60988fc8d769937e2027935","after_hash":"67d148c21bf112fe6d0360c84c8c03449adedf4188be415474220aa54f90442e"} +{"ts":"2026-07-21T12:39:58.574Z","author":"pi-agent","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:39:58.574Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-21T12:39:58.574Z","author":"pi-agent","text":"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)."}]}],"before_hash":"67d148c21bf112fe6d0360c84c8c03449adedf4188be415474220aa54f90442e","after_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b"} +{"ts":"2026-07-21T12:40:15.159Z","author":"pi-agent","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-07-21T12:40:15.159Z","author":"pi-agent","text":"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:40:15.159Z"}],"before_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b","after_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4"} diff --git a/.agents/pm/tasks/pm-linear-nvt5.toon b/.agents/pm/tasks/pm-linear-nvt5.toon new file mode 100644 index 0000000..bd98082 --- /dev/null +++ b/.agents/pm/tasks/pm-linear-nvt5.toon @@ -0,0 +1,15 @@ +id: pm-linear-nvt5 +title: Add opt-in --atomic to linear import/sync via SDK commitItemMutations +description: "Atomic, crash-resumable, multi-agent-safe bulk-write path for pm-linear. Mirrors the merged pm-github reference. Companion tracker: pm-cli-website-c9u0 at https://github.com/unbraind/pm-cli-companion (parent epic pm-cli-website-s4wz)." +type: Task +status: in_progress +priority: 2 +tags: [] +created_at: "2026-07-21T12:39:12.520Z" +updated_at: "2026-07-21T12:40:15.159Z" +assignee: pi-agent +author: pi-agent +notes[2]{created_at,author,text}: + "2026-07-21T12:39:58.574Z",pi-agent,"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)." + "2026-07-21T12:40:15.159Z",pi-agent,"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker." +body: "" diff --git a/index.ts b/index.ts index a598b87..22587af 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,13 @@ import { spawnSync } from "node:child_process"; import https from "node:https"; +import crypto from "node:crypto"; import type { defineExtension as defineExtensionType } from "@unbrained/pm-cli/sdk"; +import type { + BulkItemMutation, + CommitItemMutationsOptions, + CommitItemMutationsResult, +} from "@unbrained/pm-cli/sdk"; const defineExtension: typeof defineExtensionType = ((extension: any) => extension) as any; @@ -12,13 +18,13 @@ const defineExtension: typeof defineExtensionType = ((extension: any) => extensi // second time and exits with a generic code. We mirror the SDK's EXIT_CODE // contract here rather than importing it: standalone-installed extensions load // only their own `dist/`, so `@unbrained/pm-cli` is not resolvable at runtime. -const EXIT_CODE = { +export const EXIT_CODE = { GENERIC_FAILURE: 1, USAGE: 2, NOT_FOUND: 3, } as const; -class CommandError extends Error { +export class CommandError extends Error { exitCode: number; constructor(message: string, exitCode: number = EXIT_CODE.GENERIC_FAILURE) { super(message); @@ -49,7 +55,7 @@ interface LinearAssignee { email: string | null; } -interface LinearIssue { +export interface LinearIssue { id: string; identifier: string; title: string; @@ -893,6 +899,23 @@ interface SyncOptions { projectMap?: ProjectMap; limit: number; dryRun?: boolean; + /** Commit the whole batch as one crash-resumable transaction (pm-cli >=2026.7.20). */ + atomic?: boolean; +} + +/** Test seams for {@link syncLinearIssues}; each defaults to the live path. */ +export interface SyncRunDependencies { + /** Inject the Linear fetch (offline tests). */ + fetchIssues?: ( + apiKey: string, + team: string, + limit: number, + filters: FetchFilters, + ) => Promise; + /** Inject the local pm item read. */ + readItems?: (pmRoot: string) => PmItem[]; + /** Inject the atomic commit (offline/dry-run tests). */ + commitAtomic?: typeof importLinearAtomic; } interface SyncResult { @@ -902,6 +925,13 @@ interface SyncResult { skipped: number; team: string; issues: LinearIssue[]; + // Atomic-only fields. Present when the batch committed as one transaction + // (or, for dryRun, when --atomic --dry-run shared the atomic prep path). + atomic?: boolean; + dryRun?: boolean; + transactionId?: string; + recovered?: boolean; + recoveredItems?: number; } // Index existing pm items by their stored Linear id (parsed from the provenance @@ -1048,12 +1078,343 @@ function buildImportDryRunPlan( }; } -async function syncLinearIssues( +// --------------------------------------------------------------------------- +// Atomic Linear import (pm-cli >= 2026.7.20 commitItemMutations) +// --------------------------------------------------------------------------- +// +// `pm linear import/sync --atomic` commits the ENTIRE batch (creates + updates +// + closes) as ONE workspace-writer-locked, crash-resumable transaction via the +// SDK `commitItemMutations` helper. On ordinary failure every applied mutation +// is compensated in reverse order; if compensation itself is incomplete the +// tracker MAY hold partial state and the error tells the user to retry the same +// import to resume the durable journal. The non-atomic path is byte-compatible +// (unchanged); --atomic --dry-run shares the atomic preparation/matching path +// and reports create/update/skip counts WITHOUT touching the SDK commit. + +const ATOMIC_IMPORT_PREFIX = "linear-import-"; + +type CommitItemMutations = ( + options: CommitItemMutationsOptions, +) => Promise; +type NormalizeItemId = (input: string, prefix: string) => string; +type ReadSettings = (pmRoot: string) => Promise<{ id_prefix?: string }>; + +export interface AtomicImportOptions { + /** Author attributed to the atomic transaction journal (defaults to `pm-linear`). */ + atomicAuthor?: string; + /** Test seam: inject the SDK commit helper (skips SDK resolution). */ + commitItemMutations?: CommitItemMutations; + /** Test seam: inject normalizeItemId (skips SDK resolution). */ + normalizeItemId?: NormalizeItemId; + /** Test seam: inject readSettings (skips SDK resolution). */ + readSettings?: ReadSettings; +} + +/** Fully rendered desired state for one Linear issue import. */ +export interface PreparedLinearImport { + // The STABLE external key: Linear's human-facing issue identifier (e.g. + // "ENG-123"). Never the fetch order or the positional index. The deterministic + // managed item id is derived from this so a reordered retry resumes the same + // journal, and a prior interrupted attempt's provenance scan finds the item. + identifier: string; + // Linear's internal UUID. Carried for provenance/dedupe parity (the existing + // indexItemsByLinearId match keys on this), but NOT used as the managed id. + linearId: string; + title: string; + status: string; + priority: number; + description: string; // provenance marker + body: string; + tags: string[]; + deadline?: string; + assignee?: string; + match?: PmItem; +} + +function assertSdkFunction(fn: unknown, exportName: string): F { + if (typeof fn !== "function") { + throw new CommandError( + `--atomic requires @unbrained/pm-cli>=2026.7.20 with the commitItemMutations SDK primitive, but the installed SDK does not export ${exportName} as a function. Upgrade @unbrained/pm-cli to >=2026.7.20.`, + EXIT_CODE.USAGE, + ); + } + return fn as F; +} + +async function loadAtomicSdk( + importSdk: () => Promise> = + () => import("@unbrained/pm-cli/sdk"), +): Promise> { + try { + return await importSdk(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new CommandError( + `--atomic requires @unbrained/pm-cli>=2026.7.20, but the SDK could not be imported: ${msg}. Install or upgrade @unbrained/pm-cli.`, + EXIT_CODE.USAGE, + ); + } +} + +async function resolveAtomicSdkFunctions(opts: AtomicImportOptions): Promise<{ + commitItemMutations: CommitItemMutations; + normalizeItemId: NormalizeItemId; + readSettings: ReadSettings; +}> { + const needsSdk = + !opts.commitItemMutations || !opts.normalizeItemId || !opts.readSettings; + const mod = needsSdk ? await loadAtomicSdk() : undefined; + return { + commitItemMutations: opts.commitItemMutations ?? + assertSdkFunction( + mod?.commitItemMutations, + "commitItemMutations", + ), + normalizeItemId: opts.normalizeItemId ?? + assertSdkFunction(mod?.normalizeItemId, "normalizeItemId"), + readSettings: opts.readSettings ?? + assertSdkFunction(mod?.readSettings, "readSettings"), + }; +} + +/** + * Derive an order-independent transaction id from the desired import state and + * the exact ordered mutation plan. Content or target changes produce a fresh + * transaction; a reordered retry of the same plan resumes the durable journal. + * Canonical entries are sorted by the STABLE external key (the Linear issue + * identifier), never by fetch order. + */ +export function deriveAtomicTransactionId( + team: string, + entries: readonly PreparedLinearImport[], + mutations: readonly BulkItemMutation[], +): string { + const canonical = [...entries] + .sort((a, b) => (a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0)) + .map((entry) => ({ + identifier: entry.identifier, + linearId: entry.linearId, + title: entry.title, + status: entry.status, + priority: entry.priority, + description: entry.description, + body: entry.body, + tags: [...entry.tags].sort(), + deadline: entry.deadline ?? null, + assignee: entry.assignee ?? null, + })); + const digest = crypto + .createHash("sha256") + .update(team.toLowerCase()) + .update("\x1f") + .update(JSON.stringify(canonical)) + .update("\x1f") + // Recovery requires the exact ordered step plan. Include targets and + // options so a changed id_prefix or provenance match gets a fresh journal + // instead of colliding with an incompatible prior attempt. + .update(JSON.stringify(mutations)) + .digest("hex") + .slice(0, 16); + return `${ATOMIC_IMPORT_PREFIX}${digest}`; +} + +/** + * Stable create id keyed by the STABLE external identifier (Linear issue + * identifier like "ENG-123"), never by fetch order. The Linear issue + * identifier is the most stable, human-meaningful external key and is what a + * reordered retry reproduces exactly. + */ +export function deriveAtomicItemId( + team: string, + identifier: string, + idPrefix: string, + normalizeItemId: (input: string, prefix: string) => string, +): string { + const teamToken = crypto + .createHash("sha256") + .update(team.toLowerCase()) + .digest("hex") + .slice(0, 12); + return normalizeItemId(`linear-${teamToken}-${identifier}`, idPrefix); +} + +/** Map one rendered import entry to its reversible SDK mutation sequence. */ +export function buildAtomicImportMutations( + team: string, + entry: PreparedLinearImport, + idPrefix: string, + normalizeItemId: (input: string, prefix: string) => string, +): { itemId: string; mutations: BulkItemMutation[] } { + const sharedOptions: Record = { + title: entry.title, + status: entry.status === "closed" ? "open" : entry.status, + priority: entry.priority, + description: entry.description, + body: entry.body, + // Sort before joining so the mutations slice of the transaction-id hash is + // independent of Linear label-node order; a reordered retry resumes the same + // journal instead of starting a fresh one (the canonical-entries side in + // deriveAtomicTransactionId already sorts tags). + tags: [...entry.tags].sort().join(","), + ...(entry.deadline ? { deadline: entry.deadline } : {}), + ...(entry.assignee ? { assignee: entry.assignee } : {}), + }; + + const managedItemId = deriveAtomicItemId( + team, + entry.identifier, + idPrefix, + normalizeItemId, + ); + + // A missing match and a match at our deterministic external-key id use the + // SAME create+update upsert plan. This is essential for crash recovery: if a + // prior attempt stopped after create, the next provenance scan sees that + // item, but commitItemMutations must still receive the original plan. The + // create step treats an existing stable id as already applied; update then + // makes later content-bearing transactions refresh the item normally. The + // close transition is a separate mutation so its reason is preserved and it + // is reversible independently of the field update. + if (!entry.match?.id || entry.match.id === managedItemId) { + const mutations: BulkItemMutation[] = [ + { op: "create", id: managedItemId, options: { ...sharedOptions } }, + { + op: "update", + id: managedItemId, + options: { + ...sharedOptions, + ...(entry.status !== "closed" ? { status: entry.status } : {}), + }, + }, + ]; + if (entry.status === "closed") { + mutations.push({ + op: "close", + id: managedItemId, + reason: `Linear issue ${entry.identifier} closed`, + }); + } + return { itemId: managedItemId, mutations }; + } + + const itemId = entry.match.id; + const updateOptions: Record = { ...sharedOptions }; + if (entry.status !== "closed") { + updateOptions.status = entry.status; + } + const mutations: BulkItemMutation[] = [ + { op: "update", id: itemId, options: updateOptions }, + ]; + if (entry.status === "closed") { + mutations.push({ + op: "close", + id: itemId, + reason: `Linear issue ${entry.identifier} closed`, + }); + } + return { itemId, mutations }; +} + +/** Commit a complete issue-import batch under one crash-resumable transaction. */ +export async function importLinearAtomic( + pmRoot: string, + team: string, + entries: readonly PreparedLinearImport[], + opts: AtomicImportOptions = {}, +): Promise<{ + transactionId: string; + recovered: boolean; + imported: number; + updated: number; + recoveredItems?: number; + itemIds: Map; +}> { + const { + commitItemMutations: commit, + normalizeItemId, + readSettings, + } = await resolveAtomicSdkFunctions(opts); + + let idPrefix = "pm-"; + try { + const settings = await readSettings(pmRoot); + if (settings?.id_prefix) idPrefix = String(settings.id_prefix); + } catch { + // Match normal import resilience: an unreadable optional setting falls + // back to the canonical prefix; the mutation still validates the tracker. + } + + const mutations: BulkItemMutation[] = []; + const itemIds = new Map(); + // The transaction journal fingerprints the ordered step plan. Canonicalize + // by the stable Linear identifier so a retry whose API page/order changed + // supplies the exact same plan as well as the same transaction id. + for (const entry of [...entries].sort((a, b) => + a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0 + )) { + const planned = buildAtomicImportMutations(team, entry, idPrefix, normalizeItemId); + itemIds.set(entry.identifier, planned.itemId); + mutations.push(...planned.mutations); + } + const transactionId = deriveAtomicTransactionId(team, entries, mutations); + + try { + const result = await commit({ + pmRoot, + transactionId, + author: opts.atomicAuthor ?? "pm-linear", + mutations, + // This option selects how CREATE steps are compensated. The SDK's + // commitItemMutations contract independently snapshots and version- + // restores every UPDATE and CLOSE step (covered by the mixed rollback + // integration test below this implementation). + createCompensation: "delete", + }); + // A recovered journal may include work applied by the interrupted process + // as well as steps resumed now. The SDK intentionally returns the durable + // final results, not a per-invocation delta, so create/update counts cannot + // be reconstructed truthfully. Report the recovered batch separately. + const recovered = Boolean(result?.recovered); + return { + transactionId, + recovered, + imported: recovered ? 0 : entries.filter((e) => !e.match?.id).length, + updated: recovered ? 0 : entries.filter((e) => Boolean(e.match?.id)).length, + ...(recovered ? { recoveredItems: entries.length } : {}), + itemIds, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (err instanceof AggregateError || /compensation failed/i.test(msg)) { + throw new CommandError( + `Atomic Linear import failed and compensation was incomplete. The tracker may contain partially applied state; retry the same import to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } + if (err instanceof Error && err.name === "WorkspaceTransactionInterruptedError") { + throw new CommandError( + `Atomic Linear import was interrupted. Its durable journal is resumable; retry the same import to continue transaction ${transactionId}. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } + throw new CommandError( + `Atomic Linear import failed after the SDK completed its normal compensation path; no new partial committed state is expected. Transaction id: ${transactionId}. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } +} + +export async function syncLinearIssues( options: SyncOptions, - pm_root: string + pm_root: string, + dependencies: SyncRunDependencies = {}, ): Promise { const apiKey = process.env["LINEAR_API_KEY"]; - if (!apiKey) { + // An injected fetchIssues seam (tests/offline) does not need a real key; only + // the live fetchAllLinearIssues path requires one. Skip the check when the + // caller supplies its own fetch so the atomic prep/dry-run path is exercisable + // offline without leaking the requirement onto the test seam. + if (!apiKey && !dependencies.fetchIssues) { throw new CommandError( "LINEAR_API_KEY environment variable is not set. " + "Get your API key at https://linear.app/settings/api", @@ -1085,7 +1446,7 @@ async function syncLinearIssues( const scope = scopeBits.length ? ` (${scopeBits.join(", ")})` : ""; console.error(`Fetching issues from Linear team: ${options.team}${scope} (limit: ${options.limit})`); - const issues = await fetchAllLinearIssues(apiKey, options.team, options.limit, filters); + const issues = await (dependencies.fetchIssues ?? fetchAllLinearIssues)(apiKey ?? "", options.team, options.limit, filters); if (issues.length === 0) { console.error(`No issues found for team "${options.team}"${scope}. Check the team slug, filters, and your API key permissions.`); @@ -1098,12 +1459,111 @@ async function syncLinearIssues( // UPDATES the matching item rather than creating a duplicate. We only read // the workspace when actually writing (dry-run is read-free on Linear's side // but we still want the preview to report create-vs-update accurately). - const existingByLinearId = indexItemsByLinearId(readPmItems(pm_root)); + const existingByLinearId = indexItemsByLinearId((dependencies.readItems ?? readPmItems)(pm_root)); let created = 0; let updated = 0; let skipped = 0; + // ------------------------------------------------------------------------- + // --atomic: commit the whole batch as ONE crash-resumable transaction via + // the SDK commitItemMutations helper. --atomic --dry-run shares this exact + // preparation/matching path and reports counts WITHOUT touching the commit. + // The non-atomic per-issue loop below stays byte-compatible (unchanged). + // ------------------------------------------------------------------------- + if (options.atomic) { + const prepared: PreparedLinearImport[] = []; + for (const issue of issues) { + // Reuse the same server-side state/cycle backstop filters as the legacy + // path so `--atomic` skips the same issues and reports the same counts. + if (options.stateFilter) { + const stateName = issue.state.name.toLowerCase(); + if (!stateName.includes(options.stateFilter.toLowerCase())) { + skipped++; + continue; + } + } + if (options.cycleFilter) { + const cycleName = issue.cycle?.name?.toLowerCase() ?? ""; + if (!cycleName.includes(options.cycleFilter.toLowerCase())) { + skipped++; + continue; + } + } + const plan = buildItemPlan( + issue, + statusMap, + options.fieldMap ?? {}, + options.projectMap ?? { enabled: false, passthrough: false, map: {} } + ); + prepared.push({ + identifier: issue.identifier, + linearId: issue.id, + title: plan.title, + status: plan.status, + priority: plan.priority, + description: plan.description, + body: plan.body, + tags: plan.tags, + ...(plan.deadline ? { deadline: plan.deadline } : {}), + ...(plan.assignee ? { assignee: plan.assignee } : {}), + match: existingByLinearId[issue.id], + }); + } + + if (options.dryRun) { + const wouldCreate = prepared.filter((e) => !e.match?.id).length; + const wouldUpdate = prepared.length - wouldCreate; + console.error( + `[dry-run] Atomic plan would import ${wouldCreate}, update ${wouldUpdate}, skip ${skipped}.` + ); + return { + synced: wouldCreate + wouldUpdate, + created: wouldCreate, + updated: wouldUpdate, + skipped, + team: options.team, + issues: [], + atomic: true, + dryRun: true, + }; + } + + // All-skipped (e.g. every issue filtered out): throw the SAME error shape + // the legacy path uses for a failed batch, BEFORE touching the SDK commit. + if (prepared.length === 0) { + throw new CommandError( + `Imported 0 issue(s); ${skipped} failed.`, + EXIT_CODE.GENERIC_FAILURE + ); + } + + const atomicResult = await (dependencies.commitAtomic ?? importLinearAtomic)(pm_root, options.team, prepared); + if (atomicResult.recovered) { + console.error( + `Atomic import recovered transaction ${atomicResult.transactionId} covering ${atomicResult.recoveredItems ?? prepared.length} item(s).` + ); + } else { + console.error( + `Atomically imported ${atomicResult.imported} new, updated ${atomicResult.updated} existing, skipped ${skipped}.` + ); + } + return { + synced: atomicResult.imported + atomicResult.updated, + created: atomicResult.imported, + updated: atomicResult.updated, + skipped, + team: options.team, + issues: [], + atomic: true, + transactionId: atomicResult.transactionId, + recovered: atomicResult.recovered, + ...(atomicResult.recoveredItems !== undefined + ? { recoveredItems: atomicResult.recoveredItems } + : {}), + }; + } + for (const issue of issues) { // State name filter. The authoritative constraint is now server-side // (`state: { name: { containsIgnoreCase: $state } }` in buildIssuesQuery), @@ -1723,6 +2183,7 @@ export default defineExtension({ "pm linear sync --team ENG --cycle 'Sprint 7' --limit 50", "pm linear sync --team ENG --limit 50", "pm linear sync --team ENG --dry-run", + "pm linear sync --team ENG --atomic", ], flags: [ { long: "--team", value_name: "slug", description: "Linear team slug (e.g. ENG, BACKEND). Optional when LINEAR_DEFAULT_TEAM is set." }, @@ -1736,6 +2197,7 @@ export default defineExtension({ { long: "--map", value_name: "map", description: "Remap Linear->pm fields, e.g. \"identifier=ignore,priority=ignore\". Optional." }, { long: "--project-map", value_name: "map", description: "Tag items by Linear project name. Bare flag tags with the verbatim name; \"Mobile=mobile,Web=web\" remaps. Optional." }, { long: "--limit", value_name: "n", description: "Maximum number of issues to fetch (default: 100)" }, + { long: "--atomic", description: "Commit the complete sync as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" }, { long: "--dry-run", description: "Preview the exact GraphQL request without any network call or writes" }, { long: "--skip-preflight-network", description: "Skip the preflight reachability probe (offline/CI)" }, ], @@ -1762,14 +2224,18 @@ export default defineExtension({ const projectMap = parseProjectMap(readProjectMapOption(ctx.options)); const limit = readNumberOption(ctx.options, "limit") ?? 100; const dryRun = readBooleanOption(ctx.options, "dry-run"); + const atomic = readBooleanOption(ctx.options, "atomic"); const syncOpts: SyncOptions = { team, project, stateFilter, cycleFilter, assignee, label, updatedSince, - statusMap, fieldMap, projectMap, limit, dryRun, + statusMap, fieldMap, projectMap, limit, dryRun, atomic, }; // --dry-run is fully offline: print the literal GraphQL request, no call. - if (dryRun) { + // --atomic --dry-run is NOT offline: it fetches issues, shares the atomic + // preparation/matching path, and reports create/update/skip counts + // WITHOUT committing — handled inside syncLinearIssues. + if (dryRun && !atomic) { return renderImportDryRun(ctx, syncOpts, teamSelection.source); } @@ -1780,6 +2246,24 @@ export default defineExtension({ try { const result = await syncLinearIssues(syncOpts, ctx.pm_root); + if (result.atomic) { + // syncLinearIssues already printed the atomic summary line. + return { + success: true, + synced: result.synced, + created: result.created, + updated: result.updated, + skipped: result.skipped, + team: result.team.toUpperCase(), + teamSource: teamSelection.source, + atomic: true, + dryRun: Boolean(result.dryRun), + ...(result.transactionId !== undefined ? { transactionId: result.transactionId } : {}), + ...(result.recovered !== undefined ? { recovered: result.recovered } : {}), + ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}), + }; + } + const summary = `Synced ${result.synced} issue${result.synced !== 1 ? "s" : ""} ` + `(${result.created} new, ${result.updated} updated) from Linear team ${result.team.toUpperCase()}`; @@ -1870,6 +2354,7 @@ export default defineExtension({ { long: "--map", value_name: "map", description: "Remap Linear->pm fields, e.g. \"identifier=ignore\"." }, { long: "--project-map", value_name: "map", description: "Tag items by Linear project name (bare flag = verbatim; \"Mobile=mobile\" remaps)." }, { long: "--limit", value_name: "n", description: "Maximum number of issues to fetch (default: 100)." }, + { long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" }, { long: "--dry-run", description: "Print the exact GraphQL request offline (no network, no writes)." }, ]); api.registerFlags("linear export", [ @@ -1906,14 +2391,17 @@ export default defineExtension({ const projectMap = parseProjectMap(readProjectMapOption(ctx.options)); const limit = readNumberOption(ctx.options, "limit") ?? 100; const dryRun = readBooleanOption(ctx.options, "dry-run"); + const atomic = readBooleanOption(ctx.options, "atomic"); const syncOpts: SyncOptions = { team, project, stateFilter, cycleFilter, assignee, label, updatedSince, - statusMap, fieldMap, projectMap, limit, dryRun, + statusMap, fieldMap, projectMap, limit, dryRun, atomic, }; // --dry-run is fully offline: emit the literal GraphQL request, no call. - if (dryRun) { + // --atomic --dry-run shares the atomic prep/matching path (fetches issues, + // reports counts, no commit) - handled inside syncLinearIssues. + if (dryRun && !atomic) { const plan = renderImportDryRun(ctx, syncOpts, teamSelection.source); return { imported: 0, created: 0, updated: 0, skipped: 0, ...plan }; } @@ -1924,11 +2412,13 @@ export default defineExtension({ try { const result = await syncLinearIssues(syncOpts, ctx.pm_root); - console.error( - `Imported ${result.synced} issue(s) (${result.created} new, ${result.updated} updated) ` + - `from Linear team ${result.team.toUpperCase()}` + - (result.skipped > 0 ? ` (${result.skipped} skipped)` : "") - ); + if (!result.atomic) { + console.error( + `Imported ${result.synced} issue(s) (${result.created} new, ${result.updated} updated) ` + + `from Linear team ${result.team.toUpperCase()}` + + (result.skipped > 0 ? ` (${result.skipped} skipped)` : "") + ); + } return { imported: result.synced, created: result.created, @@ -1936,7 +2426,11 @@ export default defineExtension({ skipped: result.skipped, team: result.team.toUpperCase(), teamSource: teamSelection.source, - dryRun: false, + dryRun: Boolean(result.dryRun), + ...(result.atomic ? { atomic: true } : {}), + ...(result.transactionId !== undefined ? { transactionId: result.transactionId } : {}), + ...(result.recovered !== undefined ? { recovered: result.recovered } : {}), + ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}), }; } catch (err: unknown) { if (err instanceof CommandError) throw err; @@ -2178,16 +2672,19 @@ export default defineExtension({ const label = readStringOption(ctx.options, "label"); const statusMap = parseStatusMap(readStringOption(ctx.options, "status-map")); const projectMap = parseProjectMap(readProjectMapOption(ctx.options)); + const atomic = readBooleanOption(ctx.options, "atomic"); const result = await syncLinearIssues( - { team, project, stateFilter, cycleFilter, assignee, label, statusMap, projectMap, limit }, + { team, project, stateFilter, cycleFilter, assignee, label, statusMap, projectMap, limit, atomic }, ctx.pm_root ); - console.error( - `Synced ${result.synced} issues (${result.created} new, ${result.updated} updated) ` + - `from Linear team ${result.team.toUpperCase()}` - ); + if (!result.atomic) { + console.error( + `Synced ${result.synced} issues (${result.created} new, ${result.updated} updated) ` + + `from Linear team ${result.team.toUpperCase()}` + ); + } return { synced: result.synced, @@ -2196,6 +2693,10 @@ export default defineExtension({ skipped: result.skipped, team: result.team.toUpperCase(), teamSource: teamSelection.source, + ...(result.atomic ? { atomic: true } : {}), + ...(result.transactionId !== undefined ? { transactionId: result.transactionId } : {}), + ...(result.recovered !== undefined ? { recovered: result.recovered } : {}), + ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}), }; }); }, diff --git a/package-lock.json b/package-lock.json index 283eeca..0c24530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.10", + "@unbrained/pm-cli": "^2026.7.20", "pm-changelog": "^2026.7.10", "typescript": "^7.0.2" }, @@ -18,7 +18,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.10" + "@unbrained/pm-cli": ">=2026.7.20" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -717,14 +717,15 @@ } }, "node_modules/@unbrained/pm-cli": { - "version": "2026.7.18", - "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.18.tgz", - "integrity": "sha512-ccGb0Q4QH/+V6J9Jm+aPJtigOWSDlkR0ko0VCcvEyqRkuACbONCVXA1PE8MjXH03apZjJ3s+qyBAv00gXRIXqw==", + "version": "2026.7.20", + "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.20.tgz", + "integrity": "sha512-PRY4X/S9wq/4cc9PU5UEDg4Be8iGUo5WFdOoNnE0Ly61FhW0rOJD+ZxfnykqnC4IJlSn9wgRgDpW9Ch++0TaNQ==", "dev": true, "license": "MIT", "dependencies": { "@sentry/node": "^10.63.0", "@toon-format/toon": "^2.3.0", + "@types/node": ">=22", "commander": "^15.0.0", "fast-glob": "^3.3.3", "fast-json-patch": "^3.1.1" diff --git a/package.json b/package.json index 8645ead..81f55bb 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,11 @@ "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-linear/blob/main/.agents/pm --pm-bin ./node_modules/.bin/pm --github-step-summary" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.10" + "@unbrained/pm-cli": ">=2026.7.20" }, "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.10", + "@unbrained/pm-cli": "^2026.7.20", "pm-changelog": "^2026.7.10", "typescript": "^7.0.2" }, diff --git a/test/atomic.test.ts b/test/atomic.test.ts new file mode 100644 index 0000000..fd44bd8 --- /dev/null +++ b/test/atomic.test.ts @@ -0,0 +1,589 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CommandError, + EXIT_CODE, + buildAtomicImportMutations, + deriveAtomicItemId, + deriveAtomicTransactionId, + importLinearAtomic, + syncLinearIssues, +} from "../dist/index.js"; +import type { PreparedLinearImport, LinearIssue } from "../dist/index.js"; + +const PM_BIN = process.platform === "win32" ? "pm.cmd" : "pm"; +const PM_SPAWN_OPTS = { + encoding: "utf-8" as const, + shell: process.platform === "win32", +}; + +// A normalizeItemId stand-in mirroring the SDK: prefix + lowercased input with +// non-alphanumerics folded to dashes. The real SDK helper is used in the +// integration tests below (freshTracker + the real `pm`); this pure stub keeps +// the pure-plan tests offline and deterministic. +function fakeNormalize(input: string, prefix: string): string { + const slug = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${prefix}${slug}`; +} + +function entry( + identifier: string, + title: string, + overrides: Partial = {}, +): PreparedLinearImport { + return { + identifier, + // Linear internal UUID; carried for provenance parity but never used as + // the managed id. + linearId: `uuid-${identifier.toLowerCase()}`, + title, + status: "open", + priority: 3, + description: `[linear] linear_id=uuid-${identifier.toLowerCase()} linear_url=https://linear.app/issue/${identifier}`, + body: `Body ${identifier}`, + tags: ["bug"], + ...overrides, + }; +} + +// A minimal LinearIssue for the fetchIssues test seam. Only the fields the +// import path reads are populated; the rest default to empty/null. +function makeIssue( + identifier: string, + title: string, + overrides: Partial<{ + stateName: string; + stateType: string; + body: string; + }> = {}, +): LinearIssue { + return { + id: `uuid-${identifier.toLowerCase()}`, + identifier, + title, + description: overrides.body ?? `Body ${identifier}`, + priority: 2, + estimate: null, + state: { name: overrides.stateName ?? "In Progress", type: overrides.stateType ?? "started" }, + labels: { nodes: [{ name: "bug" }] }, + assignee: null, + dueDate: null, + cycle: null, + project: null, + customer: null, + url: `https://linear.app/issue/${identifier}`, + }; +} + +function freshTracker(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pm-linear-atomic-")); + try { + const init = spawnSync(PM_BIN, ["--path", root, "init", "test"], PM_SPAWN_OPTS); + assert.strictEqual(init.status, 0, `pm init failed: ${init.error?.message ?? init.stderr}`); + assert.strictEqual(itemCount(root), 0, "fresh tracker must be empty"); + return root; + } catch (err) { + fs.rmSync(root, { recursive: true, force: true }); + throw err; + } +} + +function listItems( + root: string, +): Array<{ id: string; title: string; status: string }> { + const result = spawnSync( + PM_BIN, + ["--path", root, "list-all", "--json", "--full", "--limit", "100"], + PM_SPAWN_OPTS, + ); + assert.strictEqual( + result.status, + 0, + `pm list-all failed: ${result.error?.message ?? result.stderr}`, + ); + const parsed = JSON.parse(result.stdout) as { + items?: Array<{ id: string; title: string; status: string }>; + }; + return parsed.items ?? []; +} + +function itemCount(root: string): number { + return listItems(root).length; +} + +function validateOk(root: string): boolean { + return spawnSync(PM_BIN, ["--path", root, "validate"], PM_SPAWN_OPTS).status === 0; +} + +// --------------------------------------------------------------------------- +// 1. mid-run failure → reverse compensation restores created/updated/closed +// --------------------------------------------------------------------------- +test("a failed atomic batch compensates every applied create (reverse rollback)", async () => { + const root = freshTracker(); + try { + const sdk = await import("@unbrained/pm-cli/sdk"); + // Wrap the real SDK commit to append a guaranteed-to-fail create so the + // whole transaction rolls back. This exercises the SDK's real reverse- + // order compensation against the live tracker. + const wrappingCommit = async ( + options: Parameters[0], + ) => { + const settings = await sdk.readSettings(options.pmRoot); + const brokenId = sdk.normalizeItemId("linear-broken", settings.id_prefix); + return sdk.commitItemMutations({ + ...options, + mutations: [ + ...options.mutations, + { + op: "create" as const, + id: brokenId, + options: { title: "Broken", type: "NoSuchType_XYZ", status: "open" }, + }, + ], + }); + }; + + await assert.rejects( + () => + importLinearAtomic( + root, + "ENG", + [entry("ENG-20", "Rollback one"), entry("ENG-21", "Rollback two")], + { commitItemMutations: wrappingCommit }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.GENERIC_FAILURE); + assert.match((err as Error).message, /no new partial committed state is expected/); + return true; + }, + ); + assert.strictEqual(itemCount(root), 0, "rollback deletes every transaction-owned create"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("a failed mixed batch restores pre-existing updates and closes", async () => { + const root = freshTracker(); + try { + const sdk = await import("@unbrained/pm-cli/sdk"); + const initialEntries = [ + entry("ENG-30", "Original update target"), + entry("ENG-31", "Original close target"), + ]; + const initial = await importLinearAtomic(root, "ENG", initialEntries); + const updateId = initial.itemIds.get("ENG-30"); + const closeId = initial.itemIds.get("ENG-31"); + assert.ok(updateId); + assert.ok(closeId); + + const wrappingCommit = async ( + options: Parameters[0], + ) => { + const settings = await sdk.readSettings(options.pmRoot); + const brokenId = sdk.normalizeItemId("linear-broken-mixed", settings.id_prefix); + return sdk.commitItemMutations({ + ...options, + mutations: [ + ...options.mutations, + { + op: "create" as const, + id: brokenId, + options: { title: "Broken", type: "NoSuchType_XYZ", status: "open" }, + }, + ], + }); + }; + + await assert.rejects( + () => + importLinearAtomic( + root, + "ENG", + [ + entry("ENG-30", "Mutated title", { + body: "Mutated body", + match: { id: updateId, status: "open" }, + }), + entry("ENG-31", "Should be restored", { + status: "closed", + match: { id: closeId, status: "open" }, + }), + ], + { commitItemMutations: wrappingCommit }, + ), + /no new partial committed state is expected/, + ); + + const items = listItems(root); + const updateTarget = items.find((candidate) => candidate.id === updateId); + const closeTarget = items.find((candidate) => candidate.id === closeId); + assert.strictEqual( + updateTarget?.title, + "Original update target", + "update compensation restores title", + ); + assert.strictEqual(updateTarget?.status, "open", "update compensation restores status"); + assert.strictEqual( + closeTarget?.title, + "Original close target", + "close target content is restored", + ); + assert.strictEqual(closeTarget?.status, "open", "close compensation reopens the item"); + assert.strictEqual( + itemCount(root), + 2, + "mixed rollback neither deletes nor duplicates existing items", + ); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// 2. resume idempotence → re-running the same fetched set recovers the SAME +// transaction, 0 duplicates +// --------------------------------------------------------------------------- +test("atomic import commits and resumes reordered input without duplicates", async () => { + const root = freshTracker(); + try { + const entries = [entry("ENG-1", "First"), entry("ENG-2", "Second")]; + const first = await importLinearAtomic(root, "ENG", entries); + assert.strictEqual(first.imported, 2); + assert.strictEqual(first.updated, 0); + assert.strictEqual(first.recovered, false); + assert.strictEqual(itemCount(root), 2); + + // Re-run the same fetched set (reversed, as if the API page order changed) + // with the prior committed items supplied as provenance matches. The + // durable journal recognizes the same transaction id and resumes it, + // reporting recovery rather than new work, with zero duplicates. + const resumedEntries = [...entries].reverse().map((candidate) => ({ + ...candidate, + match: { + id: first.itemIds.get(candidate.identifier), + status: "open", + }, + })); + const resumed = await importLinearAtomic(root, "ENG", resumedEntries); + assert.strictEqual(resumed.transactionId, first.transactionId); + assert.strictEqual(resumed.recovered, true); + assert.strictEqual(resumed.imported, 0, "a recovered journal is not misreported as new work"); + assert.strictEqual(resumed.updated, 0, "a recovered journal is not misreported as updates"); + assert.strictEqual(resumed.recoveredItems, 2); + assert.strictEqual(itemCount(root), 2, "resumed import does not duplicate items"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// 3. reordered-retry stability → shuffled fetch order yields the SAME +// transaction id & item ids +// --------------------------------------------------------------------------- +test("transaction identity is content-sensitive and independent of fetch order", () => { + const first = entry("ENG-1", "First"); + const second = entry("ENG-2", "Second"); + const plan = [ + { op: "update" as const, id: "test-first", options: { title: "First" } }, + { op: "update" as const, id: "test-second", options: { title: "Second" } }, + ]; + const id = deriveAtomicTransactionId("ENG", [first, second], plan); + assert.match(id, /^linear-import-[0-9a-f]{16}$/); + // Same rendered issues in another order resume the same transaction. + assert.strictEqual( + deriveAtomicTransactionId("eng", [second, first], plan), + id, + "same rendered issues in another order resume the same transaction", + ); + // Changed desired state starts a fresh transaction. + assert.notStrictEqual( + deriveAtomicTransactionId("eng", [first, entry("ENG-2", "Changed")], plan), + id, + "changed desired state starts a fresh transaction", + ); + // Changed mutation targets cannot collide with an incompatible recovery journal. + assert.notStrictEqual( + deriveAtomicTransactionId("eng", [first, second], [ + { ...plan[0]!, id: "other-prefix-first" }, + plan[1]!, + ]), + id, + "changed mutation targets cannot collide with an incompatible recovery journal", + ); + // Team change starts a fresh transaction. + assert.notStrictEqual( + deriveAtomicTransactionId("BACKEND", [first, second], plan), + id, + "a different team starts a fresh transaction", + ); +}); + +test("create ids are stable external-key ids and mutation planning handles transitions", () => { + const id = deriveAtomicItemId("ENG", "ENG-42", "test-", fakeNormalize); + assert.strictEqual(id, deriveAtomicItemId("eng", "ENG-42", "test-", fakeNormalize)); + assert.notStrictEqual(id, deriveAtomicItemId("ENG", "ENG-43", "test-", fakeNormalize)); + + const created = buildAtomicImportMutations("ENG", entry("ENG-42", "New"), "test-", fakeNormalize); + assert.strictEqual(created.itemId, id); + assert.deepStrictEqual(created.mutations.map((mutation) => mutation.op), ["create", "update"]); + + // A partially-created deterministic item reproduces the original journal plan + // (essential for crash recovery: the provenance scan finds the item, but the + // SDK must still receive the original create+update upsert plan). + const recoveredCreate = buildAtomicImportMutations( + "ENG", + entry("ENG-42", "New", { match: { id, status: "open" } }), + "test-", + fakeNormalize, + ); + assert.deepStrictEqual( + recoveredCreate.mutations, + created.mutations, + "a partially-created deterministic item reproduces the original journal plan", + ); + + // A closed new issue: create(open) + update + close. + const closedNew = buildAtomicImportMutations( + "ENG", + entry("ENG-42", "Closed new", { status: "closed" }), + "test-", + fakeNormalize, + ); + assert.deepStrictEqual( + closedNew.mutations.map((mutation) => mutation.op), + ["create", "update", "close"], + ); + + // An existing legacy item closed upstream: update + close (no create). + const closed = buildAtomicImportMutations( + "ENG", + entry("ENG-42", "Closed", { status: "closed", match: { id: "legacy-import-id", status: "open" } }), + "test-", + fakeNormalize, + ); + assert.deepStrictEqual(closed.mutations.map((mutation) => mutation.op), ["update", "close"]); + + // An existing legacy item reopened: a single update carrying the new status. + const reopened = buildAtomicImportMutations( + "ENG", + entry("ENG-42", "Reopened", { match: { id: "legacy-import-id", status: "closed" } }), + "test-", + fakeNormalize, + ); + assert.strictEqual(reopened.mutations.length, 1); + assert.strictEqual(reopened.mutations[0]?.op, "update"); + assert.strictEqual( + (reopened.mutations[0] as { options: Record }).options.status, + "open", + ); +}); + +// --------------------------------------------------------------------------- +// 4. --atomic --dry-run → shared prep path, correct counts, NO SDK commit call +// --------------------------------------------------------------------------- +test("atomic dry-run shares the prep path, reports counts, and never commits", async () => { + const messages: string[] = []; + const originalError = console.error; + console.error = (...values: unknown[]) => messages.push(values.join(" ")); + let sdkCalls = 0; + try { + // stateFilter "progress" matches the In Progress issues but excludes the + // completed one, so ENG-3 is dropped by the state backstop (skipped=1). + const result = await syncLinearIssues( + { team: "ENG", limit: 100, atomic: true, dryRun: true, stateFilter: "progress" }, + "/unused-dry-run-workspace", + { + fetchIssues: async () => [ + makeIssue("ENG-1", "New"), + makeIssue("ENG-2", "Existing"), + makeIssue("ENG-3", "Skipped", { stateName: "Done", stateType: "completed" }), + ], + readItems: () => [ + { + id: "existing-id", + description: entry("ENG-2", "Existing").description, + }, + ], + commitAtomic: async () => { + sdkCalls++; + throw new Error("atomic dry-run must not call the SDK commit path"); + }, + }, + ); + + assert.strictEqual(result.atomic, true); + assert.strictEqual(result.dryRun, true); + assert.strictEqual(result.created, 1, "one new issue would be created"); + assert.strictEqual(result.updated, 1, "one existing issue would be updated"); + assert.strictEqual(result.skipped, 1, "the completed issue is skipped by the state backstop"); + assert.strictEqual(sdkCalls, 0, "dry-run must never invoke the SDK commit"); + assert.ok( + messages.some((m) => /Atomic plan would import 1, update 1, skip 1/.test(m)), + `expected an atomic dry-run summary; got: ${messages.join(" | ")}`, + ); + } finally { + console.error = originalError; + } +}); + +// --------------------------------------------------------------------------- +// 5. all-skipped non-dry atomic import → throws the same error shape as +// legacy, before commit +// --------------------------------------------------------------------------- +test("atomic all-skipped imports fail with the legacy error shape before commit", async () => { + let sdkCalls = 0; + // Every fetched issue is dropped by the state backstop filter, so the + // prepared set is empty. The atomic path must throw the SAME error shape the + // legacy path uses for a failed batch, BEFORE touching the SDK commit. + await assert.rejects( + () => + syncLinearIssues( + { team: "ENG", limit: 100, atomic: true, stateFilter: "nonexistent-state" }, + "/unused-all-skipped-workspace", + { + fetchIssues: async () => [ + makeIssue("ENG-1", "Filtered out", { stateName: "In Progress", stateType: "started" }), + ], + readItems: () => [], + commitAtomic: async () => { + sdkCalls++; + throw new Error("must not commit an empty plan"); + }, + }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.GENERIC_FAILURE); + assert.match((err as Error).message, /Imported 0 issue\(s\); 1 failed/); + return true; + }, + ); + assert.strictEqual(sdkCalls, 0, "an all-skipped atomic import never reaches the SDK commit"); +}); + +// --------------------------------------------------------------------------- +// 6. transaction id changes when a mutation target changes +// --------------------------------------------------------------------------- +test("transaction id changes when a mutation target changes", () => { + const e1 = entry("ENG-1", "First"); + const e2 = entry("ENG-2", "Second"); + const basePlan = [ + { op: "update" as const, id: "pm-a", options: { title: "First" } }, + { op: "update" as const, id: "pm-b", options: { title: "Second" } }, + ]; + const baseId = deriveAtomicTransactionId("ENG", [e1, e2], basePlan); + + // Same content, same targets → identical id (order independent). + assert.strictEqual( + deriveAtomicTransactionId("ENG", [e2, e1], basePlan), + baseId, + ); + + // A changed mutation target (different id) → fresh id. + const retargeted = [ + { op: "update" as const, id: "pm-a", options: { title: "First" } }, + { op: "update" as const, id: "pm-changed", options: { title: "Second" } }, + ]; + assert.notStrictEqual( + deriveAtomicTransactionId("ENG", [e1, e2], retargeted), + baseId, + "a changed mutation target yields a fresh transaction id", + ); + + // A changed id_prefix changes the derived managed id (and thus the mutation + // plan targets), so the transaction id changes too. + const prefixA = buildAtomicImportMutations("ENG", e1, "pm-", fakeNormalize); + const prefixB = buildAtomicImportMutations("ENG", e1, "custom-", fakeNormalize); + assert.notStrictEqual(prefixA.itemId, prefixB.itemId); + assert.notStrictEqual( + deriveAtomicTransactionId("ENG", [e1], prefixA.mutations), + deriveAtomicTransactionId("ENG", [e1], prefixB.mutations), + "a changed id_prefix changes the mutation targets and the transaction id", + ); + + // A changed status on an entry changes the canonical content → fresh id. + const closedPlan = buildAtomicImportMutations( + "ENG", + entry("ENG-1", "First", { status: "closed" }), + "pm-", + fakeNormalize, + ).mutations; + assert.notStrictEqual( + deriveAtomicTransactionId("ENG", [entry("ENG-1", "First", { status: "closed" })], closedPlan), + deriveAtomicTransactionId("ENG", [e1], prefixA.mutations), + "a changed status changes canonical content and the transaction id", + ); +}); + +// --------------------------------------------------------------------------- +// incomplete compensation is reported as potentially partial and resumable +// --------------------------------------------------------------------------- +test("incomplete compensation is reported as potentially partial and resumable", async () => { + const incomplete = new AggregateError( + [new Error("apply failed"), new Error("restore failed")], + "Workspace transaction failed and compensation failed: restore failed", + ); + await assert.rejects( + () => + importLinearAtomic( + "/unused-incomplete-compensation-workspace", + "ENG", + [entry("ENG-50", "Needs repair")], + { + commitItemMutations: async () => { + throw incomplete; + }, + normalizeItemId: fakeNormalize, + readSettings: async () => ({ id_prefix: "test-" }), + }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.match((err as Error).message, /compensation was incomplete/); + assert.match((err as Error).message, /may contain partially applied state/); + assert.doesNotMatch((err as Error).message, /no new partial committed state/); + return true; + }, + ); +}); + +// --------------------------------------------------------------------------- +// end-to-end commit through the real SDK against a live tracker +// --------------------------------------------------------------------------- +test("atomic update and close commit together", async () => { + const root = freshTracker(); + try { + const initial = entry("ENG-7", "Initial"); + const created = await importLinearAtomic(root, "ENG", [initial]); + const itemId = created.itemIds.get("ENG-7"); + assert.ok(itemId); + + const changed = entry("ENG-7", "Completed upstream", { + status: "closed", + body: "Final body", + match: { id: itemId, status: "open" }, + }); + const result = await importLinearAtomic(root, "ENG", [changed]); + assert.strictEqual(result.imported, 0); + assert.strictEqual(result.updated, 1); + const item = listItems(root).find((candidate) => candidate.id === itemId); + assert.strictEqual(item?.title, "Completed upstream"); + assert.strictEqual(item?.status, "closed"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 8fcdf84644249cefcccc5dcad1db00bfb3d83787 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:55:11 +0200 Subject: [PATCH 2/7] track(pm): record orchestrator verification of --atomic (61 tests + live SDK check) Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/pm/history/pm-linear-nvt5.jsonl | 1 + .agents/pm/tasks/pm-linear-nvt5.toon | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/pm/history/pm-linear-nvt5.jsonl b/.agents/pm/history/pm-linear-nvt5.jsonl index e96cbf0..78e42bb 100644 --- a/.agents/pm/history/pm-linear-nvt5.jsonl +++ b/.agents/pm/history/pm-linear-nvt5.jsonl @@ -3,3 +3,4 @@ {"ts":"2026-07-21T12:39:19.440Z","author":"pi-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:39:19.440Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"44ba9ea0fa5280f68f9fa3cfe890cfcc920eedaff60988fc8d769937e2027935","after_hash":"67d148c21bf112fe6d0360c84c8c03449adedf4188be415474220aa54f90442e"} {"ts":"2026-07-21T12:39:58.574Z","author":"pi-agent","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:39:58.574Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-21T12:39:58.574Z","author":"pi-agent","text":"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)."}]}],"before_hash":"67d148c21bf112fe6d0360c84c8c03449adedf4188be415474220aa54f90442e","after_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b"} {"ts":"2026-07-21T12:40:15.159Z","author":"pi-agent","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-07-21T12:40:15.159Z","author":"pi-agent","text":"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:40:15.159Z"}],"before_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b","after_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4"} +{"ts":"2026-07-21T12:55:11.202Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-07-21T12:55:11.201Z","author":"claude-orchestrator","text":"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:55:11.202Z"}],"before_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4","after_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7"} diff --git a/.agents/pm/tasks/pm-linear-nvt5.toon b/.agents/pm/tasks/pm-linear-nvt5.toon index bd98082..d11a6b5 100644 --- a/.agents/pm/tasks/pm-linear-nvt5.toon +++ b/.agents/pm/tasks/pm-linear-nvt5.toon @@ -6,10 +6,11 @@ status: in_progress priority: 2 tags: [] created_at: "2026-07-21T12:39:12.520Z" -updated_at: "2026-07-21T12:40:15.159Z" +updated_at: "2026-07-21T12:55:11.202Z" assignee: pi-agent author: pi-agent -notes[2]{created_at,author,text}: +notes[3]{created_at,author,text}: "2026-07-21T12:39:58.574Z",pi-agent,"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)." "2026-07-21T12:40:15.159Z",pi-agent,"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker." + "2026-07-21T12:55:11.201Z",claude-orchestrator,"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR." body: "" From 614fe1cdbda255a63aee7b495e91ff13ff1215db Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:55:47 +0200 Subject: [PATCH 3/7] fix(linear): harden --atomic per PR #36 bot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all P1/high findings from Greptile + Gemini on PR #36: - No reopen churn on closed items: drop the shared `status` field so an already-closed item is not reopened-then-reclosed on every re-sync (churning notifications/webhooks). create seeds a valid open item, update omits status, the close step owns the closed transition. - All-filtered batch succeeds: when every issue is dropped by --state/ --cycle, return a zero-sync SyncResult before touching the SDK instead of throwing GENERIC_FAILURE — matches the legacy path and unblocks scheduled cron/CI syncs. - Settings fault no longer forks recovery: a readSettings error now throws CommandError instead of silently falling back to id_prefix "pm-". The transaction and journal identity are keyed on id_prefix, so a fallback on retry could start a different journal and duplicate every item. Fail loudly so the operator resolves the fault and resumes the same durable transaction. - linear-sync importer forwards --dry-run: --atomic --dry-run now previews (fetch + counts, no commit) instead of turning a requested preview into a real workspace write. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via the real SDK commitItemMutations: create/ close without churn, idempotent recovered resume, transaction id stable under reordered retry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/pm/history/pm-linear-nvt5.jsonl | 1 + .agents/pm/tasks/pm-linear-nvt5.toon | 5 +- index.ts | 89 ++++++++++-- test/atomic.test.ts | 182 ++++++++++++++++++++---- 4 files changed, 234 insertions(+), 43 deletions(-) diff --git a/.agents/pm/history/pm-linear-nvt5.jsonl b/.agents/pm/history/pm-linear-nvt5.jsonl index 78e42bb..4df3807 100644 --- a/.agents/pm/history/pm-linear-nvt5.jsonl +++ b/.agents/pm/history/pm-linear-nvt5.jsonl @@ -4,3 +4,4 @@ {"ts":"2026-07-21T12:39:58.574Z","author":"pi-agent","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:39:58.574Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-21T12:39:58.574Z","author":"pi-agent","text":"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)."}]}],"before_hash":"67d148c21bf112fe6d0360c84c8c03449adedf4188be415474220aa54f90442e","after_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b"} {"ts":"2026-07-21T12:40:15.159Z","author":"pi-agent","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-07-21T12:40:15.159Z","author":"pi-agent","text":"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:40:15.159Z"}],"before_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b","after_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4"} {"ts":"2026-07-21T12:55:11.202Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-07-21T12:55:11.201Z","author":"claude-orchestrator","text":"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:55:11.202Z"}],"before_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4","after_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7"} +{"ts":"2026-07-21T15:55:36.080Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-07-21T15:55:36.079Z","author":"claude-orchestrator","text":"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T15:55:36.080Z"}],"before_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7","after_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d"} diff --git a/.agents/pm/tasks/pm-linear-nvt5.toon b/.agents/pm/tasks/pm-linear-nvt5.toon index d11a6b5..632b94d 100644 --- a/.agents/pm/tasks/pm-linear-nvt5.toon +++ b/.agents/pm/tasks/pm-linear-nvt5.toon @@ -6,11 +6,12 @@ status: in_progress priority: 2 tags: [] created_at: "2026-07-21T12:39:12.520Z" -updated_at: "2026-07-21T12:55:11.202Z" +updated_at: "2026-07-21T15:55:36.080Z" assignee: pi-agent author: pi-agent -notes[3]{created_at,author,text}: +notes[4]{created_at,author,text}: "2026-07-21T12:39:58.574Z",pi-agent,"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)." "2026-07-21T12:40:15.159Z",pi-agent,"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker." "2026-07-21T12:55:11.201Z",claude-orchestrator,"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR." + "2026-07-21T15:55:36.079Z",claude-orchestrator,"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder." body: "" diff --git a/index.ts b/index.ts index 22587af..e2270ee 100644 --- a/index.ts +++ b/index.ts @@ -1245,9 +1245,15 @@ export function buildAtomicImportMutations( idPrefix: string, normalizeItemId: (input: string, prefix: string) => string, ): { itemId: string; mutations: BulkItemMutation[] } { + // `status` is deliberately NOT a shared field. An existing item that is + // already closed upstream must keep its closed state on re-sync: spreading a + // shared `status: "open"` into the update step would reopen it, and the + // follow-up close would re-close it — churning notifications, activity logs, + // and webhooks on every run. Each mutation sets status explicitly instead + // (create seeds a valid open item; update only carries status for non-closed + // entries; the close step owns the closed transition). const sharedOptions: Record = { title: entry.title, - status: entry.status === "closed" ? "open" : entry.status, priority: entry.priority, description: entry.description, body: entry.body, @@ -1277,7 +1283,16 @@ export function buildAtomicImportMutations( // is reversible independently of the field update. if (!entry.match?.id || entry.match.id === managedItemId) { const mutations: BulkItemMutation[] = [ - { op: "create", id: managedItemId, options: { ...sharedOptions } }, + { + op: "create", + id: managedItemId, + options: { + ...sharedOptions, + // A brand-new item must be created in a valid open state; the close + // step below performs the closed transition when required. + status: entry.status === "closed" ? "open" : entry.status, + }, + }, { op: "update", id: managedItemId, @@ -1335,13 +1350,26 @@ export async function importLinearAtomic( readSettings, } = await resolveAtomicSdkFunctions(opts); + // The atomic identity — every managed item id AND the durable transaction id + // — is derived from id_prefix. readSettings already resolves a missing or + // malformed settings file to defaults WITHOUT throwing, so a thrown error + // here is a genuine filesystem fault (e.g. EACCES/EIO). Silently falling back + // to "pm-" in that case would fork the identity of a retry from the original + // run: the resume would key a *different* journal and could duplicate every + // item. Fail loudly instead so the operator resolves the fault and re-runs + // the same, resumable transaction. let idPrefix = "pm-"; try { const settings = await readSettings(pmRoot); if (settings?.id_prefix) idPrefix = String(settings.id_prefix); - } catch { - // Match normal import resilience: an unreadable optional setting falls - // back to the canonical prefix; the mutation still validates the tracker. + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new CommandError( + `Atomic Linear import could not read workspace settings to resolve id_prefix: ${msg}. ` + + `The transaction identity is keyed on id_prefix, so proceeding with a fallback could duplicate items; ` + + `resolve the settings read error and retry the same import to resume its durable journal.`, + EXIT_CODE.GENERIC_FAILURE, + ); } const mutations: BulkItemMutation[] = []; @@ -1529,13 +1557,24 @@ export async function syncLinearIssues( }; } - // All-skipped (e.g. every issue filtered out): throw the SAME error shape - // the legacy path uses for a failed batch, BEFORE touching the SDK commit. + // All-skipped (e.g. every issue filtered out by --state/--cycle): return a + // successful zero-sync result BEFORE touching the SDK, matching the legacy + // non-atomic path, which never errors on an all-filtered batch. `skipped` + // counts issues dropped by a filter — an expected, common state for a + // scheduled sync — not failures, so throwing here would break cron/CI jobs. if (prepared.length === 0) { - throw new CommandError( - `Imported 0 issue(s); ${skipped} failed.`, - EXIT_CODE.GENERIC_FAILURE + console.error( + `No Linear issues to import after filters (skipped ${skipped}); nothing to commit.` ); + return { + synced: 0, + created: 0, + updated: 0, + skipped, + team: options.team, + issues: [], + atomic: true, + }; } const atomicResult = await (dependencies.commitAtomic ?? importLinearAtomic)(pm_root, options.team, prepared); @@ -2672,12 +2711,33 @@ export default defineExtension({ const label = readStringOption(ctx.options, "label"); const statusMap = parseStatusMap(readStringOption(ctx.options, "status-map")); const projectMap = parseProjectMap(readProjectMapOption(ctx.options)); + const dryRun = readBooleanOption(ctx.options, "dry-run"); const atomic = readBooleanOption(ctx.options, "atomic"); - const result = await syncLinearIssues( - { team, project, stateFilter, cycleFilter, assignee, label, statusMap, projectMap, limit, atomic }, - ctx.pm_root - ); + const syncOpts: SyncOptions = { + team, project, stateFilter, cycleFilter, assignee, label, + statusMap, projectMap, limit, dryRun, atomic, + }; + + // --dry-run is fully offline: emit the literal GraphQL request, no call + // and no writes. --atomic --dry-run shares the atomic prep/matching path + // (fetches issues, reports counts, no commit) handled inside + // syncLinearIssues, so dryRun is forwarded below. Without this guard, + // syncLinearIssues would see dryRun=false and turn a requested preview + // into a real workspace write. + if (dryRun && !atomic) { + const plan = renderImportDryRun(ctx, syncOpts, teamSelection.source); + return { + synced: 0, + created: 0, + updated: 0, + skipped: 0, + teamSource: teamSelection.source, + ...plan, + }; + } + + const result = await syncLinearIssues(syncOpts, ctx.pm_root); if (!result.atomic) { console.error( @@ -2693,6 +2753,7 @@ export default defineExtension({ skipped: result.skipped, team: result.team.toUpperCase(), teamSource: teamSelection.source, + dryRun: Boolean(result.dryRun), ...(result.atomic ? { atomic: true } : {}), ...(result.transactionId !== undefined ? { transactionId: result.transactionId } : {}), ...(result.recovered !== undefined ? { recovered: result.recovered } : {}), diff --git a/test/atomic.test.ts b/test/atomic.test.ts index fd44bd8..0cbdae8 100644 --- a/test/atomic.test.ts +++ b/test/atomic.test.ts @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { +import extension, { CommandError, EXIT_CODE, buildAtomicImportMutations, @@ -366,6 +366,23 @@ test("create ids are stable external-key ids and mutation planning handles trans closedNew.mutations.map((mutation) => mutation.op), ["create", "update", "close"], ); + // The create seeds a valid OPEN item (the close step owns the transition); + // the intermediate update must not re-assert a status. + const closedNewCreate = closedNew.mutations.find((m) => m.op === "create") as + | { options: Record } + | undefined; + assert.strictEqual( + closedNewCreate?.options.status, + "open", + "a closed new item is created open; the close step performs the transition", + ); + const closedNewUpdate = closedNew.mutations.find((m) => m.op === "update") as + | { options: Record } + | undefined; + assert.ok( + closedNewUpdate && !("status" in closedNewUpdate.options), + "the update for a closed new item must not carry status", + ); // An existing legacy item closed upstream: update + close (no create). const closed = buildAtomicImportMutations( @@ -375,6 +392,18 @@ test("create ids are stable external-key ids and mutation planning handles trans fakeNormalize, ); assert.deepStrictEqual(closed.mutations.map((mutation) => mutation.op), ["update", "close"]); + // Regression guard (Gemini review): the update for an already-closed upstream + // item must NOT carry `status`. Carrying `status: "open"` would reopen the + // existing item before the close step re-closes it — churning notifications, + // activity logs, and webhooks on every sync. + const closedUpdate = closed.mutations.find((m) => m.op === "update") as + | { options: Record } + | undefined; + assert.ok(closedUpdate, "closed existing item still issues an update for its fields"); + assert.ok( + closedUpdate && !("status" in closedUpdate.options), + "the update for a closed upstream item must not carry status (no reopen churn)", + ); // An existing legacy item reopened: a single update carrying the new status. const reopened = buildAtomicImportMutations( @@ -440,37 +469,35 @@ test("atomic dry-run shares the prep path, reports counts, and never commits", a }); // --------------------------------------------------------------------------- -// 5. all-skipped non-dry atomic import → throws the same error shape as -// legacy, before commit +// 5. all-skipped non-dry atomic import → SUCCESS zero-sync (legacy parity), +// before commit // --------------------------------------------------------------------------- -test("atomic all-skipped imports fail with the legacy error shape before commit", async () => { +test("atomic all-skipped imports succeed with a zero-sync result before commit", async () => { let sdkCalls = 0; // Every fetched issue is dropped by the state backstop filter, so the - // prepared set is empty. The atomic path must throw the SAME error shape the - // legacy path uses for a failed batch, BEFORE touching the SDK commit. - await assert.rejects( - () => - syncLinearIssues( - { team: "ENG", limit: 100, atomic: true, stateFilter: "nonexistent-state" }, - "/unused-all-skipped-workspace", - { - fetchIssues: async () => [ - makeIssue("ENG-1", "Filtered out", { stateName: "In Progress", stateType: "started" }), - ], - readItems: () => [], - commitAtomic: async () => { - sdkCalls++; - throw new Error("must not commit an empty plan"); - }, - }, - ), - (err: unknown) => { - assert.ok(err instanceof CommandError); - assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.GENERIC_FAILURE); - assert.match((err as Error).message, /Imported 0 issue\(s\); 1 failed/); - return true; + // prepared set is empty. Filtered-out issues are an expected, common state + // for a scheduled sync (not failures), so the atomic path must return a + // successful zero-sync result — matching the legacy non-atomic path, which + // never errors on an all-filtered batch — WITHOUT touching the SDK commit. + const result = await syncLinearIssues( + { team: "ENG", limit: 100, atomic: true, stateFilter: "nonexistent-state" }, + "/unused-all-skipped-workspace", + { + fetchIssues: async () => [ + makeIssue("ENG-1", "Filtered out", { stateName: "In Progress", stateType: "started" }), + ], + readItems: () => [], + commitAtomic: async () => { + sdkCalls++; + throw new Error("must not commit an empty plan"); + }, }, ); + assert.strictEqual(result.synced, 0); + assert.strictEqual(result.created, 0); + assert.strictEqual(result.updated, 0); + assert.strictEqual(result.skipped, 1, "the filtered issue is counted as skipped, not failed"); + assert.strictEqual(result.atomic, true); assert.strictEqual(sdkCalls, 0, "an all-skipped atomic import never reaches the SDK commit"); }); @@ -587,3 +614,104 @@ test("atomic update and close commit together", async () => { fs.rmSync(root, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// a hard settings read fault aborts before commit (never forks tx identity) +// --------------------------------------------------------------------------- +test("a settings read fault aborts before commit rather than forking identity", async () => { + let sdkCalls = 0; + // readSettings resolves a missing/malformed settings file to defaults WITHOUT + // throwing, so a thrown error is a genuine fault (e.g. EACCES/EIO). Because + // both the managed item ids and the durable transaction id are derived from + // id_prefix, silently falling back to "pm-" here would fork a retry's + // identity from the original run and could duplicate every item. The atomic + // path must abort loudly BEFORE any SDK commit instead. + await assert.rejects( + () => + importLinearAtomic( + "/unused-settings-fault-workspace", + "ENG", + [entry("ENG-60", "Needs prefix")], + { + commitItemMutations: async () => { + sdkCalls++; + throw new Error("commit must not run when settings cannot be read"); + }, + normalizeItemId: fakeNormalize, + readSettings: async () => { + const err = new Error( + "EACCES: permission denied, open settings.json", + ) as NodeJS.ErrnoException; + err.code = "EACCES"; + throw err; + }, + }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.GENERIC_FAILURE); + assert.match((err as Error).message, /could not read workspace settings/i); + assert.match((err as Error).message, /id_prefix/); + return true; + }, + ); + assert.strictEqual(sdkCalls, 0, "a settings fault must abort before any SDK commit"); +}); + +// --------------------------------------------------------------------------- +// the linear-sync importer honors --dry-run (never turns a preview into a write) +// --------------------------------------------------------------------------- +function captureImporter(name: string): (ctx: any) => Promise { + let handler: ((ctx: any) => Promise) | undefined; + const noop = (): void => {}; + const api: any = { + registerCommand: noop, + registerParser: noop, + registerPreflight: noop, + registerService: noop, + registerFlags: noop, + registerItemFields: noop, + registerItemTypes: noop, + registerMigration: noop, + registerRenderer: noop, + registerImporter: (registeredName: string, registeredHandler: any) => { + if (registeredName === name) handler = registeredHandler; + }, + registerExporter: noop, + registerSearchProvider: noop, + registerVectorStoreAdapter: noop, + hooks: { + beforeCommand: noop, + afterCommand: noop, + onWrite: noop, + onRead: noop, + onIndex: noop, + }, + }; + extension.activate(api); + assert.ok(handler, `expected importer "${name}" to be registered`); + return handler as (ctx: any) => Promise; +} + +test("linear-sync importer honors --dry-run and never writes", async () => { + const importer = captureImporter("linear-sync"); + const root = freshTracker(); + try { + // Plain --dry-run must take the offline preview path: no network, and no + // workspace write. Before the fix, the importer dropped dryRun and fell + // through to syncLinearIssues, which writes one pm item per issue. + const result = await importer({ + options: { team: "ENG", "dry-run": true }, + pm_root: root, + global: { json: true }, + }); + assert.strictEqual(result.dryRun, true, "dry-run result must be flagged"); + assert.strictEqual( + itemCount(root), + 0, + "a --dry-run linear-sync import must not write any items", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 35923112c0c6d5fbafdc0b6e09810aa9595649bf Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:09:17 +0200 Subject: [PATCH 4/7] chore(linear): remove dead code flagged by static analysis Strict tsc (noUnusedLocals/noUnusedParameters) surfaced two genuine dead-code items on this branch; remove both so the static-analysis gate (DeepScan) is clean: - Drop the unused, unexported `KNOWN_LINEAR_FIELDS` const. `parseFieldMap` accepts any key and never referenced it; the recognized-keys list is retained as documentation on the `--map` comment block. - Drop the unused `options` parameter from `preflightLinear` (the body only reads process.env + checkReachability); update its two callers. No behavior change. Build + typecheck + 63 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/pm/history/pm-linear-nvt5.jsonl | 1 + .agents/pm/tasks/pm-linear-nvt5.toon | 5 +++-- index.ts | 20 +++----------------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/.agents/pm/history/pm-linear-nvt5.jsonl b/.agents/pm/history/pm-linear-nvt5.jsonl index 4df3807..1bc7d92 100644 --- a/.agents/pm/history/pm-linear-nvt5.jsonl +++ b/.agents/pm/history/pm-linear-nvt5.jsonl @@ -5,3 +5,4 @@ {"ts":"2026-07-21T12:40:15.159Z","author":"pi-agent","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-07-21T12:40:15.159Z","author":"pi-agent","text":"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:40:15.159Z"}],"before_hash":"aff6e23c078cfec3cd71033ecdfd405a92c0d45570b1e734757a29fe785f2f7b","after_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4"} {"ts":"2026-07-21T12:55:11.202Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-07-21T12:55:11.201Z","author":"claude-orchestrator","text":"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:55:11.202Z"}],"before_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4","after_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7"} {"ts":"2026-07-21T15:55:36.080Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-07-21T15:55:36.079Z","author":"claude-orchestrator","text":"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T15:55:36.080Z"}],"before_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7","after_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d"} +{"ts":"2026-07-21T16:09:16.974Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-07-21T16:09:16.974Z","author":"claude-orchestrator","text":"Static-analysis cleanup (DeepScan '1 new issue' on PR #36): removed genuine dead code surfaced by strict tsc (noUnusedLocals/noUnusedParameters) — the unused, unexported KNOWN_LINEAR_FIELDS const (parseFieldMap accepts any key, never referenced it) and the unused 'options' parameter on preflightLinear (2 call sites updated). Build+typecheck+63 tests green. All other analyzers clean (Greptile pass, CodeRabbit full review 0 findings, Hound pass, CI pass)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T16:09:16.974Z"}],"before_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d","after_hash":"4794b710046111e4f2af0cfe9d36c2cc292ed4f2dce089ca2809120e76df58ee"} diff --git a/.agents/pm/tasks/pm-linear-nvt5.toon b/.agents/pm/tasks/pm-linear-nvt5.toon index 632b94d..4a86f7e 100644 --- a/.agents/pm/tasks/pm-linear-nvt5.toon +++ b/.agents/pm/tasks/pm-linear-nvt5.toon @@ -6,12 +6,13 @@ status: in_progress priority: 2 tags: [] created_at: "2026-07-21T12:39:12.520Z" -updated_at: "2026-07-21T15:55:36.080Z" +updated_at: "2026-07-21T16:09:16.974Z" assignee: pi-agent author: pi-agent -notes[4]{created_at,author,text}: +notes[5]{created_at,author,text}: "2026-07-21T12:39:58.574Z",pi-agent,"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)." "2026-07-21T12:40:15.159Z",pi-agent,"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker." "2026-07-21T12:55:11.201Z",claude-orchestrator,"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR." "2026-07-21T15:55:36.079Z",claude-orchestrator,"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder." + "2026-07-21T16:09:16.974Z",claude-orchestrator,"Static-analysis cleanup (DeepScan '1 new issue' on PR #36): removed genuine dead code surfaced by strict tsc (noUnusedLocals/noUnusedParameters) — the unused, unexported KNOWN_LINEAR_FIELDS const (parseFieldMap accepts any key, never referenced it) and the unused 'options' parameter on preflightLinear (2 call sites updated). Build+typecheck+63 tests green. All other analyzers clean (Greptile pass, CodeRabbit full review 0 findings, Hound pass, CI pass)." body: "" diff --git a/index.ts b/index.ts index e2270ee..3621110 100644 --- a/index.ts +++ b/index.ts @@ -413,21 +413,8 @@ export function resolveProjectTag( // --map priority=ignore to skip priority). Keys are Linear field names; values // are pm field names (or the sentinel "ignore" to suppress that field). // Recognized Linear keys: title, description, priority, status, labels, -// assignee, identifier, estimate, customer. Pure + exported for unit testing. +// assignee, identifier, estimate, cycle, customer. // --------------------------------------------------------------------------- -const KNOWN_LINEAR_FIELDS = [ - "title", - "description", - "priority", - "status", - "labels", - "assignee", - "identifier", - "estimate", - "cycle", - "customer", -] as const; - export function parseFieldMap(raw: string | undefined): Record { const map: Record = {}; if (!raw) return map; @@ -2053,7 +2040,6 @@ function commandMutatesLinear(command: string, options: Record) // Reachability check is skipped when SKIP_NETWORK is requested so unit/offline // runs stay deterministic. async function preflightLinear( - options: Record, checkReachability: boolean ): Promise { const apiKey = process.env["LINEAR_API_KEY"]; @@ -2202,7 +2188,7 @@ export default defineExtension({ !readBooleanOption(ctx.options, "skip-preflight-network") && !readBooleanOption(ctx.options, "no-preflight-network") && process.env["LINEAR_PREFLIGHT_NO_NETWORK"] !== "1"; - const error = await preflightLinear(ctx.options, checkReachability); + const error = await preflightLinear(checkReachability); if (!error) return {}; return { options: { ...ctx.options, [PREFLIGHT_ERROR_OPTION]: error } }; }); @@ -2354,7 +2340,7 @@ export default defineExtension({ const checkNetwork = readBooleanOption(ctx.options, "check-network"); const diag = buildValidationReport(); if (checkNetwork && diag.apiKeyPresent) { - const err = await preflightLinear(ctx.options, true); + const err = await preflightLinear(true); diag.networkChecked = true; diag.networkOk = err === null; if (err) diag.networkError = err; From 35b53ca67f8b1b8ed2312457056423c9cd90a198 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:13:05 +0200 Subject: [PATCH 5/7] chore(linear): fix dead store of created/updated on the atomic path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --atomic block is inserted before the legacy per-issue loop and returns early with its own counts, so the hoisted `let created = 0` / `let updated = 0` were a dead store on the atomic path — a UNUSED_VAR_ASSIGNMENT that noUnusedLocals cannot flag because both variables are still read on the legacy branch. Move the `created`/`updated` declarations down to the legacy loop that actually uses them; keep `skipped` hoisted since both paths increment it. No behavior change; 63 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/pm/history/pm-linear-nvt5.jsonl | 1 + .agents/pm/tasks/pm-linear-nvt5.toon | 5 +++-- index.ts | 7 +++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.agents/pm/history/pm-linear-nvt5.jsonl b/.agents/pm/history/pm-linear-nvt5.jsonl index 1bc7d92..4da6a26 100644 --- a/.agents/pm/history/pm-linear-nvt5.jsonl +++ b/.agents/pm/history/pm-linear-nvt5.jsonl @@ -6,3 +6,4 @@ {"ts":"2026-07-21T12:55:11.202Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-07-21T12:55:11.201Z","author":"claude-orchestrator","text":"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T12:55:11.202Z"}],"before_hash":"027f2144762d1631a804b7a756970071c2dbe6a14e76a1033f0f50feb79e97c4","after_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7"} {"ts":"2026-07-21T15:55:36.080Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-07-21T15:55:36.079Z","author":"claude-orchestrator","text":"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T15:55:36.080Z"}],"before_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7","after_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d"} {"ts":"2026-07-21T16:09:16.974Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-07-21T16:09:16.974Z","author":"claude-orchestrator","text":"Static-analysis cleanup (DeepScan '1 new issue' on PR #36): removed genuine dead code surfaced by strict tsc (noUnusedLocals/noUnusedParameters) — the unused, unexported KNOWN_LINEAR_FIELDS const (parseFieldMap accepts any key, never referenced it) and the unused 'options' parameter on preflightLinear (2 call sites updated). Build+typecheck+63 tests green. All other analyzers clean (Greptile pass, CodeRabbit full review 0 findings, Hound pass, CI pass)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T16:09:16.974Z"}],"before_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d","after_hash":"4794b710046111e4f2af0cfe9d36c2cc292ed4f2dce089ca2809120e76df58ee"} +{"ts":"2026-07-21T16:13:05.646Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-07-21T16:13:05.643Z","author":"claude-orchestrator","text":"DeepScan 'raw 1 new' root cause: the --atomic block was inserted BEFORE the legacy loop's 'let created=0; let updated=0;' declarations and returns early, so those two assignments are a dead store on the atomic path (a UNUSED_VAR_ASSIGNMENT tsc's noUnusedLocals can't see because the vars are read on the OTHER branch). Moved created/updated declarations down to the legacy loop; kept 'skipped' hoisted (both paths use it). No behavior change; 63 tests green."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T16:13:05.646Z"}],"before_hash":"4794b710046111e4f2af0cfe9d36c2cc292ed4f2dce089ca2809120e76df58ee","after_hash":"6f975d4dcafcf973f1d795403051eb04ea9baeaf553bdfce401533c759cd8df7"} diff --git a/.agents/pm/tasks/pm-linear-nvt5.toon b/.agents/pm/tasks/pm-linear-nvt5.toon index 4a86f7e..7f6dd8d 100644 --- a/.agents/pm/tasks/pm-linear-nvt5.toon +++ b/.agents/pm/tasks/pm-linear-nvt5.toon @@ -6,13 +6,14 @@ status: in_progress priority: 2 tags: [] created_at: "2026-07-21T12:39:12.520Z" -updated_at: "2026-07-21T16:09:16.974Z" +updated_at: "2026-07-21T16:13:05.646Z" assignee: pi-agent author: pi-agent -notes[5]{created_at,author,text}: +notes[6]{created_at,author,text}: "2026-07-21T12:39:58.574Z",pi-agent,"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)." "2026-07-21T12:40:15.159Z",pi-agent,"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker." "2026-07-21T12:55:11.201Z",claude-orchestrator,"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR." "2026-07-21T15:55:36.079Z",claude-orchestrator,"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder." "2026-07-21T16:09:16.974Z",claude-orchestrator,"Static-analysis cleanup (DeepScan '1 new issue' on PR #36): removed genuine dead code surfaced by strict tsc (noUnusedLocals/noUnusedParameters) — the unused, unexported KNOWN_LINEAR_FIELDS const (parseFieldMap accepts any key, never referenced it) and the unused 'options' parameter on preflightLinear (2 call sites updated). Build+typecheck+63 tests green. All other analyzers clean (Greptile pass, CodeRabbit full review 0 findings, Hound pass, CI pass)." + "2026-07-21T16:13:05.643Z",claude-orchestrator,"DeepScan 'raw 1 new' root cause: the --atomic block was inserted BEFORE the legacy loop's 'let created=0; let updated=0;' declarations and returns early, so those two assignments are a dead store on the atomic path (a UNUSED_VAR_ASSIGNMENT tsc's noUnusedLocals can't see because the vars are read on the OTHER branch). Moved created/updated declarations down to the legacy loop; kept 'skipped' hoisted (both paths use it). No behavior change; 63 tests green." body: "" diff --git a/index.ts b/index.ts index 3621110..76ea65e 100644 --- a/index.ts +++ b/index.ts @@ -1476,8 +1476,9 @@ export async function syncLinearIssues( // but we still want the preview to report create-vs-update accurately). const existingByLinearId = indexItemsByLinearId((dependencies.readItems ?? readPmItems)(pm_root)); - let created = 0; - let updated = 0; + // `skipped` is shared by both the atomic and legacy paths; `created`/`updated` + // are declared with the legacy loop below (the atomic path returns before it + // and reports its own counts, so hoisting them here would be a dead store). let skipped = 0; // ------------------------------------------------------------------------- @@ -1590,6 +1591,8 @@ export async function syncLinearIssues( }; } + let created = 0; + let updated = 0; for (const issue of issues) { // State name filter. The authoritative constraint is now server-side // (`state: { name: { containsIgnoreCase: $state } }` in buildIssuesQuery), From 1fdf8899f2c71dd9d4ce9355f7c5885a6d32ea9d Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:21:15 +0200 Subject: [PATCH 6/7] refactor(linear): hoist entry.match?.id into a local in the atomic upsert buildAtomicImportMutations narrowed `entry.match` via `entry.match?.id` in the guard but then read `entry.match.id` non-optionally (on the RHS of the `||` and again after the early return). TypeScript proves both safe via control-flow narrowing, but path-insensitive static analyzers flag the non-optional access as a possible undefined dereference. Hoist the optional read into `const matchId = entry.match?.id` and use it throughout. Identical behavior (matchId is narrowed to a string after the `!matchId` guard returns); removes the last non-optional match access. 63 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 76ea65e..faffe47 100644 --- a/index.ts +++ b/index.ts @@ -1268,7 +1268,8 @@ export function buildAtomicImportMutations( // makes later content-bearing transactions refresh the item normally. The // close transition is a separate mutation so its reason is preserved and it // is reversible independently of the field update. - if (!entry.match?.id || entry.match.id === managedItemId) { + const matchId = entry.match?.id; + if (!matchId || matchId === managedItemId) { const mutations: BulkItemMutation[] = [ { op: "create", @@ -1299,7 +1300,7 @@ export function buildAtomicImportMutations( return { itemId: managedItemId, mutations }; } - const itemId = entry.match.id; + const itemId = matchId; const updateOptions: Record = { ...sharedOptions }; if (entry.status !== "closed") { updateOptions.status = entry.status; From b71d2c35727b88f953e4638a03513306ecd813d7 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:43:05 +0200 Subject: [PATCH 7/7] chore(linear): clear type-aware redundant-condition findings in atomic code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local typescript-eslint strictTypeChecked identified the remaining static-analysis finding as a type-aware no-unnecessary-condition / redundant-conversion nit on defensive SDK-boundary code (no runtime-bug rule fires). Apply behavior-preserving cleanups to the three lines new to this branch; per the pinned @unbrained/pm-cli>=2026.7.20 types these are provable no-ops: - `settings?.id_prefix` / `String(settings.id_prefix)`: readSettings resolves to a non-null defaults object, and id_prefix is narrowed to a string inside the guard, so the optional chain and String() are redundant -> `if (settings.id_prefix) idPrefix = settings.id_prefix`. - `Boolean(result?.recovered)`: commitItemMutations returns a non-null result with a boolean `recovered`, so `result.recovered` is equivalent (undefined would be falsy either way). - `assertSdkFunction`: F was used only in the return position (a disguised cast). Constrain `F extends (...args: never[]) => unknown` and take `fn: F | undefined` so F is used in the parameter too — the typeof guard then narrows the return with no assertion. No behavior change; typecheck + build + 63 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/pm/history/pm-linear-nvt5.jsonl | 1 + .agents/pm/tasks/pm-linear-nvt5.toon | 5 +++-- index.ts | 11 +++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.agents/pm/history/pm-linear-nvt5.jsonl b/.agents/pm/history/pm-linear-nvt5.jsonl index 4da6a26..d6b7128 100644 --- a/.agents/pm/history/pm-linear-nvt5.jsonl +++ b/.agents/pm/history/pm-linear-nvt5.jsonl @@ -7,3 +7,4 @@ {"ts":"2026-07-21T15:55:36.080Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-07-21T15:55:36.079Z","author":"claude-orchestrator","text":"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T15:55:36.080Z"}],"before_hash":"6a54d17f27f250f160028f18dbce4dc3f710f489d572882a81a98da4f94fbaa7","after_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d"} {"ts":"2026-07-21T16:09:16.974Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-07-21T16:09:16.974Z","author":"claude-orchestrator","text":"Static-analysis cleanup (DeepScan '1 new issue' on PR #36): removed genuine dead code surfaced by strict tsc (noUnusedLocals/noUnusedParameters) — the unused, unexported KNOWN_LINEAR_FIELDS const (parseFieldMap accepts any key, never referenced it) and the unused 'options' parameter on preflightLinear (2 call sites updated). Build+typecheck+63 tests green. All other analyzers clean (Greptile pass, CodeRabbit full review 0 findings, Hound pass, CI pass)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T16:09:16.974Z"}],"before_hash":"fa8b34cb87ac1dab24b721530b1842770730d6ca5ddce5755b98b40715684d7d","after_hash":"4794b710046111e4f2af0cfe9d36c2cc292ed4f2dce089ca2809120e76df58ee"} {"ts":"2026-07-21T16:13:05.646Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-07-21T16:13:05.643Z","author":"claude-orchestrator","text":"DeepScan 'raw 1 new' root cause: the --atomic block was inserted BEFORE the legacy loop's 'let created=0; let updated=0;' declarations and returns early, so those two assignments are a dead store on the atomic path (a UNUSED_VAR_ASSIGNMENT tsc's noUnusedLocals can't see because the vars are read on the OTHER branch). Moved created/updated declarations down to the legacy loop; kept 'skipped' hoisted (both paths use it). No behavior change; 63 tests green."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T16:13:05.646Z"}],"before_hash":"4794b710046111e4f2af0cfe9d36c2cc292ed4f2dce089ca2809120e76df58ee","after_hash":"6f975d4dcafcf973f1d795403051eb04ea9baeaf553bdfce401533c759cd8df7"} +{"ts":"2026-07-21T16:43:05.393Z","author":"claude-orchestrator","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/6","value":{"created_at":"2026-07-21T16:43:05.392Z","author":"claude-orchestrator","text":"DeepScan '1 new' root-caused via local typescript-eslint strictTypeChecked (TS6 API): a type-aware no-unnecessary-condition/redundant-conversion on defensive SDK-boundary code, NOT a runtime bug (all high-signal rules — floating-promises/await/constant-condition/dupe-keys — clean). The only findings NEW to this branch were 3 lines: assertSdkFunction (type param used once), 'settings?.id_prefix / String(...)', and 'Boolean(result?.recovered)'. Applied behavior-preserving fixes (per pinned SDK types these are provable no-ops): dropped redundant optional-chains + Boolean()/String(); constrained assertSdkFunctionunknown>(fn: F|undefined) so F is used twice (also stronger typing). typecheck+build+63 tests green. User-approved cleanup."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T16:43:05.393Z"}],"before_hash":"6f975d4dcafcf973f1d795403051eb04ea9baeaf553bdfce401533c759cd8df7","after_hash":"48822fc0579c9ee51d4575ebc1701f935a77e47739b002a817622036bdbde106"} diff --git a/.agents/pm/tasks/pm-linear-nvt5.toon b/.agents/pm/tasks/pm-linear-nvt5.toon index 7f6dd8d..a715238 100644 --- a/.agents/pm/tasks/pm-linear-nvt5.toon +++ b/.agents/pm/tasks/pm-linear-nvt5.toon @@ -6,14 +6,15 @@ status: in_progress priority: 2 tags: [] created_at: "2026-07-21T12:39:12.520Z" -updated_at: "2026-07-21T16:13:05.646Z" +updated_at: "2026-07-21T16:43:05.393Z" assignee: pi-agent author: pi-agent -notes[6]{created_at,author,text}: +notes[7]{created_at,author,text}: "2026-07-21T12:39:58.574Z",pi-agent,"DESIGN DECISIONS:\n- Stable item id keyed on Linear issue identifier (e.g. ENG-123), the most stable human-meaningful external key, never fetch order. deriveAtomicItemId hashes lowercased team + identifier through normalizeItemId.\n- Transaction id fingerprints lowercased team + canonical entries sorted by the stable identifier + the exact ordered BulkItemMutation plan. A changed id_prefix or provenance match yields a fresh journal; a reordered retry resumes the same one.\n- Mutation plan: create+update upsert for new/deterministic-id items (same plan on recovery when provenance scan finds the deterministic id); update(+close) for legacy matches at a different id. close is a separate mutation preserving a reason and is independently reversible.\n- Compensation: createCompensation='delete' for creates; SDK version-restores updates+closes. On AggregateError/compensation-failed -> warn tracker MAY be partial + same-transaction recovery path; normal SDK compensation -> no new partial state; WorkspaceTransactionInterruptedError -> resumable.\n- Byte-compatible default: non-atomic per-issue spawnSync path is UNCHANGED. --atomic --dry-run shares the atomic prep/matching path (fetches issues, reports create/update/skip counts) and NEVER commits; the non-atomic --dry-run stays fully offline (GraphQL request preview).\n- All-skipped non-dry atomic import throws the SAME error shape as legacy (CommandError 'Imported 0 issue(s); N failed.', exitCode GENERIC_FAILURE) BEFORE commit.\n- Exported CommandError/EXIT_CODE/LinearIssue/syncLinearIssues (+SyncRunDependencies test seams) mirroring pm-github; package.json peerDep >=2026.7.20, devDep ^2026.7.20 (latest published with commitItemMutations; ^2026.7.21 not yet on npm)." "2026-07-21T12:40:15.159Z",pi-agent,"QUALITY GATE: npm run release:check PASSED.\n- typecheck (tsc --noEmit): clean\n- build (tsc): clean\n- npm test: 61 tests pass / 0 fail (51 smoke + 10 new atomic)\n- audit:prod: 0 vulnerabilities\n- pack:dry-run: ok (9 files, 50.7 kB)\n- changelog:check: up to date (no regeneration needed)\n\nAtomic test coverage (test/atomic.test.ts, 10 tests):\n1. mid-run failure -> reverse compensation restores created/updated/closed items (failed batch deletes every create; mixed batch restores updates+closes)\n2. resume idempotence -> re-running same fetched set recovers SAME transaction, 0 duplicates\n3. reordered-retry stability -> shuffled fetch order yields SAME transaction id & item ids\n4. --atomic --dry-run -> shared prep path, correct counts (1 import/1 update/1 skip), NO SDK commit call\n5. all-skipped non-dry atomic import -> throws legacy error shape (CommandError, 'Imported 0 issue(s); 1 failed'), before commit\n6. transaction id changes when a mutation target changes (id, id_prefix, status)\nPlus: incomplete compensation reported as partial+resumable; end-to-end update+close commit through the real SDK against a live tracker." "2026-07-21T12:55:11.201Z",claude-orchestrator,"Orchestrator independent verification (2026-07-21): re-ran the full release gate — 61/61 tests pass, 0 prod vulnerabilities, changelog current, pack clean. Reviewed the atomic design against the merged pm-github reference: deriveAtomicTransactionId hashes team + identifier-sorted canonical entries + exact ordered mutation plan; deriveAtomicItemId keys on the stable Linear identifier (linear--), never fetch order; importLinearAtomic sorts entries by identifier BEFORE building the plan (order-independence). Ran an independent live harness on the built dist/index.js + REAL SDK against throwaway workspaces: (A) atomic import of 5 realistic entries (2 closed) -> 5 items, txn linear-import-333458e94167573e; re-run recovered the SAME transaction, 0 new, 5 items total (no duplicates); (B) SHUFFLED fetch order through importLinearAtomic -> IDENTICAL transaction id and identical managed item ids. Local greptile CLI produced empty output (known flakiness) -> relying on the PR-time Greptile app + CodeRabbit/Sourcery/cubic/semgrep. Ready to push + PR." "2026-07-21T15:55:36.079Z",claude-orchestrator,"Bot-review hardening round (PR #36): (1) gemini-high + no reopen churn — removed shared status field; create seeds open, update omits status, close owns the closed transition; (2) gemini-high + greptile-P1 — all-filtered batch now returns a successful zero-sync result before touching the SDK (matches legacy path; unblocks cron/CI); (3) greptile-P1 — settings read fault now throws CommandError instead of forking id_prefix to 'pm-' (protects transaction/journal identity so resume can't duplicate items); (4) greptile-P1 — linear-sync importer now forwards --dry-run so --atomic --dry-run previews without writing. +2 tests (63 total). Functionally verified end-to-end against a real throwaway pm workspace via real SDK commitItemMutations: create/close, idempotent recovered resume, tx id stable under reorder." "2026-07-21T16:09:16.974Z",claude-orchestrator,"Static-analysis cleanup (DeepScan '1 new issue' on PR #36): removed genuine dead code surfaced by strict tsc (noUnusedLocals/noUnusedParameters) — the unused, unexported KNOWN_LINEAR_FIELDS const (parseFieldMap accepts any key, never referenced it) and the unused 'options' parameter on preflightLinear (2 call sites updated). Build+typecheck+63 tests green. All other analyzers clean (Greptile pass, CodeRabbit full review 0 findings, Hound pass, CI pass)." "2026-07-21T16:13:05.643Z",claude-orchestrator,"DeepScan 'raw 1 new' root cause: the --atomic block was inserted BEFORE the legacy loop's 'let created=0; let updated=0;' declarations and returns early, so those two assignments are a dead store on the atomic path (a UNUSED_VAR_ASSIGNMENT tsc's noUnusedLocals can't see because the vars are read on the OTHER branch). Moved created/updated declarations down to the legacy loop; kept 'skipped' hoisted (both paths use it). No behavior change; 63 tests green." + "2026-07-21T16:43:05.392Z",claude-orchestrator,"DeepScan '1 new' root-caused via local typescript-eslint strictTypeChecked (TS6 API): a type-aware no-unnecessary-condition/redundant-conversion on defensive SDK-boundary code, NOT a runtime bug (all high-signal rules — floating-promises/await/constant-condition/dupe-keys — clean). The only findings NEW to this branch were 3 lines: assertSdkFunction (type param used once), 'settings?.id_prefix / String(...)', and 'Boolean(result?.recovered)'. Applied behavior-preserving fixes (per pinned SDK types these are provable no-ops): dropped redundant optional-chains + Boolean()/String(); constrained assertSdkFunctionunknown>(fn: F|undefined) so F is used twice (also stronger typing). typecheck+build+63 tests green. User-approved cleanup." body: "" diff --git a/index.ts b/index.ts index faffe47..25f5bd7 100644 --- a/index.ts +++ b/index.ts @@ -1118,14 +1118,17 @@ export interface PreparedLinearImport { match?: PmItem; } -function assertSdkFunction(fn: unknown, exportName: string): F { +function assertSdkFunction unknown>( + fn: F | undefined, + exportName: string, +): F { if (typeof fn !== "function") { throw new CommandError( `--atomic requires @unbrained/pm-cli>=2026.7.20 with the commitItemMutations SDK primitive, but the installed SDK does not export ${exportName} as a function. Upgrade @unbrained/pm-cli to >=2026.7.20.`, EXIT_CODE.USAGE, ); } - return fn as F; + return fn; } async function loadAtomicSdk( @@ -1349,7 +1352,7 @@ export async function importLinearAtomic( let idPrefix = "pm-"; try { const settings = await readSettings(pmRoot); - if (settings?.id_prefix) idPrefix = String(settings.id_prefix); + if (settings.id_prefix) idPrefix = settings.id_prefix; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); throw new CommandError( @@ -1390,7 +1393,7 @@ export async function importLinearAtomic( // as well as steps resumed now. The SDK intentionally returns the durable // final results, not a per-invocation delta, so create/update counts cannot // be reconstructed truthfully. Report the recovered batch separately. - const recovered = Boolean(result?.recovered); + const recovered = result.recovered; return { transactionId, recovered,