pm-github 2026.10.28 → 2026.10.30
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 +30 -0
- package/README.md +43 -4
- package/dist/closeout.d.ts.map +1 -1
- package/dist/closeout.js +71 -30
- package/dist/closeout.js.map +1 -1
- package/dist/discussion-schema.d.ts +110 -0
- package/dist/discussion-schema.d.ts.map +1 -0
- package/dist/discussion-schema.js +41 -0
- package/dist/discussion-schema.js.map +1 -0
- package/dist/discussions.d.ts +26 -0
- package/dist/discussions.d.ts.map +1 -0
- package/dist/discussions.js +215 -0
- package/dist/discussions.js.map +1 -0
- package/dist/gate.d.ts +2 -0
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +20 -9
- package/dist/gate.js.map +1 -1
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +2 -1
- package/dist/identity.js.map +1 -1
- package/dist/import-fidelity.d.ts +8 -3
- package/dist/import-fidelity.d.ts.map +1 -1
- package/dist/import-fidelity.js +39 -10
- package/dist/import-fidelity.js.map +1 -1
- package/dist/import-tags.d.ts +1 -1
- package/dist/import-tags.d.ts.map +1 -1
- package/dist/import-tags.js +8 -6
- package/dist/import-tags.js.map +1 -1
- package/dist/index.d.ts +48 -40
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -69
- package/dist/index.js.map +1 -1
- package/dist/issue-forms.d.ts +1 -1
- package/dist/issue-forms.d.ts.map +1 -1
- package/dist/issue-forms.js +6 -4
- package/dist/issue-forms.js.map +1 -1
- package/dist/projects.d.ts +6 -4
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +21 -20
- package/dist/projects.js.map +1 -1
- package/dist/refresh.d.ts +2 -1
- package/dist/refresh.d.ts.map +1 -1
- package/dist/refresh.js +17 -8
- package/dist/refresh.js.map +1 -1
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +5 -2
- package/dist/webhooks.js.map +1 -1
- package/dist/workflow-diagnostic.d.ts +9 -0
- package/dist/workflow-diagnostic.d.ts.map +1 -0
- package/dist/workflow-diagnostic.js +62 -0
- package/dist/workflow-diagnostic.js.map +1 -0
- package/dist/workspace.d.ts +18 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +39 -0
- package/dist/workspace.js.map +1 -0
- package/docs/discussion-capture-example.json +14 -0
- package/docs/discussion-capture.md +44 -0
- package/manifest.json +1 -1
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { readGithubWorkspace, canonicalGithubStatus, githubStatusKind, githubStatusIsTerminal } from "./workspace.js";
|
|
1
2
|
import { ownValue } from "./own-record.js";
|
|
2
3
|
import { uniqueGithubLabelNames } from "./label-names.js";
|
|
3
4
|
import { githubLabelsToTags, importTagArguments, importTagOptions, importTagValues, nativeTagAdditions } from "./import-tags.js";
|
|
4
5
|
export { githubLabelsToTags } from "./import-tags.js";
|
|
5
6
|
import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
|
|
7
|
+
import { readWorkflowFailure } from "./workflow-diagnostic.js";
|
|
6
8
|
import { compileIssueForm, completeIssueForm, readRepositoryIssueForm, refuseIssueForm } from "./issue-forms.js";
|
|
7
9
|
export { compileIssueForm, completeIssueForm, readRepositoryIssueForm } from "./issue-forms.js";
|
|
8
|
-
import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, validateImportLifetime, } from "./import-fidelity.js";
|
|
10
|
+
import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, normalizeImportValue, persistImportFidelity, readImportReleases, selectImportContextIndex, serializeImportTags, validateImportLifetime, } from "./import-fidelity.js";
|
|
9
11
|
import { refreshChanges, runRefresh } from "./refresh.js";
|
|
10
12
|
import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
|
|
11
13
|
import { planGithubLabelMigration, readLabelMigrationJson } from "./labels.js";
|
|
14
|
+
import { captureGithubDiscussion, refuseDiscussion } from "./discussions.js";
|
|
15
|
+
export { captureGithubDiscussion, readGithubDiscussionComment, validateDiscussionCaptureReport } from "./discussions.js";
|
|
12
16
|
export { planGithubLabelMigration, readGithubLabelCatalog, validateGithubLabelMigrationReport } from "./labels.js";
|
|
13
17
|
export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
|
|
14
18
|
import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
|
|
@@ -329,7 +333,7 @@ async function request(method, url, token, payload, maxRetries = 4) {
|
|
|
329
333
|
attempt++;
|
|
330
334
|
continue;
|
|
331
335
|
}
|
|
332
|
-
throw new Error(`GitHub API returned HTTP ${res.status}`);
|
|
336
|
+
throw new Error(`GitHub API returned HTTP ${res.status} for ${method} ${new URL(url).pathname}`);
|
|
333
337
|
}
|
|
334
338
|
}
|
|
335
339
|
/** Fetch a single GitHub REST endpoint via GET and return the decoded
|
|
@@ -958,10 +962,11 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
|
|
|
958
962
|
return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
|
|
959
963
|
}
|
|
960
964
|
/** Map one rendered import entry to its reversible SDK mutation sequence. */
|
|
961
|
-
export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false) {
|
|
965
|
+
export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false, statuses) {
|
|
962
966
|
if (updateOnly && !entry.match?.id)
|
|
963
967
|
throw new CommandError("Refresh refuses an unlinked target; no item may be created.");
|
|
964
|
-
const
|
|
968
|
+
const closing = canonicalGithubStatus(entry.status, statuses) === canonicalGithubStatus("closed", statuses);
|
|
969
|
+
const changed = updateOnly ? new Set(refreshChanges(entry, statuses).map(change => change.field)) : undefined;
|
|
965
970
|
const audit = updateOnly ? { comment: [`Refreshed linked GitHub issue ${repo.toLowerCase()}#${entry.issueNumber}`] } : {};
|
|
966
971
|
if (entry.linkOnly && entry.match?.id) {
|
|
967
972
|
return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? importTagOptions(entry) : {}), ...audit } }] };
|
|
@@ -974,13 +979,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
974
979
|
...importTagOptions(entry),
|
|
975
980
|
...(entry.assignee ? { assignee: entry.assignee } : {}),
|
|
976
981
|
...(entry.milestone ? { sprint: entry.milestone } : {}),
|
|
977
|
-
...(entry.status
|
|
982
|
+
...((!closing && githubStatusIsTerminal(entry.status, statuses)) ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
|
|
978
983
|
};
|
|
979
984
|
// Explicit terminal reconciliation also handles canceled -> completed. The
|
|
980
985
|
// supported force option permits that source-authoritative repeat closure;
|
|
981
986
|
// finalization restores the real dates before the transaction commits.
|
|
982
|
-
const statusOptions =
|
|
983
|
-
? entry.fidelity ? { status:
|
|
987
|
+
const statusOptions = closing
|
|
988
|
+
? entry.fidelity ? { status: entry.status, closeReason: entry.fidelity.github_close_reason, force: true } : {}
|
|
984
989
|
: { status: entry.status };
|
|
985
990
|
const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
|
|
986
991
|
// A missing match and a match at our deterministic external-key id use the
|
|
@@ -990,7 +995,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
990
995
|
// create step treats an existing stable id as already applied; update then
|
|
991
996
|
// makes later content-bearing transactions refresh the item normally.
|
|
992
997
|
if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
|
|
993
|
-
const createStatus =
|
|
998
|
+
const createStatus = closing ? "open" : entry.status;
|
|
994
999
|
const mutations = [{
|
|
995
1000
|
op: "create",
|
|
996
1001
|
id: managedItemId,
|
|
@@ -1003,7 +1008,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1003
1008
|
...statusOptions,
|
|
1004
1009
|
},
|
|
1005
1010
|
}];
|
|
1006
|
-
if (
|
|
1011
|
+
if (closing && !entry.fidelity) {
|
|
1007
1012
|
mutations.push({
|
|
1008
1013
|
op: "close",
|
|
1009
1014
|
id: managedItemId,
|
|
@@ -1030,7 +1035,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1030
1035
|
id: itemId,
|
|
1031
1036
|
options: updateOptions,
|
|
1032
1037
|
}];
|
|
1033
|
-
if (
|
|
1038
|
+
if (closing && !entry.fidelity) {
|
|
1034
1039
|
mutations.push({
|
|
1035
1040
|
op: "close",
|
|
1036
1041
|
id: itemId,
|
|
@@ -1046,13 +1051,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1046
1051
|
* The importer's desired-state and compensation writes leave retries stable;
|
|
1047
1052
|
* local edits and later state cycles invalidate completed journals.
|
|
1048
1053
|
*/
|
|
1049
|
-
async function importContextVersions(pmRoot, entries) {
|
|
1054
|
+
async function importContextVersions(pmRoot, entries, statuses) {
|
|
1050
1055
|
const versions = [];
|
|
1051
1056
|
for (const entry of entries) {
|
|
1052
1057
|
if (!entry.fidelity || !entry.match?.id)
|
|
1053
1058
|
continue;
|
|
1054
1059
|
const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
|
|
1055
|
-
const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
|
|
1060
|
+
const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status, statuses)];
|
|
1056
1061
|
if (context)
|
|
1057
1062
|
versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
|
|
1058
1063
|
}
|
|
@@ -1197,6 +1202,7 @@ async function commitGithubImportTransaction(config) {
|
|
|
1197
1202
|
export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
1198
1203
|
pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
|
|
1199
1204
|
const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
|
|
1205
|
+
const workspace = await readGithubWorkspace(pmRoot);
|
|
1200
1206
|
let idPrefix = "pm-";
|
|
1201
1207
|
try {
|
|
1202
1208
|
const settings = await readSettings(pmRoot);
|
|
@@ -1213,11 +1219,11 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
|
1213
1219
|
// by the stable GitHub issue number so a retry whose API page/order changed
|
|
1214
1220
|
// supplies the exact same plan as well as the same transaction id.
|
|
1215
1221
|
for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
|
|
1216
|
-
const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly);
|
|
1222
|
+
const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly, workspace.statuses);
|
|
1217
1223
|
itemIds.set(entry.issueNumber, planned.itemId);
|
|
1218
1224
|
mutations.push(...planned.mutations);
|
|
1219
1225
|
}
|
|
1220
|
-
const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
|
|
1226
|
+
const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries, workspace.statuses));
|
|
1221
1227
|
// Update-only transactions derive their steps from the CURRENT item state,
|
|
1222
1228
|
// so a retry after a partial apply would fingerprint a different plan and
|
|
1223
1229
|
// orphan the journal behind a fresh transaction id. Persist the exact plan
|
|
@@ -2405,7 +2411,7 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
|
|
|
2405
2411
|
const timeline = await fidelityContext.timeline(issue, repo, token);
|
|
2406
2412
|
const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
|
|
2407
2413
|
const targetStatus = mapState(issue.state, issue.state_reason);
|
|
2408
|
-
const conflict = match?.status && match.status !== targetStatus && (
|
|
2414
|
+
const conflict = match?.status && canonicalGithubStatus(match.status, fidelityContext.statuses) !== canonicalGithubStatus(targetStatus, fidelityContext.statuses) && (githubStatusIsTerminal(match.status, fidelityContext.statuses) || issue.state === "closed")
|
|
2409
2415
|
? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
|
|
2410
2416
|
const kind = issue.pull_request ? "PR" : "issue";
|
|
2411
2417
|
const labels = issue.labels.map((label) => label.name).filter(Boolean);
|
|
@@ -2451,6 +2457,16 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
|
|
|
2451
2457
|
match,
|
|
2452
2458
|
};
|
|
2453
2459
|
}
|
|
2460
|
+
/** Recognize a complete source projection without treating the SDK mutation clock as content. */
|
|
2461
|
+
export function importEntryUnchanged(entry, types, statuses) {
|
|
2462
|
+
if (!entry.match?.id || !entry.fidelity || refreshChanges(entry, statuses).length !== 0)
|
|
2463
|
+
return false;
|
|
2464
|
+
if (entry.linkOnly)
|
|
2465
|
+
return true;
|
|
2466
|
+
const fields = { type: entry.itemType, description: entry.description, body: entry.body,
|
|
2467
|
+
...(entry.assignee ? { assignee: entry.assignee } : {}), ...(entry.milestone ? { sprint: entry.milestone } : {}) };
|
|
2468
|
+
return Object.entries(fields).every(([field, value]) => normalizeImportValue(field, ownValue(entry.match, field) ?? "", types, statuses) === normalizeImportValue(field, value, types, statuses));
|
|
2469
|
+
}
|
|
2454
2470
|
/**
|
|
2455
2471
|
* Verify that a gated import's plan accounts for every fetched issue exactly once.
|
|
2456
2472
|
*
|
|
@@ -2622,11 +2638,11 @@ function gateImportWrites(pmRoot) {
|
|
|
2622
2638
|
* throws {@link CommandError} (with a semantic exit code) on failure.
|
|
2623
2639
|
*
|
|
2624
2640
|
* @param repoArg - The `owner/repo` to import from.
|
|
2625
|
-
* @param
|
|
2641
|
+
* @param pmPath - Workspace root or pm data dir.
|
|
2626
2642
|
* @param opts - Normalized import options.
|
|
2627
2643
|
* @param deps - Injectable collaborators (token resolver, fetchers, etc.) for tests.
|
|
2628
2644
|
*/
|
|
2629
|
-
export async function runImport(repoArg,
|
|
2645
|
+
export async function runImport(repoArg, pmPath, opts, dependencies = {}) {
|
|
2630
2646
|
if (!repoArg || !repoArg.includes("/")) {
|
|
2631
2647
|
throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
|
|
2632
2648
|
"[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
|
|
@@ -2668,7 +2684,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2668
2684
|
console.error("No issues found.");
|
|
2669
2685
|
// Even a no-op gated import must prove the workspace is gateable (a Git work
|
|
2670
2686
|
// tree the scanner can read), so the empty-change gate scan runs here too.
|
|
2671
|
-
const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(
|
|
2687
|
+
const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmPath) : undefined;
|
|
2672
2688
|
if (opts.dryRun && (opts.atomic || missingIssues.length > 0)) {
|
|
2673
2689
|
return {
|
|
2674
2690
|
dryRun: true,
|
|
@@ -2688,6 +2704,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2688
2704
|
// reported "would import N, skip 0" where the real run performs updates for
|
|
2689
2705
|
// already-linked issues. A preview that overstates creates reads as "this will
|
|
2690
2706
|
// duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
|
|
2707
|
+
const workspace = await readGithubWorkspace(pmPath);
|
|
2708
|
+
const pmRoot = workspace.pmRoot;
|
|
2709
|
+
const typeNames = new Map(Object.entries(workspace.types.alias_to_type));
|
|
2691
2710
|
/** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
|
|
2692
2711
|
const readImportItems = async () => {
|
|
2693
2712
|
if (dependencies.readItems)
|
|
@@ -2697,7 +2716,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2697
2716
|
};
|
|
2698
2717
|
const existingItems = await readImportItems();
|
|
2699
2718
|
const existing = indexByProvenance(existingItems);
|
|
2700
|
-
const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
|
|
2719
|
+
const fidelityContext = { statuses: workspace.statuses, items: existingItems, releases: readImportReleases(pmRoot),
|
|
2701
2720
|
timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
|
|
2702
2721
|
let imported = 0;
|
|
2703
2722
|
let updated = 0;
|
|
@@ -2763,8 +2782,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2763
2782
|
gatePlan = planned;
|
|
2764
2783
|
gatePlanByNumber = new Map(planned.map((entry) => [entry.issueNumber, entry]));
|
|
2765
2784
|
skipped = skippedNumbers.length;
|
|
2766
|
-
if (opts.gate
|
|
2767
|
-
const preview = runCommandTrackerGate({ pmRoot, plannedItems: planned.map(entry => ({
|
|
2785
|
+
if (opts.gate) {
|
|
2786
|
+
const preview = runCommandTrackerGate({ pmRoot, planOnly: opts.dryRun, plannedItems: planned.map(entry => ({
|
|
2768
2787
|
itemId: entry.match?.id ?? `github-issue-${entry.issueNumber}`,
|
|
2769
2788
|
fields: { ...entry.fidelity, title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
|
|
2770
2789
|
assignee: entry.assignee, sprint: entry.milestone,
|
|
@@ -2784,7 +2803,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2784
2803
|
const source = entry.fidelity;
|
|
2785
2804
|
const reason = source.github_close_reason.split(" (", 1)[0];
|
|
2786
2805
|
return { issue: entry.issueNumber, item: entry.match?.id,
|
|
2787
|
-
action: entry.identity?.via === "citation" ? "adopt" : entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
|
|
2806
|
+
action: importEntryUnchanged(entry, typeNames, workspace.statuses) ? "unchanged" : entry.identity?.via === "citation" ? "adopt" : entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
|
|
2788
2807
|
...source, github_close_reason: reason,
|
|
2789
2808
|
created_at: entry.linkOnly ? entry.match.created_at : source.github_created_at,
|
|
2790
2809
|
updated_at_policy: "sdk-mutation-clock; source in github_updated_at",
|
|
@@ -2796,7 +2815,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2796
2815
|
});
|
|
2797
2816
|
const adoptionCount = gatePlan.filter(entry => entry.identity?.via === "citation").length;
|
|
2798
2817
|
const planFields = { ...(opts.gate || adoptionCount > 0 || citationCandidates.length > 0 ? { plan: fidelityPlan() } : {}), ...citationReceipt };
|
|
2799
|
-
const gatePlanFields = { ...planFields, ...(opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {}) };
|
|
2818
|
+
const gatePlanFields = { ...planFields, ...(opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: opts.dryRun ? "plan" : "post-write" } } : {}) };
|
|
2800
2819
|
if (opts.atomic) {
|
|
2801
2820
|
// The gated pipeline already prepared every entry (and counted its skips)
|
|
2802
2821
|
// during the pre-mutation plan phase; reuse that plan verbatim so the write
|
|
@@ -2817,11 +2836,16 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2817
2836
|
}
|
|
2818
2837
|
throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`, EXIT_CODE.GENERIC_FAILURE);
|
|
2819
2838
|
}
|
|
2839
|
+
// Preserve the complete ordered plan while a transaction is resumable.
|
|
2840
|
+
// Otherwise omit each normalized no-op, including in mixed batches.
|
|
2841
|
+
const changed = (await listUnfinishedGithubImports(pmRoot)).length > 0 ? prepared
|
|
2842
|
+
: prepared.filter(entry => !importEntryUnchanged(entry, typeNames, workspace.statuses));
|
|
2843
|
+
const changedAdoptions = changed.filter(entry => entry.identity?.via === "citation").length;
|
|
2820
2844
|
if (opts.dryRun) {
|
|
2821
|
-
imported =
|
|
2822
|
-
updated =
|
|
2845
|
+
imported = changed.filter((entry) => !entry.match?.id).length;
|
|
2846
|
+
updated = changed.length - imported - changedAdoptions;
|
|
2823
2847
|
for (const entry of prepared) {
|
|
2824
|
-
const action = entry.identity?.via === "citation" ? "adopt" : entry.match?.id ? "update" : "import";
|
|
2848
|
+
const action = !changed.includes(entry) ? "unchanged" : entry.identity?.via === "citation" ? "adopt" : entry.match?.id ? "update" : "import";
|
|
2825
2849
|
console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${opts.gate ? "(gated title)" : entry.title} (${entry.status})`);
|
|
2826
2850
|
}
|
|
2827
2851
|
console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}. Adopt ${adoptionCount}.`);
|
|
@@ -2841,7 +2865,16 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2841
2865
|
...fetchFields,
|
|
2842
2866
|
};
|
|
2843
2867
|
}
|
|
2844
|
-
|
|
2868
|
+
// Mixed create/update batches can select a fresh journal after the first
|
|
2869
|
+
// apply. Replaying an otherwise identical SDK UPDATE advances updated_at.
|
|
2870
|
+
// Never hide an interrupted journal behind the unchanged-state shortcut.
|
|
2871
|
+
const unchanged = changed.length === 0;
|
|
2872
|
+
const result = unchanged ? { transactionId: undefined, recovered: false, recoveredItems: undefined, imported: 0, updated: 0,
|
|
2873
|
+
itemIds: new Map(prepared.map(entry => [entry.issueNumber, entry.match.id])) }
|
|
2874
|
+
: await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, changed);
|
|
2875
|
+
for (const entry of prepared)
|
|
2876
|
+
if (!changed.includes(entry))
|
|
2877
|
+
result.itemIds.set(entry.issueNumber, entry.match.id);
|
|
2845
2878
|
if (opts.gate)
|
|
2846
2879
|
verifyImportedProvenance(await readImportItems(), prepared, repo);
|
|
2847
2880
|
// Planned native comments are part of the verified plan: a contended lock,
|
|
@@ -2861,7 +2894,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2861
2894
|
}
|
|
2862
2895
|
}
|
|
2863
2896
|
failClosedOnPlanDivergence(opts.gate, skipped, skipped, commentWriteFailures);
|
|
2864
|
-
if (
|
|
2897
|
+
if (unchanged)
|
|
2898
|
+
console.error("Atomic import unchanged; no item transaction was needed.");
|
|
2899
|
+
else if (result.recovered) {
|
|
2865
2900
|
console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
|
|
2866
2901
|
}
|
|
2867
2902
|
else {
|
|
@@ -2878,10 +2913,11 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2878
2913
|
const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
|
|
2879
2914
|
return {
|
|
2880
2915
|
transactionId: result.transactionId,
|
|
2916
|
+
...(unchanged ? { unchanged: true } : {}),
|
|
2881
2917
|
recovered: result.recovered,
|
|
2882
2918
|
imported: result.imported,
|
|
2883
|
-
updated: result.updated - (result.recovered ? 0 :
|
|
2884
|
-
adopted: result.recovered ? 0 :
|
|
2919
|
+
updated: result.updated - (result.recovered ? 0 : changedAdoptions),
|
|
2920
|
+
adopted: result.recovered ? 0 : changedAdoptions,
|
|
2885
2921
|
...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}),
|
|
2886
2922
|
skipped,
|
|
2887
2923
|
atomic: true,
|
|
@@ -2913,6 +2949,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2913
2949
|
const args = ["--path", pmRoot, "close", id, "--reason", prepared.fidelity.github_close_reason];
|
|
2914
2950
|
return args;
|
|
2915
2951
|
};
|
|
2952
|
+
// Annotation sync still runs when only comments changed; native content
|
|
2953
|
+
// mutations must never advance the SDK clock for an identical projection.
|
|
2954
|
+
if (importEntryUnchanged(prepared, typeNames, workspace.statuses)) {
|
|
2955
|
+
if (!opts.dryRun && syncAnnotations) {
|
|
2956
|
+
commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
|
|
2957
|
+
}
|
|
2958
|
+
continue;
|
|
2959
|
+
}
|
|
2916
2960
|
if (opts.dryRun) {
|
|
2917
2961
|
const action = prepared.identity?.via === "citation" ? "adopt" : match?.id ? "update" : "import";
|
|
2918
2962
|
const metadata = !opts.gate && labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
|
|
@@ -2962,22 +3006,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2962
3006
|
continue;
|
|
2963
3007
|
}
|
|
2964
3008
|
// Reconcile status separately.
|
|
2965
|
-
if (status === "canceled" && match.status !== "canceled") {
|
|
3009
|
+
if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses) && canonicalGithubStatus(match.status, workspace.statuses) !== canonicalGithubStatus("canceled", workspace.statuses)) {
|
|
2966
3010
|
const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
|
|
2967
3011
|
if (!cancel.ok)
|
|
2968
3012
|
throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
|
|
2969
3013
|
}
|
|
2970
|
-
if (status === "closed" && match.status !== "closed") {
|
|
3014
|
+
if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses) && canonicalGithubStatus(match.status, workspace.statuses) !== canonicalGithubStatus("closed", workspace.statuses)) {
|
|
2971
3015
|
// Close through `pm close` so the reason is real provenance; pass the
|
|
2972
3016
|
// source completion time as --completed-at when GitHub recorded one.
|
|
2973
|
-
const close = pmRun([...githubCloseArgs(match.id), ...(match.status
|
|
3017
|
+
const close = pmRun([...githubCloseArgs(match.id), ...(githubStatusIsTerminal(match.status, workspace.statuses) ? ["--force"] : [])]);
|
|
2974
3018
|
if (!close.ok) {
|
|
2975
3019
|
console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
|
|
2976
3020
|
skipped++;
|
|
2977
3021
|
continue;
|
|
2978
3022
|
}
|
|
2979
3023
|
}
|
|
2980
|
-
else if (status === "open" && (match.status
|
|
3024
|
+
else if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("open", workspace.statuses) && githubStatusIsTerminal(match.status, workspace.statuses)) {
|
|
2981
3025
|
const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
|
|
2982
3026
|
if (!reopen.ok) {
|
|
2983
3027
|
console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
|
|
@@ -2999,8 +3043,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2999
3043
|
// `require_close_reason` rejects `pm create --status closed`. Create the
|
|
3000
3044
|
// item open, then close it through `pm close` with the source's real
|
|
3001
3045
|
// completion timestamp (`closed_at`) as --completed-at provenance.
|
|
3002
|
-
const createStatus = status === "closed" ? "open" : status;
|
|
3003
|
-
const mustClose = status === "closed";
|
|
3046
|
+
const createStatus = canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses) ? "open" : status;
|
|
3047
|
+
const mustClose = canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses);
|
|
3004
3048
|
const createArgs = [
|
|
3005
3049
|
"--path", pmRoot, "create",
|
|
3006
3050
|
"--title", title,
|
|
@@ -3011,7 +3055,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
3011
3055
|
"--tags", importTagOptions(prepared).tags,
|
|
3012
3056
|
"--message", `Imported from GitHub #${issue.number}`,
|
|
3013
3057
|
];
|
|
3014
|
-
if (status === "canceled")
|
|
3058
|
+
if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses))
|
|
3015
3059
|
createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
|
|
3016
3060
|
if (assignee)
|
|
3017
3061
|
createArgs.push("--assignee", assignee);
|
|
@@ -3163,8 +3207,8 @@ function failClosedOnPlanDivergence(gate, plannedSkipped, actualSkipped, comment
|
|
|
3163
3207
|
// ---------------------------------------------------------------------------
|
|
3164
3208
|
// Sync core — push pm status changes back to GitHub (close / reopen)
|
|
3165
3209
|
// ---------------------------------------------------------------------------
|
|
3166
|
-
function pmStatusToGithubState(status) {
|
|
3167
|
-
return status
|
|
3210
|
+
function pmStatusToGithubState(status, statuses) {
|
|
3211
|
+
return githubStatusIsTerminal(status, statuses) ? "closed" : "open";
|
|
3168
3212
|
}
|
|
3169
3213
|
/**
|
|
3170
3214
|
* Require every requested item before a remote sync or export can begin.
|
|
@@ -3182,7 +3226,7 @@ function requireRequestedItems(items, requested) {
|
|
|
3182
3226
|
}
|
|
3183
3227
|
// Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
|
|
3184
3228
|
// a create-or-update entry keyed by the issue's provenance tag.
|
|
3185
|
-
export function planSync(items, repo) {
|
|
3229
|
+
export function planSync(items, repo, statuses) {
|
|
3186
3230
|
const plan = [];
|
|
3187
3231
|
const repoLc = repo.toLowerCase();
|
|
3188
3232
|
for (const item of items) {
|
|
@@ -3192,7 +3236,7 @@ export function planSync(items, repo) {
|
|
|
3192
3236
|
const p = parseProvenanceTag(tag);
|
|
3193
3237
|
if (!p || p.repo !== repoLc)
|
|
3194
3238
|
continue;
|
|
3195
|
-
const desired = pmStatusToGithubState(item.status);
|
|
3239
|
+
const desired = pmStatusToGithubState(item.status, statuses);
|
|
3196
3240
|
plan.push({
|
|
3197
3241
|
id: item.id,
|
|
3198
3242
|
number: p.number,
|
|
@@ -3227,7 +3271,8 @@ async function runSync(ctx) {
|
|
|
3227
3271
|
"(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
|
|
3228
3272
|
}
|
|
3229
3273
|
const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
|
|
3230
|
-
const
|
|
3274
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
3275
|
+
const plan = planSync(selected, repo, workspace.statuses);
|
|
3231
3276
|
if (plan.length === 0) {
|
|
3232
3277
|
const scopeNote = scopedIds.length > 0 ? ` from --ids (${scopedIds.join(", ")})` : "";
|
|
3233
3278
|
console.error(`No pm items${scopeNote} linked to ${repo} ` +
|
|
@@ -3289,7 +3334,7 @@ async function runSync(ctx) {
|
|
|
3289
3334
|
}
|
|
3290
3335
|
// Convert a pm item into the GitHub issue create/update payload, dropping
|
|
3291
3336
|
// internal provenance tags from labels and applying the optional label map.
|
|
3292
|
-
function itemToGithubPayload(item, labelMap) {
|
|
3337
|
+
function itemToGithubPayload(item, labelMap, statuses) {
|
|
3293
3338
|
// Drop our internal provenance tags from exported labels, then apply any
|
|
3294
3339
|
// user-supplied label mapping (pm tag → GitHub label). Both issue provenance
|
|
3295
3340
|
// (`gh:owner/repo#N`) and project provenance (`gh-project:owner/number#itemId`)
|
|
@@ -3300,18 +3345,18 @@ function itemToGithubPayload(item, labelMap) {
|
|
|
3300
3345
|
title: item.title ?? "(untitled)",
|
|
3301
3346
|
body: item.body || item.description || "",
|
|
3302
3347
|
labels: applyLabelMap(labels, labelMap),
|
|
3303
|
-
state: item.status
|
|
3348
|
+
state: pmStatusToGithubState(item.status, statuses),
|
|
3304
3349
|
};
|
|
3305
3350
|
}
|
|
3306
3351
|
// Build the create/update plan. `repo` (lowercased) decides which provenance
|
|
3307
3352
|
// tags count as an "already exported to THIS repo" link → update; everything
|
|
3308
3353
|
// else is a create. Pure + side-effect free so it can be unit-tested and
|
|
3309
3354
|
// printed verbatim in --dry-run.
|
|
3310
|
-
export function buildExportPlan(items, repo, labelMap) {
|
|
3355
|
+
export function buildExportPlan(items, repo, labelMap, statuses) {
|
|
3311
3356
|
const repoLc = repo?.toLowerCase();
|
|
3312
3357
|
const plan = [];
|
|
3313
3358
|
for (const item of items) {
|
|
3314
|
-
const payload = itemToGithubPayload(item, labelMap);
|
|
3359
|
+
const payload = itemToGithubPayload(item, labelMap, statuses);
|
|
3315
3360
|
let number;
|
|
3316
3361
|
if (repoLc) {
|
|
3317
3362
|
for (const tag of item.tags ?? []) {
|
|
@@ -3473,7 +3518,8 @@ async function runExport(ctx) {
|
|
|
3473
3518
|
else if (["form-ref", "formRef", "form-answers", "formAnswers", "form-bindings", "formBindings"].some(flag => optionProvided(options, flag))) {
|
|
3474
3519
|
refuseIssueForm("invalid_form_input", [{ field: "form", reason: "selection", remedy: "Pass --form with --repo and --form-ref to enforce an issue-form contract." }]);
|
|
3475
3520
|
}
|
|
3476
|
-
const
|
|
3521
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
3522
|
+
const plan = buildExportPlan(selected, repo, labelMap, workspace.statuses);
|
|
3477
3523
|
for (const [index, completion] of completions.entries()) {
|
|
3478
3524
|
plan[index].payload.body = completion.body;
|
|
3479
3525
|
plan[index].payload.labels = uniqueGithubLabelNames([...plan[index].payload.labels, ...completion.report.contract.labels]);
|
|
@@ -3736,11 +3782,11 @@ function isGithubBlockerDependency(target) {
|
|
|
3736
3782
|
* @param items - The complete runtime-validated item corpus.
|
|
3737
3783
|
* @returns The candidate items, in corpus order.
|
|
3738
3784
|
*/
|
|
3739
|
-
export function selectBlockerCandidates(items) {
|
|
3785
|
+
export function selectBlockerCandidates(items, statuses) {
|
|
3740
3786
|
return items.filter((item) => {
|
|
3741
|
-
if (item.status === "blocked")
|
|
3787
|
+
if (githubStatusKind(item.status, statuses) === "blocked")
|
|
3742
3788
|
return true;
|
|
3743
|
-
if (
|
|
3789
|
+
if (githubStatusIsTerminal(item.status, statuses))
|
|
3744
3790
|
return false;
|
|
3745
3791
|
return (item.dependencies.some((dep) => dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id))
|
|
3746
3792
|
|| (item.blockedBy !== undefined && isGithubBlockerDependency(item.blockedBy)));
|
|
@@ -4199,7 +4245,8 @@ async function runBlockers(ctx) {
|
|
|
4199
4245
|
}
|
|
4200
4246
|
const corpus = await readBlockerPmItems(ctx.pm_root);
|
|
4201
4247
|
const selected = requireRequestedItems(corpus, scopedIds);
|
|
4202
|
-
const
|
|
4248
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
4249
|
+
const candidates = selectBlockerCandidates(selected, workspace.statuses);
|
|
4203
4250
|
const perItem = candidates.map((item) => ({ item, references: collectBlockerReferences(item, fallbackRepo) }));
|
|
4204
4251
|
const unique = new Map();
|
|
4205
4252
|
for (const entry of perItem) {
|
|
@@ -4845,7 +4892,8 @@ async function runProjectImport(ctx) {
|
|
|
4845
4892
|
// Reading the local store is non-mutating and keeps dry-run faithful: linked
|
|
4846
4893
|
// project items must preview as updates, not misleading duplicate creates.
|
|
4847
4894
|
const pmItems = readPmItems(ctx.pm_root);
|
|
4848
|
-
const
|
|
4895
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
4896
|
+
const plan = buildProjectImportPlan(projectItems, ref, pmItems, statusMap, workspace.statuses);
|
|
4849
4897
|
let imported = 0;
|
|
4850
4898
|
let updated = 0;
|
|
4851
4899
|
let skipped = 0;
|
|
@@ -4878,9 +4926,12 @@ async function runProjectImport(ctx) {
|
|
|
4878
4926
|
// A `closed` mapping cannot go through `pm update --status closed`
|
|
4879
4927
|
// (governance require_close_reason rejects it since pm-cli 2026.8.3);
|
|
4880
4928
|
// the update omits --status and a separate `pm close` records the reason.
|
|
4881
|
-
const closeAfterUpdate = entry.mappedStatus ===
|
|
4882
|
-
if (entry.mappedStatus && !closeAfterUpdate)
|
|
4929
|
+
const closeAfterUpdate = entry.mappedStatus !== undefined && canonicalGithubStatus(entry.mappedStatus, workspace.statuses) === workspace.statuses.close_status;
|
|
4930
|
+
if (entry.mappedStatus && !closeAfterUpdate) {
|
|
4883
4931
|
updArgs.push("--status", entry.mappedStatus);
|
|
4932
|
+
if (githubStatusIsTerminal(entry.mappedStatus, workspace.statuses))
|
|
4933
|
+
updArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
|
|
4934
|
+
}
|
|
4884
4935
|
const upd = pmRun(updArgs);
|
|
4885
4936
|
if (!upd.ok) {
|
|
4886
4937
|
console.error(` ${entry.title}: update failed — ${upd.stderr}`);
|
|
@@ -4902,19 +4953,21 @@ async function runProjectImport(ctx) {
|
|
|
4902
4953
|
// `require_close_reason` rejects `pm create --status closed` (pm-cli
|
|
4903
4954
|
// 2026.8.3+). Create open, then close through `pm close` with factual
|
|
4904
4955
|
// provenance. `--json` is needed to read the assigned id for the close.
|
|
4905
|
-
const
|
|
4906
|
-
const
|
|
4956
|
+
const mustClose = canonicalGithubStatus(entry.status, workspace.statuses) === workspace.statuses.close_status;
|
|
4957
|
+
const createStatus = mustClose ? workspace.statuses.open_status : entry.status;
|
|
4907
4958
|
const createArgs = [
|
|
4908
4959
|
"--path", ctx.pm_root, "create",
|
|
4909
4960
|
"--title", entry.title,
|
|
4910
4961
|
"--type", itemType,
|
|
4911
4962
|
"--status", createStatus,
|
|
4912
4963
|
"--description", description,
|
|
4913
|
-
"--tags", entry.tags
|
|
4964
|
+
"--tags", serializeImportTags(entry.tags),
|
|
4914
4965
|
"--message", `Imported from GitHub project ${ref.owner}/${ref.number}`,
|
|
4915
4966
|
];
|
|
4916
4967
|
if (entry.body)
|
|
4917
4968
|
createArgs.push("--body", entry.body);
|
|
4969
|
+
if (!mustClose && githubStatusIsTerminal(entry.status, workspace.statuses))
|
|
4970
|
+
createArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
|
|
4918
4971
|
if (mustClose)
|
|
4919
4972
|
createArgs.push("--json");
|
|
4920
4973
|
const created = pmRun(createArgs);
|
|
@@ -5035,22 +5088,22 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
5035
5088
|
* @param pmRoot - Workspace root for the `pm` invocation.
|
|
5036
5089
|
* @returns The argv array.
|
|
5037
5090
|
*/
|
|
5038
|
-
export function buildPullEntryArgs(entry, pmRoot) {
|
|
5091
|
+
export function buildPullEntryArgs(entry, pmRoot, statuses) {
|
|
5039
5092
|
const reason = `GitHub project status → ${entry.toStatus}`;
|
|
5040
|
-
if (entry.toStatus === "closed") {
|
|
5093
|
+
if (canonicalGithubStatus(entry.toStatus, statuses) === (statuses?.close_status ?? "closed")) {
|
|
5041
5094
|
return ["--path", pmRoot, "close", entry.pmId, "--reason", reason];
|
|
5042
5095
|
}
|
|
5043
|
-
if (entry.toStatus
|
|
5096
|
+
if (githubStatusIsTerminal(entry.toStatus, statuses)) {
|
|
5044
5097
|
return [
|
|
5045
5098
|
"--path", pmRoot, "update", entry.pmId,
|
|
5046
|
-
"--status",
|
|
5099
|
+
"--status", canonicalGithubStatus(entry.toStatus, statuses),
|
|
5047
5100
|
"--close-reason", reason,
|
|
5048
5101
|
"--message", reason,
|
|
5049
5102
|
];
|
|
5050
5103
|
}
|
|
5051
5104
|
return [
|
|
5052
5105
|
"--path", pmRoot, "update", entry.pmId,
|
|
5053
|
-
"--status", entry.toStatus,
|
|
5106
|
+
"--status", canonicalGithubStatus(entry.toStatus, statuses),
|
|
5054
5107
|
"--message", reason,
|
|
5055
5108
|
];
|
|
5056
5109
|
}
|
|
@@ -5058,8 +5111,8 @@ export function buildPullEntryArgs(entry, pmRoot) {
|
|
|
5058
5111
|
// (records closed_at + close_reason); `canceled` keeps its distinct terminal
|
|
5059
5112
|
// state via `pm update` while recording `--close-reason`; everything else is a
|
|
5060
5113
|
// plain `pm update --status`.
|
|
5061
|
-
function applyPullEntry(entry, pmRoot) {
|
|
5062
|
-
const res = pmRun(buildPullEntryArgs(entry, pmRoot));
|
|
5114
|
+
function applyPullEntry(entry, pmRoot, statuses) {
|
|
5115
|
+
const res = pmRun(buildPullEntryArgs(entry, pmRoot, statuses));
|
|
5063
5116
|
return res.ok ? { changed: true } : { changed: false, error: res.stderr };
|
|
5064
5117
|
}
|
|
5065
5118
|
/**
|
|
@@ -5108,11 +5161,12 @@ async function runProjectSync(ctx) {
|
|
|
5108
5161
|
for (const it of scoped.selected)
|
|
5109
5162
|
if (it.id)
|
|
5110
5163
|
pmById.set(it.id, it);
|
|
5164
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
5111
5165
|
const pushPlan = doPush
|
|
5112
|
-
? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap })
|
|
5166
|
+
? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap, statuses: workspace.statuses })
|
|
5113
5167
|
: undefined;
|
|
5114
5168
|
const pullPlan = doPull
|
|
5115
|
-
? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap)
|
|
5169
|
+
? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap, workspace.statuses)
|
|
5116
5170
|
: undefined;
|
|
5117
5171
|
// Conflict resolution when applying BOTH directions: a linked item can appear
|
|
5118
5172
|
// in both plans. `--prefer pm` (default) lets push win (skip its pull entry);
|
|
@@ -5179,7 +5233,7 @@ async function runProjectSync(ctx) {
|
|
|
5179
5233
|
for (const e of pullActionable) {
|
|
5180
5234
|
if (prefer === "pm" && pushItemIds.has(e.itemId))
|
|
5181
5235
|
continue; // push wins
|
|
5182
|
-
const r = applyPullEntry(e, ctx.pm_root);
|
|
5236
|
+
const r = applyPullEntry(e, ctx.pm_root, workspace.statuses);
|
|
5183
5237
|
if (!r.changed) {
|
|
5184
5238
|
console.error(` pull ${e.pmId} "${e.title}": ${r.error}`);
|
|
5185
5239
|
pullFailed++;
|
|
@@ -5315,7 +5369,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
5315
5369
|
const defineExtension = (module) => module;
|
|
5316
5370
|
export default defineExtension({
|
|
5317
5371
|
name: "pm-github",
|
|
5318
|
-
version: "2026.10.
|
|
5372
|
+
version: "2026.10.30",
|
|
5319
5373
|
activate(api) {
|
|
5320
5374
|
// -----------------------------------------------------------------------
|
|
5321
5375
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -5575,6 +5629,30 @@ export default defineExtension({
|
|
|
5575
5629
|
});
|
|
5576
5630
|
},
|
|
5577
5631
|
});
|
|
5632
|
+
api.registerCommand({
|
|
5633
|
+
name: "github discussion capture",
|
|
5634
|
+
description: "Preview explicitly marked issue-comment spans as native Decisions. Apply only the reviewed digest; source instructions remain quoted data.",
|
|
5635
|
+
flags: [
|
|
5636
|
+
{ long: "--input", value_name: "file", description: "Strict JSON selection: repo, issue, item, selections with comment/start/end/quote/marked:true" },
|
|
5637
|
+
{ long: "--approve", value_name: "digest", description: "Apply the exact reviewed preview, re-reading every source and native target" },
|
|
5638
|
+
{ long: "--max-output-bytes", value_name: "N", description: "Complete JSON receipt budget, integer 1024..131072 (default 65536)" },
|
|
5639
|
+
],
|
|
5640
|
+
async run(ctx) {
|
|
5641
|
+
const { input, approve, maxOutputBytes } = ctx.options;
|
|
5642
|
+
if (typeof input !== "string" || approve !== undefined && typeof approve !== "string")
|
|
5643
|
+
refuseDiscussion("invalid_selection", "Supply --input with a strict JSON selection document and an optional full --approve digest.");
|
|
5644
|
+
let selection;
|
|
5645
|
+
try {
|
|
5646
|
+
selection = readLabelMigrationJson(input);
|
|
5647
|
+
}
|
|
5648
|
+
catch {
|
|
5649
|
+
refuseDiscussion("invalid_selection", "Supply a readable bounded regular JSON selection file; see pm github discussion capture --help.");
|
|
5650
|
+
}
|
|
5651
|
+
return captureGithubDiscussion(ctx.pm_root, selection, ctx.sdk, {
|
|
5652
|
+
approval: approve, maxOutputBytes: maxOutputBytes === undefined ? undefined : Number(maxOutputBytes),
|
|
5653
|
+
});
|
|
5654
|
+
},
|
|
5655
|
+
});
|
|
5578
5656
|
api.registerCommand({
|
|
5579
5657
|
name: "github labels plan",
|
|
5580
5658
|
description: "Preview exact provider label rename/consolidation impacts on stored tags and declared filters/mappings. Read-only; --approve revalidates a saved digest against current state.",
|
|
@@ -5763,6 +5841,19 @@ export default defineExtension({
|
|
|
5763
5841
|
return report;
|
|
5764
5842
|
},
|
|
5765
5843
|
});
|
|
5844
|
+
// Receipts stay private until the workflow scans the selected diagnostic.
|
|
5845
|
+
api.registerCommand({
|
|
5846
|
+
name: "github workflow-diagnostic",
|
|
5847
|
+
description: "Select one actionable refusal from private workflow receipts. Privacy-scan before publishing the result.",
|
|
5848
|
+
flags: [
|
|
5849
|
+
{ long: "--report", value_name: "file", description: "Command JSON stdout receipt" },
|
|
5850
|
+
{ long: "--error-file", value_name: "file", description: "Command stderr receipt" },
|
|
5851
|
+
{ long: "--diff", value_name: "file", description: "Repeat-import tracker diff" },
|
|
5852
|
+
],
|
|
5853
|
+
async run(ctx) {
|
|
5854
|
+
return readWorkflowFailure(optionString(ctx.options, "report"), optionString(ctx.options, "error-file", "errorFile"), optionString(ctx.options, "diff"));
|
|
5855
|
+
},
|
|
5856
|
+
});
|
|
5766
5857
|
// Closeout uses the existing repository resolution and REST transport.
|
|
5767
5858
|
api.registerCommand({
|
|
5768
5859
|
name: "github closeout",
|
|
@@ -5785,8 +5876,12 @@ export default defineExtension({
|
|
|
5785
5876
|
const report = await runCloseout(ctx.pm_root, resolveSearchRepo(ctx.options), ctx.options, {
|
|
5786
5877
|
base: githubApiBase(), get: url => fetchJSON(url, token), next: parseNextLink,
|
|
5787
5878
|
});
|
|
5788
|
-
if (report.failed > 0)
|
|
5879
|
+
if (report.failed > 0) {
|
|
5880
|
+
// Preserve the full private receipt before the host formats or budgets its error message.
|
|
5881
|
+
if (ctx.global.json === true)
|
|
5882
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
5789
5883
|
throw new CommandError(JSON.stringify({ ...report, error: "pm github closeout: some closes failed" }), EXIT_CODE.GENERIC_FAILURE);
|
|
5884
|
+
}
|
|
5790
5885
|
return ctx.global.json === true || ctx.options.full === true ? report : compactCloseoutReport(report);
|
|
5791
5886
|
}
|
|
5792
5887
|
catch (error) {
|