diff --git a/.agents/pm/history/pm-github-rwq9.jsonl b/.agents/pm/history/pm-github-rwq9.jsonl new file mode 100644 index 0000000..0871566 --- /dev/null +++ b/.agents/pm/history/pm-github-rwq9.jsonl @@ -0,0 +1,6 @@ +{"ts":"2026-08-03T21:59:35.068Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"kimi-k3","agent_model_source":"environment","agent_instance":"0d29e597c3da0cc6b64f7e7d","agent_provenance":{"model":{"value":"kimi-k3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-rwq9"},{"op":"add","path":"/metadata/title","value":"Fix terminal transitions for pm-cli 2026.8.3 close_reason enforcement"},{"op":"add","path":"/metadata/description","value":"Upgrading @unbrained/pm-cli from 2026.7.29 to 2026.8.3 makes pm update --status closed a hard error (close_reason_required) because governance.require_close_reason is enforced. This package relied on the old auto-route bypass in its import and sync reconciliation paths. Route terminal transitions through pm close with real provenance reasons, pass completed-at where the source carries a timestamp, and bump the pm-cli pin to 2026.8.3."},{"op":"add","path":"/metadata/type","value":"Issue"},{"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-08-03T21:59:35.068Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-03T21:59:35.068Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"npm run typecheck, npm run build, npm test, npm run coverage and npm run changelog:check all pass with @unbrained/pm-cli pinned at ^2026.8.3; the previously failing tests runImport reopens a linked item whose upstream issue was reopened and runSync --apply PATCHes GitHub to match pm status pass without editing test intent; no governance policy is disabled or weakened."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"15012fd143ef2ccae4359e9e1b319b087f00bf99eb9459cabf86c5f5d7559573","message":""} +{"ts":"2026-08-03T21:59:43.858Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"kimi-k3","agent_model_source":"environment","agent_instance":"0d29e597c3da0cc6b64f7e7d","agent_provenance":{"model":{"value":"kimi-k3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T21:59:43.858Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"15012fd143ef2ccae4359e9e1b319b087f00bf99eb9459cabf86c5f5d7559573","after_hash":"43be8f5ccacd0f677c17d20c40597c278e7c97110e06c2c52346141bcc2b4052"} +{"ts":"2026-08-03T21:59:57.994Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"kimi-k3","agent_model_source":"environment","agent_instance":"0d29e597c3da0cc6b64f7e7d","agent_provenance":{"model":{"value":"kimi-k3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T21:59:57.994Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/claim_principal","value":"pi-agent"}],"before_hash":"43be8f5ccacd0f677c17d20c40597c278e7c97110e06c2c52346141bcc2b4052","after_hash":"918dbd5e83e72d9be0ad6bb25ad37c34cf3e31078c171bd6e49894f291fd630b"} +{"ts":"2026-08-03T22:20:57.761Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"50b044f32abe7b85e5073931","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T22:20:57.761Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-03T22:20:57.760Z","author":"pi-agent","text":"Root cause: two defects surfaced under pm-cli 2026.8.3 close_reason enforcement. (1) SOURCE: runImport's non-atomic create path issued `pm create --status closed` for new closed GitHub issues, which is now a hard close_reason_required error. Fixed by creating closed issues as open then closing via `pm close --reason`, mirroring the already-correct atomic path and the reconciliation path. (2) FIXTURE: the test helper createLinkedItem used the same `pm create --status closed` pattern; switched to create-then-close so the closed linked-item fixture still represents a genuinely closed item. Also threaded GitHub `closed_at` through GhIssue -> PreparedGithubImport -> every close site (create path, reconciliation, atomic close op) as --completed-at / completedAt so imported items keep their real completion time instead of import time."}]}],"before_hash":"918dbd5e83e72d9be0ad6bb25ad37c34cf3e31078c171bd6e49894f291fd630b","after_hash":"464deda7e9d74ee9b89e443e472a58d8eb6c66c9f6a77ff8b09c0db4b38c3150"} +{"ts":"2026-08-03T22:21:03.479Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"50b044f32abe7b85e5073931","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T22:21:03.479Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-03T22:21:03.466Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-03T22:21:03.466Z"},{"op":"add","path":"/metadata/close_reason","value":"Shipped: index.ts create path now routes new closed issues through create(open)+pm close --reason [--completed-at]; reconciliation close and atomic close op carry completedAt from GitHub closed_at; createLinkedItem fixture uses create-then-close. Verified: npm run typecheck (ok), npm run build (ok), npm test (239/239 pass incl. the two previously-failing tests), npm run coverage (index.ts 88.18% lines / 79.63% branches / 89.94% funcs, all thresholds met). No governance policy weakened; closed_at is read-only provenance from the source record."}],"before_hash":"464deda7e9d74ee9b89e443e472a58d8eb6c66c9f6a77ff8b09c0db4b38c3150","after_hash":"50dcb1366c33cd61bdbcf9cf6232a2bb85d414435b82a84b72ecd957efb51802"} +{"ts":"2026-08-03T23:09:31.421Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"65d9708c9f7e99938905a9c3","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-03T23:09:31.421Z","author":"pi-agent","text":"Follow-up sweep found two more defects the initial fix missed. (1) PRODUCT BUG: runProjectImport (the github project import handler) still issued pm create --status closed and pm update --status closed for project board items wrapping closed issues or carrying a board Status mapped to closed. Fixed both the create path (create open, then pm close --reason with factual provenance from the wrapped issue or project ref) and the update path (update without --status, then pm close --reason). This is a user-facing bug: importing a GitHub project with closed items would fail under pm-cli 2026.8.3. (2) LATENT BUG: parseCreatedItemId looked for parsed.item.id but pm create --json emits { id: ... } at the top level, so the create-then-close path in runImport could not read the new id and silently left the item open instead of closing it. Fixed to accept both parsed.id and parsed.item.id. Added two handler-level tests (closed issue create path, closed board-status update path) and updated the parseCreatedItemId unit test for the real emit shape. All 241 tests pass; coverage thresholds met."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T23:09:31.421Z"}],"before_hash":"50dcb1366c33cd61bdbcf9cf6232a2bb85d414435b82a84b72ecd957efb51802","after_hash":"7280952e47bc2eff67c9fd8a42c03d340a4f3d186f0c03d5a12786d2ea242533"} diff --git a/.agents/pm/issues/pm-github-rwq9.toon b/.agents/pm/issues/pm-github-rwq9.toon new file mode 100644 index 0000000..b517b1f --- /dev/null +++ b/.agents/pm/issues/pm-github-rwq9.toon @@ -0,0 +1,19 @@ +id: pm-github-rwq9 +title: Fix terminal transitions for pm-cli 2026.8.3 close_reason enforcement +description: "Upgrading @unbrained/pm-cli from 2026.7.29 to 2026.8.3 makes pm update --status closed a hard error (close_reason_required) because governance.require_close_reason is enforced. This package relied on the old auto-route bypass in its import and sync reconciliation paths. Route terminal transitions through pm close with real provenance reasons, pass completed-at where the source carries a timestamp, and bump the pm-cli pin to 2026.8.3." +type: Issue +status: closed +priority: 2 +tags: [] +created_at: "2026-08-03T21:59:35.068Z" +updated_at: "2026-08-03T23:09:31.421Z" +closed_at: "2026-08-03T22:21:03.466Z" +completed_at: "2026-08-03T22:21:03.466Z" +claim_principal: pi-agent +author: pi-agent +acceptance_criteria: "npm run typecheck, npm run build, npm test, npm run coverage and npm run changelog:check all pass with @unbrained/pm-cli pinned at ^2026.8.3; the previously failing tests runImport reopens a linked item whose upstream issue was reopened and runSync --apply PATCHes GitHub to match pm status pass without editing test intent; no governance policy is disabled or weakened." +notes[2]{created_at,author,text}: + "2026-08-03T22:20:57.760Z",pi-agent,"Root cause: two defects surfaced under pm-cli 2026.8.3 close_reason enforcement. (1) SOURCE: runImport's non-atomic create path issued `pm create --status closed` for new closed GitHub issues, which is now a hard close_reason_required error. Fixed by creating closed issues as open then closing via `pm close --reason`, mirroring the already-correct atomic path and the reconciliation path. (2) FIXTURE: the test helper createLinkedItem used the same `pm create --status closed` pattern; switched to create-then-close so the closed linked-item fixture still represents a genuinely closed item. Also threaded GitHub `closed_at` through GhIssue -> PreparedGithubImport -> every close site (create path, reconciliation, atomic close op) as --completed-at / completedAt so imported items keep their real completion time instead of import time." + "2026-08-03T23:09:31.421Z",pi-agent,"Follow-up sweep found two more defects the initial fix missed. (1) PRODUCT BUG: runProjectImport (the github project import handler) still issued pm create --status closed and pm update --status closed for project board items wrapping closed issues or carrying a board Status mapped to closed. Fixed both the create path (create open, then pm close --reason with factual provenance from the wrapped issue or project ref) and the update path (update without --status, then pm close --reason). This is a user-facing bug: importing a GitHub project with closed items would fail under pm-cli 2026.8.3. (2) LATENT BUG: parseCreatedItemId looked for parsed.item.id but pm create --json emits { id: ... } at the top level, so the create-then-close path in runImport could not read the new id and silently left the item open instead of closing it. Fixed to accept both parsed.id and parsed.item.id. Added two handler-level tests (closed issue create path, closed board-status update path) and updated the parseCreatedItemId unit test for the real emit shape. All 241 tests pass; coverage thresholds met." +close_reason: "Shipped: index.ts create path now routes new closed issues through create(open)+pm close --reason [--completed-at]; reconciliation close and atomic close op carry completedAt from GitHub closed_at; createLinkedItem fixture uses create-then-close. Verified: npm run typecheck (ok), npm run build (ok), npm test (239/239 pass incl. the two previously-failing tests), npm run coverage (index.ts 88.18% lines / 79.63% branches / 89.94% funcs, all thresholds met). No governance policy weakened; closed_at is read-only provenance from the source record." +body: "" diff --git a/.gitattributes b/.gitattributes index c3e7c66..fe696ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,6 @@ # pm-cli:merge-drivers:start +".agents/pm/**/*.toon" merge=pm-item-toon +".agents/pm/**/*.md" merge=pm-item-markdown ".agents/pm/chores/*.toon" merge=pm-item-toon ".agents/pm/chores/*.md" merge=pm-item-markdown ".agents/pm/decisions/*.toon" merge=pm-item-toon diff --git a/index.ts b/index.ts index ee1cb5e..b3cbd8f 100644 --- a/index.ts +++ b/index.ts @@ -78,6 +78,8 @@ export interface GhIssue { milestone: { title: string } | null; created_at: string; updated_at: string; + /** GitHub completion timestamp (`null` while the issue is open); carried as `--completed-at` on close. */ + closed_at?: string | null; html_url: string; comments?: number; comments_url?: string; @@ -847,6 +849,8 @@ export interface PreparedGithubImport { milestone?: string; comments: GhComment[]; syncAnnotations: boolean; + /** Source completion timestamp (GitHub `closed_at`), forwarded as `--completed-at` when the item is closed. */ + closedAt?: string; match?: PmItem; } @@ -1008,6 +1012,8 @@ export function buildAtomicImportMutations( op: "close", id: managedItemId, reason: `GitHub issue #${entry.issueNumber} closed`, + // Preserve the source's real completion time instead of the import time. + ...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}), }); } return { @@ -1033,6 +1039,8 @@ export function buildAtomicImportMutations( op: "close", id: itemId, reason: `GitHub issue #${entry.issueNumber} closed`, + // Preserve the source's real completion time instead of the import time. + ...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}), }); } return { itemId, mutations }; @@ -1590,14 +1598,29 @@ export function extractSyncedCommentIds(stored: { text?: string }[]): Set { + const args = ["--path", pmRoot, "close", id, "--reason", `GitHub issue #${issue.number} closed`]; + if (closedAt) args.push("--completed-at", closedAt); + return args; + }; + if (opts.dryRun) { const action = match?.id ? "update" : "import"; console.error(` [dry-run] #${issue.number} ${action} ${title} (${status}, ${labels.join(",")})`); @@ -2347,7 +2389,9 @@ export async function runImport( } // Reconcile status separately. if (status === "closed" && match.status !== "closed") { - const close = pmRun(["--path", pmRoot, "close", match.id, "--reason", `GitHub issue #${issue.number} closed`]); + // Close through `pm close` so the reason is real provenance; pass the + // source completion time as --completed-at when GitHub recorded one. + const close = pmRun(githubCloseArgs(match.id)); if (!close.ok) { console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`); skipped++; @@ -2368,11 +2412,17 @@ export async function runImport( continue; } + // A closed upstream issue cannot be born closed: governance + // `require_close_reason` rejects `pm create --status closed`. Create the + // item open, then close it through `pm close` with the source's real + // completion timestamp (`closed_at`) as --completed-at provenance. + const createStatus = status === "closed" ? "open" : status; + const mustClose = status === "closed"; const createArgs = [ "--path", pmRoot, "create", "--title", title, "--type", opts.itemType, - "--status", status, + "--status", createStatus, "--description", description, "--body", body, "--tags", tags.join(","), @@ -2380,19 +2430,37 @@ export async function runImport( ]; if (assignee) createArgs.push("--assignee", assignee); if (milestone) createArgs.push("--sprint", milestone); - // --json lets the annotations sync address the freshly-created item by id - // without re-scanning the workspace; only added when we actually need the id. - if (syncAnnotations) createArgs.push("--json"); + // --json lets a following close (or annotations sync) address the freshly + // created item by id without re-scanning the workspace. + if (mustClose || syncAnnotations) createArgs.push("--json"); const created = pmRun(createArgs); if (!created.ok) { console.error(`#${issue.number}: create failed — ${created.stderr}`); skipped++; continue; } + const createdId = (mustClose || syncAnnotations) ? parseCreatedItemId(created.stdout) : undefined; + if (mustClose) { + if (createdId) { + const close = pmRun(githubCloseArgs(createdId)); + if (!close.ok) { + console.error(`#${issue.number}: close after import failed — ${close.stderr}`); + skipped++; + continue; + } + } else { + // The item exists but is still open, and without its id nothing here can + // close it. Counting it as imported would report a closed GitHub issue as + // a successfully imported *open* item, so it is reported as skipped — + // the same accounting the close-failure branch above already uses. + console.error(`#${issue.number}: could not parse created item id — left open`); + skipped++; + continue; + } + } if (syncAnnotations) { - const newId = parseCreatedItemId(created.stdout); - if (newId) { - await syncGithubCommentsToAnnotations(newId, comments, pmRoot, issue.number); + if (createdId) { + await syncGithubCommentsToAnnotations(createdId, comments, pmRoot, issue.number); } else { console.error(`#${issue.number}: could not parse created item id — comments not synced`); } @@ -3465,6 +3533,14 @@ async function gqlResolveIssueNodeId( // Compose a human-readable + provenance-bearing description for an imported // project item (parallels the issue-import description). +/** Build a factual close reason for a project-import item entering `closed`. */ +function projectImportCloseReason(ref: ProjectRef, c: ProjectItemContent): string { + if (c.repo && typeof c.number === "number") { + return `GitHub ${c.repo}#${c.number} closed (project ${ref.owner}/${ref.number})`; + } + return `GitHub project ${ref.owner}/${ref.number} item closed`; +} + function projectItemDescription(ref: ProjectRef, c: ProjectItemContent): string { const parts = [`GH project item ${ref.owner}/${ref.number}`]; if (c.typename === "DraftIssue") parts.push("· draft issue"); @@ -3662,24 +3738,45 @@ async function runProjectImport(ctx: CommandHandlerContext) { // skipped (no --status) so we never overwrite a real pm state with a guess // (no data loss). `entry.status` carries a fallback for the create path; // `entry.mappedStatus` is set only for an explicit, resolvable mapping. - if (entry.mappedStatus) updArgs.push("--status", entry.mappedStatus); + // A `closed` mapping cannot go through `pm update --status closed` + // (governance require_close_reason rejects it since pm-cli 2026.8.3); + // the update omits --status and a separate `pm close` records the reason. + const closeAfterUpdate = entry.mappedStatus === "closed"; + if (entry.mappedStatus && !closeAfterUpdate) updArgs.push("--status", entry.mappedStatus); const upd = pmRun(updArgs); if (!upd.ok) { console.error(` ${entry.title}: update failed — ${upd.stderr}`); skipped++; continue; } + if (closeAfterUpdate) { + const close = pmRun(["--path", ctx.pm_root, "close", entry.pmId, "--reason", projectImportCloseReason(ref, entry.content)]); + if (!close.ok) { console.error(` ${entry.title}: close failed — ${close.stderr}`); skipped++; continue; } + } updated++; continue; } + // A `closed` upstream item cannot be born closed: governance + // `require_close_reason` rejects `pm create --status closed` (pm-cli + // 2026.8.3+). Create open, then close through `pm close` with factual + // provenance. `--json` is needed to read the assigned id for the close. + const createStatus = entry.status === "closed" ? "open" : entry.status; + const mustClose = entry.status === "closed"; const createArgs = [ "--path", ctx.pm_root, "create", "--title", entry.title, "--type", itemType, - "--status", entry.status, + "--status", createStatus, "--description", description, "--tags", entry.tags.join(","), "--message", `Imported from GitHub project ${ref.owner}/${ref.number}`, ]; if (entry.body) createArgs.push("--body", entry.body); + if (mustClose) createArgs.push("--json"); const created = pmRun(createArgs); if (!created.ok) { console.error(` ${entry.title}: create failed — ${created.stderr}`); skipped++; continue; } + if (mustClose) { + const createdId = parseCreatedItemId(created.stdout); + if (!createdId) { console.error(` ${entry.title}: close failed — could not read created item id`); skipped++; continue; } + const close = pmRun(["--path", ctx.pm_root, "close", createdId, "--reason", projectImportCloseReason(ref, entry.content)]); + if (!close.ok) { console.error(` ${entry.title}: close failed — ${close.stderr}`); skipped++; continue; } + } imported++; } diff --git a/package-lock.json b/package-lock.json index a766422..09f3d7e 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.29", + "@unbrained/pm-cli": "^2026.8.3", "pm-changelog": "^2026.7.25", "typescript": "^7.0.2" }, @@ -18,7 +18,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.29" + "@unbrained/pm-cli": ">=2026.8.3" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -40,9 +40,9 @@ } }, "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.2.tgz", - "integrity": "sha512-GvpKWDmzBFzbtVElU+tEDMDYyMY8SnMH4NNwwlUQioNHZvE6sSuHEqNkfU3iYhWL+/XO5Ej+MzhVJoqM10NYCQ==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.3.tgz", + "integrity": "sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q==", "dev": true, "license": "MIT", "dependencies": { @@ -244,9 +244,9 @@ } }, "node_modules/@sentry/core": { - "version": "10.67.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.67.0.tgz", - "integrity": "sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==", + "version": "10.69.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.69.0.tgz", + "integrity": "sha512-+uuqVEeiDzYuAKjZLqsROKXvRTbl/QeH0gfGRtpYib1cud4rAFWRIkFmcR7Jb7JGFYwmReyQotiTj/hcDszTZg==", "dev": true, "license": "MIT", "dependencies": { @@ -257,9 +257,9 @@ } }, "node_modules/@sentry/node": { - "version": "10.67.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.67.0.tgz", - "integrity": "sha512-SFKpZGqOCEFSmP93NdDP6ikZp4NS7A/JR8+2ofK3jF6Y9Vyox7pX0pxdOnPLpFcPtMybMahfWqSAWJvsFs4RmA==", + "version": "10.69.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.69.0.tgz", + "integrity": "sha512-xEXA1YGIiTZbrW6MWV34uS6JGQuQg2ijTI0zed+FsJb9JZKPYel/GZK8Km26vfTVb+yCXFmWZNBesKegNcVdzg==", "dev": true, "license": "MIT", "dependencies": { @@ -267,10 +267,10 @@ "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node-core": "10.67.0", - "@sentry/opentelemetry": "10.67.0", - "@sentry/server-utils": "10.67.0", + "@sentry/core": "10.69.0", + "@sentry/node-core": "10.69.0", + "@sentry/opentelemetry": "10.69.0", + "@sentry/server-utils": "10.69.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -278,15 +278,15 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.67.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.67.0.tgz", - "integrity": "sha512-dBHHRwZyan1pOnFJ+sNBvR8TkXbZAfZU/jpxmALS3JZ2/8AGR7cQKL+b7SleKuJ7iUDZyklN3Nqi0i5JkcA+HA==", + "version": "10.69.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.69.0.tgz", + "integrity": "sha512-IgArHczrZJxkgxoffHscj0NxQrG6kCazgmGQnlf3j58J1ec21YaUu8Tu+7G4Lo5tCiW3teQnwlKW1ttMXSqWRw==", "dev": true, "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/opentelemetry": "10.67.0", + "@sentry/core": "10.69.0", + "@sentry/opentelemetry": "10.69.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -318,14 +318,14 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.67.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.67.0.tgz", - "integrity": "sha512-oLTOrAK1rOqmYRktOJZwz37B1seXPx1W2FTMVtzTVNjMFA/LZwGzePeZzhUOgzZgfLHixMd/ceWtGqoxAndcjQ==", + "version": "10.69.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.69.0.tgz", + "integrity": "sha512-3FyWV6YcEJuvLrlaKGE1dHXCI+1YO0a62w7PkwlRg8yp6K6YXkmdwu9GjqaYD+Ju4tm7uC7mHIsGFQMm0M7pqQ==", "dev": true, "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0" + "@sentry/core": "10.69.0" }, "engines": { "node": ">=18" @@ -337,16 +337,17 @@ } }, "node_modules/@sentry/server-utils": { - "version": "10.67.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.67.0.tgz", - "integrity": "sha512-GQ9t+RSTx5s3b/aZrLFuL4nrwPLMah5NiZk5cjxJmgmOSgm3nMdO8gdqCedDch5B13F3YsxtaQYjSJnzLz8M5A==", + "version": "10.69.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.69.0.tgz", + "integrity": "sha512-0MwHrA8+nNvMIsqf8m3cXwCBlUjr6AS7N6CZvHJtY1DkqEvQqEbD5VIrhzEyHN/KMZIgQ8XeDCQRhjnXFQGRhg==", "dev": true, "license": "MIT", "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0" + "@sentry/core": "10.69.0", + "meriyah": "^6.1.4" }, "engines": { "node": ">=18" @@ -717,13 +718,13 @@ } }, "node_modules/@unbrained/pm-cli": { - "version": "2026.7.29", - "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.29.tgz", - "integrity": "sha512-ByTNEwyvxTqG3NyHMRapK+6hbbiAOoGSSr1OlXRavX4+i+N8DiEJm3KQV46iX3FX0La+FdSbNhIyW0+ubNxYng==", + "version": "2026.8.3", + "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.8.3.tgz", + "integrity": "sha512-Eh/oYOiWfrxdXGqu9AJqebLCyglrVKwp3yXKXOuANIUh2w48ycvwlUEx+leQPX8YjfyOYxX+LsRvkmeyrzSmvw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/node": "10.67.0", + "@sentry/node": "10.69.0", "@toon-format/toon": "^4.1.0", "@types/node": ">=22", "commander": "^15.0.0", @@ -910,9 +911,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.2.tgz", - "integrity": "sha512-jTd2FfOgOWOdgjkHuk/1Ms8VKFXkPs15ymYBETw1sAOrO/dY3XeGVRWir9qBbw7pXr0T2eTFwfCZ+N02HmiNGA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz", + "integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 0ae46f0..19ccb00 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,11 @@ "coverage": "npm run build && npm run build:test && node scripts/coverage-gate.ts" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.29" + "@unbrained/pm-cli": ">=2026.8.3" }, "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.29", + "@unbrained/pm-cli": "^2026.8.3", "pm-changelog": "^2026.7.25", "typescript": "^7.0.2" }, diff --git a/test/comments-sync.test.ts b/test/comments-sync.test.ts index ff2825e..84b3021 100644 --- a/test/comments-sync.test.ts +++ b/test/comments-sync.test.ts @@ -174,10 +174,20 @@ test("extractSyncedCommentIds collects ids from markers and ignores plain commen }); test("parseCreatedItemId reads the id from `pm create --json` stdout", () => { - assert.strictEqual(parseCreatedItemId(JSON.stringify({ item: { id: "pm-ab12" } })), "pm-ab12"); + // The real envelope is FLAT — no `item` wrapper. The contract test below pins + // this literal against actual CLI output. + assert.strictEqual( + parseCreatedItemId(JSON.stringify({ id: "pm-ab12", status: "open", changed_field_count: 10 })), + "pm-ab12", + ); assert.strictEqual(parseCreatedItemId("not json"), undefined); - assert.strictEqual(parseCreatedItemId(JSON.stringify({ item: {} })), undefined); assert.strictEqual(parseCreatedItemId(JSON.stringify({})), undefined); + assert.strictEqual(parseCreatedItemId(JSON.stringify({ id: 42 })), undefined); + // The wrapper shape this parser used to read is NOT what the CLI emits, and no + // fallback for it is kept: accepting it would be dead code that no real CLI can + // exercise, and asserting it here would restore the exact failure mode that hid + // the original bug — a parser and a test agreeing with each other, not the CLI. + assert.strictEqual(parseCreatedItemId(JSON.stringify({ item: { id: "pm-ab12" } })), undefined); }); // Workspace integration: real SDK comments() primitive + dedupe ------------- @@ -213,6 +223,46 @@ function firstItemId(root: string): string { return arr[0].id as string; } +// Contract test: parseCreatedItemId against a REAL `pm create --json` run. +// +// The parser previously read `parsed.item.id`, a shape the CLI has never +// emitted. Because it returns undefined instead of throwing, the damage was +// silent — closed issues imported as open, comment sync skipped — and the unit +// test above kept passing because it asserted the same fabricated shape the +// parser expected. Asserting against real CLI stdout is the only formulation +// that can catch that class of drift, so this test must never be replaced with +// a hand-written literal. +test("parseCreatedItemId parses the envelope a real `pm create --json` actually emits", () => { + const root = mkdtempSync(join(tmpdir(), "pm-github-create-envelope-")); + try { + const env = { ...process.env, PM_AUTHOR: "tester" }; + const opts = { stdio: "ignore" as const, env, shell: process.platform === "win32" }; + try { + execFileSync(PM_BIN, ["init", "-y", "--force", "--workspace", root, "--author", "tester"], opts); + } catch { + execFileSync(PM_BIN, ["init", "-y", "--force", root, "--author", "tester"], opts); + } + const stdout = execFileSync( + PM_BIN, + ["--pm-path", root, "create", "task", "Envelope probe", "--json"], + { encoding: "utf-8", env, shell: process.platform === "win32" }, + ); + + const parsed = parseCreatedItemId(stdout); + assert.ok(parsed, "the id must be recoverable from real `pm create --json` stdout"); + // Cross-check against the workspace itself: the parsed id must be the item + // the CLI actually created, not merely some string-valued field. + assert.strictEqual(parsed, firstItemId(root)); + // Pin the envelope shape the parser depends on, so a host change that moves + // the id fails HERE with a clear message rather than silently downstream. + const envelope = JSON.parse(stdout); + assert.strictEqual(typeof envelope.id, "string", "`id` is a top-level string"); + assert.strictEqual(envelope.item, undefined, "there is no `item` wrapper"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("syncGithubCommentsToAnnotations populates the native comments collection via the SDK", async () => { const root = makeCommentsTestWorkspace(); try { diff --git a/test/handler-failures.test.ts b/test/handler-failures.test.ts index 83e9854..a1005ed 100644 --- a/test/handler-failures.test.ts +++ b/test/handler-failures.test.ts @@ -35,6 +35,7 @@ import { createExtensionTestHarness, type ExtensionTestHarness } from "@unbraine import type { SearchProviderQueryContext } from "@unbrained/pm-cli/sdk/authoring"; import extension, { parseImportOptions, runImport, type GhIssue } from "../index.ts"; +import { projectItemTag } from "../projects.ts"; import { captureStderr, jsonResponse, @@ -83,11 +84,20 @@ function freshTracker(): string { return root; } -/** Create a pm item carrying a GitHub provenance tag and the given status. */ +/** + * Create a pm item carrying a GitHub provenance tag and the given status. + * + * A `closed` fixture cannot be set at create time once governance + * `require_close_reason` is enforced (pm-cli >= 2026.8.3 rejects + * `pm create --status closed`), so the item is created open and then closed + * through `pm close` with a factual reason. The resulting item still carries + * the requested tag and status — only the mechanism changed. + */ function createLinkedItem(root: string, title: string, tag: string, status: string): string { + const createStatus = status === "closed" ? "open" : status; const r = spawnSync( PM_BIN, - ["--path", root, "create", "task", title, "--status", status, "--tags", tag], + ["--path", root, "create", "task", title, "--status", createStatus, "--tags", tag], PM_SPAWN_OPTS, ); assert.strictEqual(r.status, 0, `pm create failed: ${r.error?.message ?? r.stderr}`); @@ -96,6 +106,14 @@ function createLinkedItem(root: string, title: string, tag: string, status: stri const parsed = JSON.parse(list.stdout) as { items?: Array<{ id: string; tags?: string[] }> }; const item = (parsed.items ?? []).find((i) => (i.tags ?? []).includes(tag)); assert.ok(item, `created item carrying ${tag} should be listable`); + if (status === "closed") { + const close = spawnSync( + PM_BIN, + ["--path", root, "close", item!.id, "--reason", `Test fixture: linked item for ${tag} closed`], + PM_SPAWN_OPTS, + ); + assert.strictEqual(close.status, 0, `pm close failed: ${close.error?.message ?? close.stderr}`); + } return item!.id; } @@ -909,6 +927,128 @@ test("github project import --apply creates pm items for unlinked board items", } }); +// pm-cli 2026.8.3 enforces governance.require_close_reason: `pm create +// --status closed` and `pm update --status closed` are hard errors. A project +// board item wrapping a closed GitHub issue must be created open then closed +// through `pm close --reason`, not born closed. +test("github project import --apply routes a closed upstream issue through pm close (create path)", async () => { + const ext = await harnessPromise; + const root = freshTracker(); + try { + await withEnv({ GITHUB_TOKEN: "tok", GH_TOKEN: undefined }, async () => { + let call = 0; + await withMockGithub((_req, res) => { + call++; + if (call === 1) { + graphqlOk(res, { + user: { + projectV2: { + id: "PVT_proj", + title: "Board", + url: "u", + statusField: { id: "F", name: "Status", options: [{ id: "o_todo", name: "Todo" }] }, + }, + }, + organization: null, + }); + } else { + graphqlOk(res, { + node: { + items: { + pageInfo: { hasNextPage: false }, + nodes: [{ + id: "PVTI_closed", + // No board Status selected — the status comes from the wrapped issue. + content: { __typename: "Issue", title: "Shipped Feature", state: "closed", stateReason: "completed", repository: { nameWithOwner: "a/b" }, number: 42 }, + }], + }, + }, + }); + } + }, async () => { + const { result } = await ext.runCommand({ + command: "github project import", + args: ["u/5"], + pmRoot: root, + global: { json: true }, + }); + const r = result as { imported: number; updated: number; skipped: number }; + assert.strictEqual(r.imported, 1, "the closed issue is imported (create + close)"); + assert.strictEqual(r.skipped, 0, "nothing skipped — the close path succeeded"); + const items = listAllStatuses(root); + const item = items.find((i) => i.title === "Shipped Feature"); + assert.ok(item, "the item landed in the tracker"); + assert.strictEqual(item!.status, "closed", "the item is genuinely closed"); + assert.match(item!.close_reason ?? "", /a\/b#42/, "the close reason carries the upstream issue provenance"); + }); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +// Re-importing a board item whose Status option maps to `closed` (e.g. "Done") +// must update the existing pm item WITHOUT `--status closed` and then close +// through `pm close --reason`, not `pm update --status closed`. +test("github project import --apply routes a closed board-status mapping through pm close (update path)", async () => { + const ext = await harnessPromise; + const root = freshTracker(); + try { + // Pre-create a linked open pm item so the plan resolves to "update". + const tag = projectItemTag({ owner: "u", number: 5 }, "PVTI_done"); + pmSetup(root, ["create", "task", "Old Title", "--status", "open", "--tags", tag]); + await withEnv({ GITHUB_TOKEN: "tok", GH_TOKEN: undefined }, async () => { + let call = 0; + await withMockGithub((_req, res) => { + call++; + if (call === 1) { + graphqlOk(res, { + user: { + projectV2: { + id: "PVT_proj", + title: "Board", + url: "u", + statusField: { id: "F", name: "Status", options: [{ id: "o_todo", name: "Todo" }, { id: "o_done", name: "Done" }] }, + }, + }, + organization: null, + }); + } else { + graphqlOk(res, { + node: { + items: { + pageInfo: { hasNextPage: false }, + nodes: [{ + id: "PVTI_done", + fieldValueByName: { name: "Done", optionId: "o_done" }, + content: { __typename: "DraftIssue", title: "Old Title", body: "b" }, + }], + }, + }, + }); + } + }, async () => { + const { result } = await ext.runCommand({ + command: "github project import", + args: ["u/5"], + pmRoot: root, + global: { json: true }, + }); + const r = result as { imported: number; updated: number; skipped: number }; + assert.strictEqual(r.updated, 1, "the linked item was updated and closed"); + assert.strictEqual(r.skipped, 0, "nothing skipped — the close path succeeded"); + const items = listAllStatuses(root); + const item = items.find((i) => i.title === "Old Title"); + assert.ok(item, "the item is still present after re-import"); + assert.strictEqual(item!.status, "closed", "the item is genuinely closed"); + assert.match(item!.close_reason ?? "", /project u\/5/, "the close reason carries the project provenance"); + }); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + // `resolveProject` queries BOTH user and organization in one request and keeps // whichever resolves. An organization-owned project must take the org branch // (ownerType === "organization") — this is the symmetric counterpart to the @@ -988,6 +1128,39 @@ test("runImport closes a linked item whose upstream issue was closed", async () } }); +// End-to-end guard for the create-then-close path — a closed issue that has NO +// existing pm item, so runImport must `pm create` it open and then `pm close` it +// by the id it parses back out of `pm create --json`. +// +// This is the path that silently broke: `parseCreatedItemId` read a `{item:{id}}` +// wrapper the CLI has never emitted, so the id was always undefined, the close +// never ran, and every newly-imported closed issue landed OPEN while the import +// still reported success. The reconciliation tests above could not catch it +// because they pre-create the item and take the `match` branch, which closes by +// an id it already knows. Asserting the final STATUS here (not just the counts) +// is what makes the regression visible. +test("runImport creates and closes an unlinked issue that is already closed upstream", async () => { + const root = freshTracker(); + try { + await withEnv({ GITHUB_TOKEN: "tok", GH_TOKEN: undefined }, async () => { + await withMockGithub((_req, res) => { + jsonResponse(res, 200, [upstreamIssue(7, "Born closed", "closed")]); + }, async () => { + const result = (await runImport("a/b", root, parseImportOptions({}))) as Record; + assert.strictEqual(result.imported, 1, "the new issue is imported"); + assert.strictEqual(result.skipped, 0, "importing it is not a skip"); + assert.strictEqual( + statusForTag(root, "gh:a/b#7"), + "closed", + "an issue closed upstream must land closed, not open", + ); + }); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("runImport reopens a linked item whose upstream issue was reopened", async () => { const root = freshTracker(); try { @@ -1014,6 +1187,14 @@ function listTitles(root: string): string[] { return (arr as Array<{ title?: string }>).map((i) => i.title ?? ""); } +/** Read every pm item (including closed) with its status and close_reason. */ +function listAllStatuses(root: string): Array<{ id: string; title: string; status: string; close_reason?: string }> { + const r = spawnSync(PM_BIN, ["--path", root, "--json", "list-all", "--full"], PM_SPAWN_OPTS); + const parsed = JSON.parse(r.stdout); + const arr = Array.isArray(parsed) ? parsed : (parsed.items ?? parsed.results ?? []); + return (arr as Array<{ id: string; title: string; status: string; close_reason?: string }>); +} + // (The helper's own throw → 500 path is intentionally not asserted here: a // thrown handler surfaces as HTTP 500, which `request` legitimately retries with // exponential backoff, making such a test both slow and a test of the retry