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