pm-github 2026.10.15 → 2026.10.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,14 +13,15 @@
13
13
  //
14
14
  // Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2
15
15
  // section below and the pure plan/mapping logic in ./projects.ts).
16
- import { CloseoutUsageError, runCloseout } from "./closeout.js";
16
+ import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
17
+ import { buildImportFidelity, fetchImportTimeline, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, } from "./import-fidelity.js";
17
18
  import http from "node:http";
18
19
  import https from "node:https";
19
20
  import crypto from "node:crypto";
20
21
  import fs from "node:fs";
21
22
  import path from "node:path";
22
23
  import { spawnSync } from "node:child_process";
23
- import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
24
+ import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, resolvePmRoot as sdkResolvePmRoot, getHistoryPath as sdkGetHistoryPath, readHistorySnapshot as sdkReadHistorySnapshot, } from "@unbrained/pm-cli/sdk";
24
25
  import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
25
26
  import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
26
27
  export { resolvePmDataDir } from "./gate.js";
@@ -356,7 +357,7 @@ export function parseNextLink(linkHeader) {
356
357
  // Map a GitHub issue/PR state (+ optional stateReason) onto a pm status,
357
358
  // preserving `not_planned` closures as `canceled` rather than `closed`.
358
359
  export function mapState(state, stateReason) {
359
- if (state === "closed" && stateReason === "not_planned")
360
+ if (state === "closed" && (stateReason === "not_planned" || stateReason === "duplicate"))
360
361
  return "canceled";
361
362
  return state === "closed" ? "closed" : "open";
362
363
  }
@@ -718,6 +719,7 @@ export function decodeCompletePmItems(parsed) {
718
719
  }
719
720
  ids.add(item.id);
720
721
  items.push({
722
+ ...item,
721
723
  id: item.id,
722
724
  title,
723
725
  status,
@@ -924,8 +926,9 @@ async function resolveAtomicSdkFunctions(opts) {
924
926
  * Derive an order-independent transaction id from the desired import state and
925
927
  * exact ordered mutation plan. Content or target changes produce a fresh
926
928
  * transaction; a reordered retry of the same plan resumes the durable journal.
929
+ * External history revisions distinguish a local edit from a retry of import-owned writes.
927
930
  */
928
- export function deriveAtomicTransactionId(repo, entries, mutations) {
931
+ export function deriveAtomicTransactionId(repo, entries, mutations, contextVersions = []) {
929
932
  const canonical = [...entries]
930
933
  .sort((a, b) => a.issueNumber - b.issueNumber)
931
934
  .map((entry) => ({
@@ -938,8 +941,10 @@ export function deriveAtomicTransactionId(repo, entries, mutations) {
938
941
  tags: [...entry.tags].sort(),
939
942
  assignee: entry.assignee ?? null,
940
943
  milestone: entry.milestone ?? null,
944
+ fidelity: entry.fidelity ?? null,
945
+ linkOnly: entry.linkOnly ?? false,
941
946
  }));
942
- const digest = crypto
947
+ const payload = crypto
943
948
  .createHash("sha256")
944
949
  .update(repo.toLowerCase())
945
950
  .update("\x1f")
@@ -948,10 +953,10 @@ export function deriveAtomicTransactionId(repo, entries, mutations) {
948
953
  // Recovery requires the exact ordered step plan. Include targets and
949
954
  // options so a changed id_prefix or provenance match gets a fresh journal
950
955
  // instead of colliding with an incompatible prior attempt.
951
- .update(JSON.stringify(mutations))
952
- .digest("hex")
953
- .slice(0, 16);
954
- return `${ATOMIC_IMPORT_PREFIX}${digest}`;
956
+ .update(JSON.stringify(mutations));
957
+ if (contextVersions.length > 0)
958
+ payload.update("\x1f").update(JSON.stringify([...contextVersions].sort()));
959
+ return `${ATOMIC_IMPORT_PREFIX}${payload.digest("hex").slice(0, 16)}`;
955
960
  }
956
961
  /** Stable create id keyed by the external GitHub issue, never by fetch order. */
957
962
  export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId) {
@@ -964,6 +969,9 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
964
969
  }
965
970
  /** Map one rendered import entry to its reversible SDK mutation sequence. */
966
971
  export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId) {
972
+ if (entry.linkOnly && entry.match?.id) {
973
+ return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { addTags: entry.tags } }] };
974
+ }
967
975
  const sharedOptions = {
968
976
  title: entry.title,
969
977
  type: entry.itemType,
@@ -972,7 +980,14 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
972
980
  tags: entry.tags.join(","),
973
981
  ...(entry.assignee ? { assignee: entry.assignee } : {}),
974
982
  ...(entry.milestone ? { sprint: entry.milestone } : {}),
983
+ ...(entry.status === "canceled" ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
975
984
  };
985
+ // Explicit terminal reconciliation also handles canceled -> completed. The
986
+ // supported force option permits that source-authoritative repeat closure;
987
+ // finalization restores the real dates before the transaction commits.
988
+ const statusOptions = entry.status === "closed"
989
+ ? entry.fidelity ? { status: "closed", closeReason: entry.fidelity.github_close_reason, force: true } : {}
990
+ : { status: entry.status };
976
991
  const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
977
992
  // A missing match and a match at our deterministic external-key id use the
978
993
  // SAME create+update upsert plan. This is essential for crash recovery: if a
@@ -985,21 +1000,21 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
985
1000
  const mutations = [{
986
1001
  op: "create",
987
1002
  id: managedItemId,
988
- options: { ...sharedOptions, status: createStatus },
1003
+ options: { ...sharedOptions, status: createStatus, message: `pm-github import create ${repo.toLowerCase()}#${entry.issueNumber}` },
989
1004
  }, {
990
1005
  op: "update",
991
1006
  id: managedItemId,
992
1007
  options: {
993
1008
  ...sharedOptions,
994
- ...(entry.status !== "closed" ? { status: entry.status } : {}),
1009
+ ...statusOptions,
995
1010
  },
996
1011
  }];
997
- if (entry.status === "closed") {
1012
+ if (entry.status === "closed" && !entry.fidelity) {
998
1013
  mutations.push({
999
1014
  op: "close",
1000
1015
  id: managedItemId,
1001
1016
  reason: `GitHub issue #${entry.issueNumber} closed`,
1002
- // Preserve the source's real completion time instead of the import time.
1017
+ // Legacy callers can supply completion directly; full plans finalize both lifecycle dates.
1003
1018
  ...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}),
1004
1019
  });
1005
1020
  }
@@ -1009,30 +1024,43 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1009
1024
  };
1010
1025
  }
1011
1026
  const itemId = entry.match.id;
1012
- const updateOptions = { ...sharedOptions };
1013
- // close has a dedicated mutation so its reason is preserved. Every other
1014
- // transition (open/reopen/canceled) is safely reversible as part of update.
1015
- if (entry.status !== "closed") {
1016
- updateOptions.status = entry.status;
1017
- }
1027
+ const updateOptions = { ...sharedOptions, ...statusOptions };
1018
1028
  const mutations = [{
1019
1029
  op: "update",
1020
1030
  id: itemId,
1021
1031
  options: updateOptions,
1022
1032
  }];
1023
- if (entry.status === "closed") {
1033
+ if (entry.status === "closed" && !entry.fidelity) {
1024
1034
  mutations.push({
1025
1035
  op: "close",
1026
1036
  id: itemId,
1027
1037
  reason: `GitHub issue #${entry.issueNumber} closed`,
1028
- // Preserve the source's real completion time instead of the import time.
1038
+ // Legacy callers can supply completion directly; full plans finalize both lifecycle dates.
1029
1039
  ...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}),
1030
1040
  });
1031
1041
  }
1032
1042
  return { itemId, mutations };
1033
1043
  }
1044
+ /**
1045
+ * Bind atomic plans to the latest external edit or different lifecycle generation.
1046
+ * The importer's desired-state and compensation writes leave retries stable;
1047
+ * local edits and later state cycles invalidate completed journals.
1048
+ */
1049
+ async function importContextVersions(pmRoot, entries) {
1050
+ const versions = [];
1051
+ for (const entry of entries) {
1052
+ if (!entry.fidelity || !entry.match?.id)
1053
+ continue;
1054
+ const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
1055
+ const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
1056
+ if (context)
1057
+ versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
1058
+ }
1059
+ return versions;
1060
+ }
1034
1061
  /** Commit a complete issue-import batch under one crash-resumable transaction. */
1035
1062
  export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1063
+ pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1036
1064
  const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1037
1065
  let idPrefix = "pm-";
1038
1066
  try {
@@ -1054,7 +1082,15 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1054
1082
  itemIds.set(entry.issueNumber, planned.itemId);
1055
1083
  mutations.push(...planned.mutations);
1056
1084
  }
1057
- const transactionId = deriveAtomicTransactionId(repo, entries, mutations);
1085
+ const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
1086
+ /** Finalize before the SDK commits, and repair source metadata on recovered journals. */
1087
+ const finalize = async () => {
1088
+ for (const entry of entries) {
1089
+ if (entry.fidelity)
1090
+ await persistImportFidelity(pmRoot, itemIds.get(entry.issueNumber), entry.fidelity, Boolean(entry.linkOnly));
1091
+ }
1092
+ };
1093
+ let committed = false;
1058
1094
  try {
1059
1095
  const result = await commit({
1060
1096
  pmRoot,
@@ -1066,7 +1102,14 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1066
1102
  // restores every UPDATE and CLOSE step (covered by the mixed rollback
1067
1103
  // integration test below this implementation).
1068
1104
  createCompensation: "delete",
1105
+ onTransition: async (context) => {
1106
+ if (context.transition === "committing")
1107
+ await finalize();
1108
+ await opts.onTransition?.(context);
1109
+ },
1069
1110
  });
1111
+ committed = true;
1112
+ await finalize();
1070
1113
  // A recovered journal may include work applied by the interrupted process
1071
1114
  // as well as steps resumed now. The SDK intentionally returns the durable
1072
1115
  // final results, not a per-invocation delta, so create/update counts cannot
@@ -1083,6 +1126,8 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1083
1126
  }
1084
1127
  catch (err) {
1085
1128
  const msg = errorMessage(err);
1129
+ if (committed)
1130
+ throw new CommandError(`Atomic GitHub import has a committed journal but source metadata could not be finalized. Retry transaction ${transactionId}; nothing may be pushed. Underlying error: ${msg}`);
1086
1131
  if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
1087
1132
  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);
1088
1133
  }
@@ -2177,10 +2222,17 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
2177
2222
  * @param fetchIssueComments - Injectable comment fetcher (defaults to {@link fetchComments}).
2178
2223
  * @returns The prepared import entry, or `undefined` when the issue has no title.
2179
2224
  */
2180
- async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
2225
+ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments, fidelityContext) {
2181
2226
  const title = issue.title.trim();
2182
2227
  if (!title)
2183
2228
  return undefined;
2229
+ const identity = matchImportedIssue(fidelityContext.items, issue, match);
2230
+ match = identity.item;
2231
+ const timeline = await fidelityContext.timeline(issue, repo, token);
2232
+ const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
2233
+ const targetStatus = mapState(issue.state, issue.state_reason);
2234
+ const conflict = match?.status && match.status !== targetStatus && (["closed", "canceled"].includes(match.status) || issue.state === "closed")
2235
+ ? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
2184
2236
  const kind = issue.pull_request ? "PR" : "issue";
2185
2237
  const labels = issue.labels.map((label) => label.name).filter(Boolean);
2186
2238
  const tag = provenanceTag(repo, issue.number);
@@ -2206,19 +2258,21 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
2206
2258
  issueNumber: issue.number,
2207
2259
  title,
2208
2260
  itemType: opts.itemType,
2209
- status: mapState(issue.state, issue.state_reason),
2261
+ status: identity.linkOnly ? match.status : targetStatus,
2210
2262
  description: `GH ${kind} #${issue.number}: ${issue.html_url}` +
2211
2263
  (author ? ` · author @${author}` : "") +
2212
2264
  (issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
2213
- (issue.created_at ? ` · created ${issue.created_at}` : "") +
2214
- (issue.updated_at ? ` · updated ${issue.updated_at}` : ""),
2265
+ ` · created ${issue.created_at} · updated ${issue.updated_at}`,
2215
2266
  body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
2216
- tags: [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])],
2267
+ tags: [...new Set([...(identity.linkOnly ? match.tags : []), ...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])],
2217
2268
  assignee: issue.assignee?.login,
2218
2269
  milestone: issue.milestone?.title,
2219
2270
  comments,
2220
2271
  syncAnnotations,
2221
2272
  closedAt: issue.closed_at ?? undefined,
2273
+ fidelity,
2274
+ linkOnly: identity.linkOnly,
2275
+ identity: { via: identity.via, ...(conflict ? { conflict } : {}) },
2222
2276
  match,
2223
2277
  };
2224
2278
  }
@@ -2455,8 +2509,17 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2455
2509
  // reported "would import N, skip 0" where the real run performs updates for
2456
2510
  // already-linked issues. A preview that overstates creates reads as "this will
2457
2511
  // duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
2458
- const existingItems = (dependencies.readItems ?? readPmItems)(pmRoot);
2512
+ /** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
2513
+ const readImportItems = async () => {
2514
+ if (dependencies.readItems)
2515
+ return dependencies.readItems(pmRoot);
2516
+ const corpus = certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot, cwd: pmRoot, noExtensions: true }));
2517
+ return decodeCompletePmItems(corpus);
2518
+ };
2519
+ const existingItems = await readImportItems();
2459
2520
  const existing = indexByProvenance(existingItems);
2521
+ const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
2522
+ timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
2460
2523
  let imported = 0;
2461
2524
  let updated = 0;
2462
2525
  let skipped = 0;
@@ -2477,7 +2540,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2477
2540
  const planned = [];
2478
2541
  const skippedNumbers = [];
2479
2542
  for (const issue of filtered) {
2480
- const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2543
+ const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments, fidelityContext);
2481
2544
  if (!entry) {
2482
2545
  skippedNumbers.push(issue.number);
2483
2546
  continue;
@@ -2494,7 +2557,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2494
2557
  if (opts.gate && !opts.dryRun) {
2495
2558
  const preview = runCommandTrackerGate({ pmRoot, plannedItems: planned.map(entry => ({
2496
2559
  itemId: entry.match?.id ?? `github-issue-${entry.issueNumber}`,
2497
- fields: { title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
2560
+ fields: { ...entry.fidelity, title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
2498
2561
  assignee: entry.assignee, sprint: entry.milestone,
2499
2562
  comments: entry.syncAnnotations ? entry.comments.map(comment => ({
2500
2563
  author: comment.user?.login, text: comment.body, created_at: comment.created_at,
@@ -2507,7 +2570,21 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2507
2570
  console.error(`pm github gate: plan verified — ${gateCompleteness.planned} planned, ${gateCompleteness.skipped} skipped, ` +
2508
2571
  `${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.new_entries} new.`);
2509
2572
  }
2510
- const gatePlanFields = opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {};
2573
+ // Preview output omits unscanned PR-title quotes and private provider close text.
2574
+ const fidelityPlan = () => gatePlan.map(entry => {
2575
+ const source = entry.fidelity;
2576
+ const reason = source.github_close_reason.split(" (", 1)[0];
2577
+ return { issue: entry.issueNumber, item: entry.match?.id,
2578
+ action: entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
2579
+ ...source, github_close_reason: reason,
2580
+ created_at: entry.linkOnly ? entry.match.created_at : source.github_created_at,
2581
+ updated_at_policy: "sdk-mutation-clock; source in github_updated_at",
2582
+ closed_at: entry.linkOnly ? entry.match.closed_at : source.github_closed_at,
2583
+ close_reason: entry.linkOnly ? "preserved-provider-owner" : reason,
2584
+ release: entry.linkOnly ? "preserved-provider-owner" : source.github_release,
2585
+ identity: entry.identity };
2586
+ });
2587
+ const gatePlanFields = opts.gate ? { plan: fidelityPlan(), gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {};
2511
2588
  if (opts.atomic) {
2512
2589
  // The gated pipeline already prepared every entry (and counted its skips)
2513
2590
  // during the pre-mutation plan phase; reuse that plan verbatim so the write
@@ -2552,7 +2629,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2552
2629
  }
2553
2630
  const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
2554
2631
  if (opts.gate)
2555
- verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), prepared, repo);
2632
+ verifyImportedProvenance(await readImportItems(), prepared, repo);
2556
2633
  // Planned native comments are part of the verified plan: a contended lock,
2557
2634
  // a refused add, or a committed entry whose created id is missing from the
2558
2635
  // post-commit routing map all leave planned comments unwritten, so they are
@@ -2595,6 +2672,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2595
2672
  atomic: true,
2596
2673
  ...depLinkResultFields(atomicDepLink),
2597
2674
  ...fetchFields,
2675
+ ...(opts.gate ? { plan: fidelityPlan() } : {}),
2598
2676
  ...(gateReceipt ? { gate: gateReceipt } : {}),
2599
2677
  };
2600
2678
  }
@@ -2604,7 +2682,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2604
2682
  // the write phase cannot diverge from it.
2605
2683
  const prepared = gatePlanByNumber
2606
2684
  ? gatePlanByNumber.get(issue.number)
2607
- : await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2685
+ : await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments, fidelityContext);
2608
2686
  if (!prepared) {
2609
2687
  // A gated run counted its skips during the verified plan phase; only the
2610
2688
  // ungated path counts them here.
@@ -2613,22 +2691,19 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2613
2691
  continue;
2614
2692
  }
2615
2693
  const labels = issue.labels.map((l) => l.name).filter(Boolean);
2616
- const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, closedAt, } = prepared;
2694
+ const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, } = prepared;
2617
2695
  /**
2618
2696
  * Build the `pm close` argv for this issue's pm item.
2619
2697
  *
2620
2698
  * Two paths below close an item for the same reason — reconciling an
2621
2699
  * already-matched item whose upstream issue is now closed, and closing an
2622
2700
  * item that was just created `open` because pm-cli 2026.8.3 refuses a
2623
- * terminal `create --status closed`. Both must carry identical provenance
2624
- * (the reason) and identical completion evidence (GitHub's own `closed_at`,
2625
- * when it recorded one), so the argv is built once here rather than
2626
- * duplicated at each site where the two could silently drift apart.
2701
+ * terminal `create --status closed`. Both carry the same source reason.
2702
+ * Source completion is applied with source creation during finalization,
2703
+ * avoiding an intermediate completion date before the SDK creation date.
2627
2704
  */
2628
2705
  const githubCloseArgs = (id) => {
2629
- const args = ["--path", pmRoot, "close", id, "--reason", `GitHub issue #${issue.number} closed`];
2630
- if (closedAt)
2631
- args.push("--completed-at", closedAt);
2706
+ const args = ["--path", pmRoot, "close", id, "--reason", prepared.fidelity.github_close_reason];
2632
2707
  return args;
2633
2708
  };
2634
2709
  if (opts.dryRun) {
@@ -2641,6 +2716,15 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2641
2716
  imported++;
2642
2717
  continue;
2643
2718
  }
2719
+ if (prepared.linkOnly) {
2720
+ const ownerId = match.id;
2721
+ const linked = pmRun(["--path", pmRoot, "update", ownerId, "--add-tags", tags.join(",")]);
2722
+ if (!linked.ok)
2723
+ throw new CommandError(`Could not link GitHub issue #${issue.number} to its existing owner.`);
2724
+ await persistImportFidelity(pmRoot, ownerId, prepared.fidelity, true);
2725
+ updated++;
2726
+ continue;
2727
+ }
2644
2728
  if (match?.id) {
2645
2729
  // Idempotent update — never duplicate. Status transitions go through the
2646
2730
  // proper command (close requires a reason; reopen via update --status).
@@ -2663,17 +2747,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2663
2747
  continue;
2664
2748
  }
2665
2749
  // Reconcile status separately.
2750
+ if (status === "canceled" && match.status !== "canceled") {
2751
+ const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
2752
+ if (!cancel.ok)
2753
+ throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
2754
+ }
2666
2755
  if (status === "closed" && match.status !== "closed") {
2667
2756
  // Close through `pm close` so the reason is real provenance; pass the
2668
2757
  // source completion time as --completed-at when GitHub recorded one.
2669
- const close = pmRun(githubCloseArgs(match.id));
2758
+ const close = pmRun([...githubCloseArgs(match.id), ...(match.status === "canceled" ? ["--force"] : [])]);
2670
2759
  if (!close.ok) {
2671
2760
  console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
2672
2761
  skipped++;
2673
2762
  continue;
2674
2763
  }
2675
2764
  }
2676
- else if (status === "open" && match.status === "closed") {
2765
+ else if (status === "open" && (match.status === "closed" || match.status === "canceled")) {
2677
2766
  const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
2678
2767
  if (!reopen.ok) {
2679
2768
  console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
@@ -2684,6 +2773,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2684
2773
  if (syncAnnotations) {
2685
2774
  commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
2686
2775
  }
2776
+ await persistImportFidelity(pmRoot, match.id, prepared.fidelity, false);
2687
2777
  updated++;
2688
2778
  continue;
2689
2779
  }
@@ -2703,21 +2793,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2703
2793
  "--tags", tags.join(","),
2704
2794
  "--message", `Imported from GitHub #${issue.number}`,
2705
2795
  ];
2796
+ if (status === "canceled")
2797
+ createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
2706
2798
  if (assignee)
2707
2799
  createArgs.push("--assignee", assignee);
2708
2800
  if (milestone)
2709
2801
  createArgs.push("--sprint", milestone);
2710
2802
  // --json lets a following close (or annotations sync) address the freshly
2711
2803
  // created item by id without re-scanning the workspace.
2712
- if (mustClose || syncAnnotations)
2713
- createArgs.push("--json");
2804
+ createArgs.push("--json");
2714
2805
  const created = pmRun(createArgs);
2715
2806
  if (!created.ok) {
2716
2807
  console.error(`#${issue.number}: create failed — ${created.stderr}`);
2717
2808
  skipped++;
2718
2809
  continue;
2719
2810
  }
2720
- const createdId = (mustClose || syncAnnotations) ? parseCreatedItemId(created.stdout) : undefined;
2811
+ const createdId = parseCreatedItemId(created.stdout);
2721
2812
  if (mustClose) {
2722
2813
  if (createdId) {
2723
2814
  const close = pmRun(githubCloseArgs(createdId));
@@ -2749,6 +2840,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2749
2840
  commentWriteFailures += comments.length;
2750
2841
  }
2751
2842
  }
2843
+ if (!createdId)
2844
+ throw new CommandError(`Could not finalize GitHub issue #${issue.number}: created id missing.`);
2845
+ await persistImportFidelity(pmRoot, createdId, prepared.fidelity, false);
2752
2846
  imported++;
2753
2847
  }
2754
2848
  if (opts.dryRun) {
@@ -2784,9 +2878,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2784
2878
  // Gated pipeline, phase 2 — the writes are on disk but nothing is committed or
2785
2879
  // pushed; scan the proposed tracker change now and fail closed.
2786
2880
  if (opts.gate)
2787
- verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), gatePlan, repo);
2881
+ verifyImportedProvenance(await readImportItems(), gatePlan, repo);
2788
2882
  const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2789
- return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
2883
+ return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(opts.gate ? { plan: fidelityPlan() } : {}), ...(gateReceipt ? { gate: gateReceipt } : {}) };
2790
2884
  }
2791
2885
  /**
2792
2886
  * Emit the human-readable `--link-deps` summary to stderr.
@@ -4102,7 +4196,8 @@ function graphqlUrl() {
4102
4196
  // `errors` array, so we surface those explicitly. The combined user+org queries
4103
4197
  // below intentionally return a partial error for the wrong owner type while the
4104
4198
  // right one still resolves; we only throw when there is NO usable data.
4105
- async function githubGraphQL(token, query, variables) {
4199
+ /** Execute a typed GraphQL request for internal import closure evidence. @internal */
4200
+ export async function githubGraphQL(token, query, variables) {
4106
4201
  return (await githubGraphQLResponse(token, query, variables)).data;
4107
4202
  }
4108
4203
  /** Shared GraphQL client retaining partial errors for reference-level classification. */
@@ -4977,7 +5072,7 @@ const PROJECT_SYNC_FLAGS = [
4977
5072
  const defineExtension = (module) => module;
4978
5073
  export default defineExtension({
4979
5074
  name: "pm-github",
4980
- version: "2026.10.15",
5075
+ version: "2026.10.19",
4981
5076
  activate(api) {
4982
5077
  // -----------------------------------------------------------------------
4983
5078
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -4989,6 +5084,8 @@ export default defineExtension({
4989
5084
  { name: "github_author", type: "string", optional: true },
4990
5085
  { name: "github_created_at", type: "string", optional: true },
4991
5086
  { name: "github_updated_at", type: "string", optional: true },
5087
+ { name: "github_link_only", type: "boolean", optional: true },
5088
+ ...["github_closed_at", "github_state_reason", "github_close_reason", "github_release"].map(name => ({ name, type: "string", optional: true })),
4992
5089
  ]);
4993
5090
  // -----------------------------------------------------------------------
4994
5091
  // preflight — safe, local guard for mutating github commands. Scoped to
@@ -5311,9 +5408,11 @@ export default defineExtension({
5311
5408
  { long: "--repo", value_name: "owner/repo", description: "Repository (or PM_GITHUB_REPO)" },
5312
5409
  { long: "--pr", value_name: "n", repeatable: true, description: "Select a PR number (repeatable; conflicts with --since)" },
5313
5410
  { long: "--since", value_name: "ISO date", description: "Select PRs merged at or after this ISO date/time" },
5411
+ { long: "--required-checks", value_name: "contexts", description: "One required context per line when protection metadata is unavailable (spaces preserved; blank lines ignored)" },
5314
5412
  { long: "--skip", value_name: "id", repeatable: true, description: "Never close this item (repeatable)" },
5315
5413
  { long: "--apply", description: "Close eligible items (default: dry-run)" },
5316
5414
  { long: "--dry-run", description: "Preview only; overrides --apply" },
5415
+ { long: "--full", description: "Include every plan row and PR check (also available with --json)" },
5317
5416
  ],
5318
5417
  async run(ctx) {
5319
5418
  try {
@@ -5323,7 +5422,7 @@ export default defineExtension({
5323
5422
  });
5324
5423
  if (report.failed > 0)
5325
5424
  throw new CommandError(JSON.stringify({ ...report, error: "pm github closeout: some closes failed" }), EXIT_CODE.GENERIC_FAILURE);
5326
- return report;
5425
+ return ctx.global.json === true || ctx.options.full === true ? report : compactCloseoutReport(report);
5327
5426
  }
5328
5427
  catch (error) {
5329
5428
  // A plain Error would make the host re-run the handler (repeating GitHub reads and remaining closes).