From 12f8dd4ab1dc16775f8097f9863322e53e4b3d4a Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:09:59 +0200 Subject: [PATCH 1/3] feat(import): make GitHub issue sync atomic Add opt-in crash-resumable bulk imports using the pm 2026.7.20 commitItemMutations SDK. Canonicalize transaction plans across reordered and partially applied retries, derive IDs from stable GitHub issue keys, and compensate create/update/close mutations on failure. Adopt the field-aware PM merge driver, upgrade the SDK/changelog toolchain, convert the remaining child-process fixture to TypeScript, document the workflow, add real tracker recovery/rollback coverage, and redact legacy absolute host paths from current PM records. --- .agents/pm/features/pm-github-9eei.toon | 2 +- .agents/pm/history/pm-github-0y40.jsonl | 9 +- .agents/pm/history/pm-github-1jez.jsonl | 15 + .agents/pm/history/pm-github-9eei.jsonl | 11 +- .agents/pm/tasks/pm-github-0y40.toon | 2 +- .agents/pm/tasks/pm-github-1jez.toon | 38 +++ .gitattributes | 28 ++ README.md | 4 + index.ts | 417 +++++++++++++++++++++++- manifest.json | 2 +- package-lock.json | 19 +- package.json | 6 +- test/atomic.test.ts | 302 +++++++++++++++++ test/helpers/comment-sync-child.mjs | 39 --- test/helpers/comment-sync-child.ts | 33 ++ test/import-lock.test.ts | 2 +- 16 files changed, 859 insertions(+), 70 deletions(-) create mode 100644 .agents/pm/history/pm-github-1jez.jsonl create mode 100644 .agents/pm/tasks/pm-github-1jez.toon create mode 100644 .gitattributes create mode 100644 test/atomic.test.ts delete mode 100644 test/helpers/comment-sync-child.mjs create mode 100644 test/helpers/comment-sync-child.ts 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..76e72c0 --- /dev/null +++ b/.agents/pm/history/pm-github-1jez.jsonl @@ -0,0 +1,15 @@ +{"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"} 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..b3c8f04 --- /dev/null +++ b/.agents/pm/tasks/pm-github-1jez.toon @@ -0,0 +1,38 @@ +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:24:44.529Z" +closed_at: "2026-07-21T07:24:44.239Z" +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; 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" +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 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." +fixed_version: next-daily-release +notes[3]{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." +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 GitHub import shipped locally with 134 tests passing, release checks green, real-data recovery verified, and Greptile re-review clean at 5/5." +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..ee3aede 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). A failed batch restores all updated/closed items and deletes all creates, so agents never observe partial import state. | | `--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 and complete rendered issue state, 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 issue content creates a fresh 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..2532e16 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,18 @@ interface ImportOptions { commentsMode: CommentsMode; itemType: string; dryRun: boolean; + atomic: boolean; +} + +type CommitItemMutations = ( + options: CommitItemMutationsOptions, +) => Promise; + +export interface AtomicImportOptions { + atomicAuthor?: string; + commitItemMutations?: CommitItemMutations; + normalizeItemId?: (input: string, prefix: string) => string; + readSettings?: (pmRoot: string) => Promise<{ id_prefix?: string }>; } // --------------------------------------------------------------------------- @@ -565,7 +582,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 +653,292 @@ 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; +} + +/** Resolve the atomic bulk-mutation helper lazily so normal imports stay compatible. */ +export async function resolveCommitItemMutations( + importSdk?: () => Promise>, +): Promise { + if (importSdk) { + let mod: Partial; + try { + mod = 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, + ); + } + return assertSdkFunction(mod.commitItemMutations, "commitItemMutations"); + } + if (cachedCommitItemMutations) return cachedCommitItemMutations; + try { + const mod = await import("@unbrained/pm-cli/sdk"); + cachedCommitItemMutations = assertSdkFunction( + mod.commitItemMutations, + "commitItemMutations", + ); + return cachedCommitItemMutations; + } catch (err: unknown) { + if (err instanceof CommandError) throw err; + 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, + ); + } +} + +/** + * Derive an order-independent transaction id from the exact desired import + * state. Content changes produce a fresh transaction; a reordered retry of the + * same GitHub response resumes the durable journal. + */ +export function deriveAtomicTransactionId( + repo: string, + entries: readonly PreparedGithubImport[], +): 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)) + .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 transactionId = deriveAtomicTransactionId(repo, entries); + const commit = opts.commitItemMutations ?? await resolveCommitItemMutations(); + + let sdk: typeof import("@unbrained/pm-cli/sdk") | undefined; + const getSdk = async () => { + if (sdk) return sdk; + try { + sdk = await import("@unbrained/pm-cli/sdk"); + return sdk; + } 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, + ); + } + }; + const normalizeItemId = opts.normalizeItemId ?? assertSdkFunction< + (input: string, prefix: string) => string + >((await getSdk()).normalizeItemId, "normalizeItemId"); + const readSettings = opts.readSettings ?? assertSdkFunction< + (root: string) => Promise<{ id_prefix?: string }> + >((await getSdk()).readSettings, "readSettings"); + + 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); + } + + 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); + throw new CommandError( + `Atomic GitHub import failed and was rolled back — every applied create/update/close was compensated; the tracker has no partial committed state from this import. Transaction id: ${transactionId}. Underlying error: ${msg}`, + EXIT_CODE.GENERIC_FAILURE, + ); + } +} + // --------------------------------------------------------------------------- // Shared import core (used by both the legacy command and the importer) // --------------------------------------------------------------------------- @@ -1244,6 +1547,7 @@ export function parseImportOptions(options: Record): ImportOpti commentsMode, itemType: optionString(options, "type") || "Issue", dryRun: optionEnabled(options, "dry-run", "dryRun"), + atomic: optionEnabled(options, "atomic"), }; } @@ -1263,7 +1567,8 @@ async function runImport(repoArg: string | undefined, pmRoot: string, opts: Impo 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, ); } @@ -1302,6 +1607,96 @@ async function runImport(repoArg: string | undefined, pmRoot: string, opts: Impo let updated = 0; let skipped = 0; + if (opts.atomic && !opts.dryRun) { + const prepared: PreparedGithubImport[] = []; + for (const issue of filtered) { + const title = issue.title.trim(); + if (!title) { + skipped++; + continue; + } + + 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 tags = [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])]; + const status = mapState(issue.state, issue.state_reason); + 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}` : ""); + 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 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}`); + } + } + prepared.push({ + issueNumber: issue.number, + title, + itemType: opts.itemType, + status, + description, + body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""), + tags, + assignee: issue.assignee?.login, + milestone: issue.milestone?.title, + comments, + syncAnnotations, + match: existing.get(`${repo.toLowerCase()}#${issue.number}`), + }); + } + + if (prepared.length === 0) { + console.error(`Imported 0 new, updated 0 existing, skipped ${skipped}.`); + return { imported: 0, updated: 0, skipped, atomic: true }; + } + + const result = await 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) { @@ -1434,7 +1829,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 +3225,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 every applied mutation on failure" }, { long: "--dry-run", description: "Preview without writing" }, { long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" }, ]; @@ -2933,6 +3334,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 +3342,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 to prevent partial tracker state when a bulk import is interrupted or fails.", ], }); @@ -3099,6 +3502,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 +3510,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 to prevent partial tracker state when a bulk import is interrupted or fails.", ], 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..6e40365 --- /dev/null +++ b/test/atomic.test.ts @@ -0,0 +1,302 @@ +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, +} from "../dist/index.js"; +import type { 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("transaction identity is content-sensitive and independent of fetch order", () => { + const first = entry(1, "First"); + const second = entry(2, "Second"); + const id = deriveAtomicTransactionId("Acme/Widgets", [first, second]); + assert.match(id, /^github-import-[0-9a-f]{16}$/); + assert.strictEqual( + deriveAtomicTransactionId("acme/widgets", [second, first]), + id, + "same rendered issues in another order resume the same transaction", + ); + assert.notStrictEqual( + deriveAtomicTransactionId("acme/widgets", [first, entry(2, "Changed")]), + id, + "changed desired state starts a fresh transaction", + ); +}); + +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 partial committed state/); + 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 partial committed state/, + ); + + 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("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/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..9d20170 --- /dev/null +++ b/test/helpers/comment-sync-child.ts @@ -0,0 +1,33 @@ +// 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 { existsSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +import { syncGithubCommentsToAnnotations } from "../../dist/index.js"; +import type { GhComment } from "../../dist/index.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) { + 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 || "[]") 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..d0603b5 100644 --- a/test/import-lock.test.ts +++ b/test/import-lock.test.ts @@ -51,7 +51,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 { From 5e66c6ff3fe22d58ed7a3f97bc9f6da7434b5825 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:41:56 +0200 Subject: [PATCH 2/3] fix(import): make atomic dry-runs truthful Route atomic previews through the same issue rendering and provenance matching used by committed imports, while keeping the SDK commit path unreachable during dry-run. Centralize atomic SDK resolution so version errors and lazy loading remain consistent. Add orchestration coverage for create/update/skip summaries, deterministic barrier success and timeout tests, clearer rollback documentation, and durable pm review evidence. The full release gate passes with 137 tests. --- .agents/pm/history/pm-github-1jez.jsonl | 7 + .agents/pm/tasks/pm-github-1jez.toon | 12 +- README.md | 2 +- index.ts | 342 ++++++++++++++---------- test/atomic.test.ts | 51 +++- test/helpers/barrier.ts | 32 +++ test/helpers/comment-sync-child.ts | 13 +- test/import-lock.test.ts | 34 +++ 8 files changed, 336 insertions(+), 157 deletions(-) create mode 100644 test/helpers/barrier.ts diff --git a/.agents/pm/history/pm-github-1jez.jsonl b/.agents/pm/history/pm-github-1jez.jsonl index 76e72c0..8558175 100644 --- a/.agents/pm/history/pm-github-1jez.jsonl +++ b/.agents/pm/history/pm-github-1jez.jsonl @@ -13,3 +13,10 @@ {"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"} diff --git a/.agents/pm/tasks/pm-github-1jez.toon b/.agents/pm/tasks/pm-github-1jez.toon index b3c8f04..dfe807d 100644 --- a/.agents/pm/tasks/pm-github-1jez.toon +++ b/.agents/pm/tasks/pm-github-1jez.toon @@ -6,20 +6,22 @@ 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:24:44.529Z" -closed_at: "2026-07-21T07:24:44.239Z" +updated_at: "2026-07-21T07:41:39.157Z" +closed_at: "2026-07-21T07:41:38.803Z" 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; 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" 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 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." +actual_result: 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. fixed_version: next-daily-release -notes[3]{created_at,author,text}: +notes[5]{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." 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. @@ -34,5 +36,5 @@ tests[2]{command,scope,timeout_seconds}: npm test,project,600 docs[1]{path,scope,note}: README.md,project,user-facing atomic import guidance -close_reason: "Atomic GitHub import shipped locally with 134 tests passing, release checks green, real-data recovery verified, and Greptile re-review clean at 5/5." +close_reason: "Atomic GitHub import and all first-round review fixes verified by the complete release gate (137 tests, audit, pack, changelog)." body: "" diff --git a/README.md b/README.md index ee3aede..8d6f93f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ 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). A failed batch restores all updated/closed items and deletes all creates, so agents never observe partial import state. | +| `--atomic` | boolean | Commit every create, update, close, and reopen in one workspace-writer-locked, crash-resumable transaction (requires pm CLI/SDK >=2026.7.20). A failed batch restores all updated/closed items and deletes all newly created items, so agents never observe partial import state. | | `--dry-run` | boolean | Preview without writing | | `--type ` | string | Override pm item type (default: Issue) | diff --git a/index.ts b/index.ts index 2532e16..cc9d0b8 100644 --- a/index.ts +++ b/index.ts @@ -131,11 +131,30 @@ 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?: (input: string, prefix: string) => string; - readSettings?: (pmRoot: string) => Promise<{ id_prefix?: string }>; + 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; } // --------------------------------------------------------------------------- @@ -686,39 +705,59 @@ function assertSdkFunction(fn: unknown, exportName: string): F { 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) { - let mod: Partial; - try { - mod = 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, - ); - } + const mod = await loadAtomicSdk(importSdk); return assertSdkFunction(mod.commitItemMutations, "commitItemMutations"); } if (cachedCommitItemMutations) return cachedCommitItemMutations; - try { - const mod = await import("@unbrained/pm-cli/sdk"); - cachedCommitItemMutations = assertSdkFunction( - mod.commitItemMutations, + 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", - ); - return cachedCommitItemMutations; - } catch (err: unknown) { - if (err instanceof CommandError) throw err; - 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, - ); - } + ), + normalizeItemId: opts.normalizeItemId ?? assertSdkFunction( + mod?.normalizeItemId, + "normalizeItemId", + ), + readSettings: opts.readSettings ?? assertSdkFunction( + mod?.readSettings, + "readSettings", + ), + }; } /** @@ -862,28 +901,11 @@ export async function importGithubAtomic( itemIds: Map; }> { const transactionId = deriveAtomicTransactionId(repo, entries); - const commit = opts.commitItemMutations ?? await resolveCommitItemMutations(); - - let sdk: typeof import("@unbrained/pm-cli/sdk") | undefined; - const getSdk = async () => { - if (sdk) return sdk; - try { - sdk = await import("@unbrained/pm-cli/sdk"); - return sdk; - } 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, - ); - } - }; - const normalizeItemId = opts.normalizeItemId ?? assertSdkFunction< - (input: string, prefix: string) => string - >((await getSdk()).normalizeItemId, "normalizeItemId"); - const readSettings = opts.readSettings ?? assertSdkFunction< - (root: string) => Promise<{ id_prefix?: string }> - >((await getSdk()).readSettings, "readSettings"); + const { + commitItemMutations: commit, + normalizeItemId, + readSettings, + } = await resolveAtomicSdkFunctions(opts); let idPrefix = "pm-"; try { @@ -1559,10 +1581,65 @@ 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] " + @@ -1574,14 +1651,14 @@ async function runImport(repoArg: string | undefined, pmRoot: string, opts: Impo } 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) @@ -1595,74 +1672,83 @@ 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 && !opts.dryRun) { + if (opts.atomic) { const prepared: PreparedGithubImport[] = []; for (const issue of filtered) { - const title = issue.title.trim(); - if (!title) { + const entry = await prepareGithubImport( + issue, + repo, + opts, + token, + existing.get(`${repo.toLowerCase()}#${issue.number}`), + dependencies.fetchIssueComments, + ); + if (!entry) { skipped++; continue; } - - 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 tags = [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])]; - const status = mapState(issue.state, issue.state_reason); - 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}` : ""); - 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 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}`); - } - } - prepared.push({ - issueNumber: issue.number, - title, - itemType: opts.itemType, - status, - description, - body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""), - tags, - assignee: issue.assignee?.login, - milestone: issue.milestone?.title, - comments, - syncAnnotations, - match: existing.get(`${repo.toLowerCase()}#${issue.number}`), - }); + 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, + }; + } console.error(`Imported 0 new, updated 0 existing, skipped ${skipped}.`); return { imported: 0, updated: 0, skipped, atomic: true }; } - const result = await importGithubAtomic(pmRoot, repo, prepared); + 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); @@ -1698,54 +1784,32 @@ async function runImport(repoArg: string | undefined, pmRoot: string, opts: Impo } 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(",")})`); diff --git a/test/atomic.test.ts b/test/atomic.test.ts index 6e40365..b63aecb 100644 --- a/test/atomic.test.ts +++ b/test/atomic.test.ts @@ -14,8 +14,9 @@ import { importGithubAtomic, parseImportOptions, resolveCommitItemMutations, + runImport, } from "../dist/index.js"; -import type { PreparedGithubImport } 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 = { @@ -80,6 +81,54 @@ test("parseImportOptions enables atomic mode without changing the default", () = 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"); 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.ts b/test/helpers/comment-sync-child.ts index 9d20170..13b116a 100644 --- a/test/helpers/comment-sync-child.ts +++ b/test/helpers/comment-sync-child.ts @@ -4,11 +4,9 @@ // concurrent `pm github import` runs do: separate processes, same workspace, // same item, and the same GitHub comments. -import { existsSync } from "node:fs"; -import { setTimeout as delay } from "node:timers/promises"; - 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) { @@ -18,14 +16,7 @@ if (!itemId || !pmRoot) { 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); - } + await waitForBarrier(barrier); } const comments = JSON.parse(process.env.FAKE_COMMENTS || "[]") as GhComment[]; diff --git a/test/import-lock.test.ts b/test/import-lock.test.ts index d0603b5..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 -------------------------------------- @@ -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; From 0735f7a9084a8fd002e2db076b2c47124a92fe9e Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:49:39 +0200 Subject: [PATCH 3/3] fix(import): harden atomic recovery edge cases Fingerprint the exact ordered mutation plan in transaction identities so changed prefixes or provenance targets cannot collide with an incompatible recovery journal. Preserve legacy failure semantics when every issue is skipped. Report interrupted, normally compensated, and incompletely compensated transactions truthfully; document the operator recovery contract and add focused regressions. The full release gate passes with 139 tests. --- .agents/pm/history/pm-github-1jez.jsonl | 8 +++ .agents/pm/tasks/pm-github-1jez.toon | 14 ++-- README.md | 4 +- index.ts | 40 +++++++++--- test/atomic.test.ts | 86 +++++++++++++++++++++++-- 5 files changed, 129 insertions(+), 23 deletions(-) diff --git a/.agents/pm/history/pm-github-1jez.jsonl b/.agents/pm/history/pm-github-1jez.jsonl index 8558175..c249193 100644 --- a/.agents/pm/history/pm-github-1jez.jsonl +++ b/.agents/pm/history/pm-github-1jez.jsonl @@ -20,3 +20,11 @@ {"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/tasks/pm-github-1jez.toon b/.agents/pm/tasks/pm-github-1jez.toon index dfe807d..a861c59 100644 --- a/.agents/pm/tasks/pm-github-1jez.toon +++ b/.agents/pm/tasks/pm-github-1jez.toon @@ -6,22 +6,24 @@ 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:41:39.157Z" -closed_at: "2026-07-21T07:41:38.803Z" +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; 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" +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 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. +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[5]{created_at,author,text}: +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. @@ -36,5 +38,5 @@ tests[2]{command,scope,timeout_seconds}: npm test,project,600 docs[1]{path,scope,note}: README.md,project,user-facing atomic import guidance -close_reason: "Atomic GitHub import and all first-round review fixes verified by the complete release gate (137 tests, audit, pack, changelog)." +close_reason: Atomic import plus Sourcery and Greptile review findings verified by 139 tests and the complete release gate. body: "" diff --git a/README.md b/README.md index 8d6f93f..9b8e393 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,13 @@ 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). A failed batch restores all updated/closed items and deletes all newly created items, so agents never observe partial import state. | +| `--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 and complete rendered issue state, 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 issue content creates a fresh transaction. Native comment annotations run only after the item transaction commits and retain their cross-process deduplication lock. +`--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`) diff --git a/index.ts b/index.ts index cc9d0b8..2529fcb 100644 --- a/index.ts +++ b/index.ts @@ -761,13 +761,14 @@ async function resolveAtomicSdkFunctions(opts: AtomicImportOptions): Promise<{ } /** - * Derive an order-independent transaction id from the exact desired import - * state. Content changes produce a fresh transaction; a reordered retry of the - * same GitHub response resumes the durable journal. + * 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) @@ -787,6 +788,11 @@ export function deriveAtomicTransactionId( .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}`; @@ -900,7 +906,6 @@ export async function importGithubAtomic( recoveredItems?: number; itemIds: Map; }> { - const transactionId = deriveAtomicTransactionId(repo, entries); const { commitItemMutations: commit, normalizeItemId, @@ -926,6 +931,7 @@ export async function importGithubAtomic( itemIds.set(entry.issueNumber, planned.itemId); mutations.push(...planned.mutations); } + const transactionId = deriveAtomicTransactionId(repo, entries, mutations); try { const result = await commit({ @@ -954,8 +960,20 @@ export async function importGithubAtomic( }; } 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 and was rolled back — every applied create/update/close was compensated; the tracker has no partial committed state from this import. Transaction id: ${transactionId}. Underlying error: ${msg}`, + `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, ); } @@ -1725,8 +1743,10 @@ export async function runImport( atomic: true, }; } - console.error(`Imported 0 new, updated 0 existing, skipped ${skipped}.`); - return { imported: 0, updated: 0, skipped, atomic: true }; + throw new CommandError( + `Imported 0 issue(s); ${skipped} failed.`, + EXIT_CODE.GENERIC_FAILURE, + ); } if (opts.dryRun) { @@ -3289,7 +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 every applied mutation on failure" }, + { 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)" }, ]; @@ -3406,7 +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 to prevent partial tracker state when a bulk import is interrupted or fails.", + "Use --atomic for a durable resumable journal with reverse compensation on ordinary failures.", ], }); @@ -3574,7 +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 to prevent partial tracker state when a bulk import is interrupted or fails.", + "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/test/atomic.test.ts b/test/atomic.test.ts index b63aecb..005e7e7 100644 --- a/test/atomic.test.ts +++ b/test/atomic.test.ts @@ -132,18 +132,68 @@ test("atomic dry-run previews creates and updates without invoking the SDK", asy test("transaction identity is content-sensitive and independent of fetch order", () => { const first = entry(1, "First"); const second = entry(2, "Second"); - const id = deriveAtomicTransactionId("Acme/Widgets", [first, 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]), + 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")]), + 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", () => { @@ -272,7 +322,7 @@ test("a failed atomic batch compensates every applied create", async () => { (err: unknown) => { assert.ok(err instanceof CommandError); assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.GENERIC_FAILURE); - assert.match((err as Error).message, /no partial committed state/); + assert.match((err as Error).message, /no new partial committed state is expected/); return true; }, ); @@ -320,7 +370,7 @@ test("a failed mixed batch restores pre-existing updates and closes", async () = ], { commitItemMutations: wrappingCommit }, ), - /no partial committed state/, + /no new partial committed state is expected/, ); const items = listItems(root); @@ -337,6 +387,32 @@ test("a failed mixed batch restores pre-existing updates and closes", async () = } }); +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 () => ({})),