pm-github 2026.10.16 → 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 +45 -0
- package/README.md +419 -39
- package/dist/closeout.d.ts +11 -8
- package/dist/closeout.d.ts.map +1 -1
- package/dist/closeout.js +95 -21
- 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 +68 -0
- package/dist/import-fidelity.d.ts.map +1 -0
- package/dist/import-fidelity.js +467 -0
- package/dist/import-fidelity.js.map +1 -0
- package/dist/index.d.ts +258 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +518 -102
- 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,13 +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 { 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";
|
|
17
23
|
import http from "node:http";
|
|
18
24
|
import https from "node:https";
|
|
19
25
|
import crypto from "node:crypto";
|
|
20
26
|
import fs from "node:fs";
|
|
21
27
|
import path from "node:path";
|
|
22
28
|
import { spawnSync } from "node:child_process";
|
|
23
|
-
import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
|
|
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";
|
|
24
30
|
import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
|
|
25
31
|
import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
|
|
26
32
|
export { resolvePmDataDir } from "./gate.js";
|
|
@@ -109,7 +115,10 @@ export function githubApiBase() {
|
|
|
109
115
|
"Requests carry the GitHub token, so a plaintext non-loopback base would leak it.");
|
|
110
116
|
}
|
|
111
117
|
// Strip a trailing slash so `${base}/repos/...` cannot produce `//repos/...`.
|
|
112
|
-
|
|
118
|
+
let end = raw.length;
|
|
119
|
+
while (raw[end - 1] === "/")
|
|
120
|
+
end--;
|
|
121
|
+
return raw.slice(0, end);
|
|
113
122
|
}
|
|
114
123
|
/**
|
|
115
124
|
* One low-level HTTP request, with no retry or backoff.
|
|
@@ -356,7 +365,7 @@ export function parseNextLink(linkHeader) {
|
|
|
356
365
|
// Map a GitHub issue/PR state (+ optional stateReason) onto a pm status,
|
|
357
366
|
// preserving `not_planned` closures as `canceled` rather than `closed`.
|
|
358
367
|
export function mapState(state, stateReason) {
|
|
359
|
-
if (state === "closed" && stateReason === "not_planned")
|
|
368
|
+
if (state === "closed" && (stateReason === "not_planned" || stateReason === "duplicate"))
|
|
360
369
|
return "canceled";
|
|
361
370
|
return state === "closed" ? "closed" : "open";
|
|
362
371
|
}
|
|
@@ -718,6 +727,7 @@ export function decodeCompletePmItems(parsed) {
|
|
|
718
727
|
}
|
|
719
728
|
ids.add(item.id);
|
|
720
729
|
items.push({
|
|
730
|
+
...item,
|
|
721
731
|
id: item.id,
|
|
722
732
|
title,
|
|
723
733
|
status,
|
|
@@ -924,8 +934,9 @@ async function resolveAtomicSdkFunctions(opts) {
|
|
|
924
934
|
* Derive an order-independent transaction id from the desired import state and
|
|
925
935
|
* exact ordered mutation plan. Content or target changes produce a fresh
|
|
926
936
|
* transaction; a reordered retry of the same plan resumes the durable journal.
|
|
937
|
+
* External history revisions distinguish a local edit from a retry of import-owned writes.
|
|
927
938
|
*/
|
|
928
|
-
export function deriveAtomicTransactionId(repo, entries, mutations) {
|
|
939
|
+
export function deriveAtomicTransactionId(repo, entries, mutations, contextVersions = []) {
|
|
929
940
|
const canonical = [...entries]
|
|
930
941
|
.sort((a, b) => a.issueNumber - b.issueNumber)
|
|
931
942
|
.map((entry) => ({
|
|
@@ -938,8 +949,10 @@ export function deriveAtomicTransactionId(repo, entries, mutations) {
|
|
|
938
949
|
tags: [...entry.tags].sort(),
|
|
939
950
|
assignee: entry.assignee ?? null,
|
|
940
951
|
milestone: entry.milestone ?? null,
|
|
952
|
+
fidelity: entry.fidelity ?? null,
|
|
953
|
+
linkOnly: entry.linkOnly ?? false,
|
|
941
954
|
}));
|
|
942
|
-
const
|
|
955
|
+
const payload = crypto
|
|
943
956
|
.createHash("sha256")
|
|
944
957
|
.update(repo.toLowerCase())
|
|
945
958
|
.update("\x1f")
|
|
@@ -948,10 +961,10 @@ export function deriveAtomicTransactionId(repo, entries, mutations) {
|
|
|
948
961
|
// Recovery requires the exact ordered step plan. Include targets and
|
|
949
962
|
// options so a changed id_prefix or provenance match gets a fresh journal
|
|
950
963
|
// instead of colliding with an incompatible prior attempt.
|
|
951
|
-
.update(JSON.stringify(mutations))
|
|
952
|
-
|
|
953
|
-
.
|
|
954
|
-
return `${ATOMIC_IMPORT_PREFIX}${digest}`;
|
|
964
|
+
.update(JSON.stringify(mutations));
|
|
965
|
+
if (contextVersions.length > 0)
|
|
966
|
+
payload.update("\x1f").update(JSON.stringify([...contextVersions].sort()));
|
|
967
|
+
return `${ATOMIC_IMPORT_PREFIX}${payload.digest("hex").slice(0, 16)}`;
|
|
955
968
|
}
|
|
956
969
|
/** Stable create id keyed by the external GitHub issue, never by fetch order. */
|
|
957
970
|
export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId) {
|
|
@@ -963,7 +976,14 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
|
|
|
963
976
|
return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
|
|
964
977
|
}
|
|
965
978
|
/** Map one rendered import entry to its reversible SDK mutation sequence. */
|
|
966
|
-
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}`] } : {};
|
|
984
|
+
if (entry.linkOnly && entry.match?.id) {
|
|
985
|
+
return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? { addTags: entry.tags } : {}), ...audit } }] };
|
|
986
|
+
}
|
|
967
987
|
const sharedOptions = {
|
|
968
988
|
title: entry.title,
|
|
969
989
|
type: entry.itemType,
|
|
@@ -972,7 +992,14 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
972
992
|
tags: entry.tags.join(","),
|
|
973
993
|
...(entry.assignee ? { assignee: entry.assignee } : {}),
|
|
974
994
|
...(entry.milestone ? { sprint: entry.milestone } : {}),
|
|
995
|
+
...(entry.status === "canceled" ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
|
|
975
996
|
};
|
|
997
|
+
// Explicit terminal reconciliation also handles canceled -> completed. The
|
|
998
|
+
// supported force option permits that source-authoritative repeat closure;
|
|
999
|
+
// finalization restores the real dates before the transaction commits.
|
|
1000
|
+
const statusOptions = entry.status === "closed"
|
|
1001
|
+
? entry.fidelity ? { status: "closed", closeReason: entry.fidelity.github_close_reason, force: true } : {}
|
|
1002
|
+
: { status: entry.status };
|
|
976
1003
|
const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
|
|
977
1004
|
// A missing match and a match at our deterministic external-key id use the
|
|
978
1005
|
// SAME create+update upsert plan. This is essential for crash recovery: if a
|
|
@@ -980,26 +1007,26 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
980
1007
|
// item, but commitItemMutations must still receive the original plan. The
|
|
981
1008
|
// create step treats an existing stable id as already applied; update then
|
|
982
1009
|
// makes later content-bearing transactions refresh the item normally.
|
|
983
|
-
if (!entry.match?.id || entry.match.id === managedItemId) {
|
|
1010
|
+
if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
|
|
984
1011
|
const createStatus = entry.status === "closed" ? "open" : entry.status;
|
|
985
1012
|
const mutations = [{
|
|
986
1013
|
op: "create",
|
|
987
1014
|
id: managedItemId,
|
|
988
|
-
options: { ...sharedOptions, status: createStatus },
|
|
1015
|
+
options: { ...sharedOptions, status: createStatus, message: `pm-github import create ${repo.toLowerCase()}#${entry.issueNumber}` },
|
|
989
1016
|
}, {
|
|
990
1017
|
op: "update",
|
|
991
1018
|
id: managedItemId,
|
|
992
1019
|
options: {
|
|
993
1020
|
...sharedOptions,
|
|
994
|
-
...
|
|
1021
|
+
...statusOptions,
|
|
995
1022
|
},
|
|
996
1023
|
}];
|
|
997
|
-
if (entry.status === "closed") {
|
|
1024
|
+
if (entry.status === "closed" && !entry.fidelity) {
|
|
998
1025
|
mutations.push({
|
|
999
1026
|
op: "close",
|
|
1000
1027
|
id: managedItemId,
|
|
1001
1028
|
reason: `GitHub issue #${entry.issueNumber} closed`,
|
|
1002
|
-
//
|
|
1029
|
+
// Legacy callers can supply completion directly; full plans finalize both lifecycle dates.
|
|
1003
1030
|
...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}),
|
|
1004
1031
|
});
|
|
1005
1032
|
}
|
|
@@ -1009,52 +1036,125 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1009
1036
|
};
|
|
1010
1037
|
}
|
|
1011
1038
|
const itemId = entry.match.id;
|
|
1012
|
-
const updateOptions = {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
+
};
|
|
1018
1046
|
const mutations = [{
|
|
1019
1047
|
op: "update",
|
|
1020
1048
|
id: itemId,
|
|
1021
1049
|
options: updateOptions,
|
|
1022
1050
|
}];
|
|
1023
|
-
if (entry.status === "closed") {
|
|
1051
|
+
if (entry.status === "closed" && !entry.fidelity) {
|
|
1024
1052
|
mutations.push({
|
|
1025
1053
|
op: "close",
|
|
1026
1054
|
id: itemId,
|
|
1027
1055
|
reason: `GitHub issue #${entry.issueNumber} closed`,
|
|
1028
|
-
//
|
|
1056
|
+
// Legacy callers can supply completion directly; full plans finalize both lifecycle dates.
|
|
1029
1057
|
...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}),
|
|
1030
1058
|
});
|
|
1031
1059
|
}
|
|
1032
1060
|
return { itemId, mutations };
|
|
1033
1061
|
}
|
|
1034
|
-
/**
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1062
|
+
/**
|
|
1063
|
+
* Bind atomic plans to the latest external edit or different lifecycle generation.
|
|
1064
|
+
* The importer's desired-state and compensation writes leave retries stable;
|
|
1065
|
+
* local edits and later state cycles invalidate completed journals.
|
|
1066
|
+
*/
|
|
1067
|
+
async function importContextVersions(pmRoot, entries) {
|
|
1068
|
+
const versions = [];
|
|
1069
|
+
for (const entry of entries) {
|
|
1070
|
+
if (!entry.fidelity || !entry.match?.id)
|
|
1071
|
+
continue;
|
|
1072
|
+
const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
|
|
1073
|
+
const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
|
|
1074
|
+
if (context)
|
|
1075
|
+
versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
|
|
1076
|
+
}
|
|
1077
|
+
return versions;
|
|
1078
|
+
}
|
|
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) {
|
|
1099
|
+
pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
|
|
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`);
|
|
1038
1136
|
try {
|
|
1039
|
-
|
|
1040
|
-
if (settings?.id_prefix)
|
|
1041
|
-
idPrefix = String(settings.id_prefix);
|
|
1137
|
+
return JSON.parse(await fs.promises.readFile(file, "utf8"));
|
|
1042
1138
|
}
|
|
1043
1139
|
catch {
|
|
1044
|
-
|
|
1045
|
-
// back to the canonical prefix; the mutation still validates the tracker.
|
|
1140
|
+
return null;
|
|
1046
1141
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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;
|
|
1150
|
+
/** Finalize before the SDK commits, and repair source metadata on recovered journals. */
|
|
1151
|
+
const finalize = async () => {
|
|
1152
|
+
for (const entry of entries) {
|
|
1153
|
+
if (entry.fidelity)
|
|
1154
|
+
await persistImportFidelity(pmRoot, itemIds.get(entry.issueNumber), entry.fidelity, Boolean(entry.linkOnly), opts.updateOnly);
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
let committed = false;
|
|
1058
1158
|
try {
|
|
1059
1159
|
const result = await commit({
|
|
1060
1160
|
pmRoot,
|
|
@@ -1066,7 +1166,14 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
|
1066
1166
|
// restores every UPDATE and CLOSE step (covered by the mixed rollback
|
|
1067
1167
|
// integration test below this implementation).
|
|
1068
1168
|
createCompensation: "delete",
|
|
1169
|
+
onTransition: async (context) => {
|
|
1170
|
+
if (context.transition === "committing")
|
|
1171
|
+
await finalize();
|
|
1172
|
+
await opts.onTransition?.(context);
|
|
1173
|
+
},
|
|
1069
1174
|
});
|
|
1175
|
+
committed = true;
|
|
1176
|
+
await finalize();
|
|
1070
1177
|
// A recovered journal may include work applied by the interrupted process
|
|
1071
1178
|
// as well as steps resumed now. The SDK intentionally returns the durable
|
|
1072
1179
|
// final results, not a per-invocation delta, so create/update counts cannot
|
|
@@ -1082,15 +1189,144 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
|
1082
1189
|
};
|
|
1083
1190
|
}
|
|
1084
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
|
+
}
|
|
1085
1201
|
const msg = errorMessage(err);
|
|
1202
|
+
const label = `${opts.updateOnly ? "Refresh" : "Atomic GitHub import"} transaction ${transactionId}`;
|
|
1203
|
+
if (committed)
|
|
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}`);
|
|
1086
1205
|
if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
|
|
1087
|
-
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);
|
|
1088
1207
|
}
|
|
1089
1208
|
if (err instanceof Error && err.name === "WorkspaceTransactionInterruptedError") {
|
|
1090
|
-
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);
|
|
1210
|
+
}
|
|
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);
|
|
1212
|
+
}
|
|
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);
|
|
1091
1303
|
}
|
|
1092
|
-
|
|
1304
|
+
validateGithubImportFidelity(entry, record.repo, `Refresh cannot replay unfinished GitHub transaction ${record.transactionId}`, items);
|
|
1305
|
+
itemIds.set(entry.issueNumber, entry.match.id);
|
|
1093
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;
|
|
1094
1330
|
}
|
|
1095
1331
|
// ---------------------------------------------------------------------------
|
|
1096
1332
|
// Shared import core (used by both the legacy command and the importer)
|
|
@@ -1895,6 +2131,7 @@ export function parseImportOptions(options) {
|
|
|
1895
2131
|
atomic: optionEnabled(options, "atomic"),
|
|
1896
2132
|
linkDeps: optionEnabled(options, "link-deps", "linkDeps"),
|
|
1897
2133
|
gate: optionEnabled(options, "gate"),
|
|
2134
|
+
adoptCited: optionEnabled(options, "adopt-cited", "adoptCited"),
|
|
1898
2135
|
};
|
|
1899
2136
|
}
|
|
1900
2137
|
// Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
|
|
@@ -2177,10 +2414,17 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
|
|
|
2177
2414
|
* @param fetchIssueComments - Injectable comment fetcher (defaults to {@link fetchComments}).
|
|
2178
2415
|
* @returns The prepared import entry, or `undefined` when the issue has no title.
|
|
2179
2416
|
*/
|
|
2180
|
-
async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
|
|
2417
|
+
export async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments, fidelityContext) {
|
|
2181
2418
|
const title = issue.title.trim();
|
|
2182
2419
|
if (!title)
|
|
2183
2420
|
return undefined;
|
|
2421
|
+
const identity = matchImportedIssue(fidelityContext.items, issue, match, opts.adoptCited === true);
|
|
2422
|
+
match = identity.item;
|
|
2423
|
+
const timeline = await fidelityContext.timeline(issue, repo, token);
|
|
2424
|
+
const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
|
|
2425
|
+
const targetStatus = mapState(issue.state, issue.state_reason);
|
|
2426
|
+
const conflict = match?.status && match.status !== targetStatus && (["closed", "canceled"].includes(match.status) || issue.state === "closed")
|
|
2427
|
+
? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
|
|
2184
2428
|
const kind = issue.pull_request ? "PR" : "issue";
|
|
2185
2429
|
const labels = issue.labels.map((label) => label.name).filter(Boolean);
|
|
2186
2430
|
const tag = provenanceTag(repo, issue.number);
|
|
@@ -2206,19 +2450,22 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
|
|
|
2206
2450
|
issueNumber: issue.number,
|
|
2207
2451
|
title,
|
|
2208
2452
|
itemType: opts.itemType,
|
|
2209
|
-
status:
|
|
2453
|
+
status: identity.linkOnly ? match.status : targetStatus,
|
|
2210
2454
|
description: `GH ${kind} #${issue.number}: ${issue.html_url}` +
|
|
2211
2455
|
(author ? ` · author @${author}` : "") +
|
|
2212
2456
|
(issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
|
|
2213
|
-
|
|
2214
|
-
(issue.updated_at ? ` · updated ${issue.updated_at}` : ""),
|
|
2457
|
+
` · created ${issue.created_at} · updated ${issue.updated_at}`,
|
|
2215
2458
|
body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
|
|
2216
|
-
tags: [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])],
|
|
2459
|
+
tags: [...new Set([...(identity.linkOnly || identity.via === "citation" ? match.tags ?? [] : []), ...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])],
|
|
2217
2460
|
assignee: issue.assignee?.login,
|
|
2218
2461
|
milestone: issue.milestone?.title,
|
|
2219
2462
|
comments,
|
|
2220
2463
|
syncAnnotations,
|
|
2221
2464
|
closedAt: issue.closed_at ?? undefined,
|
|
2465
|
+
fidelity,
|
|
2466
|
+
linkOnly: identity.linkOnly,
|
|
2467
|
+
identity: { via: identity.via, ...(conflict ? { conflict } : {}) },
|
|
2468
|
+
...(identity.citation_candidates ? { citation_candidates: identity.citation_candidates } : {}),
|
|
2222
2469
|
match,
|
|
2223
2470
|
};
|
|
2224
2471
|
}
|
|
@@ -2299,6 +2546,8 @@ export function verifyImportIdempotency(existing, prepared, repo) {
|
|
|
2299
2546
|
for (const entry of prepared) {
|
|
2300
2547
|
if (entry.match?.id) {
|
|
2301
2548
|
matchedIds.push(entry.match.id);
|
|
2549
|
+
if (entry.identity?.via === "citation" && !entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
|
|
2550
|
+
untaggedNewEntries++;
|
|
2302
2551
|
continue;
|
|
2303
2552
|
}
|
|
2304
2553
|
if (!entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
|
|
@@ -2311,8 +2560,9 @@ export function verifyImportIdempotency(existing, prepared, repo) {
|
|
|
2311
2560
|
`${conflictingTargets} conflicting target(s). No item was written.`, EXIT_CODE.GENERIC_FAILURE);
|
|
2312
2561
|
}
|
|
2313
2562
|
return {
|
|
2563
|
+
adopted: prepared.filter(entry => entry.identity?.via === "citation").length,
|
|
2314
2564
|
provenance_indexed: tagOwners.size,
|
|
2315
|
-
matched_by_provenance:
|
|
2565
|
+
matched_by_provenance: prepared.filter(entry => entry.match?.id && entry.identity?.via !== "citation").length,
|
|
2316
2566
|
new_entries: prepared.length - matchedIds.length,
|
|
2317
2567
|
};
|
|
2318
2568
|
}
|
|
@@ -2442,12 +2692,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2442
2692
|
dryRun: true,
|
|
2443
2693
|
wouldImport: 0,
|
|
2444
2694
|
wouldUpdate: 0,
|
|
2695
|
+
wouldAdopt: 0,
|
|
2445
2696
|
wouldSkip: 0,
|
|
2446
2697
|
...(opts.atomic ? { atomic: true } : {}),
|
|
2447
2698
|
...fetchFields,
|
|
2448
2699
|
};
|
|
2449
2700
|
}
|
|
2450
|
-
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 } : {}) };
|
|
2451
2702
|
}
|
|
2452
2703
|
console.error(`Found ${filtered.length} issue(s).`);
|
|
2453
2704
|
// Build the idempotency index once up-front — including for a dry run. Skipping
|
|
@@ -2455,17 +2706,27 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2455
2706
|
// reported "would import N, skip 0" where the real run performs updates for
|
|
2456
2707
|
// already-linked issues. A preview that overstates creates reads as "this will
|
|
2457
2708
|
// duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
|
|
2458
|
-
|
|
2709
|
+
/** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
|
|
2710
|
+
const readImportItems = async () => {
|
|
2711
|
+
if (dependencies.readItems)
|
|
2712
|
+
return dependencies.readItems(pmRoot);
|
|
2713
|
+
const corpus = certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot, cwd: pmRoot, noExtensions: true }));
|
|
2714
|
+
return decodeCompletePmItems(corpus);
|
|
2715
|
+
};
|
|
2716
|
+
const existingItems = await readImportItems();
|
|
2459
2717
|
const existing = indexByProvenance(existingItems);
|
|
2718
|
+
const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
|
|
2719
|
+
timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
|
|
2460
2720
|
let imported = 0;
|
|
2461
2721
|
let updated = 0;
|
|
2722
|
+
let adopted = 0;
|
|
2462
2723
|
let skipped = 0;
|
|
2463
2724
|
// Planned native comments the write phase could not land (contended lock,
|
|
2464
2725
|
// refused add, unparsable created id). Ungated runs warn and continue; gated
|
|
2465
2726
|
// runs add this to the plan-divergence refusal at the end.
|
|
2466
2727
|
let commentWriteFailures = 0;
|
|
2467
|
-
//
|
|
2468
|
-
//
|
|
2728
|
+
// Plan every import BEFORE any mutation, including ungated citation refusals.
|
|
2729
|
+
// The same prepare step builds every entry up front, so gated completeness
|
|
2469
2730
|
// (every fetched issue accounted for exactly once) and idempotency (every
|
|
2470
2731
|
// re-import lands on exactly one provenance-tagged item) are proven while
|
|
2471
2732
|
// the tracker is still untouched. Both checks throw before any write.
|
|
@@ -2473,19 +2734,48 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2473
2734
|
let gateCompleteness;
|
|
2474
2735
|
let gateIdempotency;
|
|
2475
2736
|
let gatePlanByNumber;
|
|
2476
|
-
|
|
2737
|
+
const citationCandidates = [];
|
|
2738
|
+
let citationReceipt = {};
|
|
2739
|
+
{
|
|
2477
2740
|
const planned = [];
|
|
2478
2741
|
const skippedNumbers = [];
|
|
2742
|
+
const ambiguous = [];
|
|
2479
2743
|
for (const issue of filtered) {
|
|
2480
|
-
|
|
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
|
+
}
|
|
2481
2756
|
if (!entry) {
|
|
2482
2757
|
skippedNumbers.push(issue.number);
|
|
2483
2758
|
continue;
|
|
2484
2759
|
}
|
|
2485
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 };
|
|
2486
2774
|
}
|
|
2487
2775
|
if (opts.gate) {
|
|
2488
2776
|
gateCompleteness = verifyImportPlanCompleteness(filtered, planned, skippedNumbers);
|
|
2777
|
+
}
|
|
2778
|
+
if (opts.gate || planned.some(entry => entry.identity?.via === "citation")) {
|
|
2489
2779
|
gateIdempotency = verifyImportIdempotency(existingItems, planned, repo);
|
|
2490
2780
|
}
|
|
2491
2781
|
gatePlan = planned;
|
|
@@ -2494,7 +2784,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2494
2784
|
if (opts.gate && !opts.dryRun) {
|
|
2495
2785
|
const preview = runCommandTrackerGate({ pmRoot, plannedItems: planned.map(entry => ({
|
|
2496
2786
|
itemId: entry.match?.id ?? `github-issue-${entry.issueNumber}`,
|
|
2497
|
-
fields: { title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
|
|
2787
|
+
fields: { ...entry.fidelity, title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
|
|
2498
2788
|
assignee: entry.assignee, sprint: entry.milestone,
|
|
2499
2789
|
comments: entry.syncAnnotations ? entry.comments.map(comment => ({
|
|
2500
2790
|
author: comment.user?.login, text: comment.body, created_at: comment.created_at,
|
|
@@ -2505,9 +2795,26 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2505
2795
|
}
|
|
2506
2796
|
if (opts.gate)
|
|
2507
2797
|
console.error(`pm github gate: plan verified — ${gateCompleteness.planned} planned, ${gateCompleteness.skipped} skipped, ` +
|
|
2508
|
-
`${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.new_entries} new.`);
|
|
2509
|
-
}
|
|
2510
|
-
|
|
2798
|
+
`${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.adopted} adopted, ${gateIdempotency.new_entries} new.`);
|
|
2799
|
+
}
|
|
2800
|
+
// Preview output omits unscanned PR-title quotes and private provider close text.
|
|
2801
|
+
const fidelityPlan = () => gatePlan.map(entry => {
|
|
2802
|
+
const source = entry.fidelity;
|
|
2803
|
+
const reason = source.github_close_reason.split(" (", 1)[0];
|
|
2804
|
+
return { issue: entry.issueNumber, item: entry.match?.id,
|
|
2805
|
+
action: entry.identity?.via === "citation" ? "adopt" : entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
|
|
2806
|
+
...source, github_close_reason: reason,
|
|
2807
|
+
created_at: entry.linkOnly ? entry.match.created_at : source.github_created_at,
|
|
2808
|
+
updated_at_policy: "sdk-mutation-clock; source in github_updated_at",
|
|
2809
|
+
closed_at: entry.linkOnly ? entry.match.closed_at : source.github_closed_at,
|
|
2810
|
+
close_reason: entry.linkOnly ? "preserved-provider-owner" : reason,
|
|
2811
|
+
release: entry.linkOnly ? "preserved-provider-owner" : source.github_release,
|
|
2812
|
+
identity: entry.identity,
|
|
2813
|
+
...(entry.citation_candidates ? { citation_candidates: entry.citation_candidates } : {}) };
|
|
2814
|
+
});
|
|
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" } } : {}) };
|
|
2511
2818
|
if (opts.atomic) {
|
|
2512
2819
|
// The gated pipeline already prepared every entry (and counted its skips)
|
|
2513
2820
|
// during the pre-mutation plan phase; reuse that plan verbatim so the write
|
|
@@ -2520,6 +2827,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2520
2827
|
dryRun: true,
|
|
2521
2828
|
wouldImport: 0,
|
|
2522
2829
|
wouldUpdate: 0,
|
|
2830
|
+
wouldAdopt: 0,
|
|
2523
2831
|
wouldSkip: skipped,
|
|
2524
2832
|
atomic: true,
|
|
2525
2833
|
...fetchFields,
|
|
@@ -2529,12 +2837,12 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2529
2837
|
}
|
|
2530
2838
|
if (opts.dryRun) {
|
|
2531
2839
|
imported = prepared.filter((entry) => !entry.match?.id).length;
|
|
2532
|
-
updated = prepared.length - imported;
|
|
2840
|
+
updated = prepared.length - imported - adoptionCount;
|
|
2533
2841
|
for (const entry of prepared) {
|
|
2534
|
-
const action = entry.match?.id ? "update" : "import";
|
|
2842
|
+
const action = entry.identity?.via === "citation" ? "adopt" : entry.match?.id ? "update" : "import";
|
|
2535
2843
|
console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${opts.gate ? "(gated title)" : entry.title} (${entry.status})`);
|
|
2536
2844
|
}
|
|
2537
|
-
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}.`);
|
|
2538
2846
|
if (opts.linkDeps) {
|
|
2539
2847
|
console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
|
|
2540
2848
|
`resolution + edge writes run only on a real import.`);
|
|
@@ -2543,6 +2851,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2543
2851
|
dryRun: true,
|
|
2544
2852
|
wouldImport: imported,
|
|
2545
2853
|
wouldUpdate: updated,
|
|
2854
|
+
wouldAdopt: adoptionCount,
|
|
2546
2855
|
wouldSkip: skipped,
|
|
2547
2856
|
atomic: true,
|
|
2548
2857
|
...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
|
|
@@ -2552,7 +2861,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2552
2861
|
}
|
|
2553
2862
|
const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
|
|
2554
2863
|
if (opts.gate)
|
|
2555
|
-
verifyImportedProvenance(
|
|
2864
|
+
verifyImportedProvenance(await readImportItems(), prepared, repo);
|
|
2556
2865
|
// Planned native comments are part of the verified plan: a contended lock,
|
|
2557
2866
|
// a refused add, or a committed entry whose created id is missing from the
|
|
2558
2867
|
// post-commit routing map all leave planned comments unwritten, so they are
|
|
@@ -2589,58 +2898,63 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2589
2898
|
transactionId: result.transactionId,
|
|
2590
2899
|
recovered: result.recovered,
|
|
2591
2900
|
imported: result.imported,
|
|
2592
|
-
updated: result.updated,
|
|
2901
|
+
updated: result.updated - (result.recovered ? 0 : adoptionCount),
|
|
2902
|
+
adopted: result.recovered ? 0 : adoptionCount,
|
|
2593
2903
|
...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}),
|
|
2594
2904
|
skipped,
|
|
2595
2905
|
atomic: true,
|
|
2596
2906
|
...depLinkResultFields(atomicDepLink),
|
|
2597
2907
|
...fetchFields,
|
|
2908
|
+
...planFields,
|
|
2598
2909
|
...(gateReceipt ? { gate: gateReceipt } : {}),
|
|
2599
2910
|
};
|
|
2600
2911
|
}
|
|
2601
2912
|
for (const issue of filtered) {
|
|
2602
|
-
//
|
|
2603
|
-
|
|
2604
|
-
// the write phase cannot diverge from it.
|
|
2605
|
-
const prepared = gatePlanByNumber
|
|
2606
|
-
? gatePlanByNumber.get(issue.number)
|
|
2607
|
-
: await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
|
|
2913
|
+
// Every import reuses the pre-mutation identity plan, including ungated runs.
|
|
2914
|
+
const prepared = gatePlanByNumber.get(issue.number);
|
|
2608
2915
|
if (!prepared) {
|
|
2609
|
-
// A gated run counted its skips during the verified plan phase; only the
|
|
2610
|
-
// ungated path counts them here.
|
|
2611
|
-
if (!gatePlanByNumber)
|
|
2612
|
-
skipped++;
|
|
2613
2916
|
continue;
|
|
2614
2917
|
}
|
|
2615
2918
|
const labels = issue.labels.map((l) => l.name).filter(Boolean);
|
|
2616
|
-
const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations,
|
|
2919
|
+
const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, } = prepared;
|
|
2617
2920
|
/**
|
|
2618
2921
|
* Build the `pm close` argv for this issue's pm item.
|
|
2619
2922
|
*
|
|
2620
2923
|
* Two paths below close an item for the same reason — reconciling an
|
|
2621
2924
|
* already-matched item whose upstream issue is now closed, and closing an
|
|
2622
2925
|
* item that was just created `open` because pm-cli 2026.8.3 refuses a
|
|
2623
|
-
* terminal `create --status closed`. Both
|
|
2624
|
-
*
|
|
2625
|
-
*
|
|
2626
|
-
* duplicated at each site where the two could silently drift apart.
|
|
2926
|
+
* terminal `create --status closed`. Both carry the same source reason.
|
|
2927
|
+
* Source completion is applied with source creation during finalization,
|
|
2928
|
+
* avoiding an intermediate completion date before the SDK creation date.
|
|
2627
2929
|
*/
|
|
2628
2930
|
const githubCloseArgs = (id) => {
|
|
2629
|
-
const args = ["--path", pmRoot, "close", id, "--reason",
|
|
2630
|
-
if (closedAt)
|
|
2631
|
-
args.push("--completed-at", closedAt);
|
|
2931
|
+
const args = ["--path", pmRoot, "close", id, "--reason", prepared.fidelity.github_close_reason];
|
|
2632
2932
|
return args;
|
|
2633
2933
|
};
|
|
2634
2934
|
if (opts.dryRun) {
|
|
2635
|
-
const action = match?.id ? "update" : "import";
|
|
2935
|
+
const action = prepared.identity?.via === "citation" ? "adopt" : match?.id ? "update" : "import";
|
|
2636
2936
|
const metadata = !opts.gate && labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
|
|
2637
2937
|
console.error(` [dry-run] #${issue.number} ${action}: ${opts.gate ? "(gated title)" : title} (${metadata})`);
|
|
2638
|
-
if (
|
|
2938
|
+
if (prepared.identity?.via === "citation")
|
|
2939
|
+
adopted++;
|
|
2940
|
+
else if (match?.id)
|
|
2639
2941
|
updated++;
|
|
2640
2942
|
else
|
|
2641
2943
|
imported++;
|
|
2642
2944
|
continue;
|
|
2643
2945
|
}
|
|
2946
|
+
if (prepared.linkOnly) {
|
|
2947
|
+
const ownerId = match.id;
|
|
2948
|
+
const linked = pmRun(["--path", pmRoot, "update", ownerId, "--add-tags", tags.join(",")]);
|
|
2949
|
+
if (!linked.ok)
|
|
2950
|
+
throw new CommandError(`Could not link GitHub issue #${issue.number} to its existing owner.`);
|
|
2951
|
+
await persistImportFidelity(pmRoot, ownerId, prepared.fidelity, true);
|
|
2952
|
+
if (prepared.identity?.via === "citation")
|
|
2953
|
+
adopted++;
|
|
2954
|
+
else
|
|
2955
|
+
updated++;
|
|
2956
|
+
continue;
|
|
2957
|
+
}
|
|
2644
2958
|
if (match?.id) {
|
|
2645
2959
|
// Idempotent update — never duplicate. Status transitions go through the
|
|
2646
2960
|
// proper command (close requires a reason; reopen via update --status).
|
|
@@ -2663,17 +2977,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2663
2977
|
continue;
|
|
2664
2978
|
}
|
|
2665
2979
|
// Reconcile status separately.
|
|
2980
|
+
if (status === "canceled" && match.status !== "canceled") {
|
|
2981
|
+
const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
|
|
2982
|
+
if (!cancel.ok)
|
|
2983
|
+
throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
|
|
2984
|
+
}
|
|
2666
2985
|
if (status === "closed" && match.status !== "closed") {
|
|
2667
2986
|
// Close through `pm close` so the reason is real provenance; pass the
|
|
2668
2987
|
// source completion time as --completed-at when GitHub recorded one.
|
|
2669
|
-
const close = pmRun(githubCloseArgs(match.id));
|
|
2988
|
+
const close = pmRun([...githubCloseArgs(match.id), ...(match.status === "canceled" ? ["--force"] : [])]);
|
|
2670
2989
|
if (!close.ok) {
|
|
2671
2990
|
console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
|
|
2672
2991
|
skipped++;
|
|
2673
2992
|
continue;
|
|
2674
2993
|
}
|
|
2675
2994
|
}
|
|
2676
|
-
else if (status === "open" && match.status === "closed") {
|
|
2995
|
+
else if (status === "open" && (match.status === "closed" || match.status === "canceled")) {
|
|
2677
2996
|
const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
|
|
2678
2997
|
if (!reopen.ok) {
|
|
2679
2998
|
console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
|
|
@@ -2684,7 +3003,11 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2684
3003
|
if (syncAnnotations) {
|
|
2685
3004
|
commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
|
|
2686
3005
|
}
|
|
2687
|
-
|
|
3006
|
+
await persistImportFidelity(pmRoot, match.id, prepared.fidelity, false);
|
|
3007
|
+
if (prepared.identity?.via === "citation")
|
|
3008
|
+
adopted++;
|
|
3009
|
+
else
|
|
3010
|
+
updated++;
|
|
2688
3011
|
continue;
|
|
2689
3012
|
}
|
|
2690
3013
|
// A closed upstream issue cannot be born closed: governance
|
|
@@ -2703,21 +3026,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2703
3026
|
"--tags", tags.join(","),
|
|
2704
3027
|
"--message", `Imported from GitHub #${issue.number}`,
|
|
2705
3028
|
];
|
|
3029
|
+
if (status === "canceled")
|
|
3030
|
+
createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
|
|
2706
3031
|
if (assignee)
|
|
2707
3032
|
createArgs.push("--assignee", assignee);
|
|
2708
3033
|
if (milestone)
|
|
2709
3034
|
createArgs.push("--sprint", milestone);
|
|
2710
3035
|
// --json lets a following close (or annotations sync) address the freshly
|
|
2711
3036
|
// created item by id without re-scanning the workspace.
|
|
2712
|
-
|
|
2713
|
-
createArgs.push("--json");
|
|
3037
|
+
createArgs.push("--json");
|
|
2714
3038
|
const created = pmRun(createArgs);
|
|
2715
3039
|
if (!created.ok) {
|
|
2716
3040
|
console.error(`#${issue.number}: create failed — ${created.stderr}`);
|
|
2717
3041
|
skipped++;
|
|
2718
3042
|
continue;
|
|
2719
3043
|
}
|
|
2720
|
-
const createdId =
|
|
3044
|
+
const createdId = parseCreatedItemId(created.stdout);
|
|
2721
3045
|
if (mustClose) {
|
|
2722
3046
|
if (createdId) {
|
|
2723
3047
|
const close = pmRun(githubCloseArgs(createdId));
|
|
@@ -2749,10 +3073,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2749
3073
|
commentWriteFailures += comments.length;
|
|
2750
3074
|
}
|
|
2751
3075
|
}
|
|
3076
|
+
if (!createdId)
|
|
3077
|
+
throw new CommandError(`Could not finalize GitHub issue #${issue.number}: created id missing.`);
|
|
3078
|
+
await persistImportFidelity(pmRoot, createdId, prepared.fidelity, false);
|
|
2752
3079
|
imported++;
|
|
2753
3080
|
}
|
|
2754
3081
|
if (opts.dryRun) {
|
|
2755
|
-
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}.`);
|
|
2756
3083
|
if (opts.linkDeps) {
|
|
2757
3084
|
console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
|
|
2758
3085
|
`resolution + edge writes run only on a real import.`);
|
|
@@ -2761,14 +3088,15 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2761
3088
|
dryRun: true,
|
|
2762
3089
|
wouldImport: imported,
|
|
2763
3090
|
wouldUpdate: updated,
|
|
3091
|
+
wouldAdopt: adopted,
|
|
2764
3092
|
wouldSkip: skipped,
|
|
2765
3093
|
...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
|
|
2766
3094
|
...gatePlanFields,
|
|
2767
3095
|
...fetchFields,
|
|
2768
3096
|
};
|
|
2769
3097
|
}
|
|
2770
|
-
console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
|
|
2771
|
-
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) {
|
|
2772
3100
|
throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
|
|
2773
3101
|
}
|
|
2774
3102
|
// A gated import is all-or-nothing in effect: a planned write that failed
|
|
@@ -2784,9 +3112,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2784
3112
|
// Gated pipeline, phase 2 — the writes are on disk but nothing is committed or
|
|
2785
3113
|
// pushed; scan the proposed tracker change now and fail closed.
|
|
2786
3114
|
if (opts.gate)
|
|
2787
|
-
verifyImportedProvenance(
|
|
3115
|
+
verifyImportedProvenance(await readImportItems(), gatePlan, repo);
|
|
2788
3116
|
const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
|
|
2789
|
-
return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
|
|
3117
|
+
return { imported, updated, adopted, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...planFields, ...(gateReceipt ? { gate: gateReceipt } : {}) };
|
|
2790
3118
|
}
|
|
2791
3119
|
/**
|
|
2792
3120
|
* Emit the human-readable `--link-deps` summary to stderr.
|
|
@@ -4102,11 +4430,12 @@ function graphqlUrl() {
|
|
|
4102
4430
|
// `errors` array, so we surface those explicitly. The combined user+org queries
|
|
4103
4431
|
// below intentionally return a partial error for the wrong owner type while the
|
|
4104
4432
|
// right one still resolves; we only throw when there is NO usable data.
|
|
4105
|
-
|
|
4433
|
+
/** Execute a typed GraphQL request for internal import closure evidence. @internal */
|
|
4434
|
+
export async function githubGraphQL(token, query, variables) {
|
|
4106
4435
|
return (await githubGraphQLResponse(token, query, variables)).data;
|
|
4107
4436
|
}
|
|
4108
4437
|
/** Shared GraphQL client retaining partial errors for reference-level classification. */
|
|
4109
|
-
async function githubGraphQLResponse(token, query, variables) {
|
|
4438
|
+
export async function githubGraphQLResponse(token, query, variables) {
|
|
4110
4439
|
if (!token) {
|
|
4111
4440
|
throw new CommandError("GitHub GraphQL requires a token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
|
|
4112
4441
|
}
|
|
@@ -4876,7 +5205,7 @@ async function runProjectSync(ctx) {
|
|
|
4876
5205
|
export function isMutatingGithubCommand(command, options) {
|
|
4877
5206
|
const cmd = (command || "").toLowerCase();
|
|
4878
5207
|
const dryRun = optionEnabled(options, "dry-run", "dryRun");
|
|
4879
|
-
if (cmd === "github sync")
|
|
5208
|
+
if (cmd === "github sync" || cmd === "github refresh")
|
|
4880
5209
|
return !dryRun;
|
|
4881
5210
|
// Export is dry-run by default; it only mutates with an explicit apply
|
|
4882
5211
|
// (--apply / --no-dry-run / legacy --push) AND no --dry-run override.
|
|
@@ -4896,6 +5225,7 @@ export function isMutatingGithubCommand(command, options) {
|
|
|
4896
5225
|
// Extension
|
|
4897
5226
|
// ---------------------------------------------------------------------------
|
|
4898
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" },
|
|
4899
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" },
|
|
4900
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" },
|
|
4901
5231
|
{ long: "--all", description: "Include closed issues (shorthand for --state all)" },
|
|
@@ -4977,7 +5307,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
4977
5307
|
const defineExtension = (module) => module;
|
|
4978
5308
|
export default defineExtension({
|
|
4979
5309
|
name: "pm-github",
|
|
4980
|
-
version: "2026.10.
|
|
5310
|
+
version: "2026.10.23",
|
|
4981
5311
|
activate(api) {
|
|
4982
5312
|
// -----------------------------------------------------------------------
|
|
4983
5313
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -4989,6 +5319,8 @@ export default defineExtension({
|
|
|
4989
5319
|
{ name: "github_author", type: "string", optional: true },
|
|
4990
5320
|
{ name: "github_created_at", type: "string", optional: true },
|
|
4991
5321
|
{ name: "github_updated_at", type: "string", optional: true },
|
|
5322
|
+
{ name: "github_link_only", type: "boolean", optional: true },
|
|
5323
|
+
...["github_closed_at", "github_state_reason", "github_close_reason", "github_release"].map(name => ({ name, type: "string", optional: true })),
|
|
4992
5324
|
]);
|
|
4993
5325
|
// -----------------------------------------------------------------------
|
|
4994
5326
|
// preflight — safe, local guard for mutating github commands. Scoped to
|
|
@@ -5006,6 +5338,7 @@ export default defineExtension({
|
|
|
5006
5338
|
api.registerPreflight({
|
|
5007
5339
|
commands: [
|
|
5008
5340
|
"github sync",
|
|
5341
|
+
"github refresh",
|
|
5009
5342
|
"github export",
|
|
5010
5343
|
"github import",
|
|
5011
5344
|
"gh-issues import",
|
|
@@ -5187,6 +5520,88 @@ export default defineExtension({
|
|
|
5187
5520
|
},
|
|
5188
5521
|
});
|
|
5189
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
|
+
});
|
|
5190
5605
|
// command — legacy `pm gh-issues import <owner/repo>` (delegates to core)
|
|
5191
5606
|
// -----------------------------------------------------------------------
|
|
5192
5607
|
api.registerCommand({
|
|
@@ -5311,6 +5726,7 @@ export default defineExtension({
|
|
|
5311
5726
|
{ long: "--repo", value_name: "owner/repo", description: "Repository (or PM_GITHUB_REPO)" },
|
|
5312
5727
|
{ long: "--pr", value_name: "n", repeatable: true, description: "Select a PR number (repeatable; conflicts with --since)" },
|
|
5313
5728
|
{ long: "--since", value_name: "ISO date", description: "Select PRs merged at or after this ISO date/time" },
|
|
5729
|
+
{ long: "--required-checks", value_name: "contexts", description: "One required context per line when protection metadata is unavailable (spaces preserved; blank lines ignored)" },
|
|
5314
5730
|
{ long: "--skip", value_name: "id", repeatable: true, description: "Never close this item (repeatable)" },
|
|
5315
5731
|
{ long: "--apply", description: "Close eligible items (default: dry-run)" },
|
|
5316
5732
|
{ long: "--dry-run", description: "Preview only; overrides --apply" },
|