pm-github 2026.10.19 → 2026.10.23

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
@@ -14,14 +14,19 @@
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
16
  import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
17
- import { buildImportFidelity, fetchImportTimeline, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, } from "./import-fidelity.js";
17
+ import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, validateImportLifetime, } from "./import-fidelity.js";
18
+ import { refreshChanges, runRefresh } from "./refresh.js";
19
+ import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
20
+ export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
21
+ import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
22
+ export { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
18
23
  import http from "node:http";
19
24
  import https from "node:https";
20
25
  import crypto from "node:crypto";
21
26
  import fs from "node:fs";
22
27
  import path from "node:path";
23
28
  import { spawnSync } from "node:child_process";
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";
29
+ import { createPmCliExpectedError, comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, resolvePmRoot as sdkResolvePmRoot, writeFileAtomic, getHistoryPath as sdkGetHistoryPath, readHistorySnapshot as sdkReadHistorySnapshot, } from "@unbrained/pm-cli/sdk";
25
30
  import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
26
31
  import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
27
32
  export { resolvePmDataDir } from "./gate.js";
@@ -110,7 +115,10 @@ export function githubApiBase() {
110
115
  "Requests carry the GitHub token, so a plaintext non-loopback base would leak it.");
111
116
  }
112
117
  // Strip a trailing slash so `${base}/repos/...` cannot produce `//repos/...`.
113
- return raw.replace(/\/+$/, "");
118
+ let end = raw.length;
119
+ while (raw[end - 1] === "/")
120
+ end--;
121
+ return raw.slice(0, end);
114
122
  }
115
123
  /**
116
124
  * One low-level HTTP request, with no retry or backoff.
@@ -968,9 +976,13 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
968
976
  return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
969
977
  }
970
978
  /** Map one rendered import entry to its reversible SDK mutation sequence. */
971
- export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId) {
979
+ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false) {
980
+ if (updateOnly && !entry.match?.id)
981
+ throw new CommandError("Refresh refuses an unlinked target; no item may be created.");
982
+ const changed = updateOnly ? new Set(refreshChanges(entry).map(change => change.field)) : undefined;
983
+ const audit = updateOnly ? { comment: [`Refreshed linked GitHub issue ${repo.toLowerCase()}#${entry.issueNumber}`] } : {};
972
984
  if (entry.linkOnly && entry.match?.id) {
973
- return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { addTags: entry.tags } }] };
985
+ return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? { addTags: entry.tags } : {}), ...audit } }] };
974
986
  }
975
987
  const sharedOptions = {
976
988
  title: entry.title,
@@ -995,7 +1007,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
995
1007
  // item, but commitItemMutations must still receive the original plan. The
996
1008
  // create step treats an existing stable id as already applied; update then
997
1009
  // makes later content-bearing transactions refresh the item normally.
998
- if (!entry.match?.id || entry.match.id === managedItemId) {
1010
+ if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
999
1011
  const createStatus = entry.status === "closed" ? "open" : entry.status;
1000
1012
  const mutations = [{
1001
1013
  op: "create",
@@ -1024,7 +1036,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1024
1036
  };
1025
1037
  }
1026
1038
  const itemId = entry.match.id;
1027
- const updateOptions = { ...sharedOptions, ...statusOptions };
1039
+ const updateOptions = {
1040
+ ...(updateOnly ? {
1041
+ ...(changed.has("title") ? { title: entry.title } : {}),
1042
+ ...(changed.has("tags") ? { tags: entry.tags.join(",") } : {}),
1043
+ ...(changed.has("status") && sharedOptions.closeReason !== undefined ? { closeReason: sharedOptions.closeReason } : {}),
1044
+ } : sharedOptions), ...(!changed || changed.has("status") ? statusOptions : {}), ...audit,
1045
+ };
1028
1046
  const mutations = [{
1029
1047
  op: "update",
1030
1048
  id: itemId,
@@ -1058,36 +1076,82 @@ async function importContextVersions(pmRoot, entries) {
1058
1076
  }
1059
1077
  return versions;
1060
1078
  }
1061
- /** Commit a complete issue-import batch under one crash-resumable transaction. */
1062
- export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1079
+ /** Durable SDK journals live per tracker under `transactions/sdk`. */
1080
+ function sdkJournalDir(pmRoot) {
1081
+ return path.join(pmRoot, "transactions", "sdk");
1082
+ }
1083
+ /** Persisted import plans live beside the SDK journals, under `transactions/pm-github`. */
1084
+ function importPlanDir(pmRoot) {
1085
+ return path.join(pmRoot, "transactions", "pm-github");
1086
+ }
1087
+ /** Extract the mutation target ids from journal step ids like `1-update-<item>-<hash>`. @internal */
1088
+ export function journalStepItemIds(stepIds) {
1089
+ const ids = new Set();
1090
+ for (const step of stepIds) {
1091
+ const id = typeof step === "string" ? /^\d+-(?:create|update|close|release)-(.+)-[0-9a-f]{12}$/.exec(step)?.[1] : undefined;
1092
+ if (id !== undefined)
1093
+ ids.add(id);
1094
+ }
1095
+ return [...ids];
1096
+ }
1097
+ /** List pending github-import journals without changing plans or tracker bytes. */
1098
+ export async function listUnfinishedGithubImports(pmRoot) {
1063
1099
  pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1064
- const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1065
- let idPrefix = "pm-";
1100
+ const unfinished = [];
1101
+ const journalNames = await fs.promises.readdir(sdkJournalDir(pmRoot)).catch((error) => {
1102
+ if (error.code === "ENOENT")
1103
+ return [];
1104
+ throw new CommandError("Refresh cannot inspect GitHub transaction journals.");
1105
+ });
1106
+ for (const name of journalNames) {
1107
+ if (!name.startsWith(ATOMIC_IMPORT_PREFIX) || !name.endsWith(".json"))
1108
+ continue;
1109
+ const transactionId = name.slice(0, -".json".length);
1110
+ let journal;
1111
+ try {
1112
+ journal = JSON.parse(await fs.promises.readFile(path.join(sdkJournalDir(pmRoot), name), "utf8"));
1113
+ if (!journal || typeof journal !== "object")
1114
+ throw new Error("Invalid journal");
1115
+ }
1116
+ catch {
1117
+ throw new CommandError(`Refresh cannot inspect GitHub transaction ${transactionId}: its durable journal is unreadable.`);
1118
+ }
1119
+ if (journal.status === "applying" || journal.status === "compensating") {
1120
+ unfinished.push({ transactionId, status: journal.status,
1121
+ itemIds: journalStepItemIds(Array.isArray(journal.stepIds) ? journal.stepIds : []) });
1122
+ }
1123
+ }
1124
+ return unfinished.sort((left, right) => left.transactionId.localeCompare(right.transactionId));
1125
+ }
1126
+ /** Persist the exact pre-commit plan so an interrupted journal can be replayed verbatim. */
1127
+ async function persistGithubImportPlan(pmRoot, record) {
1128
+ await fs.promises.mkdir(importPlanDir(pmRoot), { recursive: true });
1129
+ await writeFileAtomic(path.join(importPlanDir(pmRoot), `${record.transactionId}.json`), `${JSON.stringify(record, null, 2)}\n`);
1130
+ }
1131
+ /** Read one persisted plan; null when the tracker holds no plan for that transaction. */
1132
+ export async function readGithubImportPlan(pmRoot, transactionId) {
1133
+ if (!/^[a-zA-Z0-9._-]+$/.test(transactionId))
1134
+ return null;
1135
+ const file = path.join(importPlanDir(sdkResolvePmRoot(process.cwd(), pmRoot)), `${transactionId}.json`);
1066
1136
  try {
1067
- const settings = await readSettings(pmRoot);
1068
- if (settings?.id_prefix)
1069
- idPrefix = String(settings.id_prefix);
1137
+ return JSON.parse(await fs.promises.readFile(file, "utf8"));
1070
1138
  }
1071
1139
  catch {
1072
- // Match normal import resilience: an unreadable optional setting falls
1073
- // back to the canonical prefix; the mutation still validates the tracker.
1074
- }
1075
- const mutations = [];
1076
- const itemIds = new Map();
1077
- // The transaction journal fingerprints the ordered step plan. Canonicalize
1078
- // by the stable GitHub issue number so a retry whose API page/order changed
1079
- // supplies the exact same plan as well as the same transaction id.
1080
- for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
1081
- const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId);
1082
- itemIds.set(entry.issueNumber, planned.itemId);
1083
- mutations.push(...planned.mutations);
1140
+ return null;
1084
1141
  }
1085
- const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
1142
+ }
1143
+ /** Remove a persisted plan once its journal reached a durable terminal state. */
1144
+ async function removeGithubImportPlan(pmRoot, transactionId) {
1145
+ await fs.promises.rm(path.join(importPlanDir(pmRoot), `${transactionId}.json`), { force: true });
1146
+ }
1147
+ /** Finalize source metadata and commit one github-import transaction through the SDK. */
1148
+ async function commitGithubImportTransaction(config) {
1149
+ const { pmRoot, entries, mutations, transactionId, itemIds, opts, commit } = config;
1086
1150
  /** Finalize before the SDK commits, and repair source metadata on recovered journals. */
1087
1151
  const finalize = async () => {
1088
1152
  for (const entry of entries) {
1089
1153
  if (entry.fidelity)
1090
- await persistImportFidelity(pmRoot, itemIds.get(entry.issueNumber), entry.fidelity, Boolean(entry.linkOnly));
1154
+ await persistImportFidelity(pmRoot, itemIds.get(entry.issueNumber), entry.fidelity, Boolean(entry.linkOnly), opts.updateOnly);
1091
1155
  }
1092
1156
  };
1093
1157
  let committed = false;
@@ -1125,18 +1189,145 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1125
1189
  };
1126
1190
  }
1127
1191
  catch (err) {
1192
+ // Retain sensitive replay data only while the durable journal is resumable.
1193
+ // Apply this to fresh commits and recovery attempts, preserving the original
1194
+ // failure when journal inspection or cleanup itself is unavailable.
1195
+ if (opts.updateOnly) {
1196
+ const pending = await listUnfinishedGithubImports(pmRoot).catch(() => null);
1197
+ if (pending && !pending.some(journal => journal.transactionId === transactionId)) {
1198
+ await removeGithubImportPlan(pmRoot, transactionId).catch(() => { });
1199
+ }
1200
+ }
1128
1201
  const msg = errorMessage(err);
1202
+ const label = `${opts.updateOnly ? "Refresh" : "Atomic GitHub import"} transaction ${transactionId}`;
1129
1203
  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}`);
1204
+ throw new CommandError(`${label} has a committed journal but source metadata could not be finalized. Retry transaction ${transactionId}; nothing may be pushed. Underlying error: ${msg}`);
1131
1205
  if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
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);
1206
+ throw new CommandError(`${label} failed and compensation was incomplete. The tracker may contain partially applied state; retry to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1133
1207
  }
1134
1208
  if (err instanceof Error && err.name === "WorkspaceTransactionInterruptedError") {
1135
- throw new CommandError(`Atomic GitHub import was interrupted. Its durable journal is resumable; retry the same import to continue transaction ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1209
+ throw new CommandError(`${label} was interrupted. Its durable journal is resumable; retry to continue transaction ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1136
1210
  }
1137
- throw new CommandError(`Atomic GitHub import failed after the SDK completed its normal compensation path; no new partial committed state is expected. Transaction id: ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1211
+ throw new CommandError(`${label} failed after the SDK completed its normal compensation path; no new partial committed state is expected. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1138
1212
  }
1139
1213
  }
1214
+ /** Commit a complete issue-import batch under one crash-resumable transaction. */
1215
+ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1216
+ pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1217
+ const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1218
+ let idPrefix = "pm-";
1219
+ try {
1220
+ const settings = await readSettings(pmRoot);
1221
+ if (settings?.id_prefix)
1222
+ idPrefix = String(settings.id_prefix);
1223
+ }
1224
+ catch {
1225
+ // Match normal import resilience: an unreadable optional setting falls
1226
+ // back to the canonical prefix; the mutation still validates the tracker.
1227
+ }
1228
+ const mutations = [];
1229
+ const itemIds = new Map();
1230
+ // The transaction journal fingerprints the ordered step plan. Canonicalize
1231
+ // by the stable GitHub issue number so a retry whose API page/order changed
1232
+ // supplies the exact same plan as well as the same transaction id.
1233
+ for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
1234
+ const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly);
1235
+ itemIds.set(entry.issueNumber, planned.itemId);
1236
+ mutations.push(...planned.mutations);
1237
+ }
1238
+ const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
1239
+ // Update-only transactions derive their steps from the CURRENT item state,
1240
+ // so a retry after a partial apply would fingerprint a different plan and
1241
+ // orphan the journal behind a fresh transaction id. Persist the exact plan
1242
+ // before the SDK touches the tracker so an interrupted refresh can replay it.
1243
+ if (opts.updateOnly)
1244
+ for (const entry of entries)
1245
+ validateGithubImportFidelity(entry, repo, `GitHub transaction ${transactionId}`);
1246
+ const plan = opts.updateOnly ? { transactionId, repo: repo.toLowerCase(), entries, mutations } : undefined;
1247
+ if (plan)
1248
+ await persistGithubImportPlan(pmRoot, plan);
1249
+ const result = await commitGithubImportTransaction({ pmRoot, repo, entries, mutations, transactionId, itemIds, opts, commit });
1250
+ if (plan)
1251
+ await removeGithubImportPlan(pmRoot, transactionId);
1252
+ return result;
1253
+ }
1254
+ /** Validate every source-finalization input against its effective native lifecycle before writes. */
1255
+ export function validateGithubImportFidelity(entry, repo, context, items) {
1256
+ // Finalization reads source metadata outside the SDK mutation preview.
1257
+ // Validate its entire projection before ANY selected journal can commit.
1258
+ const fidelity = entry.fidelity;
1259
+ if (fidelity !== undefined) {
1260
+ const fields = ["github_url", "github_number", "github_state", "github_state_reason", "github_created_at",
1261
+ "github_updated_at", "github_closed_at", "github_close_reason", "github_release"];
1262
+ if (!fidelity || typeof fidelity !== "object" || Array.isArray(fidelity) ||
1263
+ Object.keys(fidelity).some(field => !fields.includes(field)) ||
1264
+ typeof fidelity.github_url !== "string" ||
1265
+ fidelity.github_url.toLowerCase() !== `https://github.com/${repo.toLowerCase()}/issues/${entry.issueNumber}` ||
1266
+ fidelity.github_number !== entry.issueNumber ||
1267
+ !["open", "closed"].includes(fidelity.github_state) ||
1268
+ typeof fidelity.github_created_at !== "string" || typeof fidelity.github_updated_at !== "string" ||
1269
+ [fidelity.github_closed_at, fidelity.github_close_reason, fidelity.github_release, fidelity.github_state_reason]
1270
+ .some(value => value !== null && typeof value !== "string")) {
1271
+ throw new CommandError(`${context}: its persisted fidelity is invalid.`);
1272
+ }
1273
+ try {
1274
+ validateImportLifetime({ number: entry.issueNumber, created_at: fidelity.github_created_at,
1275
+ updated_at: fidelity.github_updated_at, closed_at: fidelity.github_closed_at });
1276
+ }
1277
+ catch {
1278
+ throw new CommandError(`${context}: its persisted fidelity has an invalid source lifetime.`);
1279
+ }
1280
+ // Missing source closure dates preserve native completion metadata.
1281
+ // Check the effective lifecycle from the current read-only snapshot too.
1282
+ const current = items?.find(item => item.id === entry.match.id) ?? entry.match;
1283
+ const projected = { ...current, ...importFidelityFields(fidelity, Boolean(entry.linkOnly)) };
1284
+ if (projected.closed_at && Date.parse(String(projected.closed_at)) < Date.parse(String(projected.created_at))) {
1285
+ throw new CommandError(`${context}: its persisted fidelity conflicts with the native closure date.`);
1286
+ }
1287
+ }
1288
+ if (entry.linkOnly !== undefined && typeof entry.linkOnly !== "boolean") {
1289
+ throw new CommandError(`${context}: its persisted ownership is invalid.`);
1290
+ }
1291
+ }
1292
+ /** Validate a persisted refresh plan without writing or opening an SDK transaction. */
1293
+ export function validateGithubImportPlan(record, items) {
1294
+ if (typeof record?.transactionId !== "string" || !/^[a-zA-Z0-9._-]+$/.test(record.transactionId) ||
1295
+ typeof record.repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(record.repo) ||
1296
+ !Array.isArray(record.entries) || !record.entries.length || !Array.isArray(record.mutations) || !record.mutations.length) {
1297
+ throw new CommandError(`Refresh cannot replay unfinished GitHub transaction ${typeof record?.transactionId === "string" ? record.transactionId : "<unknown>"}: its persisted plan is invalid.`, EXIT_CODE.GENERIC_FAILURE);
1298
+ }
1299
+ const itemIds = new Map();
1300
+ for (const entry of record.entries) {
1301
+ if (typeof entry?.issueNumber !== "number" || typeof entry?.match?.id !== "string") {
1302
+ throw new CommandError(`Refresh cannot replay unfinished GitHub transaction ${record.transactionId}: its persisted plan has an unlinked target.`, EXIT_CODE.GENERIC_FAILURE);
1303
+ }
1304
+ validateGithubImportFidelity(entry, record.repo, `Refresh cannot replay unfinished GitHub transaction ${record.transactionId}`, items);
1305
+ itemIds.set(entry.issueNumber, entry.match.id);
1306
+ }
1307
+ if (record.mutations.some(mutation => mutation?.op !== "update" || ![...itemIds.values()].includes(mutation.id))) {
1308
+ throw new CommandError(`Refresh cannot replay unfinished GitHub transaction ${record.transactionId}: its persisted mutation targets are invalid.`);
1309
+ }
1310
+ return itemIds;
1311
+ }
1312
+ /**
1313
+ * Resume one interrupted github-import journal by replaying its persisted
1314
+ * original plan. The plan — never a re-derivation from current item state —
1315
+ * supplies the exact ordered mutations and the transaction id, because a
1316
+ * partial apply changes both the derived step set and the history context the
1317
+ * id is fingerprinted from. When the recorded plan cannot be trusted, the
1318
+ * caller fails closed instead of leaving the journal silently unfinished.
1319
+ */
1320
+ export async function resumeGithubImport(pmRoot, record, opts = {}) {
1321
+ pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1322
+ const itemIds = validateGithubImportPlan(record);
1323
+ const { commitItemMutations: commit } = await resolveAtomicSdkFunctions(opts);
1324
+ const result = await commitGithubImportTransaction({
1325
+ pmRoot, repo: record.repo, entries: record.entries, mutations: record.mutations,
1326
+ transactionId: record.transactionId, itemIds, opts: { ...opts, updateOnly: true }, commit,
1327
+ });
1328
+ await removeGithubImportPlan(pmRoot, record.transactionId);
1329
+ return result;
1330
+ }
1140
1331
  // ---------------------------------------------------------------------------
1141
1332
  // Shared import core (used by both the legacy command and the importer)
1142
1333
  // ---------------------------------------------------------------------------
@@ -1940,6 +2131,7 @@ export function parseImportOptions(options) {
1940
2131
  atomic: optionEnabled(options, "atomic"),
1941
2132
  linkDeps: optionEnabled(options, "link-deps", "linkDeps"),
1942
2133
  gate: optionEnabled(options, "gate"),
2134
+ adoptCited: optionEnabled(options, "adopt-cited", "adoptCited"),
1943
2135
  };
1944
2136
  }
1945
2137
  // Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
@@ -2222,11 +2414,11 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
2222
2414
  * @param fetchIssueComments - Injectable comment fetcher (defaults to {@link fetchComments}).
2223
2415
  * @returns The prepared import entry, or `undefined` when the issue has no title.
2224
2416
  */
2225
- async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments, fidelityContext) {
2417
+ export async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments, fidelityContext) {
2226
2418
  const title = issue.title.trim();
2227
2419
  if (!title)
2228
2420
  return undefined;
2229
- const identity = matchImportedIssue(fidelityContext.items, issue, match);
2421
+ const identity = matchImportedIssue(fidelityContext.items, issue, match, opts.adoptCited === true);
2230
2422
  match = identity.item;
2231
2423
  const timeline = await fidelityContext.timeline(issue, repo, token);
2232
2424
  const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
@@ -2264,7 +2456,7 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
2264
2456
  (issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
2265
2457
  ` · created ${issue.created_at} · updated ${issue.updated_at}`,
2266
2458
  body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
2267
- tags: [...new Set([...(identity.linkOnly ? match.tags : []), ...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])],
2459
+ tags: [...new Set([...(identity.linkOnly || identity.via === "citation" ? match.tags ?? [] : []), ...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])],
2268
2460
  assignee: issue.assignee?.login,
2269
2461
  milestone: issue.milestone?.title,
2270
2462
  comments,
@@ -2273,6 +2465,7 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
2273
2465
  fidelity,
2274
2466
  linkOnly: identity.linkOnly,
2275
2467
  identity: { via: identity.via, ...(conflict ? { conflict } : {}) },
2468
+ ...(identity.citation_candidates ? { citation_candidates: identity.citation_candidates } : {}),
2276
2469
  match,
2277
2470
  };
2278
2471
  }
@@ -2353,6 +2546,8 @@ export function verifyImportIdempotency(existing, prepared, repo) {
2353
2546
  for (const entry of prepared) {
2354
2547
  if (entry.match?.id) {
2355
2548
  matchedIds.push(entry.match.id);
2549
+ if (entry.identity?.via === "citation" && !entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
2550
+ untaggedNewEntries++;
2356
2551
  continue;
2357
2552
  }
2358
2553
  if (!entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
@@ -2365,8 +2560,9 @@ export function verifyImportIdempotency(existing, prepared, repo) {
2365
2560
  `${conflictingTargets} conflicting target(s). No item was written.`, EXIT_CODE.GENERIC_FAILURE);
2366
2561
  }
2367
2562
  return {
2563
+ adopted: prepared.filter(entry => entry.identity?.via === "citation").length,
2368
2564
  provenance_indexed: tagOwners.size,
2369
- matched_by_provenance: matchedIds.length,
2565
+ matched_by_provenance: prepared.filter(entry => entry.match?.id && entry.identity?.via !== "citation").length,
2370
2566
  new_entries: prepared.length - matchedIds.length,
2371
2567
  };
2372
2568
  }
@@ -2496,12 +2692,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2496
2692
  dryRun: true,
2497
2693
  wouldImport: 0,
2498
2694
  wouldUpdate: 0,
2695
+ wouldAdopt: 0,
2499
2696
  wouldSkip: 0,
2500
2697
  ...(opts.atomic ? { atomic: true } : {}),
2501
2698
  ...fetchFields,
2502
2699
  };
2503
2700
  }
2504
- return { imported: 0, updated: 0, skipped: 0, ...fetchFields, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2701
+ return { imported: 0, updated: 0, adopted: 0, skipped: 0, ...fetchFields, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2505
2702
  }
2506
2703
  console.error(`Found ${filtered.length} issue(s).`);
2507
2704
  // Build the idempotency index once up-front — including for a dry run. Skipping
@@ -2522,13 +2719,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2522
2719
  timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
2523
2720
  let imported = 0;
2524
2721
  let updated = 0;
2722
+ let adopted = 0;
2525
2723
  let skipped = 0;
2526
2724
  // Planned native comments the write phase could not land (contended lock,
2527
2725
  // refused add, unparsable created id). Ungated runs warn and continue; gated
2528
2726
  // runs add this to the plan-divergence refusal at the end.
2529
2727
  let commentWriteFailures = 0;
2530
- // Gated pipeline, phase 1 — plan BEFORE any mutation. The same prepare step
2531
- // the write paths use builds every plan entry up front, so completeness
2728
+ // Plan every import BEFORE any mutation, including ungated citation refusals.
2729
+ // The same prepare step builds every entry up front, so gated completeness
2532
2730
  // (every fetched issue accounted for exactly once) and idempotency (every
2533
2731
  // re-import lands on exactly one provenance-tagged item) are proven while
2534
2732
  // the tracker is still untouched. Both checks throw before any write.
@@ -2536,19 +2734,48 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2536
2734
  let gateCompleteness;
2537
2735
  let gateIdempotency;
2538
2736
  let gatePlanByNumber;
2539
- if (opts.gate || opts.atomic) {
2737
+ const citationCandidates = [];
2738
+ let citationReceipt = {};
2739
+ {
2540
2740
  const planned = [];
2541
2741
  const skippedNumbers = [];
2742
+ const ambiguous = [];
2542
2743
  for (const issue of filtered) {
2543
- const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments, fidelityContext);
2744
+ let entry;
2745
+ try {
2746
+ entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments, fidelityContext);
2747
+ }
2748
+ catch (error) {
2749
+ if (!(error instanceof AmbiguousCitationError))
2750
+ throw error;
2751
+ ambiguous.push(error.entry);
2752
+ for (const candidate of error.entry.citation_candidates ?? [])
2753
+ citationCandidates.push({ issue: issue.number, ...candidate });
2754
+ continue;
2755
+ }
2544
2756
  if (!entry) {
2545
2757
  skippedNumbers.push(issue.number);
2546
2758
  continue;
2547
2759
  }
2548
2760
  planned.push(entry);
2761
+ for (const candidate of entry.citation_candidates ?? [])
2762
+ citationCandidates.push({ issue: issue.number, ...candidate });
2763
+ }
2764
+ if (citationCandidates.length > 0)
2765
+ citationReceipt = { citation_candidates: citationCandidates };
2766
+ if (ambiguous.length > 0) {
2767
+ const refusal = { code: "ambiguous_citation", blocked: true, plan: ambiguous, ...citationReceipt };
2768
+ if (!opts.dryRun)
2769
+ throw new CommandError(JSON.stringify(refusal));
2770
+ for (const entry of ambiguous)
2771
+ console.error(`[dry-run] #${entry.issue} ambiguous_citation: ${entry.candidates.join(", ")}. ${entry.remedy}`);
2772
+ return { dryRun: true, wouldImport: 0, wouldUpdate: 0, wouldAdopt: 0, wouldSkip: skippedNumbers.length,
2773
+ ...(opts.atomic ? { atomic: true } : {}), ...refusal, ...fetchFields };
2549
2774
  }
2550
2775
  if (opts.gate) {
2551
2776
  gateCompleteness = verifyImportPlanCompleteness(filtered, planned, skippedNumbers);
2777
+ }
2778
+ if (opts.gate || planned.some(entry => entry.identity?.via === "citation")) {
2552
2779
  gateIdempotency = verifyImportIdempotency(existingItems, planned, repo);
2553
2780
  }
2554
2781
  gatePlan = planned;
@@ -2568,23 +2795,26 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2568
2795
  }
2569
2796
  if (opts.gate)
2570
2797
  console.error(`pm github gate: plan verified — ${gateCompleteness.planned} planned, ${gateCompleteness.skipped} skipped, ` +
2571
- `${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.new_entries} new.`);
2798
+ `${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.adopted} adopted, ${gateIdempotency.new_entries} new.`);
2572
2799
  }
2573
2800
  // Preview output omits unscanned PR-title quotes and private provider close text.
2574
2801
  const fidelityPlan = () => gatePlan.map(entry => {
2575
2802
  const source = entry.fidelity;
2576
2803
  const reason = source.github_close_reason.split(" (", 1)[0];
2577
2804
  return { issue: entry.issueNumber, item: entry.match?.id,
2578
- action: entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
2805
+ action: entry.identity?.via === "citation" ? "adopt" : entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
2579
2806
  ...source, github_close_reason: reason,
2580
2807
  created_at: entry.linkOnly ? entry.match.created_at : source.github_created_at,
2581
2808
  updated_at_policy: "sdk-mutation-clock; source in github_updated_at",
2582
2809
  closed_at: entry.linkOnly ? entry.match.closed_at : source.github_closed_at,
2583
2810
  close_reason: entry.linkOnly ? "preserved-provider-owner" : reason,
2584
2811
  release: entry.linkOnly ? "preserved-provider-owner" : source.github_release,
2585
- identity: entry.identity };
2812
+ identity: entry.identity,
2813
+ ...(entry.citation_candidates ? { citation_candidates: entry.citation_candidates } : {}) };
2586
2814
  });
2587
- const gatePlanFields = opts.gate ? { plan: fidelityPlan(), gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {};
2815
+ const adoptionCount = gatePlan.filter(entry => entry.identity?.via === "citation").length;
2816
+ const planFields = { ...(opts.gate || adoptionCount > 0 || citationCandidates.length > 0 ? { plan: fidelityPlan() } : {}), ...citationReceipt };
2817
+ const gatePlanFields = { ...planFields, ...(opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {}) };
2588
2818
  if (opts.atomic) {
2589
2819
  // The gated pipeline already prepared every entry (and counted its skips)
2590
2820
  // during the pre-mutation plan phase; reuse that plan verbatim so the write
@@ -2597,6 +2827,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2597
2827
  dryRun: true,
2598
2828
  wouldImport: 0,
2599
2829
  wouldUpdate: 0,
2830
+ wouldAdopt: 0,
2600
2831
  wouldSkip: skipped,
2601
2832
  atomic: true,
2602
2833
  ...fetchFields,
@@ -2606,12 +2837,12 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2606
2837
  }
2607
2838
  if (opts.dryRun) {
2608
2839
  imported = prepared.filter((entry) => !entry.match?.id).length;
2609
- updated = prepared.length - imported;
2840
+ updated = prepared.length - imported - adoptionCount;
2610
2841
  for (const entry of prepared) {
2611
- const action = entry.match?.id ? "update" : "import";
2842
+ const action = entry.identity?.via === "citation" ? "adopt" : entry.match?.id ? "update" : "import";
2612
2843
  console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${opts.gate ? "(gated title)" : entry.title} (${entry.status})`);
2613
2844
  }
2614
- console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`);
2845
+ console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}. Adopt ${adoptionCount}.`);
2615
2846
  if (opts.linkDeps) {
2616
2847
  console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
2617
2848
  `resolution + edge writes run only on a real import.`);
@@ -2620,6 +2851,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2620
2851
  dryRun: true,
2621
2852
  wouldImport: imported,
2622
2853
  wouldUpdate: updated,
2854
+ wouldAdopt: adoptionCount,
2623
2855
  wouldSkip: skipped,
2624
2856
  atomic: true,
2625
2857
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
@@ -2666,28 +2898,21 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2666
2898
  transactionId: result.transactionId,
2667
2899
  recovered: result.recovered,
2668
2900
  imported: result.imported,
2669
- updated: result.updated,
2901
+ updated: result.updated - (result.recovered ? 0 : adoptionCount),
2902
+ adopted: result.recovered ? 0 : adoptionCount,
2670
2903
  ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}),
2671
2904
  skipped,
2672
2905
  atomic: true,
2673
2906
  ...depLinkResultFields(atomicDepLink),
2674
2907
  ...fetchFields,
2675
- ...(opts.gate ? { plan: fidelityPlan() } : {}),
2908
+ ...planFields,
2676
2909
  ...(gateReceipt ? { gate: gateReceipt } : {}),
2677
2910
  };
2678
2911
  }
2679
2912
  for (const issue of filtered) {
2680
- // With --gate, the pre-mutation plan phase already prepared (or explicitly
2681
- // skipped) every issue; reuse the verified plan instead of re-preparing so
2682
- // the write phase cannot diverge from it.
2683
- const prepared = gatePlanByNumber
2684
- ? gatePlanByNumber.get(issue.number)
2685
- : await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments, fidelityContext);
2913
+ // Every import reuses the pre-mutation identity plan, including ungated runs.
2914
+ const prepared = gatePlanByNumber.get(issue.number);
2686
2915
  if (!prepared) {
2687
- // A gated run counted its skips during the verified plan phase; only the
2688
- // ungated path counts them here.
2689
- if (!gatePlanByNumber)
2690
- skipped++;
2691
2916
  continue;
2692
2917
  }
2693
2918
  const labels = issue.labels.map((l) => l.name).filter(Boolean);
@@ -2707,10 +2932,12 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2707
2932
  return args;
2708
2933
  };
2709
2934
  if (opts.dryRun) {
2710
- const action = match?.id ? "update" : "import";
2935
+ const action = prepared.identity?.via === "citation" ? "adopt" : match?.id ? "update" : "import";
2711
2936
  const metadata = !opts.gate && labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
2712
2937
  console.error(` [dry-run] #${issue.number} ${action}: ${opts.gate ? "(gated title)" : title} (${metadata})`);
2713
- if (match?.id)
2938
+ if (prepared.identity?.via === "citation")
2939
+ adopted++;
2940
+ else if (match?.id)
2714
2941
  updated++;
2715
2942
  else
2716
2943
  imported++;
@@ -2722,7 +2949,10 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2722
2949
  if (!linked.ok)
2723
2950
  throw new CommandError(`Could not link GitHub issue #${issue.number} to its existing owner.`);
2724
2951
  await persistImportFidelity(pmRoot, ownerId, prepared.fidelity, true);
2725
- updated++;
2952
+ if (prepared.identity?.via === "citation")
2953
+ adopted++;
2954
+ else
2955
+ updated++;
2726
2956
  continue;
2727
2957
  }
2728
2958
  if (match?.id) {
@@ -2774,7 +3004,10 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2774
3004
  commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
2775
3005
  }
2776
3006
  await persistImportFidelity(pmRoot, match.id, prepared.fidelity, false);
2777
- updated++;
3007
+ if (prepared.identity?.via === "citation")
3008
+ adopted++;
3009
+ else
3010
+ updated++;
2778
3011
  continue;
2779
3012
  }
2780
3013
  // A closed upstream issue cannot be born closed: governance
@@ -2846,7 +3079,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2846
3079
  imported++;
2847
3080
  }
2848
3081
  if (opts.dryRun) {
2849
- console.error(`[dry-run] Would import ${imported}, update ${updated}, skip ${skipped}.`);
3082
+ console.error(`[dry-run] Would import ${imported}, update ${updated}, skip ${skipped}. Adopt ${adopted}.`);
2850
3083
  if (opts.linkDeps) {
2851
3084
  console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
2852
3085
  `resolution + edge writes run only on a real import.`);
@@ -2855,14 +3088,15 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2855
3088
  dryRun: true,
2856
3089
  wouldImport: imported,
2857
3090
  wouldUpdate: updated,
3091
+ wouldAdopt: adopted,
2858
3092
  wouldSkip: skipped,
2859
3093
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
2860
3094
  ...gatePlanFields,
2861
3095
  ...fetchFields,
2862
3096
  };
2863
3097
  }
2864
- console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
2865
- if (imported === 0 && updated === 0 && skipped > 0) {
3098
+ console.error(`Imported ${imported} new, updated ${updated} existing, adopted ${adopted}, skipped ${skipped}.`);
3099
+ if (imported === 0 && updated === 0 && adopted === 0 && skipped > 0) {
2866
3100
  throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
2867
3101
  }
2868
3102
  // A gated import is all-or-nothing in effect: a planned write that failed
@@ -2880,7 +3114,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2880
3114
  if (opts.gate)
2881
3115
  verifyImportedProvenance(await readImportItems(), gatePlan, repo);
2882
3116
  const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2883
- return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(opts.gate ? { plan: fidelityPlan() } : {}), ...(gateReceipt ? { gate: gateReceipt } : {}) };
3117
+ return { imported, updated, adopted, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...planFields, ...(gateReceipt ? { gate: gateReceipt } : {}) };
2884
3118
  }
2885
3119
  /**
2886
3120
  * Emit the human-readable `--link-deps` summary to stderr.
@@ -4201,7 +4435,7 @@ export async function githubGraphQL(token, query, variables) {
4201
4435
  return (await githubGraphQLResponse(token, query, variables)).data;
4202
4436
  }
4203
4437
  /** Shared GraphQL client retaining partial errors for reference-level classification. */
4204
- async function githubGraphQLResponse(token, query, variables) {
4438
+ export async function githubGraphQLResponse(token, query, variables) {
4205
4439
  if (!token) {
4206
4440
  throw new CommandError("GitHub GraphQL requires a token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
4207
4441
  }
@@ -4971,7 +5205,7 @@ async function runProjectSync(ctx) {
4971
5205
  export function isMutatingGithubCommand(command, options) {
4972
5206
  const cmd = (command || "").toLowerCase();
4973
5207
  const dryRun = optionEnabled(options, "dry-run", "dryRun");
4974
- if (cmd === "github sync")
5208
+ if (cmd === "github sync" || cmd === "github refresh")
4975
5209
  return !dryRun;
4976
5210
  // Export is dry-run by default; it only mutates with an explicit apply
4977
5211
  // (--apply / --no-dry-run / legacy --push) AND no --dry-run override.
@@ -4991,6 +5225,7 @@ export function isMutatingGithubCommand(command, options) {
4991
5225
  // Extension
4992
5226
  // ---------------------------------------------------------------------------
4993
5227
  const IMPORT_FLAGS = [
5228
+ { long: "--adopt-cited", description: "Opt in to normalized, anchored citation adoption (default: off). Tag items gh:owner/repo#N when creating them; default imports only report citation candidates" },
4994
5229
  { long: "--issue", value_name: "N[,N...]", description: "Fetch exactly these issue numbers, #N, or same-repo owner/repo#N references; conflicts with --state/--all/--labels/--since/--assignee/--milestone/--limit. Missing numbers fail before writes (reported by --dry-run); PRs require --include-prs" },
4995
5230
  { long: "--limit", value_name: "n", description: "Positive list-mode issue cap, newest-updated first; reports limit and truncated when more exist (one extra matching issue is fetched to check). Invalid with --issue" },
4996
5231
  { long: "--all", description: "Include closed issues (shorthand for --state all)" },
@@ -5072,7 +5307,7 @@ const PROJECT_SYNC_FLAGS = [
5072
5307
  const defineExtension = (module) => module;
5073
5308
  export default defineExtension({
5074
5309
  name: "pm-github",
5075
- version: "2026.10.19",
5310
+ version: "2026.10.23",
5076
5311
  activate(api) {
5077
5312
  // -----------------------------------------------------------------------
5078
5313
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -5103,6 +5338,7 @@ export default defineExtension({
5103
5338
  api.registerPreflight({
5104
5339
  commands: [
5105
5340
  "github sync",
5341
+ "github refresh",
5106
5342
  "github export",
5107
5343
  "github import",
5108
5344
  "gh-issues import",
@@ -5284,6 +5520,88 @@ export default defineExtension({
5284
5520
  },
5285
5521
  });
5286
5522
  // -----------------------------------------------------------------------
5523
+ // Refresh uses the import planner and its guarded update-only transaction path.
5524
+ api.registerCommand({
5525
+ name: "github identity",
5526
+ description: "Explore exact identity collisions and copied mentions offline. Defaults to a bounded read-only graph; --owner and --detach preview one repair, --approve applies its exact digest.",
5527
+ flags: [
5528
+ { long: "--identity", value_name: "ref", description: "Exact gh:owner/repo#N or external_ref to inspect" },
5529
+ { long: "--limit", value_name: "N", description: "Identity groups per page, 1..100 (default 20)" },
5530
+ { long: "--offset", value_name: "N", description: "Zero-based identity page offset" },
5531
+ { long: "--owner", value_name: "id", description: "Independently declared rightful owner" },
5532
+ { long: "--detach", value_name: "id", description: "One duplicate whose GitHub tag may be removed" },
5533
+ { long: "--approve", value_name: "digest", description: "Apply the exact reviewed preview digest" },
5534
+ ],
5535
+ async run(ctx) {
5536
+ const options = ctx.options;
5537
+ if (options.owner !== undefined || options.detach !== undefined || options.approve !== undefined) {
5538
+ if (typeof options.identity !== "string" || typeof options.owner !== "string" || typeof options.detach !== "string" ||
5539
+ (options.approve !== undefined && typeof options.approve !== "string")) {
5540
+ throw createPmCliExpectedError("Supply --identity, --owner and --detach together; --approve requires the preview digest.", { context: { code: "invalid_options" } });
5541
+ }
5542
+ return options.approve === undefined
5543
+ ? planGithubIdentityRepair(ctx.pm_root, options.identity, options.owner, options.detach)
5544
+ : applyGithubIdentityRepair(ctx.pm_root, { identity: options.identity, owner: options.owner, detach: options.detach, approval: options.approve, author: process.env.PM_AUTHOR ?? "pm-github" });
5545
+ }
5546
+ return exploreGithubIdentities(ctx.pm_root, {
5547
+ limit: options.limit === undefined ? undefined : Number(options.limit),
5548
+ offset: options.offset === undefined ? undefined : Number(options.offset),
5549
+ identity: typeof options.identity === "string" ? options.identity : undefined,
5550
+ });
5551
+ },
5552
+ });
5553
+ api.registerCommand({
5554
+ name: "github refresh",
5555
+ description: "Refresh existing linked items from GitHub without importing new issues.",
5556
+ intent: "keep a selected GitHub mirror current",
5557
+ arguments: [{ name: "owner/repo", required: true, description: "Linked GitHub repository" }],
5558
+ flags: [
5559
+ { long: "--dry-run", description: "Preview linked-item field changes without writing the tracker" },
5560
+ { long: "--since", value_name: "date|relative", description: "Only apply issues updated since this date or relative duration" },
5561
+ { long: "--limit", value_name: "N", description: "Select at most N linked issue numbers, in ascending order, before fetching" },
5562
+ { long: "--atomic", description: "Commit all changed linked items in one workspace writer transaction; an interrupted transaction is replayed from its persisted plan on the next refresh before unchanged items are skipped" },
5563
+ ],
5564
+ async run(ctx) {
5565
+ return runRefresh(ctx.args[0], ctx.pm_root, ctx.options);
5566
+ },
5567
+ });
5568
+ api.registerCommand({
5569
+ name: "github webhook receive",
5570
+ description: "Verify and durably deduplicate an explicitly supplied issues webhook. Reads PM_GITHUB_WEBHOOK_SECRET; never stores payloads or changes items.",
5571
+ arguments: [{ name: "owner/repo", required: true, description: "Expected repository boundary" }],
5572
+ flags: [
5573
+ { long: "--payload", value_name: "file", description: "File containing the exact original request body bytes" },
5574
+ { long: "--delivery", value_name: "id", description: "Original X-GitHub-Delivery header" },
5575
+ { long: "--event", value_name: "type", description: "Original X-GitHub-Event header (issues)" },
5576
+ { long: "--signature", value_name: "sha256=...", description: "Original X-Hub-Signature-256 header" },
5577
+ ],
5578
+ async run(ctx) {
5579
+ const { payload, delivery, event, signature } = ctx.options;
5580
+ if (!ctx.args[0] || typeof payload !== "string" || typeof delivery !== "string" || typeof event !== "string" || typeof signature !== "string") {
5581
+ throw new WebhookRefusal("invalid_options", "Supply owner/repo and --payload, --delivery, --event, --signature; set PM_GITHUB_WEBHOOK_SECRET securely.");
5582
+ }
5583
+ let bytes;
5584
+ try {
5585
+ if (fs.statSync(payload).size > 25 * 1024 * 1024)
5586
+ throw new Error("oversized");
5587
+ bytes = fs.readFileSync(payload);
5588
+ }
5589
+ catch {
5590
+ throw new WebhookRefusal("invalid_event", "Supply a readable original payload file of at most 25 MiB.");
5591
+ }
5592
+ return acceptGithubWebhook(ctx.pm_root, { repo: ctx.args[0], delivery, event, signature,
5593
+ payload: bytes, secret: process.env.PM_GITHUB_WEBHOOK_SECRET ?? "" });
5594
+ },
5595
+ });
5596
+ api.registerCommand({
5597
+ name: "github webhook reconcile",
5598
+ description: "Reconcile pending deliveries against current GitHub state into compact proposals, without changing items. Poll with github refresh for apply or recovery.",
5599
+ arguments: [{ name: "owner/repo", required: true, description: "Expected repository boundary" }],
5600
+ flags: [{ long: "--limit", value_name: "N", description: "Maximum deliveries per batch, 1 to 100 (default 20)" }],
5601
+ async run(ctx) {
5602
+ return reconcileGithubWebhooks(ctx.pm_root, ctx.args[0] ?? "", ctx.options.limit === undefined ? 20 : Number(ctx.options.limit));
5603
+ },
5604
+ });
5287
5605
  // command — legacy `pm gh-issues import <owner/repo>` (delegates to core)
5288
5606
  // -----------------------------------------------------------------------
5289
5607
  api.registerCommand({