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/CHANGELOG.md +36 -5
- package/README.md +201 -7
- package/dist/closeout.d.ts +5 -3
- package/dist/closeout.d.ts.map +1 -1
- package/dist/closeout.js +27 -28
- package/dist/closeout.js.map +1 -1
- package/dist/gate.d.ts +10 -5
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +151 -14
- package/dist/gate.js.map +1 -1
- package/dist/identity.d.ts +62 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +178 -0
- package/dist/identity.js.map +1 -0
- package/dist/import-fidelity.d.ts +28 -2
- package/dist/import-fidelity.d.ts.map +1 -1
- package/dist/import-fidelity.js +260 -28
- package/dist/import-fidelity.js.map +1 -1
- package/dist/index.d.ts +169 -76
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +389 -71
- package/dist/index.js.map +1 -1
- package/dist/refresh.d.ts +41 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +223 -0
- package/dist/refresh.js.map +1 -0
- package/dist/webhooks.d.ts +55 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +188 -0
- package/dist/webhooks.js.map +1 -0
- package/manifest.json +1 -1
- package/package.json +9 -6
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
|
-
|
|
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 = {
|
|
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
|
-
/**
|
|
1062
|
-
|
|
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
|
|
1065
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
2531
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
2908
|
+
...planFields,
|
|
2676
2909
|
...(gateReceipt ? { gate: gateReceipt } : {}),
|
|
2677
2910
|
};
|
|
2678
2911
|
}
|
|
2679
2912
|
for (const issue of filtered) {
|
|
2680
|
-
//
|
|
2681
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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), ...
|
|
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.
|
|
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({
|