diff --git a/.agents/pm/features/pm-github-fmrf.toon b/.agents/pm/features/pm-github-fmrf.toon new file mode 100644 index 0000000..7dd4b6d --- /dev/null +++ b/.agents/pm/features/pm-github-fmrf.toon @@ -0,0 +1,46 @@ +id: pm-github-fmrf +title: Revalidate blocked items against the live state of their GitHub blockers +description: "Add `pm github blockers`: re-read every blocked item (plus non-terminal items whose blocked_by dependency targets an external GitHub reference), parse blocked_reason/description/comments for GitHub references (owner/repo#N, bare #N vs the item repo or --repo, full issue/PR URLs, GH-N), query their current state via batched GraphQL, and report a verdict (cleared / partially_cleared / still_blocked / unresolvable). Report-only: never auto-unblock; --comment appends one idempotent pm comment per cleared item. Exit 0 when nothing cleared, a documented non-zero code when something cleared so CI and the scheduled sync can alert." +type: Feature +status: in_progress +priority: 2 +tags: [] +created_at: "2026-10-12T10:46:45.720Z" +updated_at: "2026-10-12T13:12:30.216Z" +author: pi-gh-blockers +comments[19]{created_at,author,text}: + "2026-10-12T10:46:49.828Z",pi-gh-blockers,"Claimed and started. Companion item: https://github.com/unbraind/pm-cli-website/blob/main/.agents/pm/features/pm-cli-website-5o9j.toon — this implements the blocker-revalidation capability the companion item calls for, in pm-github where the GitHub client, auth handling and provenance model already live. Design summary: complete pm list read (same receipts as import), candidate selection = status blocked OR non-terminal with an external GitHub blocked_by dependency, reference parsing across blocked_reason/description/comments, batched GraphQL state queries, report-only verdicts, idempotent --comment, wired into the reusable scheduled sync workflow as a report-only step." + "2026-10-12T10:55:14.500Z",pi-gh-blockers,"Implementation drafted in the package worktree: complete-read refactor (runCompletePmList shared by readPmItems and the new readBlockerPmItems), BlockerPmItem decoder validating blocked_reason/blocked_by/dependencies/comments on top of the existing complete-corpus receipts, reference parser (URL, owner/repo#N, GH-N, name#N, bare #N; gitlab/jira/linear locators masked, github: locator scheme unwrapped), hint resolution against the item's gh: provenance repo then --repo, batched GraphQL state query (issue+pullRequest per number, repos deduped+sorted so recorded fixtures stay stable), verdict function, idempotent --comment with per-item lock, report + exit codes (0 nothing cleared, 4 cleared, 2 usage/token, 1 total state failure). Real GraphQL fixtures recorded from api.github.com: unbraind/pm-cli#854 closed 2026-08-01 (the motivating blocker), #1246 open, PR #34 merged, #999999 not found, plus a repository-not-found and a data-null error response." + "2026-10-12T11:10:45.144Z",codex-sol,"Continuation audit: prior implementation and fixtures inspected; no gates or workflow integration were completed. Correcting host-rendered reports/exit status, complete SDK reads, GitHub-only dependency selection, partial error handling, and one comment per cleared item before validating. Companion scope remains pm-cli-website-5o9j." + "2026-10-12T11:18:07.181Z",codex-sol,"Verified prior fixture states live through GitHub GraphQL: pm-cli issue 854 CLOSED/COMPLETED at 2026-08-01T15:24:48Z; PR 34 MERGED at 2026-05-20T21:09:52Z; issue 1246 OPEN. Corrected focused suite passed 23/23 before added CLI/grouped-comment/permission regressions. Complete SDK reader uses listAllComplete with extensions disabled and certifyCompleteListResult. Reports now return structured exit_code for host TOON/JSON rendering. Scheduled reporting step warns on 4 and fails on read errors; comment writes are limited to fully cleared items." + "2026-10-12T11:24:38.757Z",codex-sol,"Manual acceptance used the built candidate under a distinct isolated extension identity, with explicit --pm-path, no --comment. The current pm-rl corpus is 78 items with zero blocker candidates (exit 0); both motivating items pm-rl-e20d and pm-rl-et5b are now closed. The companion corpus is 937 items with six candidates: two cleared, zero partially_cleared, zero still_blocked, four unresolvable (exit 4). Tracker items, history, and settings remained byte-identical for both runs. The complete SDK read refreshed only ignored pm-rl runtime metadata caches. No companion content or private blocker prose was copied into package evidence." + "2026-10-12T11:27:23.884Z",codex-sol,"Additional corrections: reports expose only requested item identities/status and parsed reference state, omitting full blocker prose/title from workflow logs. Annotations group newly cleared references in one comment per fully cleared item; partial query/permission or annotation failures return report + exit 1. Lock degradation now fails closed rather than permitting duplicate-prone unlocked comments. Peer/manifest floor raised together to PM CLI 2026.10.5 for required SDK and host exit_code contracts; README discloses the compatibility change." + "2026-10-12T11:34:26.749Z",codex-sol,"Behavioural revert proof in isolated source copies: replacing only parseBlockerReferences with its absent-parser behavior (empty references) made both the all-forms regression and real-tracker mixed scenario fail: 0/2 pass, exit 1. Replacing only blockerItemVerdict with the old static blocked behavior (still_blocked) made both verdict regression and real-tracker mixed scenario fail: 0/2 pass, exit 1. Production source remained intact. Scheduled report executable acceptance passed 1/1: code 4 becomes a warning and preserves item bytes; query failure remains exit 1. The first diagnostic full suite exposed installed global extension SDK/host skew in scratch health checks; workflow fixtures now isolate their global extension root." + "2026-10-12T11:38:57.331Z",codex-sol,"Corrected companion permalink after checking its canonical remote: https://github.com/unbraind/pm-cli-companion/blob/main/.agents/pm/features/pm-cli-website-5o9j.toon . The original pi comment used the legacy pm-cli-website repository name; the owner item id remains pm-cli-website-5o9j." + "2026-10-12T11:39:35.124Z",codex-sol,"Final linked regression run: pm test pm-github-fmrf --run --progress --timeout 600 passed the linked npm run build && node --test test/blockers.test.ts command, 30/30 tests, zero failures/skips/cancellations/todos. The earlier default 120-second linked run timed out on the shared host; the expanded run completed in about 136 seconds. Test-result tracking is disabled by existing project policy, so this comment records the completed evidence without altering tracker settings." + "2026-10-12T11:55:13.486Z",codex-sol,"Frozen-source npm release certification passed with CI identity isolation: env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 npm run release:check. Result: 551/551 tests, zero failures/skips/cancellations/todos; exact 100/100/100/100 coverage across all 12 authored modules at unchanged thresholds. Typecheck, build, 150 documented declarations, lint, zero duplication, privacy, production audit, pack dry-run, changelog check, and publish-attestation checks passed. Source checksums stayed unchanged throughout the run." + "2026-10-12T12:06:27.255Z",codex-sol,"Packed live consumer acceptance passed 2/2 scenarios with the same npm archive installed into a scratch tracker: Node first read two distinct closed/merged references deduplicated from all requested forms, returned exit 4 and appended one grouped comment. An actual native Bun CLI run returned exit 4, skipped the existing comment, and appended zero duplicates. The scratch item retained status blocked. All GitHub calls were observational; no --comment was used on fleet or companion trackers." + "2026-10-12T12:07:13.194Z",codex-sol,"Bun release certification passed on unchanged source with CI identity isolation: env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 bun run release:check. Result: 551/551 tests, zero failures/skips/cancellations/todos; all 12 authored modules at exact 100/100/100/100 coverage with unchanged thresholds. All release checks passed, including privacy, zero duplication, audit, packing, changelog, and attestation. Both Node/npm and Bun runner gates execute the package coverage suite under Node; native Bun behavior was separately accepted through the installed packed CLI. Item remains in_progress for orchestrator verification; no release, tag, publication, merge, or fleet/companion item mutation was performed." + "2026-10-12T12:14:48.658Z",codex-sol,"Review PR opened: https://github.com/unbraind/pm-github/pull/115 on feat/blocker-revalidation. Implementation and regression sources match the frozen source certified by both release gates; all tracked work and receipts are committed as SteveBot. CI and reviews are pending on the final PR head and are not claimed as approval. Item remains in_progress, unclaimed, for orchestrator verification. CLI UX encountered: installed project extensions shadowed the candidate and emitted a generic unknown-option error; isolated candidate identity was used for live acceptance. Unrelated installed global extension SDK/host skew contaminated scratch health until the fixtures isolated their global extension root. No CLI/SDK issue was filed." + "2026-10-12T12:23:45.555Z",codex-sol,"Final-head CodeQL reported a genuine polynomial regular expression risk in blocker comment marker extraction (alert 5): the broad repository capture could consume repeated marker openers before failing on a missing number. Reclaimed the item to fix this before handoff. Replacing that capture with the same GitHub owner/repo grammar used by reference resolution, and preventing timestamp captures from crossing HTML delimiters; adding a bounded subprocess regression against the reported repeated-prefix payload. Earlier gate receipts remain valid for their frozen source, but both gates will be rerun for the correction." + "2026-10-12T12:44:03.202Z",codex-sol,"Marker remediation verified: focused marker/idempotency cases passed 4/4; restoring only the old regex made the bounded repeated-prefix regression fail at its 10-second deadline (0/1 pass, exit 1). The corrected parser/marker protocol also preserves valid .github-style repo names and HTTP/case variants. Provenance hints now pass the same repository grammar before entering GraphQL; unresolved GH-N/#N mentions deduplicate consistently. Focused related regressions passed 11/11. Freezing this correction and rerunning both complete release gates with independent coverage directories." + "2026-10-12T12:51:37.633Z",codex-sol,"Corrected-source live acceptance repeated: pm-rl 78 items/zero candidates/exit 0; companion 937 items/six candidates/two cleared/four unresolvable/exit 4. Item/history/settings bytes remained unchanged. The newly packed corrected artifact also passed Node and native Bun acceptance 2/2: two resolved references, one grouped scratch comment on Node, zero duplicate comments on Bun, status still blocked. No --comment was used outside scratch trackers." + "2026-10-12T12:58:08.241Z",codex-sol,"Corrected pm-linked blocker suite passed 32/32, zero failures/skips/cancellations/todos, using pm test pm-github-fmrf --run --only-index 1 --progress --timeout 600. The marker performance test and repository/provenance regressions are included. Both complete release runs have passed their adversarial workflow fixtures; final receipts remain pending." + "2026-10-12T13:03:23.942Z",codex-sol,"Corrected-source final certification supersedes the earlier source receipts: npm run release:check and bun run release:check both passed with required HOME/Git identity isolation, 553/553 tests each and zero failure/skip/cancel/todo. Bun ran in an identical isolated source copy with independent coverage output. Both coverage receipts matched: 12 authored modules, 9486/9486 lines and statements, 232/232 functions, 2484/2484 branches, exact 100/100/100/100 at unchanged thresholds. All remaining release checks passed. Linked suite 32/32, scheduled acceptance 1/1, packed Node/native Bun 2/2, parser/verdict revert proofs 0/2 each, old-marker-regex revert 0/1 with bounded timeout. Implementation/test checksums stayed unchanged across these runs. Pushing the correction to PR 115 for final-head CodeQL verification." + "2026-10-12T13:12:30.216Z",codex-sol,"Final correction 50c41c6166d9333e2259e8dc9b3ead68a3ade4da reached PR 115 after transient commit_refs push rejections. CodeQL results check 114825479059 succeeded with No new alerts and zero annotations; the codeql workflow and Semgrep also succeeded. The previous polynomial marker alert is remediated. Node 22/26 CI is still running; neutral/skipped/unavailable reviewers are not approval. All implementation/test bytes remain identical to the npm/Bun 553-test, exact-coverage receipts. Claim remains released and item in_progress for orchestrator verification. No merges, tags, releases or publications were made." +files[7]{path,scope}: + index.ts,project + manifest.json,project + package-lock.json,project + package.json,project + test/blockers.test.ts,project + test/compatibility-floor.test.ts,project + test/import-gate.test.ts,project +tests[2]{command,scope,provenance{author,created_at,source_kind,source_ref}}: + npm run build && node --test test/blockers.test.ts,project,codex-sol,"2026-10-12T11:26:22.651Z",local_mutation,feat/blocker-revalidation + "node --test --test-name-pattern=\"scheduled blocker report\" test/import-gate.test.ts",project,codex-sol,"2026-10-12T11:44:17.430Z",local_mutation,feat/blocker-revalidation +docs[3]{path,scope}: + .github/workflows/pm-github-sync.yml,project + README.md,project + test/fixtures/blockers/README.md,project +body: "" diff --git a/.agents/pm/history/pm-github-fmrf.jsonl b/.agents/pm/history/pm-github-fmrf.jsonl new file mode 100644 index 0000000..dd6fa3c --- /dev/null +++ b/.agents/pm/history/pm-github-fmrf.jsonl @@ -0,0 +1,30 @@ +{"hash_algorithm":"sha256","ts":"2026-10-12T10:46:45.720Z","author":"pi-gh-blockers","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3:cloud","agent_model_source":"environment","agent_instance":"83097eb018453e49a23eaf19","agent_provenance":{"model":{"value":"glm-5.3:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-fmrf"},{"op":"add","path":"/metadata/title","value":"Revalidate blocked items against the live state of their GitHub blockers"},{"op":"add","path":"/metadata/description","value":"Add `pm github blockers`: re-read every blocked item (plus non-terminal items whose blocked_by dependency targets an external GitHub reference), parse blocked_reason/description/comments for GitHub references (owner/repo#N, bare #N vs the item repo or --repo, full issue/PR URLs, GH-N), query their current state via batched GraphQL, and report a verdict (cleared / partially_cleared / still_blocked / unresolvable). Report-only: never auto-unblock; --comment appends one idempotent pm comment per cleared item. Exit 0 when nothing cleared, a documented non-zero code when something cleared so CI and the scheduled sync can alert."},{"op":"add","path":"/metadata/type","value":"Feature"},{"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-10-12T10:46:45.720Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-12T10:46:45.720Z"},{"op":"add","path":"/metadata/author","value":"pi-gh-blockers"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"7314e0356fe842030437cfbe6441c99e2d527f4f0a9569c0637dd90baea8fe40","item_hash_version":3,"message":"","event_class":"substantive","record_hash_version":1,"record_hash":"dd30cdd5d47d48b09af9e83318cd7d1c20d40136c817c667cf55bbd878407c3e"} +{"hash_algorithm":"sha256","ts":"2026-10-12T10:46:48.676Z","author":"pi-gh-blockers","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3:cloud","agent_model_source":"environment","agent_instance":"83097eb018453e49a23eaf19","agent_provenance":{"model":{"value":"glm-5.3:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T10:46:48.676Z"},{"op":"add","path":"/metadata/assignee","value":"pi-gh-blockers"},{"op":"add","path":"/metadata/claim_principal","value":"pi-gh-blockers"}],"before_hash":"7314e0356fe842030437cfbe6441c99e2d527f4f0a9569c0637dd90baea8fe40","after_hash":"44a3f7803beb5d0883287f6c48aaf916d4c058c059f8bdb754c5d9f3a23899b5","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"cd3cc0db4849d5dabba3b4d1e05605bdd4ea8bac37158333ef32eec12fab4f68"} +{"hash_algorithm":"sha256","ts":"2026-10-12T10:46:49.300Z","author":"pi-gh-blockers","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3:cloud","agent_model_source":"environment","agent_instance":"83097eb018453e49a23eaf19","agent_provenance":{"model":{"value":"glm-5.3:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T10:46:49.300Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"44a3f7803beb5d0883287f6c48aaf916d4c058c059f8bdb754c5d9f3a23899b5","after_hash":"cfee342c518a474f85ae16d112b10933d843bd241e058c8dd62db6324de9e83a","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"66b08e5787ddaeff955112c47dec03c1a3ed7b31f9f9f898c622d04f0388c359"} +{"hash_algorithm":"sha256","ts":"2026-10-12T10:46:49.828Z","author":"pi-gh-blockers","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3:cloud","agent_model_source":"environment","agent_instance":"83097eb018453e49a23eaf19","agent_provenance":{"model":{"value":"glm-5.3:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T10:46:49.828Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-12T10:46:49.828Z","author":"pi-gh-blockers","text":"Claimed and started. Companion item: https://github.com/unbraind/pm-cli-website/blob/main/.agents/pm/features/pm-cli-website-5o9j.toon — this implements the blocker-revalidation capability the companion item calls for, in pm-github where the GitHub client, auth handling and provenance model already live. Design summary: complete pm list read (same receipts as import), candidate selection = status blocked OR non-terminal with an external GitHub blocked_by dependency, reference parsing across blocked_reason/description/comments, batched GraphQL state queries, report-only verdicts, idempotent --comment, wired into the reusable scheduled sync workflow as a report-only step."}]}],"before_hash":"cfee342c518a474f85ae16d112b10933d843bd241e058c8dd62db6324de9e83a","after_hash":"b5bd30fe4eb0d8b086325ea31661700d6fae925336b8070847694cb72ae80b6f","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"554a254e85e638a6734ca4e385e73208630b541a83f639c53676407f55bbb586"} +{"hash_algorithm":"sha256","ts":"2026-10-12T10:55:14.500Z","author":"pi-gh-blockers","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3:cloud","agent_model_source":"environment","agent_instance":"83097eb018453e49a23eaf19","agent_provenance":{"model":{"value":"glm-5.3:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-12T10:55:14.500Z","author":"pi-gh-blockers","text":"Implementation drafted in the package worktree: complete-read refactor (runCompletePmList shared by readPmItems and the new readBlockerPmItems), BlockerPmItem decoder validating blocked_reason/blocked_by/dependencies/comments on top of the existing complete-corpus receipts, reference parser (URL, owner/repo#N, GH-N, name#N, bare #N; gitlab/jira/linear locators masked, github: locator scheme unwrapped), hint resolution against the item's gh: provenance repo then --repo, batched GraphQL state query (issue+pullRequest per number, repos deduped+sorted so recorded fixtures stay stable), verdict function, idempotent --comment with per-item lock, report + exit codes (0 nothing cleared, 4 cleared, 2 usage/token, 1 total state failure). Real GraphQL fixtures recorded from api.github.com: unbraind/pm-cli#854 closed 2026-08-01 (the motivating blocker), #1246 open, PR #34 merged, #999999 not found, plus a repository-not-found and a data-null error response."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T10:55:14.500Z"}],"before_hash":"b5bd30fe4eb0d8b086325ea31661700d6fae925336b8070847694cb72ae80b6f","after_hash":"fb282546565cb9fa38707e0b12dc13b54fa2d532d235aa5c1eaf6579cd195d00","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"258e00c59caa340d33144a8eed0f92319547f9eaead82b76cd52b6fae3c2f193"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:10:42.570Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/claim_principal","value":"codex-sol"},{"op":"replace","path":"/metadata/assignee","value":"codex-sol"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:10:42.570Z"}],"before_hash":"fb282546565cb9fa38707e0b12dc13b54fa2d532d235aa5c1eaf6579cd195d00","after_hash":"0e44569379b3e34a4a5769543a8a8574f79d44c1cd5f2fb95372105ed4cd17a7","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"194387029ae781f977bda0e1772fbbf78e4966083cae960c04d6a15e2e6927ad"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:10:45.144Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-12T11:10:45.144Z","author":"codex-sol","text":"Continuation audit: prior implementation and fixtures inspected; no gates or workflow integration were completed. Correcting host-rendered reports/exit status, complete SDK reads, GitHub-only dependency selection, partial error handling, and one comment per cleared item before validating. Companion scope remains pm-cli-website-5o9j."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:10:45.144Z"}],"before_hash":"0e44569379b3e34a4a5769543a8a8574f79d44c1cd5f2fb95372105ed4cd17a7","after_hash":"c2766200fe841d950c54ded64bcdf413bfa7672575b2e93f0db853259115cc88","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f626018e51188fdf1a3808985c1c6c64addd5f70a15dd93327294245228ef9fa"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:18:07.181Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-12T11:18:07.181Z","author":"codex-sol","text":"Verified prior fixture states live through GitHub GraphQL: pm-cli issue 854 CLOSED/COMPLETED at 2026-08-01T15:24:48Z; PR 34 MERGED at 2026-05-20T21:09:52Z; issue 1246 OPEN. Corrected focused suite passed 23/23 before added CLI/grouped-comment/permission regressions. Complete SDK reader uses listAllComplete with extensions disabled and certifyCompleteListResult. Reports now return structured exit_code for host TOON/JSON rendering. Scheduled reporting step warns on 4 and fails on read errors; comment writes are limited to fully cleared items."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:18:07.181Z"}],"before_hash":"c2766200fe841d950c54ded64bcdf413bfa7672575b2e93f0db853259115cc88","after_hash":"ea057b7e97f654631ad82fd1a450efa6ada40cd0907b40c31c9670d9095a6f10","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"868f65fbe6c498945ef0f757105fbe94f83cd96dbaa2723cad05e6a0727c7405"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:24:38.757Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-12T11:24:38.757Z","author":"codex-sol","text":"Manual acceptance used the built candidate under a distinct isolated extension identity, with explicit --pm-path, no --comment. The current pm-rl corpus is 78 items with zero blocker candidates (exit 0); both motivating items pm-rl-e20d and pm-rl-et5b are now closed. The companion corpus is 937 items with six candidates: two cleared, zero partially_cleared, zero still_blocked, four unresolvable (exit 4). Tracker items, history, and settings remained byte-identical for both runs. The complete SDK read refreshed only ignored pm-rl runtime metadata caches. No companion content or private blocker prose was copied into package evidence."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:24:38.757Z"}],"before_hash":"ea057b7e97f654631ad82fd1a450efa6ada40cd0907b40c31c9670d9095a6f10","after_hash":"ab9d5af2794291933ab9740d2bab2e7505bdf52a42466f58717d9a8611223884","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"076aee7e3dca3c07fbfecdda5b0200ad50f4d5154c64ab512092fbf01ab33382"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:26:22.830Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:26:22.830Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run build && node --test test/blockers.test.ts","scope":"project","provenance":{"author":"codex-sol","created_at":"2026-10-12T11:26:22.651Z","source_kind":"local_mutation","source_ref":"feat/blocker-revalidation"}}]}],"before_hash":"ab9d5af2794291933ab9740d2bab2e7505bdf52a42466f58717d9a8611223884","after_hash":"e7b8c0bf9fb86bf914c4cb041853cb004003fe423adb66273a6cb1a811dc399d","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"6592a4e0f6cdda2283d7c7ed52ab13d88ce86a6fc8540c86b50944af526e6623"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:27:23.884Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-10-12T11:27:23.884Z","author":"codex-sol","text":"Additional corrections: reports expose only requested item identities/status and parsed reference state, omitting full blocker prose/title from workflow logs. Annotations group newly cleared references in one comment per fully cleared item; partial query/permission or annotation failures return report + exit 1. Lock degradation now fails closed rather than permitting duplicate-prone unlocked comments. Peer/manifest floor raised together to PM CLI 2026.10.5 for required SDK and host exit_code contracts; README discloses the compatibility change."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:27:23.884Z"}],"before_hash":"e7b8c0bf9fb86bf914c4cb041853cb004003fe423adb66273a6cb1a811dc399d","after_hash":"1841b18a83e2a2db529dcdae8b3a80a5db94bdff08c6358e1a1242203dd92a12","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"920e7268ea4ddb5bb8d9ab132ae3de40cb9241ed26048db85c36ab166a761fad"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:34:26.749Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-10-12T11:34:26.749Z","author":"codex-sol","text":"Behavioural revert proof in isolated source copies: replacing only parseBlockerReferences with its absent-parser behavior (empty references) made both the all-forms regression and real-tracker mixed scenario fail: 0/2 pass, exit 1. Replacing only blockerItemVerdict with the old static blocked behavior (still_blocked) made both verdict regression and real-tracker mixed scenario fail: 0/2 pass, exit 1. Production source remained intact. Scheduled report executable acceptance passed 1/1: code 4 becomes a warning and preserves item bytes; query failure remains exit 1. The first diagnostic full suite exposed installed global extension SDK/host skew in scratch health checks; workflow fixtures now isolate their global extension root."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:34:26.749Z"}],"before_hash":"1841b18a83e2a2db529dcdae8b3a80a5db94bdff08c6358e1a1242203dd92a12","after_hash":"aa6509d7e955df3fc080f4cefcd8e4b86b83ca0e5225ab24935bbf391b54c912","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e33fed5356e86a599d8fad56e979d29e2eb8de8ef6eee55e7ada9361347bcecd"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:38:57.331Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-10-12T11:38:57.331Z","author":"codex-sol","text":"Corrected companion permalink after checking its canonical remote: https://github.com/unbraind/pm-cli-companion/blob/main/.agents/pm/features/pm-cli-website-5o9j.toon . The original pi comment used the legacy pm-cli-website repository name; the owner item id remains pm-cli-website-5o9j."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:38:57.331Z"}],"before_hash":"aa6509d7e955df3fc080f4cefcd8e4b86b83ca0e5225ab24935bbf391b54c912","after_hash":"1949d50a6278ad4ae201a519b367de902551b6ea89031eca7429df6dad46f16a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2aeb486d99488982a3c5ebb58b94b509d5bd7a3ffbe4d3255fac3c5c67df5012"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:39:30.581Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:39:30.581Z"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project"},{"path":"manifest.json","scope":"project"},{"path":"package-lock.json","scope":"project"},{"path":"package.json","scope":"project"},{"path":"test/blockers.test.ts","scope":"project"},{"path":"test/compatibility-floor.test.ts","scope":"project"},{"path":"test/import-gate.test.ts","scope":"project"}]}],"before_hash":"1949d50a6278ad4ae201a519b367de902551b6ea89031eca7429df6dad46f16a","after_hash":"9e4244fefdd8e8596f9e0c09249a281e356613ff44a7e7e52f78c061c8c6d526","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"75b9d1f2233a122b6a45f899f2787ccd0fd4d2ea335e80bcec1d4815b1b5be80"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:39:32.784Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:39:32.784Z"},{"op":"add","path":"/metadata/docs","value":[{"path":".github/workflows/pm-github-sync.yml","scope":"project"},{"path":"README.md","scope":"project"},{"path":"test/fixtures/blockers/README.md","scope":"project"}]}],"before_hash":"9e4244fefdd8e8596f9e0c09249a281e356613ff44a7e7e52f78c061c8c6d526","after_hash":"0f5f96b16eebb89d64e5ff01a11afab84dbe1f4f3eadade5d8471d76c40de700","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"0c7d1c36de34f574f831184d24cc5f2feb01475f1e5c06f2e3d2cfd86c67c4cd"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:39:35.124Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-12T11:39:35.124Z","author":"codex-sol","text":"Final linked regression run: pm test pm-github-fmrf --run --progress --timeout 600 passed the linked npm run build && node --test test/blockers.test.ts command, 30/30 tests, zero failures/skips/cancellations/todos. The earlier default 120-second linked run timed out on the shared host; the expanded run completed in about 136 seconds. Test-result tracking is disabled by existing project policy, so this comment records the completed evidence without altering tracker settings."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:39:35.124Z"}],"before_hash":"0f5f96b16eebb89d64e5ff01a11afab84dbe1f4f3eadade5d8471d76c40de700","after_hash":"6368489d48038a88dd0a0a4e58c4c17900951bd87050f17a8b92b6e556fbe654","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"a63002744c8809b68d5d941fa71e3ccc6ac5724ca74e9df9390afda26e5d9ecf"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:44:17.500Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"node --test --test-name-pattern=\"scheduled blocker report\" test/import-gate.test.ts","scope":"project","provenance":{"author":"codex-sol","created_at":"2026-10-12T11:44:17.430Z","source_kind":"local_mutation","source_ref":"feat/blocker-revalidation"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:44:17.500Z"}],"before_hash":"6368489d48038a88dd0a0a4e58c4c17900951bd87050f17a8b92b6e556fbe654","after_hash":"f35c0c0ca573975ab428daa4dd8b90764957877025415ba10da3812cc3066eec","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"da3d433890872e0ecc18b5e82d3aa7576ff3a61a3aba9585e9d4a9e411cc5555"} +{"hash_algorithm":"sha256","ts":"2026-10-12T11:55:13.487Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-10-12T11:55:13.486Z","author":"codex-sol","text":"Frozen-source npm release certification passed with CI identity isolation: env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 npm run release:check. Result: 551/551 tests, zero failures/skips/cancellations/todos; exact 100/100/100/100 coverage across all 12 authored modules at unchanged thresholds. Typecheck, build, 150 documented declarations, lint, zero duplication, privacy, production audit, pack dry-run, changelog check, and publish-attestation checks passed. Source checksums stayed unchanged throughout the run."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T11:55:13.487Z"}],"before_hash":"f35c0c0ca573975ab428daa4dd8b90764957877025415ba10da3812cc3066eec","after_hash":"8d3376f1aeef0000c9f20d58ba56ec9bf413d9a98f58e9f6fff4fe59fd67e294","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"aa261ecbf00756c783f21c92c2516f3f5cccb0d137e322dcc4d9285e9c6d657e"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:06:27.255Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-10-12T12:06:27.255Z","author":"codex-sol","text":"Packed live consumer acceptance passed 2/2 scenarios with the same npm archive installed into a scratch tracker: Node first read two distinct closed/merged references deduplicated from all requested forms, returned exit 4 and appended one grouped comment. An actual native Bun CLI run returned exit 4, skipped the existing comment, and appended zero duplicates. The scratch item retained status blocked. All GitHub calls were observational; no --comment was used on fleet or companion trackers."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:06:27.255Z"}],"before_hash":"8d3376f1aeef0000c9f20d58ba56ec9bf413d9a98f58e9f6fff4fe59fd67e294","after_hash":"cbb04c0e623a50768992daac3cc506409350b0eda9a77763f62f39c7c627c5f6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6de37ad0de539802bd506e29c37dcc344d8b745e55b7db5a129fd0219b80c8ec"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:07:13.194Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-10-12T12:07:13.194Z","author":"codex-sol","text":"Bun release certification passed on unchanged source with CI identity isolation: env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 bun run release:check. Result: 551/551 tests, zero failures/skips/cancellations/todos; all 12 authored modules at exact 100/100/100/100 coverage with unchanged thresholds. All release checks passed, including privacy, zero duplication, audit, packing, changelog, and attestation. Both Node/npm and Bun runner gates execute the package coverage suite under Node; native Bun behavior was separately accepted through the installed packed CLI. Item remains in_progress for orchestrator verification; no release, tag, publication, merge, or fleet/companion item mutation was performed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:07:13.194Z"}],"before_hash":"cbb04c0e623a50768992daac3cc506409350b0eda9a77763f62f39c7c627c5f6","after_hash":"274905a1f613ac28331bd3ba073289bc1c3240eda0fc237e2bd62b32b940f82c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d17f80a938ee07d43bb0d142a1da1d58f67fa8266e7eb0bae89630ab2c306825"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:07:13.939Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:07:13.939Z"}],"before_hash":"274905a1f613ac28331bd3ba073289bc1c3240eda0fc237e2bd62b32b940f82c","after_hash":"444e09e5bb06cb8e17a1bd3b215f642c4c742597042bfce4e21d730c69b60702","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"716880cd7d5fbb91319dad0a6361591d2ac8e93bd0c51fe8d3184f185d8e5ff1"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:14:48.659Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/12","value":{"created_at":"2026-10-12T12:14:48.658Z","author":"codex-sol","text":"Review PR opened: https://github.com/unbraind/pm-github/pull/115 on feat/blocker-revalidation. Implementation and regression sources match the frozen source certified by both release gates; all tracked work and receipts are committed as SteveBot. CI and reviews are pending on the final PR head and are not claimed as approval. Item remains in_progress, unclaimed, for orchestrator verification. CLI UX encountered: installed project extensions shadowed the candidate and emitted a generic unknown-option error; isolated candidate identity was used for live acceptance. Unrelated installed global extension SDK/host skew contaminated scratch health until the fixtures isolated their global extension root. No CLI/SDK issue was filed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:14:48.659Z"}],"before_hash":"444e09e5bb06cb8e17a1bd3b215f642c4c742597042bfce4e21d730c69b60702","after_hash":"be29792edac4ada4eb71c4f3d727204b6f249f29e6baeee050a4717b520ecd0c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"982401a9260e4241656f7aafb82df73b25aafc33f9778695ff738f5f63a0e20c"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:23:44.351Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:23:44.351Z"},{"op":"add","path":"/metadata/assignee","value":"codex-sol"},{"op":"add","path":"/metadata/claim_principal","value":"codex-sol"}],"before_hash":"be29792edac4ada4eb71c4f3d727204b6f249f29e6baeee050a4717b520ecd0c","after_hash":"31fbaa1fcea2ee7f6f51b3d82fa487810d25ada98b623a0d88b4e7637ff411a4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2074ee4a57d249a2a599221862bf22f6c3d1d348b01a9575f2471abe7310e2e5"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:23:45.555Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/13","value":{"created_at":"2026-10-12T12:23:45.555Z","author":"codex-sol","text":"Final-head CodeQL reported a genuine polynomial regular expression risk in blocker comment marker extraction (alert 5): the broad repository capture could consume repeated marker openers before failing on a missing number. Reclaimed the item to fix this before handoff. Replacing that capture with the same GitHub owner/repo grammar used by reference resolution, and preventing timestamp captures from crossing HTML delimiters; adding a bounded subprocess regression against the reported repeated-prefix payload. Earlier gate receipts remain valid for their frozen source, but both gates will be rerun for the correction."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:23:45.555Z"}],"before_hash":"31fbaa1fcea2ee7f6f51b3d82fa487810d25ada98b623a0d88b4e7637ff411a4","after_hash":"5856096c2771d716d1e38e141ca817670dcca1e1d621ecfac136c1ac559f3127","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"151660a94510f421ce8bd482978ac3fbf5cb72fa3ba8b31059cb0543387ca806"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:44:03.202Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/14","value":{"created_at":"2026-10-12T12:44:03.202Z","author":"codex-sol","text":"Marker remediation verified: focused marker/idempotency cases passed 4/4; restoring only the old regex made the bounded repeated-prefix regression fail at its 10-second deadline (0/1 pass, exit 1). The corrected parser/marker protocol also preserves valid .github-style repo names and HTTP/case variants. Provenance hints now pass the same repository grammar before entering GraphQL; unresolved GH-N/#N mentions deduplicate consistently. Focused related regressions passed 11/11. Freezing this correction and rerunning both complete release gates with independent coverage directories."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:44:03.202Z"}],"before_hash":"5856096c2771d716d1e38e141ca817670dcca1e1d621ecfac136c1ac559f3127","after_hash":"0cc14d179df67330245ffe39c59eadee806424bfcdc99457f32e67d5f6f02dec","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"49718157d20b9723513b8316347b7296a346fa8be56bc4c26de6988d65b06223"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:51:37.633Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/15","value":{"created_at":"2026-10-12T12:51:37.633Z","author":"codex-sol","text":"Corrected-source live acceptance repeated: pm-rl 78 items/zero candidates/exit 0; companion 937 items/six candidates/two cleared/four unresolvable/exit 4. Item/history/settings bytes remained unchanged. The newly packed corrected artifact also passed Node and native Bun acceptance 2/2: two resolved references, one grouped scratch comment on Node, zero duplicate comments on Bun, status still blocked. No --comment was used outside scratch trackers."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:51:37.633Z"}],"before_hash":"0cc14d179df67330245ffe39c59eadee806424bfcdc99457f32e67d5f6f02dec","after_hash":"02183451eba8e41a2d0feb5fda9afffd42f42778a5db5a2698e023e6a4a51411","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"08fc1f5d029f531edaaa129454aae4058d5f254d25c8bb2f1f66e6c752d95931"} +{"hash_algorithm":"sha256","ts":"2026-10-12T12:58:08.244Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/16","value":{"created_at":"2026-10-12T12:58:08.241Z","author":"codex-sol","text":"Corrected pm-linked blocker suite passed 32/32, zero failures/skips/cancellations/todos, using pm test pm-github-fmrf --run --only-index 1 --progress --timeout 600. The marker performance test and repository/provenance regressions are included. Both complete release runs have passed their adversarial workflow fixtures; final receipts remain pending."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T12:58:08.244Z"}],"before_hash":"02183451eba8e41a2d0feb5fda9afffd42f42778a5db5a2698e023e6a4a51411","after_hash":"086ec1543864f596f3339361a870e20a435d3e724edfd2ff7c4da8c42dd9da19","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"078e6310350d5f7364104bda5836c233fb4dd05d452fd3936d61d2e0cdec3da7"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:03:23.942Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/17","value":{"created_at":"2026-10-12T13:03:23.942Z","author":"codex-sol","text":"Corrected-source final certification supersedes the earlier source receipts: npm run release:check and bun run release:check both passed with required HOME/Git identity isolation, 553/553 tests each and zero failure/skip/cancel/todo. Bun ran in an identical isolated source copy with independent coverage output. Both coverage receipts matched: 12 authored modules, 9486/9486 lines and statements, 232/232 functions, 2484/2484 branches, exact 100/100/100/100 at unchanged thresholds. All remaining release checks passed. Linked suite 32/32, scheduled acceptance 1/1, packed Node/native Bun 2/2, parser/verdict revert proofs 0/2 each, old-marker-regex revert 0/1 with bounded timeout. Implementation/test checksums stayed unchanged across these runs. Pushing the correction to PR 115 for final-head CodeQL verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:03:23.942Z"}],"before_hash":"086ec1543864f596f3339361a870e20a435d3e724edfd2ff7c4da8c42dd9da19","after_hash":"377d8a6e87a13504748cedf3727687e2be73101e0264fd637fc84fd861a4c1bd","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"93526e453452bd62837e0217f6d11389e794b28a20312278ec6d6996b9d00543"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:03:24.811Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:03:24.811Z"}],"before_hash":"377d8a6e87a13504748cedf3727687e2be73101e0264fd637fc84fd861a4c1bd","after_hash":"4d4172e5dff2ac07778c3ca7add30a7f1efed022c6b445749b23cd300008d978","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"174bd29d4700ad70bef044ac53134d3def4382d22d7ea450c0096ab126546f9d"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:12:30.216Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"685d91f44537cbb5798972df","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/18","value":{"created_at":"2026-10-12T13:12:30.216Z","author":"codex-sol","text":"Final correction 50c41c6166d9333e2259e8dc9b3ead68a3ade4da reached PR 115 after transient commit_refs push rejections. CodeQL results check 114825479059 succeeded with No new alerts and zero annotations; the codeql workflow and Semgrep also succeeded. The previous polynomial marker alert is remediated. Node 22/26 CI is still running; neutral/skipped/unavailable reviewers are not approval. All implementation/test bytes remain identical to the npm/Bun 553-test, exact-coverage receipts. Claim remains released and item in_progress for orchestrator verification. No merges, tags, releases or publications were made."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:12:30.216Z"}],"before_hash":"4d4172e5dff2ac07778c3ca7add30a7f1efed022c6b445749b23cd300008d978","after_hash":"3ed15cabfb8b31b56ec173510b21080436b6b22d8bdc91086ac8a22a406a48ca","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"480bb66a5a3dd63cdb880a2c7fe6870c8d4f735cee4edf05536069caf4f4ea1c"} diff --git a/.github/workflows/pm-github-sync.yml b/.github/workflows/pm-github-sync.yml index a61545d..7e1dbaa 100644 --- a/.github/workflows/pm-github-sync.yml +++ b/.github/workflows/pm-github-sync.yml @@ -120,6 +120,23 @@ jobs: GH_TOKEN: ${{ github.token }} run: ./node_modules/.bin/pm github validate --repo "${REPOSITORY}" --json | jq -e ' .ok == true and .repo_accessible == true ' + # Report live blocker state without writing comments or changing status. + # Code 4 is an alert receipt; read/auth failures stop this run. + - name: Revalidate GitHub blockers (report only) + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + blocker_status=0 + ./node_modules/.bin/pm github blockers --repo "${REPOSITORY}" --json > "${RUNNER_TEMP}/pm-github-blockers.json" || blocker_status=$? + cat "${RUNNER_TEMP}/pm-github-blockers.json" + case "${blocker_status}" in + 0) ;; + 4) echo "::warning::GitHub blockers have cleared; revalidate the reported pm items." ;; + *) exit "${blocker_status}" ;; + esac + - name: Preview GitHub to pm plan env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index d989cce..9699cc1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,52 @@ pm install npm:pm-github --global > `github.com/unbraind/pm-github` source cannot work either — pm copies a GitHub > source as-is without building it, and this repository does not commit `dist/`. +## Revalidate GitHub blockers + +**Compatibility:** pm-github now requires PM CLI 2026.10.5 or newer for complete +SDK reads and structured command exit statuses. Older hosts refuse installation +or activation through the matching peer dependency and manifest floor. + +`pm github blockers` reads a certified complete tracker and checks every blocked +item, plus non-terminal items with an external GitHub `blocked_by` dependency. +It scans dependency targets, `blocked_reason`, description, and stored comments +(including the latest) for `owner/repo#N`, issue/PR URLs, `#N`, and `GH-N`. +Bare references use the item's `gh:owner/repo#N` provenance repo, then `--repo`. +The extra short form `repo#N` resolves only against a matching repo hint. +References are deduplicated across items and queried in GraphQL batches of 25, +using the existing token resolution and rate-limit handling. + +```sh +pm github blockers --repo unbraind/pm-cli +pm github blockers --repo unbraind/pm-cli --json +pm --pm-path fleet/pm-rl/.agents/pm github blockers --repo unbraind/pm-cli --json +pm github blockers --ids pm-123,pm-456 --repo unbraind/pm-cli +# Preview/write annotations only on a scratch tracker: +pm --pm-path scratch/.agents/pm github blockers --repo unbraind/pm-cli --comment --dry-run +pm --pm-path scratch/.agents/pm github blockers --repo unbraind/pm-cli --comment +``` + +Reports use the host renderer (TOON by default; global `--json`) and include item +id/status, each reference's state, close reason and closed/merged timestamp. +Verdicts are `cleared` (all references closed or merged), `partially_cleared` +(some cleared), `still_blocked` (open blockers, none cleared), and `unresolvable` +(no references or only unknown states). GitHub hides nonexistent and inaccessible +private references alike: both can appear as `not_found`. Explicit permission +errors remain `error` and cannot clear a blocker. + +Exit **0** means nothing cleared, **4** means at least one item has a cleared or +partially cleared blocker, **1** means a query/comment failure (takes precedence +over 4), **2** means invalid options or missing authentication, and **3** means +an unknown `--ids` item. Structured reports remain available for query failures. +No run auto-unblocks. `--comment` adds one comment per fully cleared item, +listing newly cleared references as “Blocker X closed on D; revalidate.” Each +reference/state/timestamp carries an idempotency marker; repeats add nothing, +and a subsequent close with a different timestamp gets a fresh annotation. +`--dry-run` previews the annotation counts without appending comments. +The reusable scheduled sync runs this command report-only and emits an Actions +warning for code 4; it stops on other non-zero codes. Callers must pin a release +containing this command before adopting the updated workflow. + ## Capabilities | SDK capability | What it provides | diff --git a/index.ts b/index.ts index 7a3e109..602a73c 100644 --- a/index.ts +++ b/index.ts @@ -33,6 +33,9 @@ import { spawnSync } from "node:child_process"; import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, + isExternalDependencyReference, + listAllComplete, + certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, @@ -755,6 +758,10 @@ export const EXIT_CODE = { GENERIC_FAILURE: 1, USAGE: 2, NOT_FOUND: 3, + // `pm github blockers`: at least one revalidated item is cleared (every + // blocker closed or merged) or partially cleared. Distinct from a failure so + // a scheduled sync can alert on a lifted blocker without reading the report. + BLOCKERS_CLEARED: 4, } as const; /** @@ -3529,7 +3536,7 @@ export interface SyncPlanEntry { * @returns Matched items in corpus order. * @throws CommandError when any requested identity is absent. */ -function requireRequestedItems(items: PmItem[], requested: string[]): PmItem[] { +function requireRequestedItems(items: TItem[], requested: string[]): TItem[] { const scoped = scopeItemsByIds(items, requested.length > 0 ? requested : undefined); if (scoped.missing.length > 0) throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND); return scoped.selected; @@ -4014,6 +4021,928 @@ async function runExport(ctx: CommandHandlerContext) { }; } +// --------------------------------------------------------------------------- +// Blocker revalidation — `pm github blockers` +// --------------------------------------------------------------------------- +// +// A `blocked_reason` is a static string: nothing re-reads it, so an item whose +// named GitHub blocker has since closed stays blocked indefinitely (the +// motivating incident: two items sat blocked for 27 days after their blocker +// issue closed). This section revalidates blocked items against the LIVE state +// of the GitHub issues and pull requests their blocker text names. It is +// REPORT-ONLY by construction: it never unblocks, reopens, or edits any item; +// the optional `--comment` appends one idempotent comment per cleared item +// so a human — not the tool — decides what the cleared blocker means. + +/** Terminal pm lifecycle statuses: an item in one of these can no longer be blocked. */ +export const BLOCKER_TERMINAL_STATUSES: ReadonlySet = new Set(["closed", "canceled"]); + +/** How many blocker references are resolved per GraphQL request. */ +export const BLOCKER_STATES_BATCH_SIZE = 25; + +/** Shape an `owner/repo` (or `--repo`) hint must have before it resolves bare references. */ +const BLOCKER_REPO_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+$/; + +/** The syntactic form a parsed GitHub reference was recognized in. */ +export type BlockerReferenceForm = + | "repo_hash" // `owner/repo#N` + | "url" // https://github.com/owner/repo/issues/N or /pull/N + | "gh_dash" // `GH-N` + | "short_repo_hash" // `name#N` (bare repo short name, resolved against a hint) + | "bare_hash"; // `#N` (resolved against a hint) + +/** One GitHub reference found in blocker text, before its repo hint is applied. */ +export interface ParsedBlockerReference { + /** `owner/repo` (lowercase) when the reference carries it; `undefined` for hint-resolved forms. */ + repo?: string; + /** Issue or pull-request number. */ + number: number; + /** The syntactic form the reference was recognized in. */ + form: BlockerReferenceForm; + /** The literal matched text (for report provenance). */ + raw: string; +} + +/** A deduplicated, hint-resolved GitHub reference. `key` is its stable identity. */ +export interface BlockerReference { + /** Stable dedupe/lookup key: `owner/repo#N` when resolved, a normalized unresolved reference otherwise. */ + key: string; + /** `owner/repo` (lowercase); `undefined` when no hint could resolve the reference. */ + repo?: string; + /** Issue or pull-request number. */ + number: number; + /** The syntactic form the reference was recognized in. */ + form: BlockerReferenceForm; + /** The literal matched text (for report provenance). */ + raw: string; +} + +/** Live state of one blocker reference as queried from GitHub (or why it could not be). */ +export interface BlockerReferenceState { + /** `owner/repo` (lowercase); `undefined` when the reference never resolved to a repo. */ + repo?: string; + /** Issue or pull-request number. */ + number: number; + /** The syntactic form the reference was recognized in. */ + form: BlockerReferenceForm; + /** The literal matched text (for report provenance). */ + raw: string; + /** Live state, or the reason it is unknown. */ + state: "open" | "closed" | "merged" | "not_found" | "error" | "unresolved"; + /** GitHub close reason for issues (`COMPLETED`, `NOT_PLANNED`, `REOPENED`), when known. */ + state_reason?: string; + /** ISO timestamp the issue closed, when known. */ + closed_at?: string; + /** ISO timestamp the pull request merged, when known. */ + merged_at?: string; + /** Canonical GitHub URL, when known. */ + url?: string; + /** Failure detail, only for `state === "error"`. */ + error?: string; +} + +/** Per-item revalidation outcome. */ +export type BlockerVerdict = "cleared" | "partially_cleared" | "still_blocked" | "unresolvable"; + +/** One revalidated candidate item, as reported. */ +export interface BlockerItemReport { + /** The pm item id. */ + id: string; + /** Item status at read time (the status that made it a candidate). */ + status: string; + /** Revalidation verdict over the item's references. */ + verdict: BlockerVerdict; + /** Every parsed reference and its live state, sorted deterministically. */ + references: BlockerReferenceState[]; +} + +/** The full `pm github blockers` report, returned to the host renderer. */ +export interface BlockersReport { + /** Fixed marker naming the producing command. */ + command: "github blockers"; + /** ISO timestamp of the run. */ + generated_at: string; + /** Items read from the complete corpus (after any `--ids` scoping). */ + items_read: number; + /** Items revalidated (blocked status, or external GitHub blocked_by dependency). */ + candidates: number; + /** Per-item reports. */ + items: BlockerItemReport[]; + /** Items per verdict. */ + counts: { + cleared: number; + partially_cleared: number; + still_blocked: number; + unresolvable: number; + }; + /** Non-zero host exit status: 4 alerts, 1 query or annotation failures. */ + exit_code?: number; + /** Receipt for `--comment`, present only when comments were requested. */ + comment?: { + requested: true; + dry_run: boolean; + added: number; + skipped: number; + failed: number; + }; +} + +// GitHub reference forms, anchored so prose around a match cannot leak into it. +// The prefix class excludes word characters, path separators, dots, and colons, +// so `https://github.com/o/r/issues/N` (slash before owner) and +// `github.com/o/r#N` never produce a second, shorter match inside a URL. +const BLOCKER_URL_RE = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9_.-]+)\/(?:issues|pull)\/([1-9]\d{0,8})(?!\w)/gi; +const BLOCKER_OWNER_REPO_RE = /(^|[^\w./:-])([A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+)#([1-9]\d{0,8})(?!\w)/g; +const BLOCKER_GH_DASH_RE = /\bGH-([1-9]\d{0,8})\b/gi; +const BLOCKER_SHORT_REPO_RE = /(^|[^\w./:-])([A-Za-z0-9_.-]+)#([1-9]\d{0,8})(?!\w)/g; +const BLOCKER_BARE_HASH_RE = /(^|[^\w./:#-])#([1-9]\d{0,8})(?!\w)/g; + +/** + * Mask foreign tracker locators and the `github:` scheme prefix in blocker text. + * + * `gitlab:`/`jira:`/`linear:` locators name blockers pm-github cannot query, so + * the whole token is blanked to keep its `owner/repo#N` interior from parsing as + * a GitHub reference. The `github:` scheme — the tracker's canonical external + * dependency locator (`github:owner/repo#N`) — is replaced by a space so the + * locator's payload parses through the ordinary `owner/repo#N` form. + * + * @param text - One blocker text source (blocked_reason, description, comment, …). + * @returns The text with foreign locators blanked and the `github:` prefix removed. + */ +function maskNonGithubLocators(text: string): string { + return text.replace(/\b(?:gitlab|jira|linear):[^\s]+/gi, " ").replace(/\bgithub:/gi, " "); +} + +/** + * Parse every GitHub reference in one text source. + * + * Recognized forms: full issue/PR URLs, `owner/repo#N`, `GH-N`, bare `#N`, and + * `name#N` (a repo short name). URL and `owner/repo#N` references carry their + * repo; `GH-N`, bare `#N`, and `name#N` do not and are resolved later against + * the item's provenance repo or `--repo` (see {@link resolveBlockerReferences}). + * Duplicate mentions within one text are not deduplicated here — the resolver + * owns the stable identity — but each form's scan is independent, so one text + * never yields the same (form, raw, number) twice. + * + * @param text - One blocker text source (blocked_reason, description, comment, …). + * @returns Every recognized reference, in form order (URL, owner/repo, GH-N, short, bare). + */ +export function parseBlockerReferences(text: string): ParsedBlockerReference[] { + const masked = maskNonGithubLocators(text); + const out: ParsedBlockerReference[] = []; + for (const m of masked.matchAll(BLOCKER_URL_RE)) { + out.push({ repo: `${m[1]!.toLowerCase()}/${m[2]!.toLowerCase()}`, number: Number(m[3]), form: "url", raw: m[0] }); + } + for (const m of masked.matchAll(BLOCKER_OWNER_REPO_RE)) { + out.push({ repo: m[2]!.toLowerCase(), number: Number(m[3]), form: "repo_hash", raw: `${m[2]}#${m[3]}` }); + } + for (const m of masked.matchAll(BLOCKER_GH_DASH_RE)) { + out.push({ number: Number(m[1]), form: "gh_dash", raw: m[0]!.toUpperCase() }); + } + for (const m of masked.matchAll(BLOCKER_SHORT_REPO_RE)) { + out.push({ number: Number(m[3]), form: "short_repo_hash", raw: `${m[2]}#${m[3]}` }); + } + for (const m of masked.matchAll(BLOCKER_BARE_HASH_RE)) { + out.push({ number: Number(m[2]), form: "bare_hash", raw: `#${m[2]}` }); + } + return out; +} + +/** + * Resolve hint-dependent references and deduplicate the result. + * + * `GH-N`, bare `#N`, and `name#N` references resolve against the first + * applicable hint — the item's own `gh:owner/repo#N` provenance repo, then + * `--repo`. A `name#N` short form resolves only when the name matches the + * hint's repo segment (so `pm-cli#854` resolves against `unbraind/pm-cli`, but + * `other#854` stays unresolved instead of guessing an owner). References that + * resolve to the same `owner/repo#N` collapse to the first-seen form. + * + * @param parsed - References from {@link parseBlockerReferences}. + * @param itemRepo - The item's provenance repo hint (`owner/repo`), when it has one. + * @param fallbackRepo - The `--repo` hint, when given. + * @returns Deduplicated references, sorted by repo then number for a deterministic report. + */ +export function resolveBlockerReferences( + parsed: readonly ParsedBlockerReference[], + itemRepo: string | undefined, + fallbackRepo: string | undefined, +): BlockerReference[] { + const out: BlockerReference[] = []; + const seen = new Set(); + for (const ref of parsed) { + let repo = ref.repo; + if (repo === undefined) { + for (const hint of [itemRepo, fallbackRepo]) { + if (hint === undefined || !BLOCKER_REPO_SHAPE.test(hint)) continue; + if (ref.form === "short_repo_hash" && hint.split("/")[1]!.toLowerCase() !== ref.raw.split("#")[0]!.toLowerCase()) continue; + repo = hint.toLowerCase(); + break; + } + } + const key = repo === undefined + ? (ref.form === "short_repo_hash" ? `unresolved:${ref.raw.toLowerCase()}` : `unresolved:#${ref.number}`) + : `${repo}#${ref.number}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ key, ...(repo === undefined ? {} : { repo }), number: ref.number, form: ref.form, raw: ref.raw }); + } + return out.sort((a, b) => `${a.repo ?? ""}#${a.number}`.localeCompare(`${b.repo ?? ""}#${b.number}`)); +} + +/** + * Collect and resolve every GitHub reference named by one item's blocker text. + * + * Sources scanned, in order: external `blocked_by` dependency targets, the + * item-level `blocked_by` field, `blocked_reason`, `description`, and every + * stored comment. A `blocked_reason` is a static string that nothing re-reads — + * that is exactly the staleness this command exists to surface — so comments + * (including the latest ones) are scanned too. + * + * @param item - One complete item from {@link decodeBlockerPmItems}. + * @param fallbackRepo - The `--repo` hint, when given. + * @returns Deduplicated resolved references for the item. + */ +export function collectBlockerReferences(item: BlockerPmItem, fallbackRepo: string | undefined): BlockerReference[] { + const texts: string[] = []; + for (const dep of item.dependencies) { + if (dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id)) texts.push(dep.id); + } + if (item.blockedBy !== undefined) texts.push(item.blockedBy); + if (item.blockedReason !== undefined) texts.push(item.blockedReason); + texts.push(item.description); + for (const comment of item.comments) texts.push(comment.text); + const itemRepo = (() => { + for (const tag of item.tags ?? []) { + const provenance = parseProvenanceTag(tag); + if (provenance && BLOCKER_REPO_SHAPE.test(provenance.repo)) return provenance.repo; + } + return undefined; + })(); + const parsed = texts.flatMap((text) => parseBlockerReferences(text)); + return resolveBlockerReferences(parsed, itemRepo, fallbackRepo); +} + +/** Classify external dependency targets that explicitly name GitHub issues or PRs. */ +function isGithubBlockerDependency(target: string): boolean { + return isExternalDependencyReference(target) && /^(?:github:|https?:\/\/(?:www\.)?github\.com\/)/i.test(target) + && parseBlockerReferences(target).length > 0; +} + +/** + * Select the items `pm github blockers` revalidates. + * + * Every item whose status is `blocked`, plus every non-terminal item carrying a + * `blocked_by` dependency (or `blocked_by` field) whose target is an explicit + * external reference — the tracker's own cross-system locator classification + * plus an explicit GitHub locator or URL decides what counts as + * a GitHub target, so a foreign tracker or local `blocked_by` edge to another pm item never selects the + * item here. Terminal items are excluded: a closed item cannot be blocked. + * + * @param items - The complete runtime-validated item corpus. + * @returns The candidate items, in corpus order. + */ +export function selectBlockerCandidates(items: readonly BlockerPmItem[]): BlockerPmItem[] { + return items.filter((item) => { + if (item.status === "blocked") return true; + if (BLOCKER_TERMINAL_STATUSES.has(item.status)) return false; + return ( + item.dependencies.some((dep) => dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id)) + || (item.blockedBy !== undefined && isGithubBlockerDependency(item.blockedBy)) + ); + }); +} + +/** One `pm list --all` dependency row, as consumed by blocker revalidation. */ +export interface BlockerPmDependency { + /** Dependency target: a local item id or an external locator string. */ + id: string; + /** Dependency kind, e.g. `blocked_by`, `blocks`, `parent`. */ + kind: string; +} + +/** One `pm list --all` comment row, as consumed by blocker revalidation. */ +export interface BlockerPmComment { + /** The comment text. */ + text: string; +} + +/** A complete pm item plus the blocker fields the complete list output carries. */ +export interface BlockerPmItem extends PmItem { + /** Stable item id (required: the complete reader refuses id-less rows). */ + id: string; + /** Item title at read time (validated by the complete reader). */ + title: string; + /** Lifecycle status at read time (validated by the complete reader). */ + status: string; + /** Short summary shown in listings (validated by the complete reader). */ + description: string; + /** The static blocked-reason text, when the item carries one. */ + blockedReason?: string; + /** The item-level `blocked_by` field (an item id, external locator, or reason text). */ + blockedBy?: string; + /** Dependency rows (id + kind only; dates and authors are not consumed). */ + dependencies: BlockerPmDependency[]; + /** Stored comment texts, oldest first. */ + comments: BlockerPmComment[]; +} + +/** + * Read an optional string field from one decoded `pm list --all` row. + * + * Fail closed on a present-but-wrong-type value: a number or object where a + * string belongs means the row cannot be trusted, and an unverifiable read is + * not a whole-workspace read. + * + * @param value - The raw row field. + * @param field - Field description for the refusal message. + * @returns The string, or `undefined` when the field is absent. + * @throws {@link CommandError} When the field is present but not a string. + */ +function optionalCompletePmString(value: unknown, field: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + throw new CommandError( + `Refusing unverifiable pm list --all output: ${field} must be a string when present.`, + ); + } + return value; +} + +/** + * Decode the complete `pm list --all` envelope into blocker-revalidation items. + * + * The envelope and every base field are validated by {@link decodeCompletePmItems} + * first (the same complete-read contract import uses), then the extra rows this + * command consumes — `blocked_reason`, `blocked_by`, dependency rows, and + * comment texts — are validated on top. A wrong-type extra fails closed with + * the same refusal wording, because blocker revalidation reading a partial or + * untrustworthy corpus would silently miss items. + * + * @param parsed - JSON decoded from the installed pm CLI. + * @returns Fresh runtime-validated blocker items. + * @throws {@link CommandError} When a receipt, count, identity, or consumed row + * field is absent, incomplete, or contradictory. + */ +export function decodeBlockerPmItems(parsed: unknown): BlockerPmItem[] { + const base = decodeCompletePmItems(parsed); + // No re-validation branch here: decodeCompletePmItems has already thrown unless + // `parsed` is a record whose `items` is an array exactly as long as `base`. + const rows = (parsed as { items: unknown[] }).items; + return base.map((item, index): BlockerPmItem => { + // Downcast, not a runtime claim: the complete reader has already proven + // id/title/status/description are strings on every row it emitted, so the + // required-field view of BlockerPmItem holds for this object. + const validated = item as PmItem & { id: string; title: string; status: string; description: string }; + const row = rows[index] as Record; + const blockedReason = optionalCompletePmString(row.blocked_reason, `item ${item.id} blocked_reason`); + const blockedBy = optionalCompletePmString(row.blocked_by, `item ${item.id} blocked_by`); + const dependencies: BlockerPmDependency[] = []; + if (row.dependencies !== undefined) { + if (!Array.isArray(row.dependencies)) { + throw new CommandError( + `Refusing unverifiable pm list --all output: item ${item.id} dependencies must be an array.`, + ); + } + for (const dep of row.dependencies) { + if (!isJsonRecord(dep) || typeof dep.id !== "string" || typeof dep.kind !== "string") { + throw new CommandError( + `Refusing unverifiable pm list --all output: item ${item.id} dependency rows must carry string id and kind.`, + ); + } + dependencies.push({ id: dep.id, kind: dep.kind }); + } + } + const comments: BlockerPmComment[] = []; + if (row.comments !== undefined) { + if (!Array.isArray(row.comments)) { + throw new CommandError( + `Refusing unverifiable pm list --all output: item ${item.id} comments must be an array.`, + ); + } + for (const comment of row.comments) { + if (!isJsonRecord(comment) || typeof comment.text !== "string") { + throw new CommandError( + `Refusing unverifiable pm list --all output: item ${item.id} comment rows must carry string text.`, + ); + } + comments.push({ text: comment.text }); + } + } + return { + ...validated, + dependencies, + comments, + ...(blockedReason === undefined ? {} : { blockedReason }), + ...(blockedBy === undefined ? {} : { blockedBy }), + }; + }); +} + +/** + * Read every item through the complete SDK path and check its completeness certificate. + * + * The SDK disables extensions for the observational read. The shared item + * decoder also validates every blocker field consumed by this command. + * + * @param pmRoot - Workspace or tracker root accepted by the SDK. + * @returns The complete runtime-validated blocker item corpus. + * @throws On unreadable, incomplete, omitted, or invalid tracker data. + */ +export async function readBlockerPmItems(pmRoot: string): Promise { + const result = certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot, cwd: pmRoot, noExtensions: true })); + return decodeBlockerPmItems(result); +} + +// --- GraphQL state query ---------------------------------------------------- + +/** Issue node fields selected by the blocker states query. */ +interface GraphqlBlockerIssue { + state?: string; + stateReason?: string | null; + closedAt?: string | null; + url?: string; +} + +/** Pull-request node fields selected by the blocker states query. */ +interface GraphqlBlockerPullRequest { + state?: string; + mergedAt?: string | null; + closedAt?: string | null; + url?: string; +} + +/** Response of one blocker states query: alias `rN` → aliased issue/PR nodes (or null for an inaccessible repo). */ +type GraphqlBlockerStatesData = Record | null>; + +/** One reference's aliases inside a blocker states query, for reading its response back. */ +export interface BlockerStatesQueryPlanEntry { + /** The reference the aliases answer. */ + ref: ResolvedBlockerReference; + /** Alias of the reference's `repository(…)` block. */ + repoAlias: string; + /** Alias of the reference's `issue(number:…)` field. */ + issueAlias: string; + /** Alias of the reference's `pullRequest(number:…)` field. */ + pullAlias: string; +} + +/** The query plus the alias map needed to read its response back. */ +export interface BlockerStatesQueryPlan { + /** The literal GraphQL document to send. */ + query: string; + /** Repos in alias order: `repos[i]` answers under alias `r${i}`. */ + repos: string[]; + /** Per-reference aliases, aligned with the document's fields. */ + entries: BlockerStatesQueryPlanEntry[]; +} + +/** A reference whose repo hint resolved — the only kind GraphQL can query. */ +export interface ResolvedBlockerReference { + /** Stable dedupe/lookup key: `owner/repo#N`. */ + key: string; + /** `owner/repo` (lowercase). */ + repo: string; + /** Issue or pull-request number. */ + number: number; + /** The syntactic form the reference was recognized in. */ + form: BlockerReferenceForm; + /** The literal matched text (for report provenance). */ + raw: string; +} + +/** + * Build the batched GraphQL document resolving many references' live state. + * + * One query aliases each distinct repo (`r0: repository(owner:…, name:…)`), and + * within it each reference twice (`iN: issue(number:…)` plus `pN: + * pullRequest(number:…)`) — a number is either an issue or a PR and GraphQL + * resolves the wrong one to null, so one round-trip classifies every reference + * without guessing its kind. Repos are deduplicated and sorted, and each repo's + * references are sorted by number, so the document is a pure function of the + * reference SET, keeping recorded fixtures stable. The returned entries carry + * every reference's aliases so the response can be read back without + * re-deriving that ordering. + * + * @param refs - One batch's resolved references. + * @returns The query, its repo alias order, and the per-reference alias entries. + */ +export function buildBlockerStatesQueryPlan(refs: readonly ResolvedBlockerReference[]): BlockerStatesQueryPlan { + const byRepo = new Map(); + for (const ref of refs) { + const bucket = byRepo.get(ref.repo); + if (bucket) bucket.push(ref); + else byRepo.set(ref.repo, [ref]); + } + const repos = [...byRepo.keys()].sort(); + const entries: BlockerStatesQueryPlanEntry[] = []; + const blocks = repos.map((repo, repoIndex) => { + const bucket = byRepo.get(repo)!.slice().sort((a, b) => a.number - b.number); + const fields = bucket.map((ref, refIndex) => { + entries.push({ ref, repoAlias: `r${repoIndex}`, issueAlias: `i${repoIndex}_${refIndex}`, pullAlias: `p${repoIndex}_${refIndex}` }); + return ` i${repoIndex}_${refIndex}: issue(number: ${ref.number}) { state stateReason closedAt url }\n` + + ` p${repoIndex}_${refIndex}: pullRequest(number: ${ref.number}) { state mergedAt closedAt url }`; + }); + const [owner, name] = repo.split("/"); + return ` r${repoIndex}: repository(owner: "${owner}", name: "${name}") {\n${fields.join("\n")}\n }`; + }); + return { query: `query {\n${blocks.join("\n")}\n}`, repos, entries }; +} + +/** + * Split references into fixed-size batches. + * + * @param refs - References to batch. + * @param size - Maximum references per batch (at least 1). + * @returns Consecutive slices of the input, in order. + */ +export function chunkBlockerReferences(refs: readonly ResolvedBlockerReference[], size: number): ResolvedBlockerReference[][] { + if (!Number.isInteger(size) || size < 1) throw new CommandError("Blocker batch size must be a positive integer."); + const batches: ResolvedBlockerReference[][] = []; + for (let i = 0; i < refs.length; i += size) batches.push(refs.slice(i, i + size)); + return batches; +} + +/** Injectable GraphQL transport for the blocker states query (token closed over). */ +export type BlockerGraphQLTransport = (query: string) => Promise>; + +/** + * Map one reference's aliased issue/pull-request nodes to its live state. + * + * Null nodes for both the issue and the PR field mean GitHub would not resolve + * the reference — nonexistent, or present but invisible to the token; GitHub + * answers both identically, so both are reported `not_found`. An issue node + * wins over a PR node (only one of the two can exist for a number); an + * unrecognized state string is reported as an error rather than guessed. + * + * @param ref - The reference the nodes answer. + * @param issue - The aliased issue node, when GitHub resolved one. + * @param pullRequest - The aliased pull-request node, when GitHub resolved one. + * @returns The reference's live state. + */ +function mapBlockerReferenceNode( + ref: ResolvedBlockerReference, + issue: GraphqlBlockerIssue | null | undefined, + pullRequest: GraphqlBlockerPullRequest | null | undefined, +): BlockerReferenceState { + const base = { repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw }; + if (issue) { + if (issue.state === "OPEN") return { ...base, state: "open" }; + if (issue.state === "CLOSED") { + return { + ...base, + state: "closed", + ...(issue.stateReason ? { state_reason: issue.stateReason } : {}), + ...(issue.closedAt ? { closed_at: issue.closedAt } : {}), + ...(issue.url ? { url: issue.url } : {}), + }; + } + return { ...base, state: "error", error: "Unexpected GitHub issue state." }; + } + if (pullRequest) { + if (pullRequest.state === "OPEN") return { ...base, state: "open" }; + if (pullRequest.state === "MERGED") { + return { + ...base, + state: "merged", + ...(pullRequest.mergedAt ? { merged_at: pullRequest.mergedAt } : {}), + ...(pullRequest.closedAt ? { closed_at: pullRequest.closedAt } : {}), + ...(pullRequest.url ? { url: pullRequest.url } : {}), + }; + } + if (pullRequest.state === "CLOSED") { + return { + ...base, + state: "closed", + ...(pullRequest.closedAt ? { closed_at: pullRequest.closedAt } : {}), + ...(pullRequest.url ? { url: pullRequest.url } : {}), + }; + } + return { ...base, state: "error", error: "Unexpected GitHub pull request state." }; + } + return { ...base, state: "not_found" }; +} + +/** + * Query the live state of every resolved reference, batched. + * + * References are batched ({@link BLOCKER_STATES_BATCH_SIZE} per request) through + * the shared client, auth, and rate-limit handling. A batch whose transport + * fails (no usable data — e.g. an invalid token or a total GraphQL error) marks + * every reference in it `error` and the run continues, so one inaccessible repo + * cannot blind the report on the others. + * + * @param refs - Every resolved reference across all items. + * @param transport - GraphQL transport (the production one closes over the token). + * @returns Live state by {@link BlockerReference.key}. + */ +export async function fetchBlockerReferenceStates( + refs: readonly ResolvedBlockerReference[], + transport: BlockerGraphQLTransport, +): Promise> { + const states = new Map(); + for (const batch of chunkBlockerReferences(refs, BLOCKER_STATES_BATCH_SIZE)) { + const plan = buildBlockerStatesQueryPlan(batch); + let response: GraphQLResponse; + try { + response = await transport(plan.query); + } catch { + for (const ref of batch) states.set(ref.key, { repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw, state: "error", error: "GitHub blocker state request failed." }); + continue; + } + for (const entry of plan.entries) { + const failure = response.errors?.find((error) => error.type !== "NOT_FOUND" && + (!error.path || (error.path[0] === entry.repoAlias && + (error.path.length === 1 || error.path[1] === entry.issueAlias || error.path[1] === entry.pullAlias)))); + if (failure) { + states.set(entry.ref.key, { ...entry.ref, state: "error", error: "GitHub rejected blocker state access." }); + continue; + } + const repoBlock = response.data?.[entry.repoAlias]; + if (repoBlock === undefined || (repoBlock !== null && (repoBlock[entry.issueAlias] === undefined || repoBlock[entry.pullAlias] === undefined))) { + states.set(entry.ref.key, { ...entry.ref, state: "error", error: "Incomplete GitHub blocker state response." }); + continue; + } + states.set(entry.ref.key, mapBlockerReferenceNode(entry.ref, repoBlock?.[entry.issueAlias], repoBlock?.[entry.pullAlias])); + } + } + return states; +} + +/** + * The revalidation verdict over one item's reference states. + * + * `cleared` requires every reference closed or merged; a partially cleared item + * (some blocker closed, some still open or unreadable) is reported as such + * rather than cleared, because an unreadable reference can always be open. An + * item with no references at all — a blocked_reason naming no GitHub reference — + * is `unresolvable`: its static text can never be revalidated automatically. + * + * @param states - The item's reference states. + * @returns The item's verdict. + */ +export function blockerItemVerdict(states: readonly BlockerReferenceState[]): BlockerVerdict { + if (states.length === 0) return "unresolvable"; + let cleared = 0; + let open = 0; + for (const state of states) { + if (state.state === "closed" || state.state === "merged") cleared++; + else if (state.state === "open") open++; + } + if (cleared === states.length) return "cleared"; + if (cleared > 0) return "partially_cleared"; + if (open > 0) return "still_blocked"; + return "unresolvable"; +} + +/** Marker a blockers comment carries so re-runs can skip it (`pm-github:blockers:…`). */ +export const BLOCKERS_COMMENT_MARKER_REGEX = //g; + +/** + * Build the idempotency marker for one cleared blocker reference. + * + * Identity is the reference PLUS its observed state (repo, number, closed or + * merged, and the exact timestamp): if the blocker later reopens and closes + * again, the changed timestamp makes the marker a new one and a fresh comment + * is appended, while an unchanged state never repeats. + * + * @param repo - The blocker's `owner/repo`. + * @param number - The blocker's number. + * @param disposition - `closed` or `merged`. + * @param at - The close/merge ISO timestamp. + * @returns The marker string embedded in the comment. + */ +export function blockerCommentMarker(repo: string, number: number, disposition: "closed" | "merged", at: string): string { + return ``; +} + +/** + * The disposition and timestamp one cleared reference is reported under. + * + * Shared by the comment builder and its idempotency marker so both always + * agree on whether a reference closed or merged, and on which date. + * + * @param state - The cleared reference's live state. + * @returns `closed`/`merged` plus the close or merge ISO timestamp (empty when GitHub sent none). + */ +function blockerDispositionAndDate(state: BlockerReferenceState): { disposition: "closed" | "merged"; at: string } { + const disposition = state.state === "merged" ? "merged" : "closed"; + const at = (state.state === "merged" ? state.merged_at : state.closed_at) ?? ""; + return { disposition, at }; +} + +/** + * Build the comment appended for one cleared blocker reference. + * + * Human text first (what the blocker was and when it closed, then the ask — + * revalidate), marker last, mirroring the GitHub comment sync's layout so stored + * comments stay scannable and machine-skippable. + * + * @param state - The cleared reference's live state. + * @returns The comment text. + */ +export function buildBlockerClearedComment(state: BlockerReferenceState): string { + const repo = state.repo ?? ""; + const { disposition, at } = blockerDispositionAndDate(state); + const human = at === "" ? "an unknown date" : at.slice(0, 10); + const marker = blockerCommentMarker(repo, state.number, disposition, at === "" ? "unknown" : at); + return `Blocker ${repo}#${state.number} ${disposition} on ${human}; revalidate.\n\n${marker}`; +} + +/** + * Collect the blocker markers already present in an item's comments. + * + * Mirrors {@link extractSyncedCommentIds}: each stored comment's text is scanned + * for the marker; hand-written comments simply contribute nothing. + * + * @param stored - The item's existing native comments. + * @returns The set of marker strings already present. + */ +export function extractBlockerCommentMarkers(stored: { text?: string }[]): Set { + const markers = new Set(); + for (const entry of stored) { + if (!entry?.text) continue; + for (const m of entry.text.matchAll(BLOCKERS_COMMENT_MARKER_REGEX)) { + markers.add(blockerCommentMarker(m[1]!.toLowerCase(), Number(m[2]), m[3] as "closed" | "merged", m[4]!)); + } + } + return markers; +} + +/** + * Append one idempotent comment per cleared item reference on its item. + * + * Never unblocks, reopens, or edits anything — a cleared blocker does not + * always mean the work is unblocked, so the comment only names the closed + * blocker and asks a human to revalidate. Idempotent across re-runs and safe + * across concurrent runs: the per-item comment lock serializes the read-markers-then-append + * window, and a comment whose marker is already present is skipped, so the + * same reference state is never announced twice. + * + * @param pmRoot - Workspace root or pm data dir. + * @param cleared - Cleared reference states grouped by their item id. + * @param dryRun - Preview the comments without writing anything. + * @returns How many comments were added, skipped as already present, and not written. + * @internal Exported so the lock-contention and write-failure paths are directly + * testable without racing a real process; stripped from the published declaration. + */ +export async function appendBlockerComments( + pmRoot: string, + cleared: ReadonlyArray<{ itemId: string; states: readonly BlockerReferenceState[] }>, + dryRun: boolean, +): Promise<{ added: number; skipped: number; failed: number }> { + let added = 0; + let skipped = 0; + let failed = 0; + for (const entry of cleared) { + const acquisition = await acquireImportLock(pmRoot, entry.itemId); + if (acquisition.status !== "acquired") { + console.error( + `${entry.itemId}: blocker comment skipped — comment-sync lock unavailable; re-run to record it`, + ); + failed++; + continue; + } + const release = () => acquisition.lock.release(); + try { + let existing: { text?: string }[]; + try { + const list: CommentsResult = await pmCommentsFn(entry.itemId, {}, { pmRoot, cwd: pmRoot }); + existing = list.comments; + } catch (err: unknown) { + console.error(`${entry.itemId}: could not read existing comments — ${errorMessage(err)}`); + failed++; + continue; + } + const markers = extractBlockerCommentMarkers(existing); + const pending = entry.states.filter((state) => { + const { disposition, at } = blockerDispositionAndDate(state); + return !markers.has(blockerCommentMarker(state.repo ?? "", state.number, disposition, at === "" ? "unknown" : at)); + }); + if (pending.length === 0) { skipped++; continue; } + const text = pending.map(buildBlockerClearedComment).join("\n\n"); + if (dryRun) { added++; continue; } + try { + await pmCommentsFn(entry.itemId, { add: text, author: "pm-github" }, { pmRoot, cwd: pmRoot }); + added++; + } catch (err: unknown) { + console.error(`${entry.itemId}: blocker comment failed — ${errorMessage(err)}`); + failed++; + } + } finally { + release(); + } + } + return { added, skipped, failed }; +} + +/** + * Run `pm github blockers`: revalidate blocked items against the live state of their GitHub blockers. + * + * Reads the complete corpus through the SDK with a completeness certificate, selects blocked items plus non-terminal items with an external GitHub + * `blocked_by` dependency, parses their blocker text, queries each reference's + * live state in batched GraphQL, and reports a verdict per item. REPORT-ONLY: + * nothing is unblocked or edited; `--comment` only appends idempotent comments. + * Exit codes: 0 when nothing cleared, {@link EXIT_CODE.BLOCKERS_CLEARED} when + * something cleared or partially cleared, {@link EXIT_CODE.USAGE} for usage or a + * missing token, {@link EXIT_CODE.GENERIC_FAILURE} when the state of every + * reference failed to resolve. + * + * @param ctx - The host command-handler context. + * @returns The full report for the host renderer (JSON/TOON). + * @throws {@link CommandError} For usage or requested-item selection errors. + */ +async function runBlockers(ctx: CommandHandlerContext) { + const options = ctx.options; + const fallbackRepo = optionString(options, "repo"); + if (fallbackRepo !== undefined && !BLOCKER_REPO_SHAPE.test(fallbackRepo)) { + throw new CommandError( + "Usage: pm github blockers [--repo owner/repo] [--ids pm-1,pm-2] [--comment] [--dry-run] " + + "(--repo must be owner/repo)", + EXIT_CODE.USAGE, + ); + } + const scopedIds = optionCsv(options, "ids"); + if (optionProvided(options, "ids") && scopedIds.length === 0) { + throw new CommandError( + "--ids requires at least one pm item id (comma-separated), e.g. --ids pm-123,pm-456.", + EXIT_CODE.USAGE, + ); + } + const withComments = optionEnabled(options, "comment"); + const dryRun = optionEnabled(options, "dry-run", "dryRun"); + const token = resolveGitHubToken(); + if (!token) { + throw new CommandError( + "pm github blockers reads blocker state from GitHub and needs a token " + + "(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", + EXIT_CODE.USAGE, + ); + } + + const corpus = await readBlockerPmItems(ctx.pm_root); + const selected = requireRequestedItems(corpus, scopedIds); + const candidates = selectBlockerCandidates(selected); + const perItem = candidates.map((item) => ({ item, references: collectBlockerReferences(item, fallbackRepo) })); + + const unique = new Map(); + for (const entry of perItem) { + for (const ref of entry.references) { + if (ref.repo !== undefined && !unique.has(ref.key)) { + unique.set(ref.key, { key: ref.key, repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw }); + } + } + } + const stateByKey = await fetchBlockerReferenceStates([...unique.values()], (query) => + githubGraphQLResponse(token, query, {})); + const queried = [...stateByKey.values()]; + + + const items: BlockerItemReport[] = perItem.map(({ item, references }) => { + const refStates: BlockerReferenceState[] = references.map((ref) => + stateByKey.get(ref.key) + ?? { repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw, state: "unresolved" as const }); + return { + id: item.id, + status: item.status, + verdict: blockerItemVerdict(refStates), + references: refStates, + }; + }); + const counts = { + cleared: items.filter((entry) => entry.verdict === "cleared").length, + partially_cleared: items.filter((entry) => entry.verdict === "partially_cleared").length, + still_blocked: items.filter((entry) => entry.verdict === "still_blocked").length, + unresolvable: items.filter((entry) => entry.verdict === "unresolvable").length, + }; + + let comment: BlockersReport["comment"]; + if (withComments) { + const cleared = items + .filter((entry) => entry.verdict === "cleared") + .map((entry) => ({ + itemId: entry.id, + states: entry.references, + })); + const receipt = await appendBlockerComments(ctx.pm_root, cleared, dryRun); + comment = { requested: true, dry_run: dryRun, ...receipt }; + } + + const report: BlockersReport = { + command: "github blockers", + generated_at: new Date().toISOString(), + items_read: selected.length, + candidates: candidates.length, + items, + counts, + ...(comment === undefined ? {} : { comment }), + }; + const failed = queried.some((state) => state.state === "error") || (comment?.failed ?? 0) > 0; + if (failed) return { ...report, exit_code: EXIT_CODE.GENERIC_FAILURE }; + if (counts.cleared > 0 || counts.partially_cleared > 0) return { ...report, exit_code: EXIT_CODE.BLOCKERS_CLEARED }; + return report; +} + // --------------------------------------------------------------------------- // Search provider — reach GitHub from `pm search` for imported items // --------------------------------------------------------------------------- @@ -4242,7 +5171,7 @@ function graphqlUrl(): string { interface GraphQLResponse { data?: T | null; - errors?: Array<{ message: string; type?: string }>; + errors?: Array<{ message: string; type?: string; path?: Array }>; } // --- GraphQL response shapes (derived from the actual query strings below) --- @@ -4379,11 +5308,16 @@ interface GraphqlProjectFieldsData { // `errors` array, so we surface those explicitly. The combined user+org queries // below intentionally return a partial error for the wrong owner type while the // right one still resolves; we only throw when there is NO usable data. -async function githubGraphQL( +async function githubGraphQL(token: string | undefined, query: string, variables: Record): Promise { + return (await githubGraphQLResponse(token, query, variables)).data!; +} + +/** Shared GraphQL client retaining partial errors for reference-level classification. */ +async function githubGraphQLResponse( token: string | undefined, query: string, variables: Record, -): Promise { +): Promise> { if (!token) { throw new CommandError( "GitHub GraphQL requires a token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", @@ -4404,7 +5338,7 @@ async function githubGraphQL( `GitHub GraphQL error${messages ? `: ${messages}` : ` (HTTP ${res.status})`}.`, ); } - return parsed.data; + return parsed; } const STATUS_FIELD_GQL = ` @@ -5263,6 +6197,13 @@ const VALIDATE_FLAGS = [ { long: "--repo", value_name: "owner/repo", description: "Also check this repo is accessible with the resolved token" }, ]; +const BLOCKERS_FLAGS = [ + { long: "--repo", value_name: "owner/repo", description: "Fallback repo for bare `#N`, `GH-N`, and `name#N` references that carry no owner (after the item's own gh: provenance repo)" }, + { long: "--ids", value_name: "pm-1,pm-2", description: "Only revalidate these pm item IDs (comma-separated)" }, + { long: "--comment", description: "Append one idempotent pm comment per cleared item (never unblocks or edits the item; re-runs skip an already-announced reference state)" }, + { long: "--dry-run", description: "With --comment: preview the comments without writing anything (the report itself never mutates)" }, +]; + const PROJECT_LIST_FLAGS = [ { long: "--owner", value_name: "login", description: "GitHub user or org login (or pass positionally)" }, ]; @@ -5639,6 +6580,46 @@ export default defineExtension({ }, }); + // ----------------------------------------------------------------------- + // command — `pm github blockers` (revalidate blocked items against the + // live state of their GitHub blockers). REPORT-ONLY: it never unblocks, + // reopens, or edits any item; --comment only appends idempotent comments. + // Exit codes: 0 nothing cleared; 4 something cleared or partially cleared + // (so CI and the scheduled sync can alert); 2 usage/missing token; + // 1 failure (e.g. every reference's state failed to resolve). + // ----------------------------------------------------------------------- + api.registerCommand({ + name: "github blockers", + description: + "Revalidate blocked items against the live state of the GitHub issues and pull requests their " + + "blocked_reason, description, comments, and external blocked_by dependencies name " + + "(owner/repo#N, bare #N vs the item's repo or --repo, full issue/PR URLs, GH-N). " + + "Queries current state (open, closed with reason, merged, not found) in batched GraphQL and reports a " + + "verdict per item: cleared, partially_cleared, still_blocked, or unresolvable. NEVER auto-unblocks. " + + "Read-only; --comment appends one idempotent pm comment per cleared item. " + + "Exits 4 when something is cleared so CI and the scheduled sync can alert; use --json for machine output.", + intent: "revalidate blocked items against the live state of their GitHub blockers", + examples: [ + "pm github blockers", + "pm github blockers --json", + "pm github blockers --repo unbraind/pm-cli", + "pm github blockers --ids pm-123,pm-456 --json", + "pm github blockers --comment", + "pm github blockers --comment --dry-run", + ], + flags: BLOCKERS_FLAGS, + failure_hints: [ + "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`; blockers reads live GitHub state.", + "Pass --repo owner/repo so bare `#N`, `GH-N`, and `name#N` references resolve; imported items resolve against their own provenance repo.", + "Exit code 4 means at least one blocked item has a cleared or partially cleared blocker; nothing was changed — revalidate the item by hand.", + "--comment never unblocks: it only appends one idempotent comment per cleared item.", + "Use --ids to scope revalidation; unknown IDs fail fast.", + ], + async run(ctx: CommandHandlerContext) { + return runBlockers(ctx); + }, + }); + // ----------------------------------------------------------------------- // command — `pm github project list ` (discover ProjectsV2) // ----------------------------------------------------------------------- diff --git a/manifest.json b/manifest.json index ae8e238..6ee168b 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "description": "GitHub Issues + Projects v2 integration. Imports issues as pm items (`pm github import`), exports pm items as GitHub issues, syncs issue state, and bidirectionally syncs pm items with a GitHub Projects v2 board (`pm github project import/sync/list/fields`) — mapping pm status to the board Status column with idempotent, no-data-loss provenance.", "author": "@unbraind", "entry": "./dist/index.js", - "pm_min_version": "2026.8.20", + "pm_min_version": "2026.10.5", "priority": 50, "capabilities": [ "commands", diff --git a/package-lock.json b/package-lock.json index 7e98e20..2cc3d8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.20" + "@unbrained/pm-cli": ">=2026.10.5" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 85bea22..61bccef 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "test:bun": "bun_status=0; for test_file in test/*.test.ts; do bun test --timeout 600000 \"$test_file\" || bun_status=1; done; exit \"$bun_status\"" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.20" + "@unbrained/pm-cli": ">=2026.10.5" }, "devDependencies": { "@babel/eslint-parser": "8.0.7", diff --git a/test/blockers.test.ts b/test/blockers.test.ts new file mode 100644 index 0000000..77777d3 --- /dev/null +++ b/test/blockers.test.ts @@ -0,0 +1,797 @@ +// Tests for `pm github blockers` — revalidating blocked items against the +// live state of their GitHub blockers. +// +// Layers, mirroring the repo's established seams: +// pure units — the reference parser (every documented form, foreign-locator +// masking), hint resolution, candidate selection, the complete-read decoder +// for the blocker fields, the GraphQL plan/batching, the verdict function, +// the comment marker/idempotency helpers, and the report renderer; +// transport units — `fetchBlockerReferenceStates` over injectable transports, +// covering not-found repos, unexpected states, and per-batch failures; +// end-to-end — the REAL command handler through the extension harness against +// REAL throwaway pm trackers (no pm SDK mocks), with the REAL HTTP stack +// pointed by `PM_GITHUB_API_BASE` at a local server that replays RECORDED +// real GitHub GraphQL responses (see test/fixtures/blockers/*.json — +// unbraind/pm-cli#854 is the closed issue that motivated the feature, +// #1246 is open, PR #34 is merged, #999999 does not exist, and the +// repository-not-found / data-null bodies are real error responses). +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { comments as readComments } from "@unbrained/pm-cli/sdk"; +import { createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; +import extension, * as github from "../index.ts"; +import type { MockGithubHandler } from "./helpers/mock-github-server.ts"; +import { captureStderr, jsonResponse, withEnv, withMockGithub } from "./helpers/mock-github-server.ts"; +import { withReadOnlyDirectory } from "./helpers/read-only-directory.ts"; + +const harness = createExtensionTestHarness(extension, { capabilities: ["commands", "importers", "schema", "hooks", "preflight", "search"] }); + +const PM_BIN = fileURLToPath(new URL(`../node_modules/.bin/pm${process.platform === "win32" ? ".cmd" : ""}`, import.meta.url)); +const FIXTURES_DIR = fileURLToPath(new URL("./fixtures/blockers", import.meta.url)); +const runsAsRoot = process.platform !== "win32" && process.getuid?.() === 0; +const nonRootOnly = { skip: runsAsRoot && "permission-based fixture: root ignores directory write permissions" }; + +/** A recorded real GitHub GraphQL response body, verbatim. */ +function fixture(name: string): string { + return fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"); +} + +/** A complete `pm list --all --json` envelope, with caller overrides. */ +function completeEnvelope(overrides: Record = {}): Record { + return { + items: [ + { + id: "fixture-1", + title: "Fixture", + status: "open", + body: "Body", + description: "Description", + tags: [], + blocked_reason: "blocked on unbraind/pm-cli#854", + dependencies: [{ id: "github:unbraind/pm-cli#854", kind: "blocked_by" }], + comments: [{ text: "still waiting on GH-99" }], + }, + ], + count: 1, + total: 1, + has_more: false, + truncated: false, + next_cursor: null, + completeness: { status: "complete", unreadable_item_count: 0, unreadable_directory_count: 0 }, + projection: { mode: "full", fields: null }, + omission_receipt: { has_omissions: false, omitted_field_group_count: 0, omitted_field_groups: [] }, + read_output: { + contract_version: 1, + command: "list", + requested_dimensions: ["include", "amount", "cost"], + within_budget: true, + strings_compacted: false, + rows_compacted: false, + result_omitted: false, + }, + ...overrides, + }; +} + +/** A resolved reference for transport-level tests. */ +function resolvedRef(repo: string, number: number, key = `${repo}#${number}`): github.ResolvedBlockerReference { + return { key, repo, number, form: "repo_hash", raw: `${repo}#${number}` }; +} + +/** A live-state reference for verdict/comment/renderer tests. */ +function refState(overrides: Partial = {}): github.BlockerReferenceState { + return { repo: "unbraind/pm-cli", number: 854, form: "repo_hash", raw: "unbraind/pm-cli#854", state: "closed", ...overrides }; +} + +// Real-tracker plumbing ------------------------------------------------------- + +/** Create a real throwaway pm tracker and return its pm data dir. */ +function tracker(t: TestContext): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pm-github-blockers-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const result = spawnSync(PM_BIN, ["--path", root, "init", "fixture"], { + encoding: "utf-8", + cwd: root, + env: { ...process.env, PM_AUTHOR: "pm-github-blockers-test" }, + shell: process.platform === "win32", + }); + assert.equal(result.status, 0, result.stderr); + return root; +} + +/** Run the real pm CLI inside a tracker, failing the test on a nonzero exit. */ +function pm(root: string, args: string[]): string { + const result = spawnSync(PM_BIN, ["--pm-path", root, ...args], { + encoding: "utf-8", + cwd: root, + env: { ...process.env, PM_AUTHOR: "pm-github-blockers-test" }, + shell: process.platform === "win32", + }); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + return result.stdout; +} + +/** Create a real item and return its id (parsed from real `pm create --json`). */ +function createItem(root: string, title: string, args: string[] = []): string { + const stdout = pm(root, ["create", "task", title, ...args, "--json"]); + const id = github.parseCreatedItemId(stdout); + assert.ok(id, "the real CLI must emit a parseable id"); + return id; +} + +/** Run `pm github blockers` through the real extension handler. */ +async function runBlockers(root: string, options: Record = {}, json = false): Promise { + const outcome = await (await harness).runCommand({ command: "github blockers", options, pmRoot: root, global: { json } }); + assert.equal(outcome.handled, true, outcome.errorMessage ?? "handler must succeed"); + return outcome.result as github.BlockersReport & { exit_code?: number }; +} + +/** Assert a runCommand rejection carries a specific exit code. */ +async function assertRejectsWithExitCode(promise: Promise, exitCode: number, message: RegExp): Promise { + await assert.rejects( + promise, + (err: unknown) => err instanceof Error && message.test(err.message) && (err as { exitCode?: number }).exitCode === exitCode, + ); +} + +/** Serve recorded GraphQL fixtures by matching a marker inside the query text. */ +function graphqlFixtureHandler(bodies: ReadonlyArray<[marker: string, response: string]>): MockGithubHandler { + return (req, res, body) => { + assert.equal(req.method, "POST"); + assert.equal(req.url, "/graphql"); + const parsed = JSON.parse(body) as { query?: string }; + const query = parsed.query ?? ""; + for (const [marker, response] of bodies) { + if (query.includes(marker)) { + jsonResponse(res, 200, response); + return; + } + } + // A fixture miss must fail loudly, not silently serve the wrong state. + jsonResponse(res, 200, JSON.stringify({ data: null, errors: [{ message: `fixture miss for query: ${query.slice(0, 120)}` }] })); + }; +} + +// Parser ---------------------------------------------------------------------- + +test("parseBlockerReferences recognizes every reference form", () => { + const refs = github.parseBlockerReferences( + "blocked on unbraind/pm-cli#854; also https://github.com/unbraind/pm-cli/issues/1246 " + + "and https://github.com/unbraind/pm-cli/pull/34; see GH-42 and gh-43; bare #7; short pm-cli#99", + ); + assert.deepEqual( + refs.map((r) => ({ repo: r.repo, number: r.number, form: r.form, raw: r.raw })), + [ + { repo: "unbraind/pm-cli", number: 1246, form: "url", raw: "https://github.com/unbraind/pm-cli/issues/1246" }, + { repo: "unbraind/pm-cli", number: 34, form: "url", raw: "https://github.com/unbraind/pm-cli/pull/34" }, + { repo: "unbraind/pm-cli", number: 854, form: "repo_hash", raw: "unbraind/pm-cli#854" }, + { number: 42, form: "gh_dash", raw: "GH-42" }, + { number: 43, form: "gh_dash", raw: "GH-43" }, + { number: 99, form: "short_repo_hash", raw: "pm-cli#99" }, + { number: 7, form: "bare_hash", raw: "#7" }, + ].map((r) => ({ repo: undefined, ...r })), + ); +}); + +test("parseBlockerReferences masks foreign locators and unwraps the github: scheme", () => { + const refs = github.parseBlockerReferences( + "gitlab:gitlab-org/gitlab#1 jira:PROJ-9 linear:ENG-3 stay out; github:unbraind/pm-cli#12 unwraps", + ); + assert.deepEqual(refs.map((r) => r.raw), ["unbraind/pm-cli#12"]); + assert.equal(refs[0]?.repo, "unbraind/pm-cli"); +}); + +test("parseBlockerReferences rejects lookalikes", () => { + assert.deepEqual( + github.parseBlockerReferences("issue/854 #854abc ###12 https://example.com/x#4 GH-12345678901"), + [], + ); + // A markdown heading with a number is not a reference, and a long digit run + // cannot partially match. + assert.deepEqual(github.parseBlockerReferences("### 854\nsee #12345678901"), []); +}); + +test("resolveBlockerReferences respects item provenance before fallback and deduplicates", () => { + const parsed = github.parseBlockerReferences("#7 pm-cli#99 other#5 GH-12 unbraind/pm-cli#854 #854"); + const resolved = github.resolveBlockerReferences(parsed, "unbraind/pm-github", "unbraind/pm-cli"); + assert.deepEqual(resolved.filter(r => r.repo === "unbraind/pm-github").map(r => r.number), [12, 7, 854]); + assert.ok(resolved.some(r => r.key === "unbraind/pm-cli#99")); + assert.ok(resolved.some(r => r.repo === undefined)); + assert.equal(github.resolveBlockerReferences(github.parseBlockerReferences("#854 GH-854 unbraind/pm-cli#854"), "unbraind/pm-cli", undefined).length, 1); +}); + +test("resolveBlockerReferences leaves hint-dependent forms unresolved without a hint", () => { + const parsed = github.parseBlockerReferences("#7 GH-12"); + const resolved = github.resolveBlockerReferences(parsed, undefined, undefined); + assert.deepEqual(resolved.map((r) => [r.key, r.repo, r.form]), [ + ["unresolved:#12", undefined, "gh_dash"], + ["unresolved:#7", undefined, "bare_hash"], + ]); + // An item repo hint alone is enough when --repo is absent. + assert.deepEqual( + github.resolveBlockerReferences(parsed, "unbraind/pm-cli", undefined).map((r) => r.repo), + ["unbraind/pm-cli", "unbraind/pm-cli"], + ); +}); + +// Candidate selection ---------------------------------------------------------- + +test("selectBlockerCandidates keeps blocked items and external blocked_by dependencies, nothing else", () => { + const item = (overrides: Partial): github.BlockerPmItem => ({ + id: "fixture-x", + title: "t", + status: "open", + description: "d", + dependencies: [], + comments: [], + ...overrides, + }); + const items = [ + item({ id: "blocked", status: "blocked" }), + item({ id: "open-external-dep", dependencies: [{ id: "github:unbraind/pm-cli#854", kind: "blocked_by" }] }), + item({ id: "open-external-field", blockedBy: "https://github.com/unbraind/pm-cli/issues/854" }), + item({ id: "closed-external", status: "closed", dependencies: [{ id: "github:unbraind/pm-cli#854", kind: "blocked_by" }] }), + item({ id: "local-dep-only", dependencies: [{ id: "fixture-blocker", kind: "blocked_by" }] }), + item({ id: "related-dep", dependencies: [{ id: "github:unbraind/pm-cli#854", kind: "related" }] }), + item({ id: "plain-open" }), + ]; + assert.deepEqual( + github.selectBlockerCandidates(items).map((i) => i.id), + ["blocked", "open-external-dep", "open-external-field"], + ); +}); + +// Complete-read decoder -------------------------------------------------------- + +test("decodeBlockerPmItems validates and carries the blocker fields", () => { + const items = github.decodeBlockerPmItems(completeEnvelope()); + assert.equal(items.length, 1); + assert.equal(items[0]?.blockedReason, "blocked on unbraind/pm-cli#854"); + assert.equal(items[0]?.blockedBy, undefined); + assert.deepEqual(items[0]?.dependencies, [{ id: "github:unbraind/pm-cli#854", kind: "blocked_by" }]); + assert.deepEqual(items[0]?.comments, [{ text: "still waiting on GH-99" }]); + // Absent optional fields decode to empty, not errors. + const bare = github.decodeBlockerPmItems(completeEnvelope({ items: [{ id: "fixture-2", title: "T", status: "blocked", body: "B", description: "D", tags: [] }] })); + assert.deepEqual(bare[0]?.dependencies, []); + assert.deepEqual(bare[0]?.comments, []); + assert.equal(bare[0]?.blockedReason, undefined); +}); + +test("decodeBlockerPmItems fails closed on wrong-typed blocker fields", () => { + const cases: ReadonlyArray<[field: string, value: unknown, message: RegExp]> = [ + ["blocked_reason", 7, /blocked_reason must be a string/], + ["blocked_by", {}, /blocked_by must be a string/], + ["dependencies", "nope", /dependencies must be an array/], + ["comments", 3, /comments must be an array/], + ]; + for (const [field, value, message] of cases) { + assert.throws( + () => github.decodeBlockerPmItems(completeEnvelope({ items: [{ id: "fixture-1", title: "T", status: "open", body: "B", description: "D", tags: [], [field]: value }] })), + (err: unknown) => err instanceof github.CommandError && message.test(err.message), + ); + } + assert.throws( + () => github.decodeBlockerPmItems(completeEnvelope({ items: [{ id: "fixture-1", title: "T", status: "open", body: "B", description: "D", tags: [], dependencies: [{ id: "x" }] }] })), + /dependency rows must carry string id and kind/, + ); + assert.throws( + () => github.decodeBlockerPmItems(completeEnvelope({ items: [{ id: "fixture-1", title: "T", status: "open", body: "B", description: "D", tags: [], comments: [{ author: "x" }] }] })), + /comment rows must carry string text/, + ); +}); + +test("readBlockerPmItems reads the complete real corpus including blocker fields", async (t) => { + const root = tracker(t); + const id = createItem(root, "Blocked on the guard", ["--blocked-reason", "Externally blocked on unbraind/pm-cli#854"]); + pm(root, ["update", id, "--status", "blocked"]); + pm(root, ["comment", id, "a comment naming GH-99"]); + const items = await github.readBlockerPmItems(root); + const blocked = items.find((item) => item.id === id); + assert.ok(blocked); + assert.equal(blocked.status, "blocked"); + assert.match(blocked.blockedReason ?? "", /unbraind\/pm-cli#854/); + assert.deepEqual(blocked.comments.map((c) => c.text), ["a comment naming GH-99"]); + assert.ok(blocked.dependencies.length === 0); +}); + +// GraphQL plan, batching, and transport ---------------------------------------- + +test("buildBlockerStatesQueryPlan dedupes and sorts repos and numbers, exposing read-back aliases", () => { + const plan = github.buildBlockerStatesQueryPlan([ + resolvedRef("unbraind/pm-cli", 999), + resolvedRef("unbraind/pm-cli", 12), + resolvedRef("acme/widgets", 3), + ]); + assert.deepEqual(plan.repos, ["acme/widgets", "unbraind/pm-cli"]); + assert.match(plan.query, /r0: repository\(owner: "acme", name: "widgets"\)/); + assert.match(plan.query, /r1: repository\(owner: "unbraind", name: "pm-cli"\)/); + assert.match(plan.query, /i1_0: issue\(number: 12\)/); + assert.match(plan.query, /i1_1: issue\(number: 999\)/); + assert.deepEqual( + plan.entries.map((entry) => [entry.ref.key, entry.repoAlias, entry.issueAlias, entry.pullAlias]), + [ + ["acme/widgets#3", "r0", "i0_0", "p0_0"], + ["unbraind/pm-cli#12", "r1", "i1_0", "p1_0"], + ["unbraind/pm-cli#999", "r1", "i1_1", "p1_1"], + ], + ); +}); + +test("chunkBlockerReferences splits consecutive fixed-size batches", () => { + const refs = [1, 2, 3, 4, 5].map((n) => resolvedRef("unbraind/pm-cli", n)); + assert.deepEqual(github.chunkBlockerReferences(refs, 2).map((b) => b.map((r) => r.number)), [[1, 2], [3, 4], [5]]); + assert.deepEqual(github.chunkBlockerReferences([], 3), []); + assert.equal(github.BLOCKER_STATES_BATCH_SIZE, 25); +}); + +test("fetchBlockerReferenceStates maps aliased nodes to live states", async () => { + const transport = async () => ({ data: { + r0: { i0_0: { state: "OPEN" }, p0_0: null }, + r1: { i1_0: { state: "CLOSED", stateReason: "COMPLETED", closedAt: "2026-08-01T15:24:48Z", url: "u/i" }, p1_0: null }, + r2: { i2_0: null, p2_0: { state: "MERGED", mergedAt: "2026-05-20T21:09:52Z", closedAt: "2026-05-20T21:09:52Z", url: "u/p" } }, + r3: { i3_0: null, p3_0: { state: "CLOSED", closedAt: "2026-01-01T00:00:00Z", url: "u/c" } }, + r4: null, + r5: { i5_0: null, p5_0: null }, + r6: { i6_0: { state: "DRAFT" }, p6_0: null }, + r7: { i7_0: null, p7_0: { state: "UNKNOWN" } }, + } }); + const refs = [0, 1, 2, 3, 4, 5, 6, 7].map((n) => resolvedRef(`acme/repo${n}`, 1, `acme/repo${n}#1`)); + const plan = github.buildBlockerStatesQueryPlan(refs); + const states = await github.fetchBlockerReferenceStates(refs, transport); + assert.deepEqual([...states.values()].map((s) => s.state), [ + "open", "closed", "merged", "closed", "not_found", "not_found", "error", "error", + ]); + const closed = [...states.values()].find((s) => s.state === "closed" && s.closed_at === "2026-08-01T15:24:48Z"); + assert.equal(closed?.state_reason, "COMPLETED"); + assert.equal(plan.entries.length, 8); +}); + +test("fetchBlockerReferenceStates batches at BLOCKER_STATES_BATCH_SIZE and marks failed batches error", async () => { + const refs = Array.from({ length: 26 }, (_, i) => resolvedRef("unbraind/pm-cli", i + 1)); + let calls = 0; + const states = await github.fetchBlockerReferenceStates(refs, async () => { + calls++; + if (calls === 1) return { data: { r0: { i0_0: { state: "OPEN" }, p0_0: null } } }; + throw new github.CommandError("GraphQL error: bad token.", 2); + }); + assert.equal(calls, 2); + const values = [...states.values()]; + assert.equal(values[0]?.state, "open"); + assert.ok(values.slice(1).every((s) => s.state === "error" && /response|request failed/.test(s.error ?? ""))); +}); + +// Verdict ---------------------------------------------------------------------- + +test("blockerItemVerdict covers all four outcomes", () => { + const open = refState({ state: "open" }); + const closed = refState({ state: "closed" }); + const merged = refState({ state: "merged" }); + const notFound = refState({ state: "not_found" }); + const error = refState({ state: "error", error: "boom" }); + const unresolved = refState({ state: "unresolved" }); + assert.equal(github.blockerItemVerdict([]), "unresolvable"); + assert.equal(github.blockerItemVerdict([closed, merged]), "cleared"); + assert.equal(github.blockerItemVerdict([closed, open]), "partially_cleared"); + assert.equal(github.blockerItemVerdict([merged, notFound]), "partially_cleared"); + assert.equal(github.blockerItemVerdict([open, unresolved]), "still_blocked"); + assert.equal(github.blockerItemVerdict([notFound, error, unresolved]), "unresolvable"); +}); + +// Comment helpers ---------------------------------------------------------------- + +test("blocker comments are built with a state-identity marker and detected idempotently", () => { + const closed = refState({ closed_at: "2026-08-01T15:24:48Z" }); + const text = github.buildBlockerClearedComment(closed); + assert.equal( + text, + "Blocker unbraind/pm-cli#854 closed on 2026-08-01; revalidate.\n\n" + + "", + ); + const merged = refState({ state: "merged", merged_at: "2026-05-20T21:09:52Z" }); + assert.match(github.buildBlockerClearedComment(merged), /^Blocker unbraind\/pm-cli#854 merged on 2026-05-20; revalidate\./); + assert.match(github.buildBlockerClearedComment(merged), //); + // A closed blocker GitHub sent no timestamp for still produces a comment + // (with an honest unknown date) and a stable marker. + const noDate = refState({ state: "merged" }); + assert.match(github.buildBlockerClearedComment(noDate), /merged on an unknown date/); + assert.match(github.buildBlockerClearedComment(noDate), /:merged:unknown -->/); + const markers = github.extractBlockerCommentMarkers([{ text }, { text: "hand-written" }, { text: undefined }]); + assert.deepEqual([...markers], [github.blockerCommentMarker("unbraind/pm-cli", 854, "closed", "2026-08-01T15:24:48Z")]); + // A different observed state (reopened and closed again later) is a NEW marker. + const reopened = github.buildBlockerClearedComment(refState({ closed_at: "2026-10-01T00:00:00Z" })); + assert.equal(github.extractBlockerCommentMarkers([{ text: reopened }]).size, 1); + assert.ok(!github.extractBlockerCommentMarkers([{ text: reopened }]).has(github.blockerCommentMarker("unbraind/pm-cli", 854, "closed", "2026-08-01T15:24:48Z"))); +}); + +// End-to-end: the real handler, real trackers, recorded real responses ---------- + +/** The full mixed scenario: one cleared (closed issue), one partially cleared + * (closed + open), one cleared via a merged PR comment, one not-found, one + * unresolved (no GitHub references at all), and one candidate selected only + * through an external `blocked_by` dependency edge. */ +function seedMixedScenario(root: string): Record { + const closed = createItem(root, "Blocked on the guard", ["--blocked-reason", "Externally blocked on unbraind/pm-cli#854"]); + pm(root, ["update", closed, "--status", "blocked"]); + const mixed = createItem(root, "Two blockers", [ + "--blocked-reason", "unbraind/pm-cli#854 plus an open one", + "--description", "needs https://github.com/unbraind/pm-cli/issues/1246 before finishing", + ]); + pm(root, ["update", mixed, "--status", "blocked"]); + const merged = createItem(root, "Waiting on the PR"); + pm(root, ["update", merged, "--status", "blocked"]); + pm(root, ["comment", merged, "upstream landed https://github.com/unbraind/pm-cli/pull/34"]); + const notFound = createItem(root, "Blocked on a ghost", ["--blocked-reason", "blocked on unbraind/pm-cli#999999"]); + pm(root, ["update", notFound, "--status", "blocked"]); + const local = createItem(root, "Blocked for a local reason", ["--blocked-reason", "waiting on the design review"]); + pm(root, ["update", local, "--status", "blocked"]); + const externalDep = createItem(root, "Externally blocked via a dependency edge"); + pm(root, ["update", externalDep, "--dep", "id=github:unbraind/pm-cli#854,kind=blocked_by", "--allow-unresolved-deps"]); + return { "Blocked on the guard": closed, "Two blockers": mixed, "Waiting on the PR": merged, + "Blocked on a ghost": notFound, "Blocked for a local reason": local, "Externally blocked via a dependency edge": externalDep }; +} + +test("pm github blockers revalidates a mixed scenario against recorded real states", async (t) => { + const root = tracker(t); + const seeded = seedMixedScenario(root); + await withEnv({ GH_TOKEN: "ghp-test" }, async () => { + await withMockGithub(graphqlFixtureHandler([ + ['owner: "unbraind", name: "pm-cli"', fixture("pm-cli-blockers-batch.json")], + ]), async (server) => { + const report = await runBlockers(root, {}, true); + assert.equal(report.exit_code, 4); + assert.equal(report.command, "github blockers"); + assert.equal(report.candidates, 6); + assert.equal(report.counts.cleared, 3); + assert.equal(report.counts.partially_cleared, 1); + assert.equal(report.counts.still_blocked, 0); + assert.equal(report.counts.unresolvable, 2); + const byId = new Map(Object.entries(seeded).map(([title, id]) => [title, report.items.find(item => item.id === id)!])); + const closed = byId.get("Blocked on the guard"); + assert.ok(closed); + assert.equal(closed.verdict, "cleared"); + const reference = closed.references[0]; + assert.equal(reference?.state, "closed"); + assert.equal(reference?.state_reason, "COMPLETED"); + assert.equal(reference?.closed_at, "2026-08-01T15:24:48Z"); + assert.equal(reference?.url, "https://github.com/unbraind/pm-cli/issues/854"); + assert.equal(byId.get("Two blockers")?.verdict, "partially_cleared"); + + const merged = byId.get("Waiting on the PR"); + assert.equal(merged?.verdict, "cleared"); + assert.equal(merged?.references[0]?.state, "merged"); + assert.equal(merged?.references[0]?.merged_at, "2026-05-20T21:09:52Z"); + const ghost = byId.get("Blocked on a ghost"); + assert.equal(ghost?.verdict, "unresolvable"); + assert.equal(ghost?.references[0]?.state, "not_found"); + assert.deepEqual(byId.get("Blocked for a local reason")?.references, []); + assert.equal(byId.get("Externally blocked via a dependency edge")?.verdict, "cleared"); + // Exactly one batched GraphQL request resolved all four distinct references. + assert.equal(server.requests.length, 1); + assert.match(server.requests[0]?.body ?? "", /p0_3: pullRequest\(number: 999999\)/); + }); + }); +}); + +test("pm github blockers exits 0 with a report when nothing is cleared", async (t) => { + const root = tracker(t); + const id = createItem(root, "Blocked on an open issue", ["--blocked-reason", "blocked on https://github.com/unbraind/pm-cli/issues/1246"]); + pm(root, ["update", id, "--status", "blocked"]); + await withEnv({ GH_TOKEN: "ghp-test" }, () => + withMockGithub(graphqlFixtureHandler([ + ['owner: "unbraind", name: "pm-cli"', fixture("issue-1246-open.json")], + ]), async () => { + const { result, stderr } = await captureStderr(() => runBlockers(root)); + const report = result as github.BlockersReport; + assert.equal(report.counts.still_blocked, 1); + assert.equal(report.items[0]?.verdict, "still_blocked"); + assert.equal(stderr.length, 0); + })); +}); + +test("pm github blockers reports a bare #N against --repo and an unresolvable repo as not found", async (t) => { + const root = tracker(t); + const bare = createItem(root, "Bare reference", ["--blocked-reason", "blocked on #854"]); + pm(root, ["update", bare, "--status", "blocked"]); + const ghostRepo = createItem(root, "Ghost repo", ["--blocked-reason", "waiting on unbraind/unbraind-no-such-repo-xyz#7"]); + pm(root, ["update", ghostRepo, "--status", "blocked"]); + await withEnv({ GH_TOKEN: "ghp-test" }, () => + withMockGithub(graphqlFixtureHandler([ + ['owner: "unbraind", name: "pm-cli"', fixture("bare-and-ghost-repo.json")], + ['name: "unbraind-no-such-repo-xyz"', fixture("repository-not-found.json")], + ]), async () => { + const { result } = await captureStderr(() => runBlockers(root, { repo: "unbraind/pm-cli" }, true)); + const report = result as github.BlockersReport; + assert.equal(report.items.length, 2); + assert.equal(report.items.find(item => item.id === bare)?.verdict, "cleared"); + assert.equal(report.items.find(item => item.id === ghostRepo)?.references[0]?.state, "not_found"); + assert.equal(report.items.find(item => item.id === ghostRepo)?.verdict, "unresolvable"); + })); +}); + +test("pm github blockers exits 1 when every reference state failed to resolve", async (t) => { + const root = tracker(t); + const id = createItem(root, "Blocked on the guard", ["--blocked-reason", "Externally blocked on unbraind/pm-cli#854"]); + pm(root, ["update", id, "--status", "blocked"]); + await withEnv({ GH_TOKEN: "ghp-test" }, () => + withMockGithub(graphqlFixtureHandler([]), async () => { + assert.equal((await runBlockers(root)).exit_code, 1); + })); +}); + +test("pm github blockers exits 0 with an empty report when nothing is a candidate", async (t) => { + const root = tracker(t); + createItem(root, "Plain open item"); + await withEnv({ GH_TOKEN: "ghp-test" }, async () => { + const { result, stderr } = await captureStderr(() => runBlockers(root)); + const report = result as github.BlockersReport; + assert.equal(report.candidates, 0); + assert.deepEqual(report.items, []); + assert.equal(stderr.length, 0); + }); +}); + +test("--comment appends one idempotent comment per cleared item and --dry-run writes nothing", async (t) => { + const root = tracker(t); + const id = createItem(root, "Blocked on the guard", ["--blocked-reason", "Externally blocked on unbraind/pm-cli#854"]); + pm(root, ["update", id, "--status", "blocked"]); + await withEnv({ GH_TOKEN: "ghp-test" }, () => + withMockGithub(graphqlFixtureHandler([ + ['owner: "unbraind", name: "pm-cli"', fixture("issue-854-closed.json")], + ]), async () => { + // A dry run previews the comment and writes nothing. + const preview = await runBlockers(root, { comment: true, "dry-run": true }); + assert.equal(preview.exit_code, 4); + assert.equal(preview.comment?.added, 1); + const before = await readComments(id, {}, { pmRoot: root }); + assert.equal(before.comments.length, 0); + // The real run appends exactly one comment. + assert.equal((await runBlockers(root, { comment: true })).exit_code, 4); + const after = await readComments(id, {}, { pmRoot: root }); + assert.equal(after.comments.length, 1); + assert.equal(after.comments[0]?.author, "pm-github"); + assert.match(after.comments[0]?.text ?? "", /Blocker unbraind\/pm-cli#854 closed on 2026-08-01; revalidate\./); + assert.match(after.comments[0]?.text ?? "", //); + // Re-running --comment against the same observed state appends nothing. + const repeated = await runBlockers(root, { comment: true }, true); + assert.equal(repeated.exit_code, 4); + assert.equal(repeated.comment?.skipped, 1); + const again = await readComments(id, {}, { pmRoot: root }); + assert.equal(again.comments.length, 1); + })); +}); + +test("--comment survives a contended lock, a degraded lock, and reports write failures", nonRootOnly, async (t) => { + const root = tracker(t); + const id = createItem(root, "Blocked on the guard", ["--blocked-reason", "Externally blocked on unbraind/pm-cli#854"]); + pm(root, ["update", id, "--status", "blocked"]); + const cleared = [{ itemId: id, states: [refState({ closed_at: "2026-08-01T15:24:48Z" })] }]; + // A live foreign process holding the item's comment lock makes the append + // skip (never run unlocked into a duplicate risk window). + const holder = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" }); + try { + assert.ok(holder.pid && holder.pid > 0); + const lockPath = github.importCommentSyncLockPath(root, id); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, JSON.stringify({ pid: holder.pid, token: "held", created_at: new Date().toISOString() }) + "\n"); + const contended = await captureStderr(() => github.appendBlockerComments(root, cleared, false)); + assert.deepEqual(contended.result, { added: 0, skipped: 0, failed: 1 }); + assert.match(contended.stderr.join("\n"), /comment-sync lock unavailable/); + fs.rmSync(lockPath, { force: true }); + + // A missing item cannot have its comments read: the append fails closed. + const missing = await captureStderr(() => + github.appendBlockerComments(root, [{ itemId: "fixture-missing", states: cleared[0].states }], false)); + assert.deepEqual(missing.result, { added: 0, skipped: 0, failed: 1 }); + assert.match(missing.stderr.join("\n"), /could not read existing comments/); + + // A read-only item directory turns the append into a reported failure. + const tasksDir = path.join(root, "tasks"); + await withReadOnlyDirectory(tasksDir, async () => { + const failed = await captureStderr(() => github.appendBlockerComments(root, cleared, false)); + assert.deepEqual(failed.result, { added: 0, skipped: 0, failed: 1 }); + assert.match(failed.stderr.join("\n"), /blocker comment failed/); + }); + + // When the lock is available again, the pending annotation can be appended. + const degraded = await captureStderr(() => github.appendBlockerComments(root, cleared, false)); + assert.deepEqual(degraded.result, { added: 1, skipped: 0, failed: 0 }); + const stored = await readComments(id, {}, { pmRoot: root }); + assert.equal(stored.comments.length, 1); + } finally { + holder.kill("SIGKILL"); + } +}); + +// Usage and failure envelopes ---------------------------------------------------- + +test("pm github blockers rejects malformed --repo, empty --ids, unknown --ids, and a missing token", async (t) => { + const root = tracker(t); + await withEnv({ GH_TOKEN: "ghp-test" }, async () => { + assert.equal((await runBlockers(root, { repo: "unbraind/.github" })).candidates, 0); + await assertRejectsWithExitCode(runBlockers(root, { repo: "not-a-repo" }), 2, /--repo must be owner\/repo/); + await assertRejectsWithExitCode(runBlockers(root, { ids: "" }), 2, /--ids requires at least one pm item id/); + await assertRejectsWithExitCode(runBlockers(root, { ids: "fixture-nope" }), 3, /unknown pm item id\(s\): fixture-nope/); + }); + await withEnv({ GITHUB_TOKEN: undefined, GH_TOKEN: undefined, PATH: "" }, async () => { + await assertRejectsWithExitCode(runBlockers(root), 2, /needs a token/); + }); +}); +test("partial GraphQL permission errors cannot clear a reference or hide a failure", async (t) => { + const refs = [resolvedRef("unbraind/pm-cli", 854), resolvedRef("unbraind/pm-cli", 999999)]; + const states = await github.fetchBlockerReferenceStates(refs, async () => ({ + data: { r0: { i0_0: { state: "CLOSED" }, p0_0: null, i0_1: null, p0_1: null } }, + errors: [{ type: "FORBIDDEN", path: ["r0", "i0_1"], message: "Resource not accessible by integration" }], + })); + assert.equal(states.get(refs[0].key)?.state, "closed"); + assert.equal(states.get(refs[1].key)?.state, "error"); + const root = tracker(t); + const id = createItem(root, "Mixed access", ["--blocked-reason", "unbraind/pm-cli#854 unbraind/pm-cli#999999"]); + pm(root, ["update", id, "--status", "blocked"]); + // Real recorded closed-issue data with a constructed permission error on + // the other alias; access denial cannot be elicited safely from a public issue. + const response = JSON.parse(fixture("issue-854-closed.json")) as { data: Record; errors: unknown[] }; + response.errors.push({ type: "FORBIDDEN", path: ["r0", "i0_1"], message: "Resource not accessible by integration" }); + await withEnv({ GH_TOKEN: "ghp-test" }, () => withMockGithub(graphqlFixtureHandler([["query", JSON.stringify(response)]]), async () => { + const report = await runBlockers(root, { comment: true }); + assert.equal(report.exit_code, 1); + assert.equal(report.items[0].verdict, "partially_cleared"); + assert.equal(report.comment?.added, 0); + assert.equal((await readComments(id, {}, { pmRoot: root })).comments.length, 0); + })); +}); + +test("grouped blocker annotations remain idempotent across multiple references and later closure", async (t) => { + const root = tracker(t); + const id = createItem(root, "Both blockers resolved"); + const states = [refState({ closed_at: "2026-08-01T15:24:48Z" }), refState({ number: 34, state: "merged", merged_at: "2026-05-20T21:09:52Z" })]; + assert.deepEqual(await github.appendBlockerComments(root, [{ itemId: id, states }], false), { added: 1, skipped: 0, failed: 0 }); + assert.equal((await readComments(id, {}, { pmRoot: root })).comments.length, 1); + assert.deepEqual(await github.appendBlockerComments(root, [{ itemId: id, states }], false), { added: 0, skipped: 1, failed: 0 }); + const changed = [refState({ closed_at: "2026-10-12T10:00:00Z" }), states[1]]; + assert.deepEqual(await github.appendBlockerComments(root, [{ itemId: id, states: changed }], false), { added: 1, skipped: 0, failed: 0 }); + const comments = (await readComments(id, {}, { pmRoot: root })).comments; + assert.equal(comments.length, 2); + assert.equal(github.extractBlockerCommentMarkers(comments).size, 3); +}); + +test("GitHub-only candidates and provenance hints cover each text source", () => { + const item: github.BlockerPmItem = { + id: "fixture-hints", title: "Hints", status: "open", description: "GH-854 #854", + tags: ["ordinary", "gh:unbraind/pm-cli#1"], blockedBy: "github:unbraind/pm-cli#854", + dependencies: [{ id: "github:unbraind/pm-cli#854", kind: "blocked_by" }], + comments: [{ text: "Latest comment: https://github.com/unbraind/pm-cli/issues/854" }], + }; + assert.deepEqual(github.collectBlockerReferences(item, undefined).map(r => r.key), ["unbraind/pm-cli#854"]); + const foreign = { ...item, blockedBy: "gitlab:group/repo#1", dependencies: [{ id: "jira:TEST-1", kind: "blocked_by" }] }; + assert.deepEqual(github.selectBlockerCandidates([foreign]), []); + assert.deepEqual(github.selectBlockerCandidates([{ ...item, status: "canceled" }]), []); + assert.throws(() => github.chunkBlockerReferences([], 0), /positive integer/); +}); + +/** Run a real CLI asynchronously so the fixture HTTP server remains responsive. */ +function cli(root: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.resolve(import.meta.dirname, "../node_modules/@unbrained/pm-cli/dist/cli.js"), "--pm-path", root, ...args], { + cwd: path.resolve(import.meta.dirname, ".."), env: process.env, stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; let stderr = ""; + child.stdout.on("data", chunk => { stdout += String(chunk); }); + child.stderr.on("data", chunk => { stderr += String(chunk); }); + child.once("error", reject); + child.once("close", code => resolve({ code, stdout, stderr })); + }); +} + +test("actual CLI renders cleared reports through the host in JSON and TOON with code 4", async (t) => { + const root = tracker(t); + // Copy the built extension into a scratch tracker; discovery skips directory symlinks. + const extensions = path.join(root, "extensions"); + fs.mkdirSync(extensions, { recursive: true }); + const installed = path.join(extensions, "pm-github"); + fs.mkdirSync(installed); + const source = path.resolve(import.meta.dirname, ".."); + fs.cpSync(path.join(source, "dist"), path.join(installed, "dist"), { recursive: true }); + for (const name of ["manifest.json", "package.json"]) fs.copyFileSync(path.join(source, name), path.join(installed, name)); + fs.symlinkSync(path.join(source, "node_modules"), path.join(installed, "node_modules"), "dir"); + const id = createItem(root, "Closed external blocker", ["--blocked-reason", "unbraind/pm-cli#854"]); + pm(root, ["update", id, "--status", "blocked"]); + const before = fs.readFileSync(path.join(root, "tasks", `${id}.toon`), "utf8"); + await withEnv({ GH_TOKEN: "ghp-test" }, () => withMockGithub(graphqlFixtureHandler([["query", fixture("issue-854-closed.json")]]), async () => { + const json = await cli(root, ["github", "blockers", "--json"]); + assert.equal(json.code, 4, json.stderr); + const report = JSON.parse(json.stdout) as github.BlockersReport; + assert.equal(report.items[0].verdict, "cleared"); + assert.equal(report.items[0].references[0].closed_at, "2026-08-01T15:24:48Z"); + const toon = await cli(root, ["github", "blockers"]); + assert.equal(toon.code, 4, toon.stderr); + assert.match(toon.stdout, /verdict: "cleared"/); + assert.match(toon.stdout, /command: "github blockers"/); + })); + assert.equal(fs.readFileSync(path.join(root, "tasks", `${id}.toon`), "utf8"), before); +}); + +test("sparse states, partial permission paths and missing aliases fail closed", async () => { + const refs = Array.from({ length: 7 }, (_, i) => resolvedRef(`acme/sparse${i}`, 1)); + const states = await github.fetchBlockerReferenceStates(refs, async () => ({ data: { + r0: { i0_0: null, p0_0: { state: "OPEN" } }, + r1: { i1_0: { state: "CLOSED" }, p1_0: null }, + r2: { i2_0: null, p2_0: { state: "MERGED" } }, + r3: { i3_0: null, p3_0: { state: "CLOSED" } }, + r4: null, + r5: { i5_0: null }, + r6: { p6_0: null }, + } })); + assert.deepEqual([...states.values()].map(s => s.state), ["open", "closed", "merged", "closed", "not_found", "error", "error"]); + const one = [refs[0]]; + for (const errors of [ + [{ type: "FORBIDDEN", message: "Denied" }], + [{ type: "FORBIDDEN", message: "Denied", path: ["r0"] }], + [{ type: "FORBIDDEN", message: "Denied", path: ["r0", "p0_0"] }], + ]) { + assert.equal((await github.fetchBlockerReferenceStates(one, async () => ({ data: { r0: null }, errors }))).get(one[0].key)?.state, "error"); + } + assert.equal((await github.fetchBlockerReferenceStates(one, async () => ({ errors: [{ type: "FORBIDDEN", message: "Denied", path: ["other"] }] }))).get(one[0].key)?.state, "error"); + assert.equal((await github.fetchBlockerReferenceStates(one, async () => ({ data: { r0: { i0_0: null, p0_0: null } }, errors: [{ type: "FORBIDDEN", message: "Denied", path: ["r0", "other"] }] }))).get(one[0].key)?.state, "not_found"); +}); + +test("optional metadata and marker whitespace are handled without inventing references", async (t) => { + const item = github.decodeBlockerPmItems(completeEnvelope({ items: [{ id: "fixture-a", title: "A", status: "blocked", body: "", description: "", tags: [], blocked_by: "github:unbraind/pm-cli#854" }] }))[0]; + assert.equal(item.blockedBy, "github:unbraind/pm-cli#854"); + assert.equal(github.collectBlockerReferences({ ...item, tags: undefined }, undefined).length, 1); + const malformed = refState({ repo: undefined }); + assert.match(github.buildBlockerClearedComment(malformed), /unknown date/); + const root = tracker(t); + const id = createItem(root, "Unknown historical state date"); + assert.deepEqual(await github.appendBlockerComments(root, [{ itemId: id, states: [malformed] }], true), { added: 1, skipped: 0, failed: 0 }); + const existing = ""; + assert.ok(github.extractBlockerCommentMarkers([{ text: existing }]).has(github.blockerCommentMarker("unbraind/pm-cli", 854, "closed", "unknown"))); +}); + +test("partial-only alerts and unresolved bare references return complete reports with scoped ids", async (t) => { + const root = tracker(t); + const mixed = createItem(root, "Only partial clearance", ["--blocked-reason", "unbraind/pm-cli#854 unbraind/pm-cli#1246"]); + pm(root, ["update", mixed, "--status", "blocked"]); + const bare = createItem(root, "No repository hint", ["--blocked-reason", "GH-854"]); + pm(root, ["update", bare, "--status", "blocked"]); + const batch = JSON.parse(fixture("pm-cli-blockers-batch.json")) as { data: { r0: Record } }; + const response = { data: { r0: { i0_0: batch.data.r0.i0_1, p0_0: null, i0_1: batch.data.r0.i0_2, p0_1: null } } }; + await withEnv({ GH_TOKEN: "ghp-test" }, () => withMockGithub(graphqlFixtureHandler([["query", JSON.stringify(response)]]), async () => { + const partial = await runBlockers(root, { ids: mixed, comment: true }); + assert.equal(partial.items_read, 1); + assert.equal(partial.exit_code, 4); + assert.equal(partial.counts.partially_cleared, 1); + assert.equal(partial.comment?.added, 0); + const unresolved = await runBlockers(root, { ids: bare }); + assert.equal(unresolved.exit_code, undefined); + assert.equal(unresolved.items[0].references[0].state, "unresolved"); + })); +}); + + +test("marker extraction finishes on repeated unclosed marker prefixes", () => { + const script = `import { extractBlockerCommentMarkers } from './index.ts'; + const text = '