From e0aae1381859411c61eb1f112e0045cdbb6c4ced Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:58:04 +0200 Subject: [PATCH] feat: select GitHub issue imports and bound list volume --- .agents/pm/features/pm-github-6srd.toon | 32 +++ .agents/pm/history/pm-github-6srd.jsonl | 17 ++ README.md | 9 + index.ts | 112 +++++++- test/fixtures/import-selection/README.md | 9 + .../pm-slack-standup-113.json | 15 + .../import-selection/pm-web-175-comments.json | 8 + .../fixtures/import-selection/pm-web-175.json | 23 ++ .../fixtures/import-selection/pm-web-189.json | 15 + test/fixtures/import-selection/pm-web-96.json | 15 + test/import-selection.test.ts | 262 ++++++++++++++++++ 11 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 .agents/pm/features/pm-github-6srd.toon create mode 100644 .agents/pm/history/pm-github-6srd.jsonl create mode 100644 test/fixtures/import-selection/README.md create mode 100644 test/fixtures/import-selection/pm-slack-standup-113.json create mode 100644 test/fixtures/import-selection/pm-web-175-comments.json create mode 100644 test/fixtures/import-selection/pm-web-175.json create mode 100644 test/fixtures/import-selection/pm-web-189.json create mode 100644 test/fixtures/import-selection/pm-web-96.json create mode 100644 test/import-selection.test.ts diff --git a/.agents/pm/features/pm-github-6srd.toon b/.agents/pm/features/pm-github-6srd.toon new file mode 100644 index 0000000..85ebe33 --- /dev/null +++ b/.agents/pm/features/pm-github-6srd.toon @@ -0,0 +1,32 @@ +id: pm-github-6srd +title: Select GitHub issue imports and cap list volume +description: Fetch explicitly selected issue numbers through individual REST endpoints; validate conflicts and missing issues before writes; bound newest-updated list imports with truncation receipts. +type: Feature +status: in_progress +priority: 1 +tags: [] +created_at: "2026-10-12T13:25:14.333Z" +updated_at: "2026-10-12T13:57:05.635Z" +author: codex-sol +comments[7]{created_at,author,text}: + "2026-10-12T13:25:56.037Z",codex-sol,"Plan: use individual REST issue endpoints for explicit selection, validate all numbers before tracker mutation, preserve the shared comment/dependency/atomic/gate pipeline and provenance idempotency. List limits request newest-updated order and stop after one extra eligible issue establishes truncation. Add recorded public fleet REST fixtures and real scratch-tracker regressions. Keep PR #115 blocker code outside this diff." + "2026-10-12T13:34:57.011Z",codex-sol,"Implemented selection prefetch and missing-number/PR refusal before writes, conflict validation, and bounded newest-updated list fetches with limit/truncated receipts. Recorded public REST fixtures cover pm-web #189/#96/PR #175 and pm-slack-standup #113. Initial focused run passed 23/26; fixture assertions needed existing body trimming and native-comment reads, and CLI acceptance now installs a packed archive because directory installs hit the SDK source-scan ceiling. Type checking, documentation, lint, zero-duplication and privacy gates pass." + "2026-10-12T13:37:55.946Z",codex-sol,"Live manual acceptance passed using the packed candidate in a scratch pm project: pm github import unbraind/pm-web --issue 189 --dry-run --json reported wouldImport=1, wouldUpdate=0, wouldSkip=0 and left tracker count=0. pm github import unbraind/pm-web --issue 189 --json reported imported=1, updated=0, skipped=0; the tracker contained exactly one item with gh:unbraind/pm-web#189. Both used the real public GitHub endpoint. Corrected focused acceptance passed 3/3, including comments and packed CLI dispatch." + "2026-10-12T13:43:05.407Z",codex-sol,"Behavioral revert proof passed in disposable copies of the candidate with unchanged regression tests and fixtures: restoring only runImport from the base revision made the one-recorded-issue regression fail 0/1 with AssertionError (2 imports instead of 1). Restoring only buildIssuesUrl and fetchAllIssues made the bounded-probe regression fail 0/1 with AssertionError (3 retained issues instead of 2). Candidate source bytes stayed unchanged. These tests import TypeScript source, so no dist rebuild was needed for the revert experiments." + "2026-10-12T13:47:07.209Z",codex-sol,"Isolated-Git-identity npm run release:check passed: 546/546 tests, zero failures/cancellations/skips/todo; 12 authored modules with exact 100/100/100/100 coverage (8597/8597 lines and statements, 211/211 functions, 2315/2315 branches). Type checks, build, docstrings, lint, zero duplication, privacy, production audit (0 vulnerabilities), pack dry-run, changelog and publish-attestation checks all passed. The Bun release chain is running. PM-linked execution reached 25 passing cases before its default 120-second timeout during the final acceptance; rerunning with --timeout 300. Tracker test-result persistence is disabled by existing project policy, so comments retain receipts." + "2026-10-12T13:49:47.304Z",codex-sol,"PM-linked acceptance passed with pm test pm-github-6srd --run --progress --timeout 300: 1/1 linked commands and 26/26 regression cases, zero failures/cancellations/skips/todo, elapsed 188 seconds. Increased the linked command budget to 300 seconds for repeatability on the shared runner. The earlier default-budget timeout is retained as a timeout receipt, not a passing result." + "2026-10-12T13:57:04.892Z",codex-sol,"Final isolated-Git-identity bun run release:check passed: 546/546 tests, zero failures/cancellations/skips/todo; 12 authored modules with exact 100/100/100/100 coverage (8597/8597 lines and statements, 211/211 functions, 2315/2315 branches), unchanged thresholds and inventories. All release-chain checks passed, including zero production vulnerabilities, packing, changelog and publish attestation. Bun orchestrates the release chain; its coverage runner invokes Node. Implementation, docs, tests and fixture hashes match the source validated by the npm gate. Manual public selection, both assertion-based revert proofs, and PM-linked 26/26 acceptance are complete. No blocker code changed. Claims will be released; the item remains in progress for PR review." +files[7]{path,scope}: + index.ts,project + test/fixtures/import-selection/pm-slack-standup-113.json,project + test/fixtures/import-selection/pm-web-175-comments.json,project + test/fixtures/import-selection/pm-web-175.json,project + test/fixtures/import-selection/pm-web-189.json,project + test/fixtures/import-selection/pm-web-96.json,project + test/import-selection.test.ts,project +tests[1]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}: + npm run build && node --test test/import-selection.test.ts,project,300,codex-sol,"2026-10-12T13:50:17.312Z",local_mutation,feat/import-issue-selection +docs[2]{path,scope}: + README.md,project + test/fixtures/import-selection/README.md,project +body: "" diff --git a/.agents/pm/history/pm-github-6srd.jsonl b/.agents/pm/history/pm-github-6srd.jsonl new file mode 100644 index 0000000..a94fede --- /dev/null +++ b/.agents/pm/history/pm-github-6srd.jsonl @@ -0,0 +1,17 @@ +{"hash_algorithm":"sha256","ts":"2026-10-12T13:25:14.333Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-6srd"},{"op":"add","path":"/metadata/title","value":"Select GitHub issue imports and cap list volume"},{"op":"add","path":"/metadata/description","value":"Fetch explicitly selected issue numbers through individual REST endpoints; validate conflicts and missing issues before writes; bound newest-updated list imports with truncation receipts."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-10-12T13:25:14.333Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-12T13:25:14.333Z"},{"op":"add","path":"/metadata/author","value":"codex-sol"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"559359dfd57308b6473765ec17f85940ad5ef5e35f2e67753c81f242fd34833f","item_hash_version":3,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"bbfa43a04072528dbf7b22c0d50bedcf1d5b550f9bb93abd96b1532032c18f9a"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:25:54.941Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:25:54.941Z"},{"op":"add","path":"/metadata/assignee","value":"codex-sol"},{"op":"add","path":"/metadata/claim_principal","value":"codex-sol"}],"before_hash":"559359dfd57308b6473765ec17f85940ad5ef5e35f2e67753c81f242fd34833f","after_hash":"e32ec8512fea49f49f364137ad758af7585c2a1b62b7dab46d49d73bcba6e7e0","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"55ac5ca6dac67882286d822c188c6ee5586f1804fd0a9f70df7d5a04072f8ac0"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:25:55.472Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:25:55.472Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"e32ec8512fea49f49f364137ad758af7585c2a1b62b7dab46d49d73bcba6e7e0","after_hash":"1ac1017adc5b2b0aae5030ce82bcf8ebdab9edf61b10971f4e4c1e5109340338","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"aeba2305f003d51e2e2dee228271261187ffa21ffe83ad539b03cfe0116bc5d3"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:25:56.037Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:25:56.037Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-12T13:25:56.037Z","author":"codex-sol","text":"Plan: use individual REST issue endpoints for explicit selection, validate all numbers before tracker mutation, preserve the shared comment/dependency/atomic/gate pipeline and provenance idempotency. List limits request newest-updated order and stop after one extra eligible issue establishes truncation. Add recorded public fleet REST fixtures and real scratch-tracker regressions. Keep PR #115 blocker code outside this diff."}]}],"before_hash":"1ac1017adc5b2b0aae5030ce82bcf8ebdab9edf61b10971f4e4c1e5109340338","after_hash":"c31bc7967aa87d452e5f235ef54359efc502844d4e87d4114b8610e250cae06f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"ae2deb4605ed4fd60bcad41717fb216711a9f101cda47cfbb84bfb14d335f29c"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:33:48.061Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:33:48.061Z"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project"},{"path":"test/fixtures/import-selection","scope":"project"},{"path":"test/import-selection.test.ts","scope":"project"}]}],"before_hash":"c31bc7967aa87d452e5f235ef54359efc502844d4e87d4114b8610e250cae06f","after_hash":"cee943ee9e9f20b4b67c5290523acfceb07dc6601b72bc17649e873dc16019a2","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"bb5eb06557cb1de01ccc6f414b037dcb9fb8799e56f5f610787e10a41a386738"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:33:49.072Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:33:49.072Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"README.md","scope":"project"},{"path":"test/fixtures/import-selection/README.md","scope":"project"}]}],"before_hash":"cee943ee9e9f20b4b67c5290523acfceb07dc6601b72bc17649e873dc16019a2","after_hash":"731d104c392c7323b63f2729e1ebaab383be8a49cc89e3562ef254c03f2551fb","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"f6287b65507d0d5120398b28ae8388bc0bd9980f1bedbbc4fdfa7c386fdb051c"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:33:49.848Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:33:49.848Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run build && node --test test/import-selection.test.ts","scope":"project","provenance":{"author":"codex-sol","created_at":"2026-10-12T13:33:49.818Z","source_kind":"local_mutation","source_ref":"feat/import-issue-selection"}}]}],"before_hash":"731d104c392c7323b63f2729e1ebaab383be8a49cc89e3562ef254c03f2551fb","after_hash":"3c19f09404b11047cf060f3889df1cdff0eb0317109a360985da5ced5026fea6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"bab7e1b802ef26fbd1085a4c8bfaf2e78108d19777cb04ed2413e68ecdcb086a"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:34:57.011Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-12T13:34:57.011Z","author":"codex-sol","text":"Implemented selection prefetch and missing-number/PR refusal before writes, conflict validation, and bounded newest-updated list fetches with limit/truncated receipts. Recorded public REST fixtures cover pm-web #189/#96/PR #175 and pm-slack-standup #113. Initial focused run passed 23/26; fixture assertions needed existing body trimming and native-comment reads, and CLI acceptance now installs a packed archive because directory installs hit the SDK source-scan ceiling. Type checking, documentation, lint, zero-duplication and privacy gates pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:34:57.011Z"}],"before_hash":"3c19f09404b11047cf060f3889df1cdff0eb0317109a360985da5ced5026fea6","after_hash":"6fbd75f9688ce0a463f971d9eb6c48eb9ddae8a5b208a25a30cc20ac5abd1045","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"92756fd9a3c3d5c0832a54c42481e5234d82d83798f287809877d14123df5163"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:36:12.562Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/2/path","value":"test/fixtures/import-selection/pm-web-175-comments.json"},{"op":"replace","path":"/metadata/files/1/path","value":"test/fixtures/import-selection/pm-slack-standup-113.json"},{"op":"add","path":"/metadata/files/3","value":{"path":"test/fixtures/import-selection/pm-web-175.json","scope":"project"}},{"op":"add","path":"/metadata/files/4","value":{"path":"test/fixtures/import-selection/pm-web-189.json","scope":"project"}},{"op":"add","path":"/metadata/files/5","value":{"path":"test/fixtures/import-selection/pm-web-96.json","scope":"project"}},{"op":"add","path":"/metadata/files/6","value":{"path":"test/import-selection.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:36:12.562Z"}],"before_hash":"6fbd75f9688ce0a463f971d9eb6c48eb9ddae8a5b208a25a30cc20ac5abd1045","after_hash":"afbffd89f2763f5f031694a92fdaaa8823bb4ea56c6150cb0a09dd35e090f589","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"5cedadf9468a1757d33644a21799024ea37de2d4c66f286faf09f43623d94ec5"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:37:55.946Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-12T13:37:55.946Z","author":"codex-sol","text":"Live manual acceptance passed using the packed candidate in a scratch pm project: pm github import unbraind/pm-web --issue 189 --dry-run --json reported wouldImport=1, wouldUpdate=0, wouldSkip=0 and left tracker count=0. pm github import unbraind/pm-web --issue 189 --json reported imported=1, updated=0, skipped=0; the tracker contained exactly one item with gh:unbraind/pm-web#189. Both used the real public GitHub endpoint. Corrected focused acceptance passed 3/3, including comments and packed CLI dispatch."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:37:55.946Z"}],"before_hash":"afbffd89f2763f5f031694a92fdaaa8823bb4ea56c6150cb0a09dd35e090f589","after_hash":"e1cc8e1b722c767a58ed5bbc79794b2cdbf8a78eaaf772bff3fe3bdb75a4168f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"733314f95dc5880bcac442e5ed6e0781d6e75f96e8502a79b1b3cf2d40d34b9f"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:43:05.407Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-12T13:43:05.407Z","author":"codex-sol","text":"Behavioral revert proof passed in disposable copies of the candidate with unchanged regression tests and fixtures: restoring only runImport from the base revision made the one-recorded-issue regression fail 0/1 with AssertionError (2 imports instead of 1). Restoring only buildIssuesUrl and fetchAllIssues made the bounded-probe regression fail 0/1 with AssertionError (3 retained issues instead of 2). Candidate source bytes stayed unchanged. These tests import TypeScript source, so no dist rebuild was needed for the revert experiments."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:43:05.407Z"}],"before_hash":"e1cc8e1b722c767a58ed5bbc79794b2cdbf8a78eaaf772bff3fe3bdb75a4168f","after_hash":"c75bc9240e21f0f0d11d663503d2f5f3fed0b22d73cd2380db36325868f83f67","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"840ef7d109464e2e25be74aec30c8c9623364a26ab5c0499f370fb8e8b864baf"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:47:07.209Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-12T13:47:07.209Z","author":"codex-sol","text":"Isolated-Git-identity npm run release:check passed: 546/546 tests, zero failures/cancellations/skips/todo; 12 authored modules with exact 100/100/100/100 coverage (8597/8597 lines and statements, 211/211 functions, 2315/2315 branches). Type checks, build, docstrings, lint, zero duplication, privacy, production audit (0 vulnerabilities), pack dry-run, changelog and publish-attestation checks all passed. The Bun release chain is running. PM-linked execution reached 25 passing cases before its default 120-second timeout during the final acceptance; rerunning with --timeout 300. Tracker test-result persistence is disabled by existing project policy, so comments retain receipts."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:47:07.209Z"}],"before_hash":"c75bc9240e21f0f0d11d663503d2f5f3fed0b22d73cd2380db36325868f83f67","after_hash":"0731671115a867547472e6795f6935c76a1a706cff2230e217dfc41a323d15c5","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"28630b38bfaae2d451aab68bfec52fa181fbe8022ee3d82d3abdcdcad92dd0e8"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:49:47.304Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-10-12T13:49:47.304Z","author":"codex-sol","text":"PM-linked acceptance passed with pm test pm-github-6srd --run --progress --timeout 300: 1/1 linked commands and 26/26 regression cases, zero failures/cancellations/skips/todo, elapsed 188 seconds. Increased the linked command budget to 300 seconds for repeatability on the shared runner. The earlier default-budget timeout is retained as a timeout receipt, not a passing result."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:49:47.304Z"}],"before_hash":"0731671115a867547472e6795f6935c76a1a706cff2230e217dfc41a323d15c5","after_hash":"07e84d45974edbb6cb1f1b7339e7d59f51c35d5c94be02d178c17fbe84f1b460","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"3bb547ac786cfbaa69ce844a92f60ccfd25bb3fd6e2e9245c007ef921098f2de"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:50:14.371Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_remove","patch":[{"op":"remove","path":"/metadata/tests"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:50:14.371Z"}],"before_hash":"07e84d45974edbb6cb1f1b7339e7d59f51c35d5c94be02d178c17fbe84f1b460","after_hash":"ad66d61b245ba07d8c93cd4b533231a59fa321e406cfa8d35fcb1ac1f689f60d","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"ba8ac313ff243d549c6ecc0d851ba0bff82c88f085dc576856d06de6cc23b963"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:50:17.425Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:50:17.425Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run build && node --test test/import-selection.test.ts","scope":"project","timeout_seconds":300,"provenance":{"author":"codex-sol","created_at":"2026-10-12T13:50:17.312Z","source_kind":"local_mutation","source_ref":"feat/import-issue-selection"}}]}],"before_hash":"ad66d61b245ba07d8c93cd4b533231a59fa321e406cfa8d35fcb1ac1f689f60d","after_hash":"d7a37fad8d8c10a4da05dbbc4e0d420f7dfb09108873c2a41aab9a8e44b31e73","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"a08163ac77eb65f6d9e5cbbb8659f98c242ed8ea6252a0b528156e868751fe72"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:57:04.892Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-10-12T13:57:04.892Z","author":"codex-sol","text":"Final isolated-Git-identity bun run release:check passed: 546/546 tests, zero failures/cancellations/skips/todo; 12 authored modules with exact 100/100/100/100 coverage (8597/8597 lines and statements, 211/211 functions, 2315/2315 branches), unchanged thresholds and inventories. All release-chain checks passed, including zero production vulnerabilities, packing, changelog and publish attestation. Bun orchestrates the release chain; its coverage runner invokes Node. Implementation, docs, tests and fixture hashes match the source validated by the npm gate. Manual public selection, both assertion-based revert proofs, and PM-linked 26/26 acceptance are complete. No blocker code changed. Claims will be released; the item remains in progress for PR review."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:57:04.892Z"}],"before_hash":"d7a37fad8d8c10a4da05dbbc4e0d420f7dfb09108873c2a41aab9a8e44b31e73","after_hash":"ab3c76776132c7cf9b8f8eb13ad1b22c0eb7b4062b5772ee043704b105150fa4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"0237b0135d617a4f601f98a2d1484de1baacd10a6ec608869a46289093d6a660"} +{"hash_algorithm":"sha256","ts":"2026-10-12T13:57:05.635Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"a50dada2dcd7f28358c36335","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-12T13:57:05.635Z"}],"before_hash":"ab3c76776132c7cf9b8f8eb13ad1b22c0eb7b4062b5772ee043704b105150fa4","after_hash":"82dc340afe1e9cd63408c75f5181c40cea1c652996e7dc814b5c055c5ba77b03","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"c742ab8ce87a919ebf11fef9207618916cb730ab5e491321bee487d796d9ce4d"} diff --git a/README.md b/README.md index d989cce..a8d7775 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ checks. ```bash pm github import unbraind/pm-cli +pm github import owner/repo --issue 189 +pm github import owner/repo --issue '189,#190,owner/repo#191' +pm github import owner/repo --limit 10 pm github import owner/repo --state all pm github import owner/repo --labels bug,enhancement pm github import owner/repo --since 2026-01-01T00:00:00Z # incremental sync @@ -79,6 +82,8 @@ pm github import owner/repo --dry-run | Flag | Type | Description | |---|---|---| +| `--issue ` | string | Fetch only selected issue endpoints; accepts numbers, `#N`, and same-repo `owner/repo#N` references. Conflicts with `--state`, `--all`, `--labels`, `--since`, `--assignee`, `--milestone`, and `--limit`. | +| `--limit ` | positive integer | Cap list-mode imports, newest-updated first. Returns `limit` and `truncated` (true when more matching issues exist). Invalid with `--issue`. | | `--all` | boolean | Include closed issues (shorthand for `--state all`) | | `--state ` | string | `open` \| `closed` \| `all` (default: open) | | `--labels ` | string | Comma-separated label filter | @@ -97,6 +102,10 @@ pm github import owner/repo --dry-run 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. +`--issue` deduplicates references and fetches each selected issue directly, including closed issues, without listing the repository. Every selected number is fetched and checked before any tracker write. A missing number fails the whole import; `--dry-run` instead reports `missingIssues` and previews the available selection. Pull request numbers require `--include-prs`. Selection combines with comment modes, dependency linking, atomic imports, gates, dry runs, and custom item types, preserving provenance-based re-import idempotency. + +`--limit` bounds the matching list after PR, draft, and milestone filters. Pagination stops once one extra matching issue establishes truncation, so at most `n + 1` eligible issues are retained; GitHub pages may also contain excluded entries. Without a limit, existing list ordering is unchanged. + `--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 5ffb892..e89bd32 100644 --- a/index.ts +++ b/index.ts @@ -155,6 +155,10 @@ const COMMENTS_MODES: readonly CommentsMode[] = ["body", "annotations", "both"]; * of the import path consumes a typed shape rather than re-parsing strings. */ export interface ImportOptions { + /** Explicit comma-separated issue numbers or same-repository references. */ + issue?: string; + /** Positive cap on list-mode issues, ordered by latest update. */ + limit?: number; /** GitHub issue state to fetch: `open`, `closed`, or `all`. */ state: "open" | "closed" | "all"; /** Comma-separated label filter applied server-side. */ @@ -1579,7 +1583,9 @@ export async function importGithubAtomic( // are honored server-side; `milestone` is filtered client-side (the REST API // keys milestones by number, not title). export function buildIssuesUrl(repo: string, opts: ImportOptions): string { - let url = `${githubApiBase()}/repos/${repo}/issues?state=${opts.state}&per_page=100`; + const pageSize = opts.limit === undefined ? 100 : Math.min(100, opts.limit + 1); + let url = `${githubApiBase()}/repos/${repo}/issues?state=${opts.state}&per_page=${pageSize}`; + if (opts.limit !== undefined) url += "&sort=updated&direction=desc"; if (opts.labels) url += `&labels=${encodeURIComponent(opts.labels)}`; if (opts.since) url += `&since=${encodeURIComponent(opts.since)}`; if (opts.assignee) url += `&assignee=${encodeURIComponent(opts.assignee)}`; @@ -1588,7 +1594,8 @@ export function buildIssuesUrl(repo: string, opts: ImportOptions): string { /** * Page through GitHub's issues REST endpoint, following the Link header, applying - * the import filters, and returning the full issue list. + * the import filters, and returning the issue list. Limited runs stop after + * retaining the cap plus one matching issue so callers can report truncation. * * @internal Exported only so the HTTP-boundary tests can drive the real client. * `stripInternal` keeps it out of the published `.d.ts`, so this is NOT a public API @@ -1608,12 +1615,56 @@ export async function fetchAllIssues(repo: string, opts: ImportOptions, token?: if (!Array.isArray(page)) { throw new Error("Unexpected GitHub API response (expected an array of issues)."); } - issues.push(...(page as GhIssue[])); + const fetched = page as GhIssue[]; + issues.push(...(opts.limit === undefined ? fetched : applyClientFilters(fetched, opts))); + // One extra eligible issue proves truncation without draining the repo. + if (opts.limit !== undefined && issues.length > opts.limit) return issues.slice(0, opts.limit + 1); nextUrl = parseNextLink(linkHeader); } return issues; } +/** Parse, deduplicate, and validate selected issue references before any fetch. */ +export function parseIssueNumbers(repo: string, input: string): number[] { + const numbers = new Set(); + for (const reference of input.split(",")) { + const match = /^(?:([^/#\s]+\/[^/#\s]+)#|#)?([1-9]\d*)$/.exec(reference.trim()); + if (!match || !Number.isSafeInteger(Number(match[2]))) { + throw new CommandError("--issue must contain positive issue numbers, #N, or owner/repo#N references.", EXIT_CODE.USAGE); + } + if (match[1] && match[1].toLowerCase() !== repo.toLowerCase()) { + throw new CommandError(`--issue reference ${reference.trim()} conflicts with repository ${repo}.`, EXIT_CODE.USAGE); + } + numbers.add(Number(match[2])); + } + return [...numbers]; +} + +/** Fetch only selected REST issue endpoints, refusing PRs and missing numbers before writes. */ +async function fetchSelectedIssues(repo: string, numbers: readonly number[], opts: ImportOptions, token?: string): Promise<{ issues: GhIssue[]; missingIssues: number[] }> { + const issues: GhIssue[] = []; + const missingIssues: number[] = []; + for (const number of numbers) { + let body: string; + try { + ({ body } = await fetchJSON(`${githubApiBase()}/repos/${repo}/issues/${number}`, token)); + } catch (err: unknown) { + if (!/HTTP 404/.test(errorMessage(err))) throw err; + if (!opts.dryRun) throw new Error(`GitHub issue ${repo}#${number} was not found (HTTP 404); no issues were imported.`); + missingIssues.push(number); + console.error(`[dry-run] Missing issue: ${repo}#${number} (HTTP 404).`); + continue; + } + const issue = JSON.parse(body) as GhIssue; + if (issue.number !== number) throw new Error(`Unexpected GitHub issue response for ${repo}#${number}.`); + if (issue.pull_request && !opts.includePrs) { + throw new Error(`${repo}#${number} is a pull request; use --include-prs to import it. No issues were imported.`); + } + issues.push(issue); + } + return { issues, missingIssues }; +} + /** * Fetch all review comments for a single issue or PR, paging through the Link header. * @@ -2295,6 +2346,21 @@ export function applyClientFilters(issues: GhIssue[], opts: ImportOptions): GhIs * @returns The normalized import options. */ export function parseImportOptions(options: Record): ImportOptions { + const issue = optionString(options, "issue"); + if (optionProvided(options, "issue")) { + if (!issue) throw new CommandError("--issue requires a non-empty issue list.", EXIT_CODE.USAGE); + for (const flag of ["state", "all", "labels", "since", "assignee", "milestone", "limit"]) { + if (optionProvided(options, flag)) throw new CommandError(`--issue conflicts with --${flag}.`, EXIT_CODE.USAGE); + } + } + let limit: number | undefined; + if (optionProvided(options, "limit")) { + const input = optionString(options, "limit"); + if (!input || !/^[1-9]\d*$/.test(input) || !Number.isSafeInteger(Number(input))) { + throw new CommandError("--limit must be a positive integer.", EXIT_CODE.USAGE); + } + limit = Number(input); + } // --state takes precedence; --all is the legacy shorthand for "all". const stateOpt = optionString(options, "state") as ImportOptions["state"] | undefined; const includeAll = optionEnabled(options, "all"); @@ -2333,6 +2399,8 @@ export function parseImportOptions(options: Record): ImportOpti commentsMode = "both"; } return { + issue, + limit, state, labels: optionString(options, "labels"), since, @@ -2999,11 +3067,13 @@ export async function runImport( "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] [--atomic]", + "[--comments-mode body|annotations|both] [--atomic] [--issue | --limit ] " + + "[--link-deps] [--gate] [--dry-run] [--type ]", EXIT_CODE.USAGE, ); } const repo = repoArg; + const selectedNumbers = opts.issue === undefined ? undefined : parseIssueNumbers(repo, opts.issue); const token = (dependencies.resolveToken ?? resolveGitHubToken)(); console.error( @@ -3011,8 +3081,13 @@ export async function runImport( ); let fetched: GhIssue[]; + let missingIssues: number[] = []; try { - fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token); + if (selectedNumbers) { + ({ issues: fetched, missingIssues } = await fetchSelectedIssues(repo, selectedNumbers, opts, token)); + } else { + fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token); + } } catch (err: unknown) { const msg = errorMessage(err); const hint = !token && /HTTP 403/.test(msg) @@ -3022,23 +3097,30 @@ export async function runImport( throw new CommandError(`Failed to fetch issues from ${repo}: ${msg}${hint}`, exitCode); } - const filtered = applyClientFilters(fetched, opts); + const eligible = applyClientFilters(fetched, opts); + const filtered = opts.limit === undefined ? eligible : eligible.slice(0, opts.limit); + const fetchFields = { + ...(opts.limit === undefined ? {} : { limit: opts.limit, truncated: eligible.length > opts.limit }), + ...(missingIssues.length === 0 ? {} : { missingIssues }), + }; + if (opts.limit !== undefined && eligible.length > opts.limit) console.error(`Issue list truncated at --limit ${opts.limit}.`); if (filtered.length === 0) { console.error("No issues found."); // Even a no-op gated import must prove the workspace is gateable (a Git work // tree the scanner can read), so the empty-change gate scan runs here too. const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmRoot) : undefined; - if (opts.atomic && opts.dryRun) { + if (opts.dryRun && (opts.atomic || missingIssues.length > 0)) { return { dryRun: true, wouldImport: 0, wouldUpdate: 0, wouldSkip: 0, - atomic: true, + ...(opts.atomic ? { atomic: true } : {}), + ...fetchFields, }; } - return { imported: 0, updated: 0, skipped: 0, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) }; + return { imported: 0, updated: 0, skipped: 0, ...fetchFields, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) }; } console.error(`Found ${filtered.length} issue(s).`); @@ -3131,6 +3213,7 @@ export async function runImport( wouldUpdate: 0, wouldSkip: skipped, atomic: true, + ...fetchFields, }; } throw new CommandError( @@ -3163,6 +3246,7 @@ export async function runImport( atomic: true, ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}), ...gatePlanFields, + ...fetchFields, }; } @@ -3215,6 +3299,7 @@ export async function runImport( skipped, atomic: true, ...depLinkResultFields(atomicDepLink), + ...fetchFields, ...(gateReceipt ? { gate: gateReceipt } : {}), }; } @@ -3400,6 +3485,7 @@ export async function runImport( wouldSkip: skipped, ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}), ...gatePlanFields, + ...fetchFields, }; } @@ -3426,7 +3512,7 @@ export async function runImport( // pushed; scan the proposed tracker change now and fail closed. if (opts.gate) verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), gatePlan!, repo); const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined; - return { imported, updated, skipped, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) }; + return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) }; } /** @@ -5219,6 +5305,8 @@ export function isMutatingGithubCommand(command: string, options: Record block.type === \"section\").text.text;\n}\nconst title = \"a\".repeat(2975) + \"\ud83d\ude00\" + \"b\".repeat(100);\nconst output = render(title);\nconsole.log({\n inputWellFormed: title.isWellFormed(),\n outputWellFormed: output.isWellFormed(),\n length: output.length,\n tail: JSON.stringify(output.slice(-8))\n});\nassert.equal(render(\"Short \ud83d\ude00 title\").isWellFormed(), true);\nassert.equal(render(\"a\".repeat(3100)).isWellFormed(), true);\nassert.equal(render(\"a\".repeat(3100)).length, 3000);\nassert.equal(output.isWellFormed(), true); // Fails today\n```\n\nActual: input is well formed; output is not; length is 3000; tail contains `aaaaaa\\ud83d\u2026`.\nExpected: clipping retains complete Unicode code points and stays within the existing size budget.\n\n## Impact and limits\n\nThe renderer itself demonstrably corrupts valid Unicode before delivery. This may produce replacement characters or downstream interoperability problems. No Slack request was sent, and this report does **not** claim a verified Slack API rejection.\n\nShort Unicode titles and long ASCII sections pass. The existing code-point-aware truncator in [pm-slack](https://github.com/unbraind/pm-slack/blob/389ae059a6beea07e2d08e2daa3d97d9066102b5/index.ts#L1018-L1044) also preserves well-formedness for the equivalent boundary case.\n\n## Ownership and prior decisions\n\nChecked all 91 canonical PM item documents at the pinned tree, plus all-state GitHub issue/PR searches for surrogate, Unicode and truncation. No existing owner describes this defect.\n\nThe closed [Block Kit schema decision pm-slack-standup-rpty](https://github.com/unbraind/pm-slack-standup/blob/864e6da7383e854830b4914f0225a7ff4bc613df/.agents/pm/decisions/pm-slack-standup-rpty.toon) intentionally keeps one bounded section per bucket. Preserve that behavior. The closed [formatter test task pm-slack-standup-uf4j](https://github.com/unbraind/pm-slack-standup/blob/864e6da7383e854830b4914f0225a7ff4bc613df/.agents/pm/tasks/pm-slack-standup-uf4j.toon) covered validity/truncation but does not establish this Unicode boundary invariant.\n\n## Acceptance criteria\n\n- Clip only at a complete code-point boundary while retaining the current length ceiling and ellipsis policy\n- Cover both ordinary and compact section rendering with non-BMP text at, before and after the clipping boundary\n- Retain the short-Unicode and long-ASCII positive controls\n- Run the regression against the built/packed public entry as well as source; do not require a Slack credential or live post\n", + "state": "open", + "state_reason": null, + "assignee": null, + "milestone": null, + "created_at": "2026-10-12T08:44:49Z", + "updated_at": "2026-10-12T08:44:49Z", + "closed_at": null, + "html_url": "https://github.com/unbraind/pm-slack-standup/issues/113", + "comments": 0, + "labels": [] +} diff --git a/test/fixtures/import-selection/pm-web-175-comments.json b/test/fixtures/import-selection/pm-web-175-comments.json new file mode 100644 index 0000000..051c94b --- /dev/null +++ b/test/fixtures/import-selection/pm-web-175-comments.json @@ -0,0 +1,8 @@ +[ + { + "id": 6105893043, + "created_at": "2026-10-11T05:37:03Z", + "body": "Merged main and resolved the SDK 2026.10.10 conflicts while retaining the newer deterministic canonical discovery and unpublished npm-link guard. The catalog now lists all 22 public fleet identities with verified package/manifest/README facts; native Rust has an explicit non-npm boundary. Added real built HTTP install, enable and command acceptance plus behavioral revert proofs, regenerated dist/changelog, and reconciled tracker histories without dropping either side. Focused checks pass. The full npm gate is running, and CI now runs both required release commands with isolated Git identity and unchanged thresholds. No PR merge or deployment was performed.", + "user": null + } +] diff --git a/test/fixtures/import-selection/pm-web-175.json b/test/fixtures/import-selection/pm-web-175.json new file mode 100644 index 0000000..68e3f55 --- /dev/null +++ b/test/fixtures/import-selection/pm-web-175.json @@ -0,0 +1,23 @@ +{ + "number": 175, + "title": "Complete fleet catalog and bind project commands to package ownership", + "body": "The Packages view needs a complete public fleet catalog and usable project-scoped extension commands. The previous catalog omitted the independent native Rust package and lacked complete command/documentation metadata. Its in-process argument adapter could also drop extension subcommands and flags, leaving an enabled Presets package unable to execute its documented command.", + "state": "closed", + "state_reason": null, + "assignee": null, + "milestone": null, + "created_at": "2026-10-07T22:19:25Z", + "updated_at": "2026-10-11T20:55:28Z", + "closed_at": "2026-10-11T20:55:26Z", + "html_url": "https://github.com/unbraind/pm-web/pull/175", + "comments": 20, + "pull_request": { + "url": "https://api.github.com/repos/unbraind/pm-web/pulls/175", + "html_url": "https://github.com/unbraind/pm-web/pull/175", + "diff_url": "https://github.com/unbraind/pm-web/pull/175.diff", + "patch_url": "https://github.com/unbraind/pm-web/pull/175.patch", + "merged_at": "2026-10-11T20:55:26Z" + }, + "draft": false, + "labels": [] +} diff --git a/test/fixtures/import-selection/pm-web-189.json b/test/fixtures/import-selection/pm-web-189.json new file mode 100644 index 0000000..4ec95d2 --- /dev/null +++ b/test/fixtures/import-selection/pm-web-189.json @@ -0,0 +1,15 @@ +{ + "number": 189, + "title": "Command ownership check misses colliding handlers after separate-value global flags", + "body": "## Summary\n\nThe new catalog runner can approve `pm-presets` ownership while the pinned CLI executes a different package's `presets list` handler. Separate-value global options cause the ownership check and real dispatch to resolve different command paths.\n\n## Exact scope and provenance\n\n- Source tested: `unbraind/pm-web@f5b90ac7bc8a8cf895af754a6fbfdb235dd438d7`, package version `2026.10.11`.\n- Actual installed dispatcher: official `@unbrained/pm-cli@2026.10.10`, matching Web's exact dependency; Node `24.19.0`.\n- [Affected ownership helper](https://github.com/unbraind/pm-web/blob/f5b90ac7bc8a8cf895af754a6fbfdb235dd438d7/src/services/pm-runner.ts#L1057-L1091) and [route](https://github.com/unbraind/pm-web/blob/f5b90ac7bc8a8cf895af754a6fbfdb235dd438d7/src/routes/extensions.ts#L254-L289).\n- Introduced by merged [#175](https://github.com/unbraind/pm-web/pull/175); related completed PM owner [pm-web-s99c](https://github.com/unbraind/pm-web/blob/f5b90ac7bc8a8cf895af754a6fbfdb235dd438d7/.agents/pm/issues/pm-web-s99c.toon).\n- This is a **current-main/source finding**. Registry `@unbrained/pm-web@2026.10.11` identifies `ff02cf5de9ce2ec13ea0d30d6114450560cd1861`, before #175. No deployed or published-package exposure is asserted.\n\n## Sanitized reproduction\n\nUse a disposable initialized tracker and script-disabled official CLI install. Install and activate two entirely synthetic extensions:\n\n1. `pm-presets`, priority 1, registers command `presets`, returning `{ok:true,owner:\"pm-presets\"}`.\n2. `scratch-shadow`, priority 1000, registers `presets list`, returning `{ok:true,owner:\"scratch-shadow\"}`.\n\nEach extension uses a normal manifest with `manifest_version:1`, `entry:\"./index.ts\"`, `capabilities:[\"commands\"]`, and `activate(api) { api.registerCommand({name,description:\"synthetic\",run(){return result}}) }`. Run `package install --project --json`, then `package activate --project --json`.\n\nObtain the actual `package describe --project --json` receipt. Execute the **unchanged** source `commandOwnershipError` with that receipt returned by its `runProcess` dependency and expected `{command:\"presets\",name:\"pm-presets\",args}`. Only the process transport is replaced; ownership rows come from real installed CLI execution. Compare with real CLI dispatch:\n\n| Supplied args after presets | Ownership helper | Actual CLI owner |\n|---|---|---|\n| `list --json` | Refuses scratch-shadow | scratch-shadow |\n| `--author synthetic list --json` | **null: allowed** | **scratch-shadow** |\n| `--output-format json list --json` | **null: allowed** | **scratch-shadow** |\n| `--author=synthetic list --json` | Refuses scratch-shadow | Not needed for the failing-case proof |\n\nExample passing-through route input: `{\"command\":\"pm presets\",\"args\":[\"--author\",\"synthetic\",\"list\",\"--json\"]}`.\n\nThe exact extracted TypeScript helper SHA-256 was `ae77965435324d7bf16273f86d580a193c75f3c747927f1bc3dbe19bdeb6668c`.\n\n## Expected and actual\n\nExpected: owner validation resolves the same effective command as the pinned dispatcher and refuses the conflicting `presets list` winner.\n\nActual: the helper skips tokens starting with `-` but appends their values to the positional path. It therefore checks `presets synthetic list` or `presets json list`, never the actual `presets list` row, then returns null. The CLI consumes recognized global option values and selects the shadow handler.\n\n## Impact and limits\n\nA project editor selecting a catalog package can execute another enabled package's colliding handler, breaking the new owner-binding contract. This does not establish privilege escalation or cross-project access.\n\nThis is a fresh exact-source-helper reproduction plus real pinned-CLI dispatch in synthetic workspaces. The complete Web HTTP/PostgreSQL suite and hosted deployment were **not** run. The existing [source/built collision acceptance](https://github.com/unbraind/pm-web/blob/f5b90ac7bc8a8cf895af754a6fbfdb235dd438d7/test/catalog-install-e2e.test.ts) covers plain positional extension but not separate-value global options.\n\n## Acceptance criteria\n\n- Normalize argv using the same command resolver/flag arity contract as the pinned CLI, preferably a reusable public SDK contract; fail closed when exact resolution is unavailable.\n- Extend real source and built HTTP collision acceptance with both separate-value reproductions. Require HTTP 409, no shadow execution marker, and unchanged settings.\n- Cover equivalent equals forms, flags before/after nested subcommands, boolean flags and terminators; preserve successful commands actually owned by the selected package.\n- Keep existing full release/coverage gates and report their exact tested source and publication boundary.\n\nAll-state issue/PR review and canonical owner checks found no separate existing follow-up for this flag-value divergence. This issue follows the completed #175 scope rather than duplicating the fleet-catalog feature.", + "state": "open", + "state_reason": null, + "assignee": null, + "milestone": null, + "created_at": "2026-10-12T08:47:05Z", + "updated_at": "2026-10-12T08:47:05Z", + "closed_at": null, + "html_url": "https://github.com/unbraind/pm-web/issues/189", + "comments": 0, + "labels": [] +} diff --git a/test/fixtures/import-selection/pm-web-96.json b/test/fixtures/import-selection/pm-web-96.json new file mode 100644 index 0000000..f970595 --- /dev/null +++ b/test/fixtures/import-selection/pm-web-96.json @@ -0,0 +1,15 @@ +{ + "number": 96, + "title": "Authorize reachable-history privacy remediation before release", + "state": "closed", + "state_reason": "completed", + "created_at": "2026-08-21T11:07:40Z", + "updated_at": "2026-08-27T04:12:54Z", + "closed_at": "2026-08-27T04:12:54Z", + "html_url": "https://github.com/unbraind/pm-web/issues/96", + "comments": 1, + "body": "## Measured boundary", + "assignee": null, + "milestone": null, + "labels": [] +} diff --git a/test/import-selection.test.ts b/test/import-selection.test.ts new file mode 100644 index 0000000..bea4847 --- /dev/null +++ b/test/import-selection.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, delimiter } from "node:path"; +import test from "node:test"; +import { comments as readComments } from "@unbrained/pm-cli/sdk"; +import { + CommandError, EXIT_CODE, fetchAllIssues, parseImportOptions, parseIssueNumbers, + readPmItems, runImport, type GhIssue, type GhComment, +} from "../index.ts"; +import { boundedCli } from "./helpers/bounded-cli.ts"; +import { captureStderr, jsonResponse, nextLinkHeader, withEnv, withMockGithub, type MockGithubHandler } from "./helpers/mock-github-server.ts"; + +const packageRoot = join(import.meta.dirname, ".."); +const cli = join(packageRoot, "node_modules/.bin/pm"); +const fixtureRoot = join(import.meta.dirname, "fixtures/import-selection"); +const web = JSON.parse(readFileSync(join(fixtureRoot, "pm-web-189.json"), "utf8")) as GhIssue; +const closed = JSON.parse(readFileSync(join(fixtureRoot, "pm-web-96.json"), "utf8")) as GhIssue; +const pr = JSON.parse(readFileSync(join(fixtureRoot, "pm-web-175.json"), "utf8")) as GhIssue; +const slack = JSON.parse(readFileSync(join(fixtureRoot, "pm-slack-standup-113.json"), "utf8")) as GhIssue; +const comments = JSON.parse(readFileSync(join(fixtureRoot, "pm-web-175-comments.json"), "utf8")) as GhComment[]; + +/** Run setup commands in a real disposable project with explicit tracker routing. */ +function setup(root: string, command: string, args: string[]): string { + const result = spawnSync(command, args, { cwd: root, encoding: "utf8", timeout: 45000, + env: { ...process.env, PM_PATH: join(root, ".agents/pm"), PM_AUTHOR: "selection-acceptance" } }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; +} + +/** Initialize and clean a real Git tracker, using the installed PM CLI and SDK. */ +async function tracker(fn: (root: string, pmRoot: string) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "pm-github-selection-")); + try { + setup(root, "git", ["init", "-q", "-b", "main"]); + setup(root, cli, ["--path", join(root, ".agents/pm"), "init", "selection-fixture"]); + return await withEnv({ PM_PATH: join(root, ".agents/pm"), PM_AUTHOR: "selection-acceptance", + GITHUB_TOKEN: "fixture", PATH: join(packageRoot, "node_modules/.bin") + delimiter + process.env.PATH }, + () => fn(root, join(root, ".agents/pm"))); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +/** Replay recorded issue endpoints; a list request is visible and returns extra issues. */ +const recorded: MockGithubHandler = (request, response) => { + const url = request.url ?? ""; + if (url === "/repos/unbraind/pm-web/issues/175/comments?per_page=100") { + jsonResponse(response, 200, comments); + return; + } + const match = /^\/repos\/unbraind\/(pm-web|pm-slack-standup)\/issues\/(\d+)$/.exec(url); + if (match) { + const number = Number(match[2]); + const issue = match[1] === "pm-slack-standup" ? (number === 113 ? slack : undefined) + : [web, closed, pr].find(value => value.number === number); + jsonResponse(response, issue ? 200 : 404, issue ?? { message: "Not Found" }); + return; + } + jsonResponse(response, 200, [web, closed, pr]); +}; + +/** Run the real import pipeline while retaining progress for assertions. */ +async function importing(pmRoot: string, options: Record, repo = "unbraind/pm-web") { + return captureStderr(async () => await runImport(repo, pmRoot, parseImportOptions(options)) as Record); +} + +for (const flag of ["state", "all", "labels", "since", "assignee", "milestone", "limit"]) { + test(`--issue refuses explicit --${flag} before requests or writes`, async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async server => { + await assert.rejects(() => importing(pmRoot, { issue: "189", [flag]: flag === "all" ? true : "1" }), + (error: unknown) => error instanceof CommandError && error.exitCode === EXIT_CODE.USAGE && error.message.includes(`--issue conflicts with --${flag}`)); + assert.equal(server.requests.length, 0); + assert.deepEqual(readPmItems(pmRoot), []); + }); + })); +} + +test("selection accepts numbers and same-repo references, deduplicates and validates the complete list", () => { + assert.deepEqual(parseIssueNumbers("unbraind/pm-web", "189,#96,UNBRAIND/PM-WEB#189"), [189, 96]); + for (const input of ["", "0", "-1", "1.2", "1e2", "#", "189,", "9007199254740992", "other/repo#189"]) { + assert.throws(() => parseIssueNumbers("unbraind/pm-web", input), CommandError); + } + for (const issue of ["", false, 189]) assert.throws(() => parseImportOptions({ issue }), /--issue requires/); + for (const limit of ["", "0", "-1", "1.5", "1e2", "Infinity", "9007199254740992", true]) { + assert.throws(() => parseImportOptions({ limit }), /--limit must be a positive integer/); + } + assert.equal(parseImportOptions({ limit: "1" }).limit, 1); +}); + +test("one recorded issue uses one REST endpoint and re-import updates its existing provenance item", async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async server => { + const first = await importing(pmRoot, { issue: "189,#189,unbraind/pm-web#189", type: "Task" }); + assert.equal(first.result.imported, 1); + const items = readPmItems(pmRoot); + assert.equal(items.length, 1); + assert.equal(items[0]!.title, web.title); + assert.equal((JSON.parse(setup(join(pmRoot, "../.."), cli, ["show", items[0]!.id!, "--json"])) as { item: { type: string } }).item.type, "Task"); + assert.ok(items[0]!.tags?.includes("gh:unbraind/pm-web#189")); + const repeat = await importing(pmRoot, { issue: "189" }); + assert.equal(repeat.result.imported, 0); + assert.equal(repeat.result.updated, 1); + assert.equal(readPmItems(pmRoot)[0]!.id, items[0]!.id); + assert.equal(readPmItems(pmRoot).length, 1); + assert.deepEqual(server.requests.map(request => request.url), Array(2).fill("/repos/unbraind/pm-web/issues/189")); + }); +})); + +test("recorded standup issue imports directly into a real tracker", async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async server => { + const { result } = await importing(pmRoot, { issue: "unbraind/pm-slack-standup#113" }, "unbraind/pm-slack-standup"); + assert.equal(result.imported, 1); + assert.equal(readPmItems(pmRoot)[0]!.body, slack.body!.trim()); + assert.deepEqual(server.requests.map(request => request.url), ["/repos/unbraind/pm-slack-standup/issues/113"]); + }); +})); + +test("explicit selection imports closed issues without a state filter", async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async server => { + const { result } = await importing(pmRoot, { issue: "189,#96" }); + assert.equal(result.imported, 2); + assert.equal(readPmItems(pmRoot).find(item => item.tags?.includes("gh:unbraind/pm-web#96"))!.status, "closed"); + assert.deepEqual(server.requests.map(request => request.url), ["/repos/unbraind/pm-web/issues/189", "/repos/unbraind/pm-web/issues/96"]); + }); +})); + +for (const atomic of [false, true]) { + test(`missing selection aborts the whole ${atomic ? "atomic" : "plain"} import before writes`, async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async () => { + await assert.rejects(() => importing(pmRoot, { issue: "189,999999", atomic }), + (error: unknown) => error instanceof CommandError && error.exitCode === EXIT_CODE.NOT_FOUND && /999999.*not found.*no issues were imported/i.test(error.message)); + assert.deepEqual(readPmItems(pmRoot), []); + }); + })); + test(`missing numbers are reported by a ${atomic ? "atomic" : "plain"} dry run with zero writes`, async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async () => { + const { result, stderr } = await importing(pmRoot, { issue: "999999,189", "dry-run": true, atomic }); + assert.equal(result.wouldImport, 1); + assert.deepEqual(result.missingIssues, [999999]); + assert.ok(stderr.some(line => line.includes("Missing issue: unbraind/pm-web#999999"))); + const onlyMissing = await importing(pmRoot, { issue: "999999", "dry-run": true, atomic }); + assert.equal(onlyMissing.result.dryRun, true); + assert.equal(onlyMissing.result.wouldImport, 0); + assert.deepEqual(onlyMissing.result.missingIssues, [999999]); + assert.deepEqual(readPmItems(pmRoot), []); + }); + })); +} + +test("a selected PR refuses the entire batch unless --include-prs is set", async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async () => { + await assert.rejects(() => importing(pmRoot, { issue: "189,175" }), /175 is a pull request; use --include-prs/); + assert.deepEqual(readPmItems(pmRoot), []); + const { result } = await importing(pmRoot, { issue: "175", "include-prs": true }); + assert.equal(result.imported, 1); + assert.equal(readPmItems(pmRoot)[0]!.status, "closed"); + }); +})); + +test("selection composes atomic, gate, comment modes and dependency linking with real SDK writes", async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async server => { + const options = { issue: "189,175", "include-prs": true, "with-comments": true, "comments-mode": "annotations", atomic: true, gate: true, "link-deps": true }; + const preview = await importing(pmRoot, { ...options, "dry-run": true }); + assert.equal(preview.result.wouldImport, 2); + assert.deepEqual(readPmItems(pmRoot), []); + const first = await importing(pmRoot, options); + assert.equal(first.result.imported, 2); + assert.equal((first.result.gate as { verdict: string }).verdict, "pass"); + assert.equal(first.result.linkedDependencies, 0); + const item = readPmItems(pmRoot).find(value => value.tags?.includes("gh:unbraind/pm-web#175"))!; + assert.ok(item.body?.includes(comments[0]!.body!)); + assert.equal((await readComments(item.id!, {}, { pmRoot })).comments.length, 1); + const repeat = await importing(pmRoot, options); + assert.equal(repeat.result.imported, 0); + assert.equal(readPmItems(pmRoot).length, 2); + assert.equal((await readComments(item.id!, {}, { pmRoot })).comments.length, 1); + assert.ok(server.requests.every(request => /\/issues\/(189|175)(\/comments\?per_page=100)?$/.test(request.url))); + }); +})); + +test("limit returns newest-updated first, truncates and stops before the remaining pages", async () => tracker(async (_root, pmRoot) => { + await withMockGithub((request, response, _body, base) => { + const page = new URL(request.url!, base).searchParams.get("page"); + jsonResponse(response, 200, page === "2" ? [closed] : [pr, web], { + Link: nextLinkHeader(base, `/repos/unbraind/pm-web/issues?page=${page === "2" ? "3" : "2"}`), + }); + }, async server => { + const { result } = await importing(pmRoot, { limit: "1", state: "all" }); + assert.equal(result.imported, 1); + assert.equal(result.limit, 1); + assert.equal(result.truncated, true); + assert.equal(readPmItems(pmRoot)[0]!.title, web.title); + assert.equal(server.requests.length, 2); + const query = new URL(server.requests[0]!.url, server.baseUrl).searchParams; + assert.equal(query.get("sort"), "updated"); + assert.equal(query.get("direction"), "desc"); + assert.equal(query.get("per_page"), "2"); + }); +})); + +for (const options of [{}, { atomic: true }, { "dry-run": true }, { atomic: true, "dry-run": true }]) { + test(`limit metadata survives ${JSON.stringify(options)} result paths`, async () => tracker(async (_root, pmRoot) => { + await withMockGithub(recorded, async server => { + const { result } = await importing(pmRoot, { ...options, limit: "1", state: "all" }); + assert.equal(result.limit, 1); + assert.equal(result.truncated, true); + assert.equal(server.requests.length, 1); + }); + })); +} + +test("exact cap, larger caps, empty lists and filtered lists report no truncation", async () => tracker(async (_root, pmRoot) => { + for (const issues of [[web], [], [pr]]) { + await withMockGithub((_request, response) => jsonResponse(response, 200, issues), async () => { + for (const atomic of [false, true]) { + const { result } = await importing(pmRoot, { limit: "1", "dry-run": true, atomic }); + assert.equal(result.truncated, false); + assert.equal(result.limit, 1); + } + assert.equal((await importing(pmRoot, { limit: "1000", "dry-run": true })).result.truncated, false); + }); + } + assert.deepEqual(readPmItems(pmRoot), []); +})); + +test("limit retains only its cap plus one probe even if a response contains a larger page", async () => { + await withMockGithub(recorded, async () => { + assert.equal((await fetchAllIssues("unbraind/pm-web", parseImportOptions({ limit: "1", state: "all", "include-prs": true }))).length, 2); + }); +}); + +test("selected transport and malformed response errors fail before writes", async () => tracker(async (_root, pmRoot) => { + for (const response of [{ status: 401, body: {} }, { status: 200, body: "invalid" }, { status: 200, body: { number: 96 } }]) { + await withMockGithub((_request, res) => jsonResponse(res, response.status, response.body), async () => { + await assert.rejects(() => importing(pmRoot, { issue: "189" }), /Failed to fetch issues/); + assert.deepEqual(readPmItems(pmRoot), []); + }); + } +})); + +test("built package dispatch advertises and honors selectors and limits through the real CLI", async () => tracker(async (root, pmRoot) => { + const packed = await boundedCli("npm", ["pack", "--ignore-scripts", "--foreground-scripts=false", "--json", "--pack-destination", root], packageRoot, + { ...process.env, PM_PATH: join(packageRoot, ".agents/pm") }); + assert.equal(packed.code, 0, packed.stderr); + const archive = (JSON.parse(packed.stdout) as [{ filename: string }])[0].filename; + setup(root, cli, ["package", "install", join(root, archive), "--project"]); + await withMockGithub(recorded, async server => { + const call = async (args: string[]) => boundedCli(cli, ["--path", pmRoot, "github", "import", ...args], root, process.env); + const help = await call(["--help"]); + assert.equal(help.code, 0, help.stderr); + assert.match(help.stdout, /--issue/); + assert.match(help.stdout, /--limit/); + const selected = await call(["unbraind/pm-web", "--issue", "189", "--dry-run", "--json"]); + assert.equal(selected.code, 0, selected.stderr); + assert.equal((JSON.parse(selected.stdout) as { wouldImport: number }).wouldImport, 1); + const limited = await call(["unbraind/pm-web", "--limit", "1", "--state", "all", "--dry-run", "--json"]); + assert.equal(limited.code, 0, limited.stderr); + assert.equal((JSON.parse(limited.stdout) as { truncated: boolean }).truncated, true); + assert.equal(server.requests.length, 2); + assert.equal(server.requests[0]!.url, "/repos/unbraind/pm-web/issues/189"); + assert.deepEqual(readPmItems(pmRoot), []); + }); +}));