diff --git a/.agents/pm/features/pm-github-9eei.toon b/.agents/pm/features/pm-github-9eei.toon index 5aad636..521af61 100644 --- a/.agents/pm/features/pm-github-9eei.toon +++ b/.agents/pm/features/pm-github-9eei.toon @@ -17,6 +17,6 @@ files[3]{path,scope,note}: test/smoke.test.ts,project,null tests[2]{command,scope,timeout_seconds}: "npm run release:check",project,3600 - rm -rf /tmp/pm-realdata-github && mkdir -p /tmp/pm-realdata-github && cd /tmp/pm-realdata-github && pm init >/dev/null && pm install /home/steve/container/pm-sweep/pm-github --project >/dev/null && pm github validate --repo unbraind/pm-cli --json > github-validate.json && pm github import unbraind/pm-cli --state open --dry-run --json > github-import-dryrun.json,project,1200 + rm -rf /tmp/pm-realdata-github && mkdir -p /tmp/pm-realdata-github && cd /tmp/pm-realdata-github && pm init >/dev/null && pm install /workspace/pm-github --project >/dev/null && pm github validate --repo unbraind/pm-cli --json > github-validate.json && pm github import unbraind/pm-cli --state open --dry-run --json > github-import-dryrun.json,project,1200 close_reason: "Broad ecosystem-pass tracker from 2026-06-10; superseded by subsequent release waves. Package is at 2026.7.11, CI green, 0 alerts/issues. Closing stale tracker." body: "" diff --git a/.agents/pm/history/pm-github-0y40.jsonl b/.agents/pm/history/pm-github-0y40.jsonl index 7077d63..8db1d5e 100644 --- a/.agents/pm/history/pm-github-0y40.jsonl +++ b/.agents/pm/history/pm-github-0y40.jsonl @@ -1,5 +1,6 @@ {"ts":"2026-05-28T05:13:13.081Z","author":"codex","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-0y40"},{"op":"add","path":"/metadata/title","value":"Production-readiness audit 2026-05-28"},{"op":"add","path":"/metadata/description","value":""},{"op":"add","path":"/metadata/type","value":"Task"},{"op":"add","path":"/metadata/status","value":"in_progress"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-05-28T05:13:13.081Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-05-28T05:13:13.081Z"},{"op":"add","path":"/metadata/author","value":"codex"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"7362819d08d4d9fd9bd74b07dfc2607f4f1509d534c5670f38457cdac4eac21a","message":""} -{"ts":"2026-05-29T21:59:07.300Z","author":"codex","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-05-29T21:59:07.300Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-05-29T21:59:07.300Z","author":"codex","text":"2026-05-29 functional readiness audit evidence.\n\nPackage checks from /home/steve/container/pm-packages/pm-github:\n- `npm run check`: exit 0, 6s.\n- `npm test`: exit 0, 13s; TAP reported `# tests 2`, `# pass 2`, `# fail 0`.\n- `npm run pack:dry-run`: exit 0, 9s; tarball `pm-github-2026.5.28.tgz`, package size 6.2 kB, unpacked size 20.1 kB, total files 9.\n- `npm run changelog:check`: exit 0, 3s; `Changelog is up to date: /home/steve/container/pm-packages/pm-github/CHANGELOG.md`.\n\nHands-on test used temporary workspace `/tmp/pm-core-audit-20260529/workspaces/pm-github.*` with local install `pm install /home/steve/container/pm-packages/pm-github`.\n- Ran no-token public import dry run: `env -u GITHUB_TOKEN -u GH_TOKEN pm --json gh-issues import octocat/Hello-World --dry-run --type Issue`.\n- Process exit code was 0, but command result was `{ \"error\": \"GitHub API returned HTTP 403\" }` after stderr `Failed to fetch: GitHub API returned HTTP 403`.\n- Follow-up GitHub issues endpoint probe returned HTTP 403 with `x-ratelimit-remaining=0`, confirming unauthenticated API rate limit for the caller IP.\n- `pm --json list-all | jq -e '.items | length == 0'`: exit 0; dry run/error path did not create local items.\n\nSensitive-data scan:\n- Current tracked files and git history scanned without printing matching values using pattern classes `private_key`, `provider_token`, `secret_assignment`, and `email_address`.\n- No matches found for pm-github.\n\nBlockers:\n- Hands-on GitHub public import could not complete because GitHub unauthenticated API quota was exhausted (HTTP 403, remaining 0).\n- The extension returned an error object while the CLI process exit code stayed 0; release readiness should decide whether fetch failures must produce non-zero exit status."}]}],"before_hash":"7362819d08d4d9fd9bd74b07dfc2607f4f1509d534c5670f38457cdac4eac21a","after_hash":"e275f4806aff3af6a5e33f33099493c1227a57eaae0296cb7d534fba2e2e5646","message":"Record 2026-05-29 functional readiness audit evidence"} -{"ts":"2026-05-29T22:03:16.538Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-05-29T22:03:16.538Z","author":"codex","text":"2026-05-29 follow-up: fast-forwarded to current origin/main (v2026.05.29), which already contains the production hardening for authenticated GitHub requests, pagination, dry-run flag handling, and non-zero exit on fetch/create failures. Reapplied dependency floor updates to @unbrained/pm-cli ^2026.5.29, pm-changelog ^2026.5.29-2, peer @unbrained/pm-cli >=2026.5.29. Local gate after merge: npm run check PASS, npm test PASS (6 tests), npm run pack:dry-run PASS (pm-github-2026.5.29.tgz, 9 files, 8.2 kB). Regenerated CHANGELOG.md and changelog:check PASS."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-05-29T22:03:16.538Z"}],"before_hash":"e275f4806aff3af6a5e33f33099493c1227a57eaae0296cb7d534fba2e2e5646","after_hash":"d1481dd50789a8ab51cea914e39739c299c7c0dcc96aabc6374c00a8cb731fb1","message":"Record pm-github merge verification"} -{"ts":"2026-05-29T22:33:12.706Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-05-29T22:33:12.706Z","author":"codex","text":"2026-05-29 release-readiness refresh: package manifest/lock now target @unbrained/pm-cli >=2026.5.29 / ^2026.5.29 and pm-changelog ^2026.5.29-2 where applicable. Verified npm run check, npm test, npm run pack:dry-run, npm run audit:prod, npm run changelog:full, npm run changelog:check, npm run release:check, and pm validate. High-confidence worktree/history secret scan recorded no credential-shaped token findings for active package code; nonzero scan rows were classified as placeholders or vendored historical test data where applicable. Logs: /tmp/pm-active-package-gates-20260529.tsv, /tmp/pm-active-changelog-20260529.tsv, /tmp/pm-active-release-check-20260529.tsv, /tmp/pm-active-validate-20260529.tsv, /tmp/pm-active-secret-scan-20260529.tsv."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-05-29T22:33:12.706Z"}],"before_hash":"d1481dd50789a8ab51cea914e39739c299c7c0dcc96aabc6374c00a8cb731fb1","after_hash":"44cf6a889799fef4d1f049e567621ab849680475789eeb72bdcfcb671c09bd03"} -{"ts":"2026-06-03T05:06:39.836Z","author":"codex","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-03T05:06:39.836Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/close_reason","value":"Production-readiness audit completed in prior sweeps (rate-limit/backoff, pagination, exit codes all landed). Closing stale in_progress."}],"before_hash":"44cf6a889799fef4d1f049e567621ab849680475789eeb72bdcfcb671c09bd03","after_hash":"e1472c620bd0ca07bb5edb8c66775d11930c09fb3f21aa8e3276c26b16455b54"} +{"ts":"2026-05-29T21:59:07.300Z","author":"codex","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-05-29T21:59:07.300Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-05-29T21:59:07.300Z","author":"codex","text":"2026-05-29 functional readiness audit evidence.\n\nPackage checks from /workspace/pm-github:\n- `npm run check`: exit 0, 6s.\n- `npm test`: exit 0, 13s; TAP reported `# tests 2`, `# pass 2`, `# fail 0`.\n- `npm run pack:dry-run`: exit 0, 9s; tarball `pm-github-2026.5.28.tgz`, package size 6.2 kB, unpacked size 20.1 kB, total files 9.\n- `npm run changelog:check`: exit 0, 3s; `Changelog is up to date: /workspace/pm-github/CHANGELOG.md`.\n\nHands-on test used temporary workspace `/tmp/pm-core-audit-20260529/workspaces/pm-github.*` with local install `pm install /workspace/pm-github`.\n- Ran no-token public import dry run: `env -u GITHUB_TOKEN -u GH_TOKEN pm --json gh-issues import octocat/Hello-World --dry-run --type Issue`.\n- Process exit code was 0, but command result was `{ \"error\": \"GitHub API returned HTTP 403\" }` after stderr `Failed to fetch: GitHub API returned HTTP 403`.\n- Follow-up GitHub issues endpoint probe returned HTTP 403 with `x-ratelimit-remaining=0`, confirming unauthenticated API rate limit for the caller IP.\n- `pm --json list-all | jq -e '.items | length == 0'`: exit 0; dry run/error path did not create local items.\n\nSensitive-data scan:\n- Current tracked files and git history scanned without printing matching values using pattern classes `private_key`, `provider_token`, `secret_assignment`, and `email_address`.\n- No matches found for pm-github.\n\nBlockers:\n- Hands-on GitHub public import could not complete because GitHub unauthenticated API quota was exhausted (HTTP 403, remaining 0).\n- The extension returned an error object while the CLI process exit code stayed 0; release readiness should decide whether fetch failures must produce non-zero exit status."}]}],"before_hash":"7362819d08d4d9fd9bd74b07dfc2607f4f1509d534c5670f38457cdac4eac21a","after_hash":"cbb531316bc9e6c18001b7ac854eabaf7f10e5d8b744f000be0f1714c948d463","message":"Record 2026-05-29 functional readiness audit evidence"} +{"ts":"2026-05-29T22:03:16.538Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-05-29T22:03:16.538Z","author":"codex","text":"2026-05-29 follow-up: fast-forwarded to current origin/main (v2026.05.29), which already contains the production hardening for authenticated GitHub requests, pagination, dry-run flag handling, and non-zero exit on fetch/create failures. Reapplied dependency floor updates to @unbrained/pm-cli ^2026.5.29, pm-changelog ^2026.5.29-2, peer @unbrained/pm-cli >=2026.5.29. Local gate after merge: npm run check PASS, npm test PASS (6 tests), npm run pack:dry-run PASS (pm-github-2026.5.29.tgz, 9 files, 8.2 kB). Regenerated CHANGELOG.md and changelog:check PASS."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-05-29T22:03:16.538Z"}],"before_hash":"cbb531316bc9e6c18001b7ac854eabaf7f10e5d8b744f000be0f1714c948d463","after_hash":"6472a00ed8cf9bc8c2ff9d520bcdf6adaae3c9942ed3a0a598cd84dda063672c","message":"Record pm-github merge verification"} +{"ts":"2026-05-29T22:33:12.706Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-05-29T22:33:12.706Z","author":"codex","text":"2026-05-29 release-readiness refresh: package manifest/lock now target @unbrained/pm-cli >=2026.5.29 / ^2026.5.29 and pm-changelog ^2026.5.29-2 where applicable. Verified npm run check, npm test, npm run pack:dry-run, npm run audit:prod, npm run changelog:full, npm run changelog:check, npm run release:check, and pm validate. High-confidence worktree/history secret scan recorded no credential-shaped token findings for active package code; nonzero scan rows were classified as placeholders or vendored historical test data where applicable. Logs: /tmp/pm-active-package-gates-20260529.tsv, /tmp/pm-active-changelog-20260529.tsv, /tmp/pm-active-release-check-20260529.tsv, /tmp/pm-active-validate-20260529.tsv, /tmp/pm-active-secret-scan-20260529.tsv."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-05-29T22:33:12.706Z"}],"before_hash":"6472a00ed8cf9bc8c2ff9d520bcdf6adaae3c9942ed3a0a598cd84dda063672c","after_hash":"1a78ffe541977d98dc195bb47a2fb085b4b5ad4d1d8c029b99fc9ed3451bb39a"} +{"ts":"2026-06-03T05:06:39.836Z","author":"codex","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-03T05:06:39.836Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/close_reason","value":"Production-readiness audit completed in prior sweeps (rate-limit/backoff, pagination, exit codes all landed). Closing stale in_progress."}],"before_hash":"1a78ffe541977d98dc195bb47a2fb085b4b5ad4d1d8c029b99fc9ed3451bb39a","after_hash":"2c6f2bcba3dee96b31c8965ad533294ec589da483c6c9985c214812ec08c17a6"} +{"ts":"2026-07-21T07:08:42.175Z","author":"codex","op":"history_redact","patch":[],"before_hash":"2c6f2bcba3dee96b31c8965ad533294ec589da483c6c9985c214812ec08c17a6","after_hash":"2c6f2bcba3dee96b31c8965ad533294ec589da483c6c9985c214812ec08c17a6","message":"Redact legacy absolute host path from published tracker evidence"} diff --git a/.agents/pm/history/pm-github-1jez.jsonl b/.agents/pm/history/pm-github-1jez.jsonl new file mode 100644 index 0000000..c249193 --- /dev/null +++ b/.agents/pm/history/pm-github-1jez.jsonl @@ -0,0 +1,30 @@ +{"ts":"2026-07-21T06:54:37.581Z","author":"codex","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-1jez"},{"op":"add","path":"/metadata/title","value":"Make GitHub issue imports atomic and crash-resumable"},{"op":"add","path":"/metadata/description","value":"Implement opt-in --atomic for pm github import and legacy gh-issues import with @unbrained/pm-cli 2026.7.20 commitItemMutations. Preserve default behavior byte-for-byte, derive stable transaction/item IDs from repo and GitHub issue number, support create/update/close/reopen mutations without partial tracker state, and keep native comment sync safe after transaction commit. Companion tracker: https://github.com/unbraind/pm-cli-companion/blob/main/.agents/pm/tasks/pm-cli-website-kwq3.toon"},{"op":"add","path":"/metadata/type","value":"Task"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["2026.7.20","atomic","concurrency","multi-agent-merge","sdk"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-21T06:54:37.581Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-21T06:54:37.581Z"},{"op":"add","path":"/metadata/author","value":"codex"},{"op":"add","path":"/metadata/acceptance_criteria","value":"--atomic is available on both GitHub issue-import command surfaces and uses commitItemMutations; same fetched set resumes idempotently after interruption and is order-independent; a failed batch compensates all applied mutations with no partial committed state; default non-atomic and dry-run behavior remains compatible; tests, typecheck, package audit, pack check, changelog and Greptile pass"},{"op":"add","path":"/metadata/risk","value":"medium"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project","note":"atomic import implementation"},{"path":"test/atomic.test.ts","scope":"project","note":"transaction and recovery coverage"}]},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run release:check","scope":"project","timeout_seconds":900},{"command":"npm test","scope":"project","timeout_seconds":600}]},{"op":"add","path":"/metadata/docs","value":[{"path":"README.md","scope":"project","note":"user-facing atomic import guidance"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"4ed276c485059231888b5de26ab55a6aa1dd5633f766d33262d03d234954c4bf","message":"Track companion pm-cli-website-kwq3 implementation locally"} +{"ts":"2026-07-21T06:54:37.941Z","author":"codex","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T06:54:37.941Z"},{"op":"add","path":"/metadata/assignee","value":"codex"}],"before_hash":"4ed276c485059231888b5de26ab55a6aa1dd5633f766d33262d03d234954c4bf","after_hash":"cae1f6155cf36e3d6f983a5c075b8a508dd73238d1f5f19ba1f8e1f5f74c29a3","message":"Claim atomic GitHub import implementation"} +{"ts":"2026-07-21T06:54:38.263Z","author":"codex","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T06:54:38.263Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"cae1f6155cf36e3d6f983a5c075b8a508dd73238d1f5f19ba1f8e1f5f74c29a3","after_hash":"7f725d29d0c9c6cc6becc83be672df131ead3d7de3fd74e8b86b508cd16c55e0","message":"Start implementation from pm-jira reference contract"} +{"ts":"2026-07-21T07:02:54.553Z","author":"codex","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/1/note","value":"atomic import implementation"},{"op":"replace","path":"/metadata/files/1/path","value":"index.ts"},{"op":"replace","path":"/metadata/files/0/note","value":"field-aware PM merge driver for concurrent branches"},{"op":"replace","path":"/metadata/files/0/path","value":".gitattributes"},{"op":"add","path":"/metadata/files/2","value":{"path":"test/atomic.test.ts","scope":"project","note":"transaction and recovery coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:02:54.553Z"}],"before_hash":"7f725d29d0c9c6cc6becc83be672df131ead3d7de3fd74e8b86b508cd16c55e0","after_hash":"54b9982539e206d79dc91212b7c93f641a3ddb0371aeb73f2a4adddad6c29080","message":"Adopt pm 2026.7.20 merge driver with SDK upgrade"} +{"ts":"2026-07-21T07:04:18.041Z","author":"codex","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:04:18.041Z"},{"op":"add","path":"/metadata/estimated_minutes","value":300}],"before_hash":"54b9982539e206d79dc91212b7c93f641a3ddb0371aeb73f2a4adddad6c29080","after_hash":"3bb533777dd9dae151c58f39ed0d11a958caad72e1e9a53689f25921d666ad96","message":"Add implementation estimate required by package governance"} +{"ts":"2026-07-21T07:08:42.857Z","author":"codex","op":"files_add","patch":[{"op":"add","path":"/metadata/files/3","value":{"path":"test/helpers/comment-sync-child.ts","scope":"project","note":"TypeScript-only concurrent-process fixture"}},{"op":"add","path":"/metadata/files/4","value":{"path":"test/import-lock.test.ts","scope":"project","note":"TypeScript child-process test routing"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:08:42.857Z"}],"before_hash":"3bb533777dd9dae151c58f39ed0d11a958caad72e1e9a53689f25921d666ad96","after_hash":"fcf16f0d1b976a8cb642f7b9a1c8734521cc8705874cd9a8a22d53036a758f64","message":"Track TypeScript-only test conversion"} +{"ts":"2026-07-21T07:08:43.198Z","author":"codex","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:08:43.198Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-21T07:08:43.197Z","author":"codex","text":"Privacy review scanned all 110 reachable commits for precise credential signatures (0 findings). Current-tree legacy absolute /home/user package paths in pm-github-0y40 and pm-github-9eei were redacted to /workspace/pm-github with history-chain verification. Accepted author email identities follow companion decision pm-cli-website-ikjv."}]}],"before_hash":"fcf16f0d1b976a8cb642f7b9a1c8734521cc8705874cd9a8a22d53036a758f64","after_hash":"4b035a2b59a225dfc9e73f56f096a3556fdf138c3596ec2bc2e684b7be0d0952","message":"Record privacy audit and path remediation"} +{"ts":"2026-07-21T07:08:50.427Z","author":"codex","op":"history_redact","patch":[],"before_hash":"4b035a2b59a225dfc9e73f56f096a3556fdf138c3596ec2bc2e684b7be0d0952","after_hash":"4b035a2b59a225dfc9e73f56f096a3556fdf138c3596ec2bc2e684b7be0d0952","message":"Avoid reintroducing host identity in privacy-audit evidence"} +{"ts":"2026-07-21T07:17:36.722Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-07-21T07:17:36.721Z","author":"codex","text":"Pre-push Greptile review a895c1d7-69a7-4b83-b679-e2ab9d022b71: fixed long-lived-host SDK retry poisoning; replaced misleading recovered create/update counts with recoveredItems; added mixed pre-existing update+close rollback integration coverage proving the SDK version-restore contract. Targeted atomic suite: 8/8 pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:17:36.722Z"}],"before_hash":"4b035a2b59a225dfc9e73f56f096a3556fdf138c3596ec2bc2e684b7be0d0952","after_hash":"8ac67b1dde33209a208e8d7503c388064592aa37a48274343f745740f0cf10e1","message":"Record and resolve pre-push Greptile findings"} +{"ts":"2026-07-21T07:17:36.995Z","author":"codex","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:17:36.995Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-21T07:17:36.994Z","author":"codex","text":"A resumable workspace transaction requires both a stable transaction id and a byte-equivalent ordered mutation plan. Sort by the external issue key and preserve create+update upsert steps when a partial retry discovers a deterministic ID created by the interrupted attempt."}]}],"before_hash":"8ac67b1dde33209a208e8d7503c388064592aa37a48274343f745740f0cf10e1","after_hash":"dbdd5a54d6546c0f42e6678fea25201bec0a5ecd8b899e85d4b168ae2f604c14","message":"Capture crash-recovery plan invariant"} +{"ts":"2026-07-21T07:17:37.279Z","author":"codex","op":"learning_add","patch":[{"op":"add","path":"/metadata/learnings/1","value":{"created_at":"2026-07-21T07:17:37.278Z","author":"codex","text":"commitItemMutations createCompensation only selects close/delete behavior for create steps; update and close steps are independently snapshot-restored by the SDK. Mixed-batch rollback tests should prove this contract."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:17:37.279Z"}],"before_hash":"dbdd5a54d6546c0f42e6678fea25201bec0a5ecd8b899e85d4b168ae2f604c14","after_hash":"7d7ab15251ca73b6d4ec53e4de48cf8ea18f02a78b625d55df471d2a676d3cbd","message":"Capture SDK compensation semantics"} +{"ts":"2026-07-21T07:22:31.168Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-07-21T07:22:31.167Z","author":"codex","text":"Second pre-push Greptile review e7e6bbcc-8d7f-470f-85fd-ca677873e6ee completed at 5/5 confidence: safe to merge, no review comments, all prior concerns verified resolved."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:22:31.168Z"}],"before_hash":"7d7ab15251ca73b6d4ec53e4de48cf8ea18f02a78b625d55df471d2a676d3cbd","after_hash":"f2856cc6cf753cdef7fb52789feee5b3a3298b2414366651a673a9baab7c77c2","message":"Record clean Greptile re-review"} +{"ts":"2026-07-21T07:22:31.468Z","author":"codex","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:22:31.468Z"},{"op":"add","path":"/metadata/resolution","value":"Implemented SDK-backed atomic GitHub issue imports with deterministic transaction plans and field-aware merge support; verified compensation, recovery, TypeScript-only tests, packaging, dependency security, and real GitHub data."},{"op":"add","path":"/metadata/expected_result","value":"GitHub issue imports can opt into one crash-resumable transaction with no partial tracker state or duplicate items under retries and concurrent agents."},{"op":"add","path":"/metadata/actual_result","value":"Atomic create/update/close batches commit under the workspace writer lock, reordered/partial retries recover the same journal, mixed failures restore pre-existing items, and real unbraind/pm-cli data imported and resumed without duplicates."},{"op":"add","path":"/metadata/fixed_version","value":"next-daily-release"}],"before_hash":"f2856cc6cf753cdef7fb52789feee5b3a3298b2414366651a673a9baab7c77c2","after_hash":"5cfa2d115973d6973443af528473418c72bf00e6f6031b76fcb82a6b09f7203c","message":"Record completed implementation outcome before closure"} +{"ts":"2026-07-21T07:24:44.239Z","author":"codex","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:24:44.239Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-21T07:24:44.239Z"},{"op":"add","path":"/metadata/close_reason","value":"Atomic GitHub import shipped locally with 134 tests passing, release checks green, real-data recovery verified, and Greptile re-review clean at 5/5."}],"before_hash":"5cfa2d115973d6973443af528473418c72bf00e6f6031b76fcb82a6b09f7203c","after_hash":"f570b3a9ec24d9d1acd74f4857629cb33e19f32b488413e0506ae12a381c464f","message":"Close after full production-readiness verification"} +{"ts":"2026-07-21T07:24:44.529Z","author":"codex","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:24:44.529Z"}],"before_hash":"f570b3a9ec24d9d1acd74f4857629cb33e19f32b488413e0506ae12a381c464f","after_hash":"d201cd93644dad272826d3eead3b1fd968da2a68f3c667b229e4c62d0f59cb5e","message":"Release completed implementation claim"} +{"ts":"2026-07-21T07:36:16.320Z","author":"codex","op":"update","patch":[{"op":"remove","path":"/metadata/close_reason"},{"op":"remove","path":"/metadata/closed_at"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:36:16.320Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"d201cd93644dad272826d3eead3b1fd968da2a68f3c667b229e4c62d0f59cb5e","after_hash":"f03249e0dc7f5cb02338605a4e9ebcc1367a113b3f30593b6473174e8de3c6d5","message":"Reopen for PR review feedback: atomic dry-run parity, shared preparation, barrier coverage, and wording"} +{"ts":"2026-07-21T07:36:16.653Z","author":"codex","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:36:16.653Z"},{"op":"add","path":"/metadata/assignee","value":"codex"}],"before_hash":"f03249e0dc7f5cb02338605a4e9ebcc1367a113b3f30593b6473174e8de3c6d5","after_hash":"7e5348ba7ccb75d5fa1c1281c7bbd55c39fe5c809f9c368428d4882223625a4b","message":"Address first PR bot review round"} +{"ts":"2026-07-21T07:36:16.993Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-07-21T07:36:16.993Z","author":"codex","text":"Sourcery first-round review identified four actionable improvements: atomic dry-run must use atomic matching without SDK mutation, add orchestration coverage proving the no-SDK result shape, cover the child barrier timeout, and clarify rollback wording. Shared issue preparation and SDK resolution will also be consolidated to prevent behavioral drift."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:36:16.993Z"}],"before_hash":"7e5348ba7ccb75d5fa1c1281c7bbd55c39fe5c809f9c368428d4882223625a4b","after_hash":"a13ba2d318d841ac8aae905803b6e9b5bf26e08a18521ffa2e8e7ce4985c39d0","message":"Record PR review scope"} +{"ts":"2026-07-21T07:41:38.161Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-07-21T07:41:38.161Z","author":"codex","text":"Addressed all four Sourcery findings: atomic dry-run now shares the production preparation/matching path, reports create/update/skip counts, and cannot enter SDK commit; shared issue rendering and centralized atomic SDK loading remove drift; the barrier wait has deterministic success/timeout unit coverage; README rollback wording is explicit. Full release gate passes with 137/137 tests and zero production vulnerabilities."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:41:38.161Z"}],"before_hash":"a13ba2d318d841ac8aae905803b6e9b5bf26e08a18521ffa2e8e7ce4985c39d0","after_hash":"e287ff6aa385f8de11f0eeb412e0017c21f3efd500452e7619dd26f9e0bcab21","message":"Record first review fixes"} +{"ts":"2026-07-21T07:41:38.479Z","author":"codex","op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Atomic create/update/close batches remain crash-resumable; atomic dry-run now truthfully distinguishes new and existing items without SDK mutation; shared preparation prevents legacy/atomic rendering drift; barrier timeout behavior is deterministic; 137 tests and the complete release gate pass."},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:41:38.479Z"}],"before_hash":"e287ff6aa385f8de11f0eeb412e0017c21f3efd500452e7619dd26f9e0bcab21","after_hash":"23bcb72073e576407972b23fae441d4e1db39e4394153e0635f727ef0d3bc6b9","message":"Update verified review outcome"} +{"ts":"2026-07-21T07:41:38.804Z","author":"codex","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:41:38.804Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-21T07:41:38.803Z"},{"op":"add","path":"/metadata/close_reason","value":"Atomic GitHub import and all first-round review fixes verified by the complete release gate (137 tests, audit, pack, changelog)."}],"before_hash":"23bcb72073e576407972b23fae441d4e1db39e4394153e0635f727ef0d3bc6b9","after_hash":"869994bfb1ad6f60d84cfb31aa0c441c57facd3da1130cff4fe1ae7aa7123a9a"} +{"ts":"2026-07-21T07:41:39.157Z","author":"codex","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:41:39.157Z"}],"before_hash":"869994bfb1ad6f60d84cfb31aa0c441c57facd3da1130cff4fe1ae7aa7123a9a","after_hash":"26e5b096bedfed68c4bb5ccef3ba90fe987c92cca0a6dc292e7e350440f8b38d","message":"Release review-fix claim after full verification"} +{"ts":"2026-07-21T07:48:29.164Z","author":"codex","op":"update","patch":[{"op":"remove","path":"/metadata/close_reason"},{"op":"remove","path":"/metadata/closed_at"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:48:29.164Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"26e5b096bedfed68c4bb5ccef3ba90fe987c92cca0a6dc292e7e350440f8b38d","after_hash":"2fadd5b8f7844c18326474981ce9dbcb22da7e3e09b723ac942d22da65ce4c3f","message":"Reopen for Greptile recovery-target and compensation findings"} +{"ts":"2026-07-21T07:48:29.455Z","author":"codex","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:48:29.455Z"},{"op":"add","path":"/metadata/assignee","value":"codex"}],"before_hash":"2fadd5b8f7844c18326474981ce9dbcb22da7e3e09b723ac942d22da65ce4c3f","after_hash":"5290ea248291afbf8e9718bae01580aa51073c1161a542f05e6964d8b4964d47","message":"Address hosted Greptile review findings"} +{"ts":"2026-07-21T07:48:29.732Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-07-21T07:48:29.731Z","author":"codex","text":"Hosted Greptile review found three actionable edge cases (plus a duplicate prefix report): transaction IDs did not fingerprint mutation targets, all-skipped non-dry atomic imports returned success, and incomplete SDK compensation was described as clean. Fixes now hash the exact ordered mutation plan, preserve legacy failure parity for empty plans, distinguish interrupted/incomplete/normally compensated failures, and add focused regressions."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:48:29.732Z"}],"before_hash":"5290ea248291afbf8e9718bae01580aa51073c1161a542f05e6964d8b4964d47","after_hash":"8be470b3d1b60baef87b317de1e8bd89bc4e809db54d91a3cc7213f120395f24","message":"Record Greptile review scope"} +{"ts":"2026-07-21T07:48:38.822Z","author":"codex","op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"--atomic is available on both GitHub issue-import command surfaces and uses commitItemMutations; same fetched set resumes idempotently after interruption and is order-independent; transaction identity fingerprints the exact ordered mutation plan; ordinary failures compensate all applied mutations while incomplete compensation is reported as potentially partial; default non-atomic and dry-run behavior remains compatible; tests, typecheck, package audit, pack check, changelog and Greptile pass"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:48:38.822Z"}],"before_hash":"8be470b3d1b60baef87b317de1e8bd89bc4e809db54d91a3cc7213f120395f24","after_hash":"88d8d242a7768f67b16ed8485735fa0567816bd87358bf5ef7e115336360feef","message":"Refine compensation acceptance contract from review"} +{"ts":"2026-07-21T07:49:21.016Z","author":"codex","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/6","value":{"created_at":"2026-07-21T07:49:21.016Z","author":"codex","text":"Greptile edge-case fixes verified: transaction identity changes when mutation targets change; all-skipped non-dry atomic imports fail before commit; AggregateError compensation failures warn that state may be partial and give a same-transaction recovery path; normal rollback tests still restore creates/updates/closes. Full release gate passes with 139/139 tests."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:49:21.016Z"}],"before_hash":"88d8d242a7768f67b16ed8485735fa0567816bd87358bf5ef7e115336360feef","after_hash":"976ea7a4553233dc7f691e60718bdc716d2512ed0640641718e3b73583da15bd","message":"Record hosted review verification"} +{"ts":"2026-07-21T07:49:21.347Z","author":"codex","op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Atomic batches and previews are deterministic and crash-resumable; transaction journals are keyed by exact ordered mutation plans; normal compensation restores creates/updates/closes while incomplete compensation is truthfully reported for retry/repair; all-skipped imports fail consistently; 139 tests and the complete release gate pass."},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:49:21.347Z"}],"before_hash":"976ea7a4553233dc7f691e60718bdc716d2512ed0640641718e3b73583da15bd","after_hash":"46272090d86a94ef0f9e838afeec7863528b4d1ce3880119d631d8f2a0f80de6","message":"Update final verified outcome"} +{"ts":"2026-07-21T07:49:21.643Z","author":"codex","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:49:21.643Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-21T07:49:21.642Z"},{"op":"add","path":"/metadata/close_reason","value":"Atomic import plus Sourcery and Greptile review findings verified by 139 tests and the complete release gate."}],"before_hash":"46272090d86a94ef0f9e838afeec7863528b4d1ce3880119d631d8f2a0f80de6","after_hash":"031a662ca65c178b4950cedc735ca7b47a6f09eed0d1ff56e4570bee4092df3f"} +{"ts":"2026-07-21T07:49:21.926Z","author":"codex","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-21T07:49:21.926Z"}],"before_hash":"031a662ca65c178b4950cedc735ca7b47a6f09eed0d1ff56e4570bee4092df3f","after_hash":"a13ad78f05a3b48e8b6aeea91404405c681385dda6ce7ae7712e25b2f551669f","message":"Release Greptile review-fix claim"} diff --git a/.agents/pm/history/pm-github-9eei.jsonl b/.agents/pm/history/pm-github-9eei.jsonl index 13ace17..1f38a73 100644 --- a/.agents/pm/history/pm-github-9eei.jsonl +++ b/.agents/pm/history/pm-github-9eei.jsonl @@ -5,8 +5,9 @@ {"ts":"2026-06-09T23:01:19.263Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:01:19.263Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node scripts/run-tests.mjs test -- npm test","scope":"project"}]}],"before_hash":"1937d3b9a65040a168d358407e5ea6ac5e2150fc1ebefcfde9d3599fc49f8056","after_hash":"de097ed22161152ac65c0c90030e77b808d8fdfa305c00ac443b53ac7af0c949"} {"ts":"2026-06-09T23:25:55.549Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npm run release:check","scope":"project","timeout_seconds":3600}},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:25:55.549Z"}],"before_hash":"de097ed22161152ac65c0c90030e77b808d8fdfa305c00ac443b53ac7af0c949","after_hash":"e4eff9b8d9d824e8b80652537f000d34c602ceaaa3fba38a1f58cfefe9fa906d"} {"ts":"2026-06-09T23:29:19.071Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"remove","path":"/metadata/tests/1"},{"op":"add","path":"/metadata/tests/0/timeout_seconds","value":3600},{"op":"replace","path":"/metadata/tests/0/command","value":"npm run release:check"},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:29:19.071Z"}],"before_hash":"e4eff9b8d9d824e8b80652537f000d34c602ceaaa3fba38a1f58cfefe9fa906d","after_hash":"9ce2868e82109a6b405d8d3d7cb700c61201b81b6c081cba7d3616d72014929f","message":"Remove stale run-tests wrapper command"} -{"ts":"2026-06-09T23:37:55.996Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"tmp=\"/tmp/pm-realdata-github\" && rm -rf \"\" && mkdir -p \"\" && cd \"\" && pm init >/dev/null && pm install \"/home/steve/container/pm-sweep/pm-github\" --project >/dev/null && pm github validate --repo unbraind/pm-cli --json > github-validate.json && pm github import unbraind/pm-cli --state open --dry-run --json > github-import-dryrun.json","scope":"project","timeout_seconds":1200}},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:37:55.996Z"}],"before_hash":"9ce2868e82109a6b405d8d3d7cb700c61201b81b6c081cba7d3616d72014929f","after_hash":"1ebe72d4dda7bc3e4c94427366e8780314ffe2248fda72632d200dec2e9e312f","message":"Add real GitHub dry-run validation scenario"} -{"ts":"2026-06-09T23:38:23.595Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"remove","path":"/metadata/tests/1"},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:38:23.595Z"}],"before_hash":"1ebe72d4dda7bc3e4c94427366e8780314ffe2248fda72632d200dec2e9e312f","after_hash":"657b2f3dde8419b6ede3a5e4a60bfee32fe991918602e79f75944f2d2225c95a","message":"Remove malformed real-data command"} -{"ts":"2026-06-09T23:38:23.806Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"rm -rf /tmp/pm-realdata-github && mkdir -p /tmp/pm-realdata-github && cd /tmp/pm-realdata-github && pm init >/dev/null && pm install /home/steve/container/pm-sweep/pm-github --project >/dev/null && pm github validate --repo unbraind/pm-cli --json > github-validate.json && pm github import unbraind/pm-cli --state open --dry-run --json > github-import-dryrun.json","scope":"project","timeout_seconds":1200}},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:38:23.806Z"}],"before_hash":"657b2f3dde8419b6ede3a5e4a60bfee32fe991918602e79f75944f2d2225c95a","after_hash":"7922977e830f9ea0653cbb6b7b47e4735bab16b1cac110d498dbafe800ab687f","message":"Add stable real GitHub dry-run command"} -{"ts":"2026-07-10T11:07:14.481Z","author":"codex","op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-10T11:07:14.481Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-10T11:07:14.480Z","author":"codex","text":"PR #19 clean verification (2026-07-10): npm ci/build passed; final suite 52/52 pass (0 fail/skip). Real project install activated all commands; timeout 20 pm --help returned. Found installed pm github import contract was functionally dead (repo/flags rejected), fixed in e02e6d7. Real import with --since 30d --include-prs --include-comments imported 8 items and appended 4 comments to PR #19. Export dry-run mapped bug/enhancement to kind/bug and kind/enhancement; malformed inputs now exit 2/3 cleanly with no stack trace. Default export stdout/stderr are byte-for-byte identical to origin/main. Cubic oversized-relative-duration concern fixed in 78883f0."}]}],"before_hash":"7922977e830f9ea0653cbb6b7b47e4735bab16b1cac110d498dbafe800ab687f","after_hash":"18ad5e883fb1d9b019e8721d7cc0510849a633fa9815a57826f17d8dac28e5e2","message":"Record PR 19 clean verification evidence"} -{"ts":"2026-07-11T15:31:05.389Z","author":"claude","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-11T15:31:05.389Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-11T15:31:05.389Z"},{"op":"add","path":"/metadata/close_reason","value":"Broad ecosystem-pass tracker from 2026-06-10; superseded by subsequent release waves. Package is at 2026.7.11, CI green, 0 alerts/issues. Closing stale tracker."}],"before_hash":"18ad5e883fb1d9b019e8721d7cc0510849a633fa9815a57826f17d8dac28e5e2","after_hash":"a3f1111bf71c4a18c5cba26fc5f00afc5a4a3bafb8843b6a01ea4d68e3a45d18","message":"Broad ecosystem-pass tracker from 2026-06-10; superseded by subsequent release waves. Package is at 2026.7.11, CI green, 0 alerts/issues. Closing stale tracker."} +{"ts":"2026-06-09T23:37:55.996Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"tmp=\"/tmp/pm-realdata-github\" && rm -rf \"\" && mkdir -p \"\" && cd \"\" && pm init >/dev/null && pm install \"/workspace/pm-github\" --project >/dev/null && pm github validate --repo unbraind/pm-cli --json > github-validate.json && pm github import unbraind/pm-cli --state open --dry-run --json > github-import-dryrun.json","scope":"project","timeout_seconds":1200}},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:37:55.996Z"}],"before_hash":"9ce2868e82109a6b405d8d3d7cb700c61201b81b6c081cba7d3616d72014929f","after_hash":"c29e15f13e636c6d4d3ab1924976bebbcfb669bd93402b75a399d37470da5088","message":"Add real GitHub dry-run validation scenario"} +{"ts":"2026-06-09T23:38:23.595Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"remove","path":"/metadata/tests/1"},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:38:23.595Z"}],"before_hash":"c29e15f13e636c6d4d3ab1924976bebbcfb669bd93402b75a399d37470da5088","after_hash":"657b2f3dde8419b6ede3a5e4a60bfee32fe991918602e79f75944f2d2225c95a","message":"Remove malformed real-data command"} +{"ts":"2026-06-09T23:38:23.806Z","author":"codex-5-3-sweep","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"rm -rf /tmp/pm-realdata-github && mkdir -p /tmp/pm-realdata-github && cd /tmp/pm-realdata-github && pm init >/dev/null && pm install /workspace/pm-github --project >/dev/null && pm github validate --repo unbraind/pm-cli --json > github-validate.json && pm github import unbraind/pm-cli --state open --dry-run --json > github-import-dryrun.json","scope":"project","timeout_seconds":1200}},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-09T23:38:23.806Z"}],"before_hash":"657b2f3dde8419b6ede3a5e4a60bfee32fe991918602e79f75944f2d2225c95a","after_hash":"5261806b968089c28dfea1e88f37f9e5ed5cb1a081eab80d9e2a6e7fb71c9a85","message":"Add stable real GitHub dry-run command"} +{"ts":"2026-07-10T11:07:14.481Z","author":"codex","op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-10T11:07:14.481Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-10T11:07:14.480Z","author":"codex","text":"PR #19 clean verification (2026-07-10): npm ci/build passed; final suite 52/52 pass (0 fail/skip). Real project install activated all commands; timeout 20 pm --help returned. Found installed pm github import contract was functionally dead (repo/flags rejected), fixed in e02e6d7. Real import with --since 30d --include-prs --include-comments imported 8 items and appended 4 comments to PR #19. Export dry-run mapped bug/enhancement to kind/bug and kind/enhancement; malformed inputs now exit 2/3 cleanly with no stack trace. Default export stdout/stderr are byte-for-byte identical to origin/main. Cubic oversized-relative-duration concern fixed in 78883f0."}]}],"before_hash":"5261806b968089c28dfea1e88f37f9e5ed5cb1a081eab80d9e2a6e7fb71c9a85","after_hash":"7d8b25cb1a7d707adeafc4e9ab6c51cd54781beaf82ef567770dac3e23537397","message":"Record PR 19 clean verification evidence"} +{"ts":"2026-07-11T15:31:05.389Z","author":"claude","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-11T15:31:05.389Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-11T15:31:05.389Z"},{"op":"add","path":"/metadata/close_reason","value":"Broad ecosystem-pass tracker from 2026-06-10; superseded by subsequent release waves. Package is at 2026.7.11, CI green, 0 alerts/issues. Closing stale tracker."}],"before_hash":"7d8b25cb1a7d707adeafc4e9ab6c51cd54781beaf82ef567770dac3e23537397","after_hash":"2508cb47818bddc039980ae3566f2e401dd8a88b79bca12777598b3f80084e00","message":"Broad ecosystem-pass tracker from 2026-06-10; superseded by subsequent release waves. Package is at 2026.7.11, CI green, 0 alerts/issues. Closing stale tracker."} +{"ts":"2026-07-21T07:08:42.548Z","author":"codex","op":"history_redact","patch":[],"before_hash":"2508cb47818bddc039980ae3566f2e401dd8a88b79bca12777598b3f80084e00","after_hash":"2508cb47818bddc039980ae3566f2e401dd8a88b79bca12777598b3f80084e00","message":"Redact legacy absolute host path from published test evidence"} diff --git a/.agents/pm/tasks/pm-github-0y40.toon b/.agents/pm/tasks/pm-github-0y40.toon index 5f096a4..f3aaa10 100644 --- a/.agents/pm/tasks/pm-github-0y40.toon +++ b/.agents/pm/tasks/pm-github-0y40.toon @@ -9,7 +9,7 @@ created_at: "2026-05-28T05:13:13.081Z" updated_at: "2026-06-03T05:06:39.836Z" author: codex notes[3]{created_at,author,text}: - "2026-05-29T21:59:07.300Z",codex,"2026-05-29 functional readiness audit evidence.\n\nPackage checks from /home/steve/container/pm-packages/pm-github:\n- `npm run check`: exit 0, 6s.\n- `npm test`: exit 0, 13s; TAP reported `# tests 2`, `# pass 2`, `# fail 0`.\n- `npm run pack:dry-run`: exit 0, 9s; tarball `pm-github-2026.5.28.tgz`, package size 6.2 kB, unpacked size 20.1 kB, total files 9.\n- `npm run changelog:check`: exit 0, 3s; `Changelog is up to date: /home/steve/container/pm-packages/pm-github/CHANGELOG.md`.\n\nHands-on test used temporary workspace `/tmp/pm-core-audit-20260529/workspaces/pm-github.*` with local install `pm install /home/steve/container/pm-packages/pm-github`.\n- Ran no-token public import dry run: `env -u GITHUB_TOKEN -u GH_TOKEN pm --json gh-issues import octocat/Hello-World --dry-run --type Issue`.\n- Process exit code was 0, but command result was `{ \"error\": \"GitHub API returned HTTP 403\" }` after stderr `Failed to fetch: GitHub API returned HTTP 403`.\n- Follow-up GitHub issues endpoint probe returned HTTP 403 with `x-ratelimit-remaining=0`, confirming unauthenticated API rate limit for the caller IP.\n- `pm --json list-all | jq -e '.items | length == 0'`: exit 0; dry run/error path did not create local items.\n\nSensitive-data scan:\n- Current tracked files and git history scanned without printing matching values using pattern classes `private_key`, `provider_token`, `secret_assignment`, and `email_address`.\n- No matches found for pm-github.\n\nBlockers:\n- Hands-on GitHub public import could not complete because GitHub unauthenticated API quota was exhausted (HTTP 403, remaining 0).\n- The extension returned an error object while the CLI process exit code stayed 0; release readiness should decide whether fetch failures must produce non-zero exit status." + "2026-05-29T21:59:07.300Z",codex,"2026-05-29 functional readiness audit evidence.\n\nPackage checks from /workspace/pm-github:\n- `npm run check`: exit 0, 6s.\n- `npm test`: exit 0, 13s; TAP reported `# tests 2`, `# pass 2`, `# fail 0`.\n- `npm run pack:dry-run`: exit 0, 9s; tarball `pm-github-2026.5.28.tgz`, package size 6.2 kB, unpacked size 20.1 kB, total files 9.\n- `npm run changelog:check`: exit 0, 3s; `Changelog is up to date: /workspace/pm-github/CHANGELOG.md`.\n\nHands-on test used temporary workspace `/tmp/pm-core-audit-20260529/workspaces/pm-github.*` with local install `pm install /workspace/pm-github`.\n- Ran no-token public import dry run: `env -u GITHUB_TOKEN -u GH_TOKEN pm --json gh-issues import octocat/Hello-World --dry-run --type Issue`.\n- Process exit code was 0, but command result was `{ \"error\": \"GitHub API returned HTTP 403\" }` after stderr `Failed to fetch: GitHub API returned HTTP 403`.\n- Follow-up GitHub issues endpoint probe returned HTTP 403 with `x-ratelimit-remaining=0`, confirming unauthenticated API rate limit for the caller IP.\n- `pm --json list-all | jq -e '.items | length == 0'`: exit 0; dry run/error path did not create local items.\n\nSensitive-data scan:\n- Current tracked files and git history scanned without printing matching values using pattern classes `private_key`, `provider_token`, `secret_assignment`, and `email_address`.\n- No matches found for pm-github.\n\nBlockers:\n- Hands-on GitHub public import could not complete because GitHub unauthenticated API quota was exhausted (HTTP 403, remaining 0).\n- The extension returned an error object while the CLI process exit code stayed 0; release readiness should decide whether fetch failures must produce non-zero exit status." "2026-05-29T22:03:16.538Z",codex,"2026-05-29 follow-up: fast-forwarded to current origin/main (v2026.05.29), which already contains the production hardening for authenticated GitHub requests, pagination, dry-run flag handling, and non-zero exit on fetch/create failures. Reapplied dependency floor updates to @unbrained/pm-cli ^2026.5.29, pm-changelog ^2026.5.29-2, peer @unbrained/pm-cli >=2026.5.29. Local gate after merge: npm run check PASS, npm test PASS (6 tests), npm run pack:dry-run PASS (pm-github-2026.5.29.tgz, 9 files, 8.2 kB). Regenerated CHANGELOG.md and changelog:check PASS." "2026-05-29T22:33:12.706Z",codex,"2026-05-29 release-readiness refresh: package manifest/lock now target @unbrained/pm-cli >=2026.5.29 / ^2026.5.29 and pm-changelog ^2026.5.29-2 where applicable. Verified npm run check, npm test, npm run pack:dry-run, npm run audit:prod, npm run changelog:full, npm run changelog:check, npm run release:check, and pm validate. High-confidence worktree/history secret scan recorded no credential-shaped token findings for active package code; nonzero scan rows were classified as placeholders or vendored historical test data where applicable. Logs: /tmp/pm-active-package-gates-20260529.tsv, /tmp/pm-active-changelog-20260529.tsv, /tmp/pm-active-release-check-20260529.tsv, /tmp/pm-active-validate-20260529.tsv, /tmp/pm-active-secret-scan-20260529.tsv." close_reason: "Production-readiness audit completed in prior sweeps (rate-limit/backoff, pagination, exit codes all landed). Closing stale in_progress." diff --git a/.agents/pm/tasks/pm-github-1jez.toon b/.agents/pm/tasks/pm-github-1jez.toon new file mode 100644 index 0000000..a861c59 --- /dev/null +++ b/.agents/pm/tasks/pm-github-1jez.toon @@ -0,0 +1,42 @@ +id: pm-github-1jez +title: Make GitHub issue imports atomic and crash-resumable +description: "Implement opt-in --atomic for pm github import and legacy gh-issues import with @unbrained/pm-cli 2026.7.20 commitItemMutations. Preserve default behavior byte-for-byte, derive stable transaction/item IDs from repo and GitHub issue number, support create/update/close/reopen mutations without partial tracker state, and keep native comment sync safe after transaction commit. Companion tracker: https://github.com/unbraind/pm-cli-companion/blob/main/.agents/pm/tasks/pm-cli-website-kwq3.toon" +type: Task +status: closed +priority: 2 +tags[5]: 2026.7.20,atomic,concurrency,multi-agent-merge,sdk +created_at: "2026-07-21T06:54:37.581Z" +updated_at: "2026-07-21T07:49:21.926Z" +closed_at: "2026-07-21T07:49:21.642Z" +author: codex +estimated_minutes: 300 +acceptance_criteria: "--atomic is available on both GitHub issue-import command surfaces and uses commitItemMutations; same fetched set resumes idempotently after interruption and is order-independent; transaction identity fingerprints the exact ordered mutation plan; ordinary failures compensate all applied mutations while incomplete compensation is reported as potentially partial; default non-atomic and dry-run behavior remains compatible; tests, typecheck, package audit, pack check, changelog and Greptile pass" +risk: medium +resolution: "Implemented SDK-backed atomic GitHub issue imports with deterministic transaction plans and field-aware merge support; verified compensation, recovery, TypeScript-only tests, packaging, dependency security, and real GitHub data." +expected_result: GitHub issue imports can opt into one crash-resumable transaction with no partial tracker state or duplicate items under retries and concurrent agents. +actual_result: Atomic batches and previews are deterministic and crash-resumable; transaction journals are keyed by exact ordered mutation plans; normal compensation restores creates/updates/closes while incomplete compensation is truthfully reported for retry/repair; all-skipped imports fail consistently; 139 tests and the complete release gate pass. +fixed_version: next-daily-release +notes[7]{created_at,author,text}: + "2026-07-21T07:08:43.197Z",codex,Privacy review scanned all 110 reachable commits for precise credential signatures (0 findings). Current-tree legacy absolute /home/user package paths in pm-github-0y40 and pm-github-9eei were redacted to /workspace/pm-github with history-chain verification. Accepted author email identities follow companion decision pm-cli-website-ikjv. + "2026-07-21T07:17:36.721Z",codex,"Pre-push Greptile review a895c1d7-69a7-4b83-b679-e2ab9d022b71: fixed long-lived-host SDK retry poisoning; replaced misleading recovered create/update counts with recoveredItems; added mixed pre-existing update+close rollback integration coverage proving the SDK version-restore contract. Targeted atomic suite: 8/8 pass." + "2026-07-21T07:22:31.167Z",codex,"Second pre-push Greptile review e7e6bbcc-8d7f-470f-85fd-ca677873e6ee completed at 5/5 confidence: safe to merge, no review comments, all prior concerns verified resolved." + "2026-07-21T07:36:16.993Z",codex,"Sourcery first-round review identified four actionable improvements: atomic dry-run must use atomic matching without SDK mutation, add orchestration coverage proving the no-SDK result shape, cover the child barrier timeout, and clarify rollback wording. Shared issue preparation and SDK resolution will also be consolidated to prevent behavioral drift." + "2026-07-21T07:41:38.161Z",codex,"Addressed all four Sourcery findings: atomic dry-run now shares the production preparation/matching path, reports create/update/skip counts, and cannot enter SDK commit; shared issue rendering and centralized atomic SDK loading remove drift; the barrier wait has deterministic success/timeout unit coverage; README rollback wording is explicit. Full release gate passes with 137/137 tests and zero production vulnerabilities." + "2026-07-21T07:48:29.731Z",codex,"Hosted Greptile review found three actionable edge cases (plus a duplicate prefix report): transaction IDs did not fingerprint mutation targets, all-skipped non-dry atomic imports returned success, and incomplete SDK compensation was described as clean. Fixes now hash the exact ordered mutation plan, preserve legacy failure parity for empty plans, distinguish interrupted/incomplete/normally compensated failures, and add focused regressions." + "2026-07-21T07:49:21.016Z",codex,"Greptile edge-case fixes verified: transaction identity changes when mutation targets change; all-skipped non-dry atomic imports fail before commit; AggregateError compensation failures warn that state may be partial and give a same-transaction recovery path; normal rollback tests still restore creates/updates/closes. Full release gate passes with 139/139 tests." +learnings[2]{created_at,author,text}: + "2026-07-21T07:17:36.994Z",codex,A resumable workspace transaction requires both a stable transaction id and a byte-equivalent ordered mutation plan. Sort by the external issue key and preserve create+update upsert steps when a partial retry discovers a deterministic ID created by the interrupted attempt. + "2026-07-21T07:17:37.278Z",codex,commitItemMutations createCompensation only selects close/delete behavior for create steps; update and close steps are independently snapshot-restored by the SDK. Mixed-batch rollback tests should prove this contract. +files[5]{path,scope,note}: + .gitattributes,project,field-aware PM merge driver for concurrent branches + index.ts,project,atomic import implementation + test/atomic.test.ts,project,transaction and recovery coverage + test/helpers/comment-sync-child.ts,project,TypeScript-only concurrent-process fixture + test/import-lock.test.ts,project,TypeScript child-process test routing +tests[2]{command,scope,timeout_seconds}: + "npm run release:check",project,900 + npm test,project,600 +docs[1]{path,scope,note}: + README.md,project,user-facing atomic import guidance +close_reason: Atomic import plus Sourcery and Greptile review findings verified by 139 tests and the complete release gate. +body: "" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..119c7f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +# pm-cli:merge-drivers:start +".agents/pm/chores/*.toon" merge=pm-item-toon +".agents/pm/chores/*.md" merge=pm-item-markdown +".agents/pm/decisions/*.toon" merge=pm-item-toon +".agents/pm/decisions/*.md" merge=pm-item-markdown +".agents/pm/epics/*.toon" merge=pm-item-toon +".agents/pm/epics/*.md" merge=pm-item-markdown +".agents/pm/events/*.toon" merge=pm-item-toon +".agents/pm/events/*.md" merge=pm-item-markdown +".agents/pm/features/*.toon" merge=pm-item-toon +".agents/pm/features/*.md" merge=pm-item-markdown +".agents/pm/issues/*.toon" merge=pm-item-toon +".agents/pm/issues/*.md" merge=pm-item-markdown +".agents/pm/meetings/*.toon" merge=pm-item-toon +".agents/pm/meetings/*.md" merge=pm-item-markdown +".agents/pm/milestones/*.toon" merge=pm-item-toon +".agents/pm/milestones/*.md" merge=pm-item-markdown +".agents/pm/plans/*.toon" merge=pm-item-toon +".agents/pm/plans/*.md" merge=pm-item-markdown +".agents/pm/reminders/*.toon" merge=pm-item-toon +".agents/pm/reminders/*.md" merge=pm-item-markdown +".agents/pm/tasks/*.toon" merge=pm-item-toon +".agents/pm/tasks/*.md" merge=pm-item-markdown +".agents/pm/**/*.jsonl" merge=pm-relationship +".agents/pm/history/*.jsonl" merge=pm-history +".agents/pm/settings.json" merge=pm-json +".agents/pm/schema/*.json" merge=pm-json +# pm-cli:merge-drivers:end diff --git a/README.md b/README.md index 9c6db80..9b8e393 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ pm github import owner/repo --since 2026-01-01T00:00:00Z # incremental sync pm github import owner/repo --assignee octocat pm github import owner/repo --milestone "v1.0" pm github import owner/repo --include-prs +pm github import owner/repo --atomic pm github import owner/repo --dry-run ``` @@ -53,11 +54,14 @@ pm github import owner/repo --dry-run | `--skip-drafts` | boolean | Exclude draft pull requests (only meaningful with `--include-prs`) | | `--with-comments` | boolean | Fetch issue comments and append them to the item body | | `--comments-mode ` | `body`\|`annotations`\|`both` | How fetched GitHub comments are persisted (default `body`). `annotations` syncs comments into the pm item's native comments collection via the SDK; `both` writes the body section AND native comments. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id) | +| `--atomic` | boolean | Commit every create, update, close, and reopen in one workspace-writer-locked, crash-resumable transaction (requires pm CLI/SDK >=2026.7.20). Normal failure compensation restores updated/closed items and deletes newly created items; an incomplete compensation is reported explicitly for retry or repair. | | `--dry-run` | boolean | Preview without writing | | `--type ` | string | Override pm item type (default: Issue) | Each imported item records GitHub provenance: the `gh:owner/repo#N` idempotency tag, a `github_author:` tag, and an enriched description (`author @ · state reason · created · updated `). GitHub issues closed as `not_planned` import as pm `canceled` instead of `closed`, preserving the difference between completed work and deliberately dropped work. The integration declares the `github_url`, `github_number`, `github_state`, `github_author`, `github_created_at`, and `github_updated_at` schema fields. +`--atomic` derives its durable transaction identity from the repository, complete rendered issue state, and exact ordered mutation plan (including target item ids), and derives each create id from the stable `owner/repo#number` external key rather than fetch position. Retrying the same response in a different order therefore resumes without duplicates; changed content, workspace prefixes, or resolved targets create a fresh compatible transaction. Native comment annotations run only after the item transaction commits and retain their cross-process deduplication lock. + ### Native comment sync (`--comments-mode`) By default (`--comments-mode body`, or `--with-comments`), fetched GitHub issue comments are flattened into the item body as blockquoted markdown under a `### GitHub comments (N)` heading — the historical behavior, byte-identical across releases. diff --git a/index.ts b/index.ts index 4898b78..2529fcb 100644 --- a/index.ts +++ b/index.ts @@ -20,6 +20,11 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import type { defineExtension as defineExtensionType } from "@unbrained/pm-cli/sdk"; +import type { + BulkItemMutation, + CommitItemMutationsOptions, + CommitItemMutationsResult, +} from "@unbrained/pm-cli/sdk"; // The `comments()` annotation primitive is only exported from the public SDK // since pm CLI 2026.7.14. Load it lazily so hosts on older CLI versions still @@ -64,7 +69,7 @@ const defineExtension: typeof defineExtensionType = ((extension: any) => extensi // Types // --------------------------------------------------------------------------- -interface GhIssue { +export interface GhIssue { number: number; title: string; body: string | null; @@ -84,7 +89,7 @@ interface GhIssue { draft?: boolean; } -interface GhComment { +export interface GhComment { id: number; user: { login: string } | null; created_at: string; @@ -107,7 +112,7 @@ type CommentsMode = "body" | "annotations" | "both"; const COMMENTS_MODES: readonly CommentsMode[] = ["body", "annotations", "both"]; -interface ImportOptions { +export interface ImportOptions { state: "open" | "closed" | "all"; labels?: string; since?: string; @@ -119,6 +124,37 @@ interface ImportOptions { commentsMode: CommentsMode; itemType: string; dryRun: boolean; + atomic: boolean; +} + +type CommitItemMutations = ( + options: CommitItemMutationsOptions, +) => Promise; + +type NormalizeItemId = (input: string, prefix: string) => string; +type ReadSettings = (pmRoot: string) => Promise<{ id_prefix?: string }>; + +export interface AtomicImportOptions { + atomicAuthor?: string; + commitItemMutations?: CommitItemMutations; + normalizeItemId?: NormalizeItemId; + readSettings?: ReadSettings; +} + +export interface ImportRunDependencies { + resolveToken?: () => string | undefined; + fetchIssues?: ( + repo: string, + opts: ImportOptions, + token?: string, + ) => Promise; + fetchIssueComments?: ( + issue: GhIssue, + repo: string, + token?: string, + ) => Promise; + readItems?: (pmRoot: string) => PmItem[]; + commitAtomic?: typeof importGithubAtomic; } // --------------------------------------------------------------------------- @@ -565,7 +601,7 @@ export function authorTag(issue: GhIssue): string | undefined { // pm workspace I/O // --------------------------------------------------------------------------- -interface PmItem { +export interface PmItem { id?: string; title?: string; status?: string; @@ -636,6 +672,313 @@ export function indexByProvenance(items: PmItem[]): Map { return index; } +// --------------------------------------------------------------------------- +// Atomic GitHub issue import (pm-cli >= 2026.7.20 commitItemMutations) +// --------------------------------------------------------------------------- + +const ATOMIC_IMPORT_PREFIX = "github-import-"; +let cachedCommitItemMutations: CommitItemMutations | undefined; + +/** Fully rendered desired state for one GitHub issue import. */ +export interface PreparedGithubImport { + issueNumber: number; + title: string; + itemType: string; + status: string; + description: string; + body: string; + tags: string[]; + assignee?: string; + milestone?: string; + comments: GhComment[]; + syncAnnotations: boolean; + match?: PmItem; +} + +function assertSdkFunction(fn: unknown, exportName: string): F { + if (typeof fn !== "function") { + throw new CommandError( + `--atomic requires @unbrained/pm-cli>=2026.7.20 with the commitItemMutations SDK primitive, but the installed SDK does not export ${exportName} as a function. Upgrade @unbrained/pm-cli to >=2026.7.20.`, + EXIT_CODE.USAGE, + ); + } + return fn as F; +} + +async function loadAtomicSdk( + importSdk: () => Promise> = + () => import("@unbrained/pm-cli/sdk"), +): Promise> { + try { + return await importSdk(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new CommandError( + `--atomic requires @unbrained/pm-cli>=2026.7.20, but the SDK could not be imported: ${msg}. Install or upgrade @unbrained/pm-cli.`, + EXIT_CODE.USAGE, + ); + } +} + +/** Resolve the atomic bulk-mutation helper lazily so normal imports stay compatible. */ +export async function resolveCommitItemMutations( + importSdk?: () => Promise>, +): Promise { + if (importSdk) { + const mod = await loadAtomicSdk(importSdk); + return assertSdkFunction(mod.commitItemMutations, "commitItemMutations"); + } + if (cachedCommitItemMutations) return cachedCommitItemMutations; + const mod = await loadAtomicSdk(); + cachedCommitItemMutations = assertSdkFunction( + mod.commitItemMutations, + "commitItemMutations", + ); + return cachedCommitItemMutations; +} + +async function resolveAtomicSdkFunctions(opts: AtomicImportOptions): Promise<{ + commitItemMutations: CommitItemMutations; + normalizeItemId: NormalizeItemId; + readSettings: ReadSettings; +}> { + const needsSdk = !opts.commitItemMutations || !opts.normalizeItemId || !opts.readSettings; + const mod = needsSdk ? await loadAtomicSdk() : undefined; + return { + commitItemMutations: opts.commitItemMutations ?? assertSdkFunction( + mod?.commitItemMutations, + "commitItemMutations", + ), + normalizeItemId: opts.normalizeItemId ?? assertSdkFunction( + mod?.normalizeItemId, + "normalizeItemId", + ), + readSettings: opts.readSettings ?? assertSdkFunction( + mod?.readSettings, + "readSettings", + ), + }; +} + +/** + * Derive an order-independent transaction id from the desired import state and + * exact ordered mutation plan. Content or target changes produce a fresh + * transaction; a reordered retry of the same plan resumes the durable journal. + */ +export function deriveAtomicTransactionId( + repo: string, + entries: readonly PreparedGithubImport[], + mutations: readonly BulkItemMutation[], +): string { + const canonical = [...entries] + .sort((a, b) => a.issueNumber - b.issueNumber) + .map((entry) => ({ + issueNumber: entry.issueNumber, + title: entry.title, + itemType: entry.itemType, + status: entry.status, + description: entry.description, + body: entry.body, + tags: [...entry.tags].sort(), + assignee: entry.assignee ?? null, + milestone: entry.milestone ?? null, + })); + const digest = crypto + .createHash("sha256") + .update(repo.toLowerCase()) + .update("\x1f") + .update(JSON.stringify(canonical)) + .update("\x1f") + // Recovery requires the exact ordered step plan. Include targets and + // options so a changed id_prefix or provenance match gets a fresh journal + // instead of colliding with an incompatible prior attempt. + .update(JSON.stringify(mutations)) + .digest("hex") + .slice(0, 16); + return `${ATOMIC_IMPORT_PREFIX}${digest}`; +} + +/** Stable create id keyed by the external GitHub issue, never by fetch order. */ +export function deriveAtomicItemId( + repo: string, + issueNumber: number, + idPrefix: string, + normalizeItemId: (input: string, prefix: string) => string, +): string { + const repoToken = crypto + .createHash("sha256") + .update(repo.toLowerCase()) + .digest("hex") + .slice(0, 12); + return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix); +} + +/** Map one rendered import entry to its reversible SDK mutation sequence. */ +export function buildAtomicImportMutations( + repo: string, + entry: PreparedGithubImport, + idPrefix: string, + normalizeItemId: (input: string, prefix: string) => string, +): { itemId: string; mutations: BulkItemMutation[] } { + const sharedOptions: Record = { + title: entry.title, + type: entry.itemType, + description: entry.description, + body: entry.body, + tags: entry.tags.join(","), + ...(entry.assignee ? { assignee: entry.assignee } : {}), + ...(entry.milestone ? { sprint: entry.milestone } : {}), + }; + + const managedItemId = deriveAtomicItemId( + repo, + entry.issueNumber, + idPrefix, + normalizeItemId, + ); + + // A missing match and a match at our deterministic external-key id use the + // SAME create+update upsert plan. This is essential for crash recovery: if a + // prior attempt stopped after create, the next provenance scan sees that + // item, but commitItemMutations must still receive the original plan. The + // create step treats an existing stable id as already applied; update then + // makes later content-bearing transactions refresh the item normally. + if (!entry.match?.id || entry.match.id === managedItemId) { + const createStatus = entry.status === "closed" ? "open" : entry.status; + const mutations: BulkItemMutation[] = [{ + op: "create", + id: managedItemId, + options: { ...sharedOptions, status: createStatus }, + }, { + op: "update", + id: managedItemId, + options: { + ...sharedOptions, + ...(entry.status !== "closed" ? { status: entry.status } : {}), + }, + }]; + if (entry.status === "closed") { + mutations.push({ + op: "close", + id: managedItemId, + reason: `GitHub issue #${entry.issueNumber} closed`, + }); + } + return { + itemId: managedItemId, + mutations, + }; + } + + const itemId = entry.match.id; + const updateOptions: Record = { ...sharedOptions }; + // close has a dedicated mutation so its reason is preserved. Every other + // transition (open/reopen/canceled) is safely reversible as part of update. + if (entry.status !== "closed") { + updateOptions.status = entry.status; + } + const mutations: BulkItemMutation[] = [{ + op: "update", + id: itemId, + options: updateOptions, + }]; + if (entry.status === "closed") { + mutations.push({ + op: "close", + id: itemId, + reason: `GitHub issue #${entry.issueNumber} closed`, + }); + } + return { itemId, mutations }; +} + +/** Commit a complete issue-import batch under one crash-resumable transaction. */ +export async function importGithubAtomic( + pmRoot: string, + repo: string, + entries: readonly PreparedGithubImport[], + opts: AtomicImportOptions = {}, +): Promise<{ + transactionId: string; + recovered: boolean; + imported: number; + updated: number; + recoveredItems?: number; + itemIds: Map; +}> { + const { + commitItemMutations: commit, + normalizeItemId, + readSettings, + } = await resolveAtomicSdkFunctions(opts); + + let idPrefix = "pm-"; + try { + const settings = await readSettings(pmRoot); + if (settings?.id_prefix) idPrefix = String(settings.id_prefix); + } catch { + // Match normal import resilience: an unreadable optional setting falls + // back to the canonical prefix; the mutation still validates the tracker. + } + + const mutations: BulkItemMutation[] = []; + const itemIds = new Map(); + // The transaction journal fingerprints the ordered step plan. Canonicalize + // by the stable GitHub issue number so a retry whose API page/order changed + // supplies the exact same plan as well as the same transaction id. + for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) { + const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId); + itemIds.set(entry.issueNumber, planned.itemId); + mutations.push(...planned.mutations); + } + const transactionId = deriveAtomicTransactionId(repo, entries, mutations); + + try { + const result = await commit({ + pmRoot, + transactionId, + author: opts.atomicAuthor ?? "pm-github", + mutations, + // This option selects how CREATE steps are compensated. The SDK's + // commitItemMutations contract independently snapshots and version- + // restores every UPDATE and CLOSE step (covered by the mixed rollback + // integration test below this implementation). + createCompensation: "delete", + }); + // A recovered journal may include work applied by the interrupted process + // as well as steps resumed now. The SDK intentionally returns the durable + // final results, not a per-invocation delta, so create/update counts cannot + // be reconstructed truthfully. Report the recovered batch separately. + const recovered = Boolean(result?.recovered); + return { + transactionId, + recovered, + imported: recovered ? 0 : entries.filter((entry) => !entry.match?.id).length, + updated: recovered ? 0 : entries.filter((entry) => Boolean(entry.match?.id)).length, + ...(recovered ? { recoveredItems: entries.length } : {}), + itemIds, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (err instanceof AggregateError || /compensation failed/i.test(msg)) { + throw new CommandError( + `Atomic GitHub import failed and compensation was incomplete. The tracker may contain partially applied state; retry the same import to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } + if (err instanceof Error && err.name === "WorkspaceTransactionInterruptedError") { + throw new CommandError( + `Atomic GitHub import was interrupted. Its durable journal is resumable; retry the same import to continue transaction ${transactionId}. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } + throw new CommandError( + `Atomic GitHub import failed after the SDK completed its normal compensation path; no new partial committed state is expected. Transaction id: ${transactionId}. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } +} + // --------------------------------------------------------------------------- // Shared import core (used by both the legacy command and the importer) // --------------------------------------------------------------------------- @@ -1244,6 +1587,7 @@ export function parseImportOptions(options: Record): ImportOpti commentsMode, itemType: optionString(options, "type") || "Issue", dryRun: optionEnabled(options, "dry-run", "dryRun"), + atomic: optionEnabled(options, "atomic"), }; } @@ -1255,28 +1599,84 @@ function pmRun(args: string[]): { ok: boolean; stderr: string; stdout: string } return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" }; } +async function prepareGithubImport( + issue: GhIssue, + repo: string, + opts: ImportOptions, + token: string | undefined, + match: PmItem | undefined, + fetchIssueComments: ImportRunDependencies["fetchIssueComments"] = fetchComments, +): Promise { + const title = issue.title.trim(); + if (!title) return undefined; + + const kind = issue.pull_request ? "PR" : "issue"; + const labels = issue.labels.map((label) => label.name).filter(Boolean); + const tag = provenanceTag(repo, issue.number); + const ghAuthorTag = authorTag(issue); + const author = issue.user?.login; + const syncAnnotations = opts.commentsMode === "annotations" || opts.commentsMode === "both"; + const shouldFetchComments = opts.withComments || syncAnnotations; + const writeCommentsToBody = opts.commentsMode === "body" || opts.commentsMode === "both"; + let comments: GhComment[] = []; + if (shouldFetchComments) { + try { + comments = await fetchIssueComments!(issue, repo, token); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`#${issue.number}: failed to fetch comments — ${msg}`); + } + } + + return { + issueNumber: issue.number, + title, + itemType: opts.itemType, + status: mapState(issue.state, issue.state_reason), + description: + `GH ${kind} #${issue.number}: ${issue.html_url}` + + (author ? ` · author @${author}` : "") + + (issue.state_reason ? ` · state reason ${issue.state_reason}` : "") + + (issue.created_at ? ` · created ${issue.created_at}` : "") + + (issue.updated_at ? ` · updated ${issue.updated_at}` : ""), + body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""), + tags: [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])], + assignee: issue.assignee?.login, + milestone: issue.milestone?.title, + comments, + syncAnnotations, + match, + }; +} + // Run the full import flow. Idempotent: items already linked (provenance tag) // to a fetched issue are UPDATEd; new issues are created. Returns a structured // result; throws CommandError (with a semantic exitCode) on failure. -async function runImport(repoArg: string | undefined, pmRoot: string, opts: ImportOptions) { +export async function runImport( + repoArg: string | undefined, + pmRoot: string, + opts: ImportOptions, + dependencies: ImportRunDependencies = {}, +) { if (!repoArg || !repoArg.includes("/")) { throw new CommandError( "Usage: pm github import [--all|--state open|closed|all] " + "[--labels bug,enhancement] [--since ] [--assignee ] " + - "[--milestone ] [--include-prs] [--skip-drafts] [--with-comments] [--comments-mode body|annotations|both]", + "[--milestone ] [--include-prs] [--skip-drafts] [--with-comments] " + + "[--comments-mode body|annotations|both] [--atomic]", EXIT_CODE.USAGE, ); } const repo = repoArg; - const token = resolveGitHubToken(); + const token = (dependencies.resolveToken ?? resolveGitHubToken)(); console.error( `Fetching issues from ${repo}…${token ? "" : " (unauthenticated — 60 req/hr)"}`, ); let fetched: GhIssue[]; try { - fetched = await fetchAllIssues(repo, opts, token); + fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); const hint = !token && /HTTP 403/.test(msg) @@ -1290,67 +1690,146 @@ async function runImport(repoArg: string | undefined, pmRoot: string, opts: Impo if (filtered.length === 0) { console.error("No issues found."); + if (opts.atomic && opts.dryRun) { + return { + dryRun: true, + wouldImport: 0, + wouldUpdate: 0, + wouldSkip: 0, + atomic: true, + }; + } return { imported: 0, updated: 0, skipped: 0 }; } console.error(`Found ${filtered.length} issue(s).`); // Build the idempotency index once up-front. - const existing = opts.dryRun ? new Map() : indexByProvenance(readPmItems(pmRoot)); + const shouldMatchExisting = !opts.dryRun || opts.atomic; + const existing = shouldMatchExisting + ? indexByProvenance((dependencies.readItems ?? readPmItems)(pmRoot)) + : new Map(); let imported = 0; let updated = 0; let skipped = 0; + if (opts.atomic) { + const prepared: PreparedGithubImport[] = []; + for (const issue of filtered) { + const entry = await prepareGithubImport( + issue, + repo, + opts, + token, + existing.get(`${repo.toLowerCase()}#${issue.number}`), + dependencies.fetchIssueComments, + ); + if (!entry) { + skipped++; + continue; + } + prepared.push(entry); + } + + if (prepared.length === 0) { + if (opts.dryRun) { + console.error(`[dry-run] Atomic plan would import 0, update 0, skip ${skipped}.`); + return { + dryRun: true, + wouldImport: 0, + wouldUpdate: 0, + wouldSkip: skipped, + atomic: true, + }; + } + throw new CommandError( + `Imported 0 issue(s); ${skipped} failed.`, + EXIT_CODE.GENERIC_FAILURE, + ); + } + + if (opts.dryRun) { + imported = prepared.filter((entry) => !entry.match?.id).length; + updated = prepared.length - imported; + for (const entry of prepared) { + const action = entry.match?.id ? "update" : "import"; + console.error(` [dry-run][atomic] #${entry.issueNumber} ${action} ${entry.title} (${entry.status})`); + } + console.error( + `[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`, + ); + return { + dryRun: true, + wouldImport: imported, + wouldUpdate: updated, + wouldSkip: skipped, + atomic: true, + }; + } + + const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared); + for (const entry of prepared) { + if (!entry.syncAnnotations) continue; + const itemId = result.itemIds.get(entry.issueNumber); + if (itemId) { + await syncGithubCommentsToAnnotations( + itemId, + entry.comments, + pmRoot, + entry.issueNumber, + ); + } + } + if (result.recovered) { + console.error( + `Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`, + ); + } else { + console.error( + `Atomically imported ${result.imported} new, updated ${result.updated} existing, skipped ${skipped}.`, + ); + } + // itemIds is an internal post-commit routing map for native comments. Maps + // serialize as `{}` in JSON, so keep it out of the public command result. + return { + transactionId: result.transactionId, + recovered: result.recovered, + imported: result.imported, + updated: result.updated, + ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}), + skipped, + atomic: true, + }; + } + for (const issue of filtered) { - const title = issue.title.trim(); - if (!title) { + const prepared = await prepareGithubImport( + issue, + repo, + opts, + token, + existing.get(`${repo.toLowerCase()}#${issue.number}`), + dependencies.fetchIssueComments, + ); + if (!prepared) { skipped++; continue; } - const kind = issue.pull_request ? "PR" : "issue"; const labels = issue.labels.map((l) => l.name).filter(Boolean); - const tag = provenanceTag(repo, issue.number); - const ghAuthorTag = authorTag(issue); - const tags = [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])]; - const status = mapState(issue.state, issue.state_reason); - const assignee = issue.assignee?.login; - const milestone = issue.milestone?.title; - const key = `${repo.toLowerCase()}#${issue.number}`; - const match = existing.get(key); - - // GitHub provenance lives in the description (human-readable) and the - // declared schema fields (github_url/github_number/github_state/ - // github_author/github_created_at/github_updated_at). Author + timestamps - // are appended additively so a re-import keeps them current. - const author = issue.user?.login; - const description = - `GH ${kind} #${issue.number}: ${issue.html_url}` + - (author ? ` · author @${author}` : "") + - (issue.state_reason ? ` · state reason ${issue.state_reason}` : "") + - (issue.created_at ? ` · created ${issue.created_at}` : "") + - (issue.updated_at ? ` · updated ${issue.updated_at}` : ""); - - // Comments are fetched when --with-comments asks for the legacy body - // embedding OR when --comments-mode targets the native comments collection - // (annotations/both). In default mode (body + no --with-comments) nothing is - // fetched, keeping import output byte-identical to pre-2026.7.14 behavior. - const syncAnnotations = opts.commentsMode === "annotations" || opts.commentsMode === "both"; - const shouldFetchComments = opts.withComments || syncAnnotations; - // Comments land in the body only in body/both mode (the historical path). - const writeCommentsToBody = opts.commentsMode === "body" || opts.commentsMode === "both"; - - let comments: GhComment[] = []; - if (shouldFetchComments) { - try { - comments = await fetchComments(issue, repo, token); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`#${issue.number}: failed to fetch comments — ${msg}`); - } - } - const body = writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""); + const { + title, + status, + assignee, + milestone, + match, + description, + body, + tags, + comments, + syncAnnotations, + } = prepared; if (opts.dryRun) { console.error(` [dry-run] #${issue.number} ${title} (${status}, ${labels.join(",")})`); @@ -1434,7 +1913,12 @@ async function runImport(repoArg: string | undefined, pmRoot: string, opts: Impo if (opts.dryRun) { console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`); - return { dryRun: true, wouldImport: imported, wouldSkip: skipped }; + return { + dryRun: true, + wouldImport: imported, + wouldSkip: skipped, + ...(opts.atomic ? { atomic: true } : {}), + }; } console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`); @@ -2825,6 +3309,7 @@ const IMPORT_FLAGS = [ { long: "--with-comments", description: "Fetch issue comments and append them to the item body" }, { long: "--include-comments", description: "Alias for --with-comments" }, { long: "--comments-mode", value_name: "body|annotations|both", description: "How to persist fetched GitHub comments: `body` (default, embed in item body), `annotations` (sync to the pm item's native comments collection), or `both`. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id)" }, + { long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" }, { long: "--dry-run", description: "Preview without writing" }, { long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" }, ]; @@ -2933,6 +3418,7 @@ export default defineExtension({ "pm github import owner/repo --since 7d", "pm github import owner/repo --include-comments", "pm github import owner/repo --comments-mode annotations", + "pm github import owner/repo --atomic", "pm github import owner/repo --dry-run", ], flags: IMPORT_FLAGS, @@ -2940,6 +3426,7 @@ export default defineExtension({ "Pass , e.g. `pm github import unbraind/pm-cli`.", "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for private repos / 5000 req/hr.", "Re-running is safe: existing items are updated, not duplicated.", + "Use --atomic for a durable resumable journal with reverse compensation on ordinary failures.", ], }); @@ -3099,6 +3586,7 @@ export default defineExtension({ "pm gh-issues import unbraind/pm-cli --since 2026-01-01T00:00:00Z", "pm github import owner/repo --with-comments", "pm github import owner/repo --comments-mode annotations", + "pm github import owner/repo --atomic", "pm github import owner/repo --dry-run", ], flags: IMPORT_FLAGS, @@ -3106,6 +3594,7 @@ export default defineExtension({ "Pass , e.g. `pm gh-issues import unbraind/pm-cli`.", "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for private repos / 5000 req/hr.", "Re-running is safe: existing items are updated, not duplicated.", + "Use --atomic for a durable resumable journal with reverse compensation on ordinary failures.", ], async run(ctx: any) { return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options)); diff --git a/manifest.json b/manifest.json index 231e905..5b2d703 100644 --- a/manifest.json +++ b/manifest.json @@ -5,7 +5,7 @@ "author": "@unbraind", "entry": "./dist/index.js", "priority": 50, - "pm_min_version": "2026.7.10", + "pm_min_version": "2026.7.20", "capabilities": [ "commands", "importers", diff --git a/package-lock.json b/package-lock.json index 7f6e97e..be88b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,15 @@ "license": "MIT", "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.10", - "pm-changelog": "^2026.7.10", + "@unbrained/pm-cli": "^2026.7.20", + "pm-changelog": "^2026.7.18", "typescript": "^7.0.2" }, "engines": { "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.14" + "@unbrained/pm-cli": ">=2026.7.20" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -717,14 +717,15 @@ } }, "node_modules/@unbrained/pm-cli": { - "version": "2026.7.17", - "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.17.tgz", - "integrity": "sha512-sdvIdimqnUSgWIVephgQy23ubbo82nhUk4SHpJZih/G9itK/aZbm3yKhfoxE5+AHdxuDi4VkI6PRrsbHvVxzXw==", + "version": "2026.7.20", + "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.20.tgz", + "integrity": "sha512-PRY4X/S9wq/4cc9PU5UEDg4Be8iGUo5WFdOoNnE0Ly61FhW0rOJD+ZxfnykqnC4IJlSn9wgRgDpW9Ch++0TaNQ==", "dev": true, "license": "MIT", "dependencies": { "@sentry/node": "^10.63.0", "@toon-format/toon": "^2.3.0", + "@types/node": ">=22", "commander": "^15.0.0", "fast-glob": "^3.3.3", "fast-json-patch": "^3.1.1" @@ -1014,9 +1015,9 @@ } }, "node_modules/pm-changelog": { - "version": "2026.7.15", - "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.7.15.tgz", - "integrity": "sha512-AzVyjF6OtuQ9kuJZj3fdzj6sT2J89g3sRR+DQ1tZHMToHv2e7nqkQku4pNCzPqBbrFX+j5BJDcLmBs3y8QrbbA==", + "version": "2026.7.18", + "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.7.18.tgz", + "integrity": "sha512-HPjZk02Oh/BY/HX/lPdfTk26v5QrhrXtJ6GmImnGE5QxgtGsqCr/CzA4+M7PuSBlbyv+qwDPeqjAHmfdE5cFcg==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 40306c1..d6c8483 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,12 @@ "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --pm-bin ./node_modules/.bin/pm --github-step-summary" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.14" + "@unbrained/pm-cli": ">=2026.7.20" }, "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.10", - "pm-changelog": "^2026.7.10", + "@unbrained/pm-cli": "^2026.7.20", + "pm-changelog": "^2026.7.18", "typescript": "^7.0.2" }, "keywords": [ diff --git a/test/atomic.test.ts b/test/atomic.test.ts new file mode 100644 index 0000000..005e7e7 --- /dev/null +++ b/test/atomic.test.ts @@ -0,0 +1,427 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CommandError, + EXIT_CODE, + buildAtomicImportMutations, + deriveAtomicItemId, + deriveAtomicTransactionId, + importGithubAtomic, + parseImportOptions, + resolveCommitItemMutations, + runImport, +} from "../dist/index.js"; +import type { GhIssue, PreparedGithubImport } from "../dist/index.js"; + +const PM_BIN = process.platform === "win32" ? "pm.cmd" : "pm"; +const PM_SPAWN_OPTS = { + encoding: "utf-8" as const, + shell: process.platform === "win32", +}; + +function entry( + issueNumber: number, + title: string, + overrides: Partial = {}, +): PreparedGithubImport { + return { + issueNumber, + title, + itemType: "Issue", + status: "open", + description: `GH issue #${issueNumber}: https://github.com/acme/widgets/issues/${issueNumber}`, + body: `Body ${issueNumber}`, + tags: ["bug", `gh:acme/widgets#${issueNumber}`], + comments: [], + syncAnnotations: false, + ...overrides, + }; +} + +function freshTracker(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pm-github-atomic-")); + try { + const init = spawnSync(PM_BIN, ["--path", root, "init", "test"], PM_SPAWN_OPTS); + assert.strictEqual(init.status, 0, `pm init failed: ${init.error?.message ?? init.stderr}`); + assert.strictEqual(itemCount(root), 0, "fresh tracker must be empty"); + return root; + } catch (err) { + fs.rmSync(root, { recursive: true, force: true }); + throw err; + } +} + +function listItems(root: string): Array<{ id: string; title: string; status: string }> { + const result = spawnSync( + PM_BIN, + ["--path", root, "list-all", "--json", "--full", "--limit", "100"], + PM_SPAWN_OPTS, + ); + assert.strictEqual(result.status, 0, `pm list-all failed: ${result.error?.message ?? result.stderr}`); + const parsed = JSON.parse(result.stdout) as { items?: Array<{ id: string; title: string; status: string }> }; + return parsed.items ?? []; +} + +function itemCount(root: string): number { + return listItems(root).length; +} + +function validateOk(root: string): boolean { + return spawnSync(PM_BIN, ["--path", root, "validate"], PM_SPAWN_OPTS).status === 0; +} + +test("parseImportOptions enables atomic mode without changing the default", () => { + assert.strictEqual(parseImportOptions({}).atomic, false); + assert.strictEqual(parseImportOptions({ atomic: true }).atomic, true); + assert.strictEqual(parseImportOptions({ atomic: "1" }).atomic, true); +}); + +test("atomic dry-run previews creates and updates without invoking the SDK", async () => { + const issue = (number: number, title: string): GhIssue => ({ + number, + title, + body: `Body ${number}`, + state: "open", + labels: [{ name: "bug" }], + user: { login: "alice" }, + assignee: null, + milestone: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + html_url: `https://github.com/acme/widgets/issues/${number}`, + }); + let sdkCalls = 0; + const messages: string[] = []; + const originalError = console.error; + console.error = (...values: unknown[]) => messages.push(values.join(" ")); + try { + const result = await runImport( + "acme/widgets", + "/unused-dry-run-workspace", + parseImportOptions({ atomic: true, dryRun: true }), + { + resolveToken: () => undefined, + fetchIssues: async () => [issue(1, "New"), issue(2, "Existing"), issue(3, " ")], + readItems: () => [{ id: "existing-id", tags: ["gh:acme/widgets#2"] }], + commitAtomic: async () => { + sdkCalls++; + throw new Error("atomic dry-run must not call the SDK commit path"); + }, + }, + ); + + assert.deepStrictEqual(result, { + dryRun: true, + wouldImport: 1, + wouldUpdate: 1, + wouldSkip: 1, + atomic: true, + }); + assert.strictEqual(sdkCalls, 0); + assert.ok(messages.some((message) => /Atomic plan would import 1, update 1, skip 1/.test(message))); + } finally { + console.error = originalError; + } +}); + +test("transaction identity is content-sensitive and independent of fetch order", () => { + const first = entry(1, "First"); + const second = entry(2, "Second"); + const plan = [ + { op: "update" as const, id: "test-first", options: { title: "First" } }, + { op: "update" as const, id: "test-second", options: { title: "Second" } }, + ]; + const id = deriveAtomicTransactionId("Acme/Widgets", [first, second], plan); + assert.match(id, /^github-import-[0-9a-f]{16}$/); + assert.strictEqual( + deriveAtomicTransactionId("acme/widgets", [second, first], plan), + id, + "same rendered issues in another order resume the same transaction", + ); + assert.notStrictEqual( + deriveAtomicTransactionId("acme/widgets", [first, entry(2, "Changed")], plan), + id, + "changed desired state starts a fresh transaction", + ); + assert.notStrictEqual( + deriveAtomicTransactionId("acme/widgets", [first, second], [ + { ...plan[0]!, id: "other-prefix-first" }, + plan[1]!, + ]), + id, + "changed mutation targets cannot collide with an incompatible recovery journal", + ); +}); + +test("atomic all-skipped imports fail without invoking the SDK", async () => { + const blankIssue: GhIssue = { + number: 1, + title: " ", + body: null, + state: "open", + labels: [], + assignee: null, + milestone: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + html_url: "https://github.com/acme/widgets/issues/1", + }; + let sdkCalls = 0; + await assert.rejects( + () => runImport( + "acme/widgets", + "/unused-all-skipped-workspace", + parseImportOptions({ atomic: true }), + { + resolveToken: () => undefined, + fetchIssues: async () => [blankIssue], + readItems: () => [], + commitAtomic: async () => { + sdkCalls++; + throw new Error("must not commit an empty plan"); + }, + }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.match((err as Error).message, /Imported 0 issue\(s\); 1 failed/); + return true; + }, + ); + assert.strictEqual(sdkCalls, 0); +}); + +test("create ids are stable external-key ids and mutation planning handles transitions", () => { + const normalize = (input: string, prefix: string) => `${prefix}${input}`; + const id = deriveAtomicItemId("Acme/Widgets", 42, "test-", normalize); + assert.strictEqual(id, deriveAtomicItemId("acme/widgets", 42, "test-", normalize)); + assert.notStrictEqual(id, deriveAtomicItemId("acme/widgets", 43, "test-", normalize)); + + const created = buildAtomicImportMutations("acme/widgets", entry(42, "New"), "test-", normalize); + assert.strictEqual(created.itemId, id); + assert.deepStrictEqual(created.mutations.map((mutation) => mutation.op), ["create", "update"]); + + const recoveredCreate = buildAtomicImportMutations( + "acme/widgets", + entry(42, "New", { match: { id, status: "open" } }), + "test-", + normalize, + ); + assert.deepStrictEqual( + recoveredCreate.mutations, + created.mutations, + "a partially-created deterministic item reproduces the original journal plan", + ); + + const closed = buildAtomicImportMutations( + "acme/widgets", + entry(42, "Closed", { status: "closed", match: { id: "legacy-import-id", status: "open" } }), + "test-", + normalize, + ); + assert.deepStrictEqual(closed.mutations.map((mutation) => mutation.op), ["update", "close"]); + + const reopened = buildAtomicImportMutations( + "acme/widgets", + entry(42, "Reopened", { match: { id: "legacy-import-id", status: "closed" } }), + "test-", + normalize, + ); + assert.strictEqual(reopened.mutations.length, 1); + assert.strictEqual(reopened.mutations[0]?.op, "update"); + assert.strictEqual((reopened.mutations[0] as { options: Record }).options.status, "open"); +}); + +test("atomic import commits and resumes reordered input without duplicates", async () => { + const root = freshTracker(); + try { + const entries = [entry(1, "First"), entry(2, "Second")]; + const first = await importGithubAtomic(root, "acme/widgets", entries); + assert.strictEqual(first.imported, 2); + assert.strictEqual(first.updated, 0); + assert.strictEqual(first.recovered, false); + assert.strictEqual(itemCount(root), 2); + + const resumedEntries = [...entries].reverse().map((candidate) => ({ + ...candidate, + match: { + id: first.itemIds.get(candidate.issueNumber), + status: "open", + }, + })); + const resumed = await importGithubAtomic(root, "acme/widgets", resumedEntries); + assert.strictEqual(resumed.transactionId, first.transactionId); + assert.strictEqual(resumed.recovered, true); + assert.strictEqual(resumed.imported, 0, "a recovered journal is not misreported as new work"); + assert.strictEqual(resumed.updated, 0, "a recovered journal is not misreported as updates"); + assert.strictEqual(resumed.recoveredItems, 2); + assert.strictEqual(itemCount(root), 2, "resumed import does not duplicate items"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("atomic update and close commit together", async () => { + const root = freshTracker(); + try { + const initial = entry(7, "Initial"); + const created = await importGithubAtomic(root, "acme/widgets", [initial]); + const itemId = created.itemIds.get(7); + assert.ok(itemId); + + const changed = entry(7, "Completed upstream", { + status: "closed", + body: "Final body", + match: { id: itemId, status: "open" }, + }); + const result = await importGithubAtomic(root, "acme/widgets", [changed]); + assert.strictEqual(result.imported, 0); + assert.strictEqual(result.updated, 1); + const item = listItems(root).find((candidate) => candidate.id === itemId); + assert.strictEqual(item?.title, "Completed upstream"); + assert.strictEqual(item?.status, "closed"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("a failed atomic batch compensates every applied create", async () => { + const root = freshTracker(); + try { + const sdk = await import("@unbrained/pm-cli/sdk"); + const wrappingCommit = async (options: Parameters[0]) => { + const settings = await sdk.readSettings(options.pmRoot); + const brokenId = sdk.normalizeItemId("github-broken", settings.id_prefix); + return sdk.commitItemMutations({ + ...options, + mutations: [ + ...options.mutations, + { + op: "create" as const, + id: brokenId, + options: { title: "Broken", type: "NoSuchType_XYZ", status: "open" }, + }, + ], + }); + }; + + await assert.rejects( + () => importGithubAtomic( + root, + "acme/widgets", + [entry(20, "Rollback one"), entry(21, "Rollback two")], + { commitItemMutations: wrappingCommit }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.GENERIC_FAILURE); + assert.match((err as Error).message, /no new partial committed state is expected/); + return true; + }, + ); + assert.strictEqual(itemCount(root), 0, "rollback deletes every transaction-owned create"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("a failed mixed batch restores pre-existing updates and closes", async () => { + const root = freshTracker(); + try { + const sdk = await import("@unbrained/pm-cli/sdk"); + const initialEntries = [entry(30, "Original update target"), entry(31, "Original close target")]; + const initial = await importGithubAtomic(root, "acme/widgets", initialEntries); + const updateId = initial.itemIds.get(30); + const closeId = initial.itemIds.get(31); + assert.ok(updateId); + assert.ok(closeId); + + const wrappingCommit = async (options: Parameters[0]) => { + const settings = await sdk.readSettings(options.pmRoot); + const brokenId = sdk.normalizeItemId("github-broken-mixed", settings.id_prefix); + return sdk.commitItemMutations({ + ...options, + mutations: [ + ...options.mutations, + { + op: "create" as const, + id: brokenId, + options: { title: "Broken", type: "NoSuchType_XYZ", status: "open" }, + }, + ], + }); + }; + + await assert.rejects( + () => importGithubAtomic( + root, + "acme/widgets", + [ + entry(30, "Mutated title", { body: "Mutated body", match: { id: updateId, status: "open" } }), + entry(31, "Should be restored", { status: "closed", match: { id: closeId, status: "open" } }), + ], + { commitItemMutations: wrappingCommit }, + ), + /no new partial committed state is expected/, + ); + + const items = listItems(root); + const updateTarget = items.find((candidate) => candidate.id === updateId); + const closeTarget = items.find((candidate) => candidate.id === closeId); + assert.strictEqual(updateTarget?.title, "Original update target", "update compensation restores title"); + assert.strictEqual(updateTarget?.status, "open", "update compensation restores status"); + assert.strictEqual(closeTarget?.title, "Original close target", "close target content is restored"); + assert.strictEqual(closeTarget?.status, "open", "close compensation reopens the item"); + assert.strictEqual(itemCount(root), 2, "mixed rollback neither deletes nor duplicates existing items"); + assert.ok(validateOk(root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("incomplete compensation is reported as potentially partial and resumable", async () => { + const incomplete = new AggregateError( + [new Error("apply failed"), new Error("restore failed")], + "Workspace transaction failed and compensation failed: restore failed", + ); + await assert.rejects( + () => importGithubAtomic( + "/unused-incomplete-compensation-workspace", + "acme/widgets", + [entry(50, "Needs repair")], + { + commitItemMutations: async () => { throw incomplete; }, + normalizeItemId: (input, prefix) => `${prefix}${input}`, + readSettings: async () => ({ id_prefix: "test-" }), + }, + ), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.match((err as Error).message, /compensation was incomplete/); + assert.match((err as Error).message, /may contain partially applied state/); + assert.doesNotMatch((err as Error).message, /no new partial committed state/); + return true; + }, + ); +}); + +test("SDK resolution reports an actionable version error", async () => { + await assert.rejects( + () => resolveCommitItemMutations(async () => ({})), + (err: unknown) => { + assert.ok(err instanceof CommandError); + assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.USAGE); + assert.match((err as Error).message, /does not export commitItemMutations/); + assert.match((err as Error).message, />=2026\.7\.20/); + return true; + }, + ); +}); diff --git a/test/helpers/barrier.ts b/test/helpers/barrier.ts new file mode 100644 index 0000000..becede2 --- /dev/null +++ b/test/helpers/barrier.ts @@ -0,0 +1,32 @@ +import { existsSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +export interface BarrierWaitDependencies { + now?: () => number; + delayMs?: (ms: number) => Promise; + deadlineMs?: number; + exists?: (path: string) => boolean; + logError?: (message: string) => void; + exit?: (code: number) => void; +} + +export async function waitForBarrier( + barrierPath: string, + dependencies: BarrierWaitDependencies = {}, +): Promise { + const now = dependencies.now ?? Date.now; + const delayMs = dependencies.delayMs ?? delay; + const deadline = now() + (dependencies.deadlineMs ?? 10_000); + const exists = dependencies.exists ?? existsSync; + const logError = dependencies.logError ?? console.error; + const exit = dependencies.exit ?? process.exit; + + while (!exists(barrierPath)) { + if (now() > deadline) { + logError("barrier file never appeared"); + exit(2); + return; + } + await delayMs(5); + } +} diff --git a/test/helpers/comment-sync-child.mjs b/test/helpers/comment-sync-child.mjs deleted file mode 100644 index a48578a..0000000 --- a/test/helpers/comment-sync-child.mjs +++ /dev/null @@ -1,39 +0,0 @@ -// Child-process entry for the cross-process concurrent comment-sync test. -// -// Spawning real OS processes (rather than two in-process calls) exercises the -// lockfile serialization the way two concurrent `pm github import` runs hit -// it: separate processes, same workspace, same item, same GitHub comments. -// -// Usage: node test/helpers/comment-sync-child.mjs -// Input: FAKE_COMMENTS env var — JSON array of GhComment-shaped objects. -// BARRIER_FILE env var (optional) — path both children poll for before -// starting, so the parent can line them up and the test exercises real -// concurrent entry into the sync instead of an accidental sequence. -// Output: one JSON line on stdout with the { added, skipped } result. - -import { existsSync } from "node:fs"; -import { setTimeout as delay } from "node:timers/promises"; - -import { syncGithubCommentsToAnnotations } from "../../dist/index.js"; - -const [itemId, pmRoot] = process.argv.slice(2); -if (!itemId || !pmRoot) { - console.error("usage: comment-sync-child.mjs "); - process.exit(2); -} - -const barrier = process.env.BARRIER_FILE; -if (barrier) { - const deadline = Date.now() + 10_000; - while (!existsSync(barrier)) { - if (Date.now() > deadline) { - console.error("barrier file never appeared"); - process.exit(2); - } - await delay(5); - } -} - -const comments = JSON.parse(process.env.FAKE_COMMENTS || "[]"); -const result = await syncGithubCommentsToAnnotations(itemId, comments, pmRoot, 1); -process.stdout.write(`${JSON.stringify(result)}\n`); diff --git a/test/helpers/comment-sync-child.ts b/test/helpers/comment-sync-child.ts new file mode 100644 index 0000000..13b116a --- /dev/null +++ b/test/helpers/comment-sync-child.ts @@ -0,0 +1,24 @@ +// Child-process entry for the cross-process concurrent comment-sync test. +// +// Spawning real OS processes exercises the lockfile serialization the way two +// concurrent `pm github import` runs do: separate processes, same workspace, +// same item, and the same GitHub comments. + +import { syncGithubCommentsToAnnotations } from "../../dist/index.js"; +import type { GhComment } from "../../dist/index.js"; +import { waitForBarrier } from "./barrier.js"; + +const [itemId, pmRoot] = process.argv.slice(2); +if (!itemId || !pmRoot) { + console.error("usage: comment-sync-child.js "); + process.exit(2); +} + +const barrier = process.env.BARRIER_FILE; +if (barrier) { + await waitForBarrier(barrier); +} + +const comments = JSON.parse(process.env.FAKE_COMMENTS || "[]") as GhComment[]; +const result = await syncGithubCommentsToAnnotations(itemId, comments, pmRoot, 1); +process.stdout.write(`${JSON.stringify(result)}\n`); diff --git a/test/import-lock.test.ts b/test/import-lock.test.ts index cf47c57..70be705 100644 --- a/test/import-lock.test.ts +++ b/test/import-lock.test.ts @@ -32,6 +32,7 @@ import { resolvePmDataDir, syncGithubCommentsToAnnotations, } from "../dist/index.js"; +import { waitForBarrier } from "./helpers/barrier.js"; // Minimal factories + workspace helpers -------------------------------------- @@ -51,7 +52,7 @@ const PM_BIN = fileURLToPath( ); const CHILD_SCRIPT = fileURLToPath( - new URL("../test/helpers/comment-sync-child.mjs", import.meta.url), + new URL("./helpers/comment-sync-child.js", import.meta.url), ); function makeWorkspace(): string { @@ -80,6 +81,39 @@ function firstItemId(root: string): string { return arr[0].id as string; } +test("comment-sync child barrier proceeds when the file appears", async () => { + let checks = 0; + const exitCodes: number[] = []; + const errors: string[] = []; + await waitForBarrier("fixture-barrier", { + now: () => checks, + deadlineMs: 10, + exists: () => ++checks >= 3, + delayMs: async () => undefined, + logError: (message) => errors.push(message), + exit: (code) => exitCodes.push(code), + }); + assert.deepEqual(exitCodes, []); + assert.deepEqual(errors, []); + assert.strictEqual(checks, 3); +}); + +test("comment-sync child barrier exits predictably when the file never appears", async () => { + let now = 0; + const exitCodes: number[] = []; + const errors: string[] = []; + await waitForBarrier("missing-barrier", { + now: () => now++, + deadlineMs: 1, + exists: () => false, + delayMs: async () => undefined, + logError: (message) => errors.push(message), + exit: (code) => exitCodes.push(code), + }); + assert.deepEqual(exitCodes, [2]); + assert.deepEqual(errors, ["barrier file never appeared"]); +}); + // Run fn with console.error captured; returns the messages it logged. async function captureStderr(fn: () => Promise): Promise { const original = console.error;