pm-github 2026.10.27 → 2026.10.29
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 +52 -39
- package/README.md +75 -3
- 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.map +1 -1
- package/dist/gate.js +10 -4
- 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 +15 -0
- package/dist/import-tags.d.ts.map +1 -0
- package/dist/import-tags.js +35 -0
- package/dist/import-tags.js.map +1 -0
- package/dist/index.d.ts +52 -41
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +188 -89
- 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 +8 -5
- package/dist/issue-forms.js.map +1 -1
- package/dist/label-names.d.ts +6 -0
- package/dist/label-names.d.ts.map +1 -0
- package/dist/label-names.js +15 -0
- package/dist/label-names.js.map +1 -0
- package/dist/labels.d.ts +161 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +235 -0
- package/dist/labels.js.map +1 -0
- package/dist/projects.d.ts +6 -4
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +25 -24
- 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 +19 -9
- 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 +2 -2
- package/package.json +15 -10
package/dist/index.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { readGithubWorkspace, canonicalGithubStatus, githubStatusKind, githubStatusIsTerminal } from "./workspace.js";
|
|
1
2
|
import { ownValue } from "./own-record.js";
|
|
3
|
+
import { uniqueGithubLabelNames } from "./label-names.js";
|
|
4
|
+
import { githubLabelsToTags, importTagArguments, importTagOptions, importTagValues, nativeTagAdditions } from "./import-tags.js";
|
|
5
|
+
export { githubLabelsToTags } from "./import-tags.js";
|
|
2
6
|
import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
|
|
7
|
+
import { readWorkflowFailure } from "./workflow-diagnostic.js";
|
|
3
8
|
import { compileIssueForm, completeIssueForm, readRepositoryIssueForm, refuseIssueForm } from "./issue-forms.js";
|
|
4
9
|
export { compileIssueForm, completeIssueForm, readRepositoryIssueForm } from "./issue-forms.js";
|
|
5
|
-
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";
|
|
6
11
|
import { refreshChanges, runRefresh } from "./refresh.js";
|
|
7
12
|
import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
|
|
13
|
+
import { planGithubLabelMigration, readLabelMigrationJson } from "./labels.js";
|
|
14
|
+
import { captureGithubDiscussion, refuseDiscussion } from "./discussions.js";
|
|
15
|
+
export { captureGithubDiscussion, readGithubDiscussionComment, validateDiscussionCaptureReport } from "./discussions.js";
|
|
16
|
+
export { planGithubLabelMigration, readGithubLabelCatalog, validateGithubLabelMigrationReport } from "./labels.js";
|
|
8
17
|
export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
|
|
9
18
|
import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
|
|
10
19
|
export { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
|
|
@@ -14,7 +23,7 @@ import crypto from "node:crypto";
|
|
|
14
23
|
import fs from "node:fs";
|
|
15
24
|
import path from "node:path";
|
|
16
25
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
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";
|
|
26
|
+
import { createPmCliExpectedError, comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, resolvePmRoot as sdkResolvePmRoot, toImportTags, writeFileAtomic, getHistoryPath as sdkGetHistoryPath, readHistorySnapshot as sdkReadHistorySnapshot, } from "@unbrained/pm-cli/sdk";
|
|
18
27
|
import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
|
|
19
28
|
import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
|
|
20
29
|
export { resolvePmDataDir } from "./gate.js";
|
|
@@ -324,7 +333,7 @@ async function request(method, url, token, payload, maxRetries = 4) {
|
|
|
324
333
|
attempt++;
|
|
325
334
|
continue;
|
|
326
335
|
}
|
|
327
|
-
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}`);
|
|
328
337
|
}
|
|
329
338
|
}
|
|
330
339
|
/** Fetch a single GitHub REST endpoint via GET and return the decoded
|
|
@@ -423,18 +432,7 @@ export function parseLabelMap(options, ...keys) {
|
|
|
423
432
|
// map to the same GitHub label are collapsed (GitHub rejects duplicate labels
|
|
424
433
|
// on an issue with a 422), preserving first-seen order.
|
|
425
434
|
export function applyLabelMap(labels, labelMap) {
|
|
426
|
-
|
|
427
|
-
return labels;
|
|
428
|
-
const out = [];
|
|
429
|
-
const seen = new Set();
|
|
430
|
-
for (const label of labels) {
|
|
431
|
-
const mapped = labelMap.get(label) ?? label;
|
|
432
|
-
if (seen.has(mapped))
|
|
433
|
-
continue;
|
|
434
|
-
seen.add(mapped);
|
|
435
|
-
out.push(mapped);
|
|
436
|
-
}
|
|
437
|
-
return out;
|
|
435
|
+
return uniqueGithubLabelNames(labels.map(label => labelMap?.get(label) ?? label));
|
|
438
436
|
}
|
|
439
437
|
// Parse one or more CSV-like option values into a deduplicated string list.
|
|
440
438
|
// Accepts a single string ("a,b") or repeated values (["a,b", "c"]).
|
|
@@ -964,29 +962,30 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
|
|
|
964
962
|
return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
|
|
965
963
|
}
|
|
966
964
|
/** Map one rendered import entry to its reversible SDK mutation sequence. */
|
|
967
|
-
export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false) {
|
|
965
|
+
export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false, statuses) {
|
|
968
966
|
if (updateOnly && !entry.match?.id)
|
|
969
967
|
throw new CommandError("Refresh refuses an unlinked target; no item may be created.");
|
|
970
|
-
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;
|
|
971
970
|
const audit = updateOnly ? { comment: [`Refreshed linked GitHub issue ${repo.toLowerCase()}#${entry.issueNumber}`] } : {};
|
|
972
971
|
if (entry.linkOnly && entry.match?.id) {
|
|
973
|
-
return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ?
|
|
972
|
+
return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? importTagOptions(entry) : {}), ...audit } }] };
|
|
974
973
|
}
|
|
975
974
|
const sharedOptions = {
|
|
976
975
|
title: entry.title,
|
|
977
976
|
type: entry.itemType,
|
|
978
977
|
description: entry.description,
|
|
979
978
|
body: entry.body,
|
|
980
|
-
|
|
979
|
+
...importTagOptions(entry),
|
|
981
980
|
...(entry.assignee ? { assignee: entry.assignee } : {}),
|
|
982
981
|
...(entry.milestone ? { sprint: entry.milestone } : {}),
|
|
983
|
-
...(entry.status
|
|
982
|
+
...((!closing && githubStatusIsTerminal(entry.status, statuses)) ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
|
|
984
983
|
};
|
|
985
984
|
// Explicit terminal reconciliation also handles canceled -> completed. The
|
|
986
985
|
// supported force option permits that source-authoritative repeat closure;
|
|
987
986
|
// finalization restores the real dates before the transaction commits.
|
|
988
|
-
const statusOptions =
|
|
989
|
-
? entry.fidelity ? { status:
|
|
987
|
+
const statusOptions = closing
|
|
988
|
+
? entry.fidelity ? { status: entry.status, closeReason: entry.fidelity.github_close_reason, force: true } : {}
|
|
990
989
|
: { status: entry.status };
|
|
991
990
|
const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
|
|
992
991
|
// A missing match and a match at our deterministic external-key id use the
|
|
@@ -996,7 +995,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
996
995
|
// create step treats an existing stable id as already applied; update then
|
|
997
996
|
// makes later content-bearing transactions refresh the item normally.
|
|
998
997
|
if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
|
|
999
|
-
const createStatus =
|
|
998
|
+
const createStatus = closing ? "open" : entry.status;
|
|
1000
999
|
const mutations = [{
|
|
1001
1000
|
op: "create",
|
|
1002
1001
|
id: managedItemId,
|
|
@@ -1009,7 +1008,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1009
1008
|
...statusOptions,
|
|
1010
1009
|
},
|
|
1011
1010
|
}];
|
|
1012
|
-
if (
|
|
1011
|
+
if (closing && !entry.fidelity) {
|
|
1013
1012
|
mutations.push({
|
|
1014
1013
|
op: "close",
|
|
1015
1014
|
id: managedItemId,
|
|
@@ -1027,7 +1026,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1027
1026
|
const updateOptions = {
|
|
1028
1027
|
...(updateOnly ? {
|
|
1029
1028
|
...(changed.has("title") ? { title: entry.title } : {}),
|
|
1030
|
-
...(changed.has("tags") ?
|
|
1029
|
+
...(changed.has("tags") ? importTagOptions(entry) : {}),
|
|
1031
1030
|
...(changed.has("status") && sharedOptions.closeReason !== undefined ? { closeReason: sharedOptions.closeReason } : {}),
|
|
1032
1031
|
} : sharedOptions), ...(!changed || changed.has("status") ? statusOptions : {}), ...audit,
|
|
1033
1032
|
};
|
|
@@ -1036,7 +1035,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1036
1035
|
id: itemId,
|
|
1037
1036
|
options: updateOptions,
|
|
1038
1037
|
}];
|
|
1039
|
-
if (
|
|
1038
|
+
if (closing && !entry.fidelity) {
|
|
1040
1039
|
mutations.push({
|
|
1041
1040
|
op: "close",
|
|
1042
1041
|
id: itemId,
|
|
@@ -1052,13 +1051,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
|
|
|
1052
1051
|
* The importer's desired-state and compensation writes leave retries stable;
|
|
1053
1052
|
* local edits and later state cycles invalidate completed journals.
|
|
1054
1053
|
*/
|
|
1055
|
-
async function importContextVersions(pmRoot, entries) {
|
|
1054
|
+
async function importContextVersions(pmRoot, entries, statuses) {
|
|
1056
1055
|
const versions = [];
|
|
1057
1056
|
for (const entry of entries) {
|
|
1058
1057
|
if (!entry.fidelity || !entry.match?.id)
|
|
1059
1058
|
continue;
|
|
1060
1059
|
const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
|
|
1061
|
-
const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
|
|
1060
|
+
const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status, statuses)];
|
|
1062
1061
|
if (context)
|
|
1063
1062
|
versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
|
|
1064
1063
|
}
|
|
@@ -1203,6 +1202,7 @@ async function commitGithubImportTransaction(config) {
|
|
|
1203
1202
|
export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
1204
1203
|
pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
|
|
1205
1204
|
const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
|
|
1205
|
+
const workspace = await readGithubWorkspace(pmRoot);
|
|
1206
1206
|
let idPrefix = "pm-";
|
|
1207
1207
|
try {
|
|
1208
1208
|
const settings = await readSettings(pmRoot);
|
|
@@ -1219,11 +1219,11 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
|
1219
1219
|
// by the stable GitHub issue number so a retry whose API page/order changed
|
|
1220
1220
|
// supplies the exact same plan as well as the same transaction id.
|
|
1221
1221
|
for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
|
|
1222
|
-
const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly);
|
|
1222
|
+
const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly, workspace.statuses);
|
|
1223
1223
|
itemIds.set(entry.issueNumber, planned.itemId);
|
|
1224
1224
|
mutations.push(...planned.mutations);
|
|
1225
1225
|
}
|
|
1226
|
-
const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
|
|
1226
|
+
const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries, workspace.statuses));
|
|
1227
1227
|
// Update-only transactions derive their steps from the CURRENT item state,
|
|
1228
1228
|
// so a retry after a partial apply would fingerprint a different plan and
|
|
1229
1229
|
// orphan the journal behind a fresh transaction id. Persist the exact plan
|
|
@@ -2411,7 +2411,7 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
|
|
|
2411
2411
|
const timeline = await fidelityContext.timeline(issue, repo, token);
|
|
2412
2412
|
const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
|
|
2413
2413
|
const targetStatus = mapState(issue.state, issue.state_reason);
|
|
2414
|
-
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")
|
|
2415
2415
|
? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
|
|
2416
2416
|
const kind = issue.pull_request ? "PR" : "issue";
|
|
2417
2417
|
const labels = issue.labels.map((label) => label.name).filter(Boolean);
|
|
@@ -2444,7 +2444,7 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
|
|
|
2444
2444
|
(issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
|
|
2445
2445
|
` · created ${issue.created_at} · updated ${issue.updated_at}`,
|
|
2446
2446
|
body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
|
|
2447
|
-
tags:
|
|
2447
|
+
tags: toImportTags([...(identity.linkOnly || identity.via === "citation" ? match.tags ?? [] : []), ...githubLabelsToTags([...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])]),
|
|
2448
2448
|
assignee: issue.assignee?.login,
|
|
2449
2449
|
milestone: issue.milestone?.title,
|
|
2450
2450
|
comments,
|
|
@@ -2457,6 +2457,16 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
|
|
|
2457
2457
|
match,
|
|
2458
2458
|
};
|
|
2459
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
|
+
}
|
|
2460
2470
|
/**
|
|
2461
2471
|
* Verify that a gated import's plan accounts for every fetched issue exactly once.
|
|
2462
2472
|
*
|
|
@@ -2534,11 +2544,11 @@ export function verifyImportIdempotency(existing, prepared, repo) {
|
|
|
2534
2544
|
for (const entry of prepared) {
|
|
2535
2545
|
if (entry.match?.id) {
|
|
2536
2546
|
matchedIds.push(entry.match.id);
|
|
2537
|
-
if (entry.identity?.via === "citation" && !entry.
|
|
2547
|
+
if (entry.identity?.via === "citation" && !importTagValues(entry).includes(provenanceTag(repo, entry.issueNumber)))
|
|
2538
2548
|
untaggedNewEntries++;
|
|
2539
2549
|
continue;
|
|
2540
2550
|
}
|
|
2541
|
-
if (!entry.
|
|
2551
|
+
if (!importTagValues(entry).includes(provenanceTag(repo, entry.issueNumber)))
|
|
2542
2552
|
untaggedNewEntries++;
|
|
2543
2553
|
}
|
|
2544
2554
|
const conflictingTargets = matchedIds.length - new Set(matchedIds).size;
|
|
@@ -2628,11 +2638,11 @@ function gateImportWrites(pmRoot) {
|
|
|
2628
2638
|
* throws {@link CommandError} (with a semantic exit code) on failure.
|
|
2629
2639
|
*
|
|
2630
2640
|
* @param repoArg - The `owner/repo` to import from.
|
|
2631
|
-
* @param
|
|
2641
|
+
* @param pmPath - Workspace root or pm data dir.
|
|
2632
2642
|
* @param opts - Normalized import options.
|
|
2633
2643
|
* @param deps - Injectable collaborators (token resolver, fetchers, etc.) for tests.
|
|
2634
2644
|
*/
|
|
2635
|
-
export async function runImport(repoArg,
|
|
2645
|
+
export async function runImport(repoArg, pmPath, opts, dependencies = {}) {
|
|
2636
2646
|
if (!repoArg || !repoArg.includes("/")) {
|
|
2637
2647
|
throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
|
|
2638
2648
|
"[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
|
|
@@ -2674,7 +2684,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2674
2684
|
console.error("No issues found.");
|
|
2675
2685
|
// Even a no-op gated import must prove the workspace is gateable (a Git work
|
|
2676
2686
|
// tree the scanner can read), so the empty-change gate scan runs here too.
|
|
2677
|
-
const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(
|
|
2687
|
+
const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmPath) : undefined;
|
|
2678
2688
|
if (opts.dryRun && (opts.atomic || missingIssues.length > 0)) {
|
|
2679
2689
|
return {
|
|
2680
2690
|
dryRun: true,
|
|
@@ -2694,6 +2704,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2694
2704
|
// reported "would import N, skip 0" where the real run performs updates for
|
|
2695
2705
|
// already-linked issues. A preview that overstates creates reads as "this will
|
|
2696
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;
|
|
2697
2709
|
/** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
|
|
2698
2710
|
const readImportItems = async () => {
|
|
2699
2711
|
if (dependencies.readItems)
|
|
@@ -2703,7 +2715,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2703
2715
|
};
|
|
2704
2716
|
const existingItems = await readImportItems();
|
|
2705
2717
|
const existing = indexByProvenance(existingItems);
|
|
2706
|
-
const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
|
|
2718
|
+
const fidelityContext = { statuses: workspace.statuses, items: existingItems, releases: readImportReleases(pmRoot),
|
|
2707
2719
|
timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
|
|
2708
2720
|
let imported = 0;
|
|
2709
2721
|
let updated = 0;
|
|
@@ -2847,7 +2859,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2847
2859
|
...fetchFields,
|
|
2848
2860
|
};
|
|
2849
2861
|
}
|
|
2850
|
-
|
|
2862
|
+
// Mixed create/update batches can select a fresh journal after the first
|
|
2863
|
+
// apply. Replaying an otherwise identical SDK UPDATE advances updated_at.
|
|
2864
|
+
// Never hide an interrupted journal behind the unchanged-state shortcut.
|
|
2865
|
+
const typeNames = new Map(Object.entries(workspace.types.alias_to_type));
|
|
2866
|
+
const unchanged = prepared.every(entry => importEntryUnchanged(entry, typeNames, workspace.statuses)) && (await listUnfinishedGithubImports(pmRoot)).length === 0;
|
|
2867
|
+
const result = unchanged ? { transactionId: undefined, recovered: false, recoveredItems: undefined, imported: 0, updated: 0,
|
|
2868
|
+
itemIds: new Map(prepared.map(entry => [entry.issueNumber, entry.match.id])) }
|
|
2869
|
+
: await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
|
|
2851
2870
|
if (opts.gate)
|
|
2852
2871
|
verifyImportedProvenance(await readImportItems(), prepared, repo);
|
|
2853
2872
|
// Planned native comments are part of the verified plan: a contended lock,
|
|
@@ -2867,7 +2886,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2867
2886
|
}
|
|
2868
2887
|
}
|
|
2869
2888
|
failClosedOnPlanDivergence(opts.gate, skipped, skipped, commentWriteFailures);
|
|
2870
|
-
if (
|
|
2889
|
+
if (unchanged)
|
|
2890
|
+
console.error("Atomic import unchanged; no item transaction was needed.");
|
|
2891
|
+
else if (result.recovered) {
|
|
2871
2892
|
console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
|
|
2872
2893
|
}
|
|
2873
2894
|
else {
|
|
@@ -2884,6 +2905,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2884
2905
|
const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
|
|
2885
2906
|
return {
|
|
2886
2907
|
transactionId: result.transactionId,
|
|
2908
|
+
...(unchanged ? { unchanged: true } : {}),
|
|
2887
2909
|
recovered: result.recovered,
|
|
2888
2910
|
imported: result.imported,
|
|
2889
2911
|
updated: result.updated - (result.recovered ? 0 : adoptionCount),
|
|
@@ -2904,7 +2926,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2904
2926
|
continue;
|
|
2905
2927
|
}
|
|
2906
2928
|
const labels = issue.labels.map((l) => l.name).filter(Boolean);
|
|
2907
|
-
const { title, status, assignee, milestone, match, description, body,
|
|
2929
|
+
const { title, status, assignee, milestone, match, description, body, comments, syncAnnotations, } = prepared;
|
|
2908
2930
|
/**
|
|
2909
2931
|
* Build the `pm close` argv for this issue's pm item.
|
|
2910
2932
|
*
|
|
@@ -2933,7 +2955,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2933
2955
|
}
|
|
2934
2956
|
if (prepared.linkOnly) {
|
|
2935
2957
|
const ownerId = match.id;
|
|
2936
|
-
const
|
|
2958
|
+
const nativeTags = importTagArguments(prepared);
|
|
2959
|
+
const linked = nativeTags.length
|
|
2960
|
+
? pmRun(["--path", pmRoot, "update", ownerId, ...nativeTags]) : { ok: true };
|
|
2937
2961
|
if (!linked.ok)
|
|
2938
2962
|
throw new CommandError(`Could not link GitHub issue #${issue.number} to its existing owner.`);
|
|
2939
2963
|
await persistImportFidelity(pmRoot, ownerId, prepared.fidelity, true);
|
|
@@ -2946,12 +2970,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2946
2970
|
if (match?.id) {
|
|
2947
2971
|
// Idempotent update — never duplicate. Status transitions go through the
|
|
2948
2972
|
// proper command (close requires a reason; reopen via update --status).
|
|
2973
|
+
const nativeTags = importTagArguments(prepared);
|
|
2949
2974
|
const updArgs = [
|
|
2950
2975
|
"--path", pmRoot, "update", match.id,
|
|
2951
2976
|
"--title", title,
|
|
2952
2977
|
"--description", description,
|
|
2953
2978
|
"--body", body,
|
|
2954
|
-
|
|
2979
|
+
...nativeTags,
|
|
2955
2980
|
"--message", `Re-imported from GitHub #${issue.number}`,
|
|
2956
2981
|
];
|
|
2957
2982
|
if (assignee)
|
|
@@ -2965,22 +2990,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2965
2990
|
continue;
|
|
2966
2991
|
}
|
|
2967
2992
|
// Reconcile status separately.
|
|
2968
|
-
if (status === "canceled" && match.status !== "canceled") {
|
|
2993
|
+
if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses) && canonicalGithubStatus(match.status, workspace.statuses) !== canonicalGithubStatus("canceled", workspace.statuses)) {
|
|
2969
2994
|
const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
|
|
2970
2995
|
if (!cancel.ok)
|
|
2971
2996
|
throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
|
|
2972
2997
|
}
|
|
2973
|
-
if (status === "closed" && match.status !== "closed") {
|
|
2998
|
+
if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses) && canonicalGithubStatus(match.status, workspace.statuses) !== canonicalGithubStatus("closed", workspace.statuses)) {
|
|
2974
2999
|
// Close through `pm close` so the reason is real provenance; pass the
|
|
2975
3000
|
// source completion time as --completed-at when GitHub recorded one.
|
|
2976
|
-
const close = pmRun([...githubCloseArgs(match.id), ...(match.status
|
|
3001
|
+
const close = pmRun([...githubCloseArgs(match.id), ...(githubStatusIsTerminal(match.status, workspace.statuses) ? ["--force"] : [])]);
|
|
2977
3002
|
if (!close.ok) {
|
|
2978
3003
|
console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
|
|
2979
3004
|
skipped++;
|
|
2980
3005
|
continue;
|
|
2981
3006
|
}
|
|
2982
3007
|
}
|
|
2983
|
-
else if (status === "open" && (match.status
|
|
3008
|
+
else if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("open", workspace.statuses) && githubStatusIsTerminal(match.status, workspace.statuses)) {
|
|
2984
3009
|
const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
|
|
2985
3010
|
if (!reopen.ok) {
|
|
2986
3011
|
console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
|
|
@@ -3002,8 +3027,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
3002
3027
|
// `require_close_reason` rejects `pm create --status closed`. Create the
|
|
3003
3028
|
// item open, then close it through `pm close` with the source's real
|
|
3004
3029
|
// completion timestamp (`closed_at`) as --completed-at provenance.
|
|
3005
|
-
const createStatus = status === "closed" ? "open" : status;
|
|
3006
|
-
const mustClose = status === "closed";
|
|
3030
|
+
const createStatus = canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses) ? "open" : status;
|
|
3031
|
+
const mustClose = canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses);
|
|
3007
3032
|
const createArgs = [
|
|
3008
3033
|
"--path", pmRoot, "create",
|
|
3009
3034
|
"--title", title,
|
|
@@ -3011,10 +3036,10 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
3011
3036
|
"--status", createStatus,
|
|
3012
3037
|
"--description", description,
|
|
3013
3038
|
"--body", body,
|
|
3014
|
-
"--tags",
|
|
3039
|
+
"--tags", importTagOptions(prepared).tags,
|
|
3015
3040
|
"--message", `Imported from GitHub #${issue.number}`,
|
|
3016
3041
|
];
|
|
3017
|
-
if (status === "canceled")
|
|
3042
|
+
if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses))
|
|
3018
3043
|
createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
|
|
3019
3044
|
if (assignee)
|
|
3020
3045
|
createArgs.push("--assignee", assignee);
|
|
@@ -3166,8 +3191,8 @@ function failClosedOnPlanDivergence(gate, plannedSkipped, actualSkipped, comment
|
|
|
3166
3191
|
// ---------------------------------------------------------------------------
|
|
3167
3192
|
// Sync core — push pm status changes back to GitHub (close / reopen)
|
|
3168
3193
|
// ---------------------------------------------------------------------------
|
|
3169
|
-
function pmStatusToGithubState(status) {
|
|
3170
|
-
return status
|
|
3194
|
+
function pmStatusToGithubState(status, statuses) {
|
|
3195
|
+
return githubStatusIsTerminal(status, statuses) ? "closed" : "open";
|
|
3171
3196
|
}
|
|
3172
3197
|
/**
|
|
3173
3198
|
* Require every requested item before a remote sync or export can begin.
|
|
@@ -3185,7 +3210,7 @@ function requireRequestedItems(items, requested) {
|
|
|
3185
3210
|
}
|
|
3186
3211
|
// Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
|
|
3187
3212
|
// a create-or-update entry keyed by the issue's provenance tag.
|
|
3188
|
-
export function planSync(items, repo) {
|
|
3213
|
+
export function planSync(items, repo, statuses) {
|
|
3189
3214
|
const plan = [];
|
|
3190
3215
|
const repoLc = repo.toLowerCase();
|
|
3191
3216
|
for (const item of items) {
|
|
@@ -3195,7 +3220,7 @@ export function planSync(items, repo) {
|
|
|
3195
3220
|
const p = parseProvenanceTag(tag);
|
|
3196
3221
|
if (!p || p.repo !== repoLc)
|
|
3197
3222
|
continue;
|
|
3198
|
-
const desired = pmStatusToGithubState(item.status);
|
|
3223
|
+
const desired = pmStatusToGithubState(item.status, statuses);
|
|
3199
3224
|
plan.push({
|
|
3200
3225
|
id: item.id,
|
|
3201
3226
|
number: p.number,
|
|
@@ -3230,7 +3255,8 @@ async function runSync(ctx) {
|
|
|
3230
3255
|
"(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
|
|
3231
3256
|
}
|
|
3232
3257
|
const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
|
|
3233
|
-
const
|
|
3258
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
3259
|
+
const plan = planSync(selected, repo, workspace.statuses);
|
|
3234
3260
|
if (plan.length === 0) {
|
|
3235
3261
|
const scopeNote = scopedIds.length > 0 ? ` from --ids (${scopedIds.join(", ")})` : "";
|
|
3236
3262
|
console.error(`No pm items${scopeNote} linked to ${repo} ` +
|
|
@@ -3292,7 +3318,7 @@ async function runSync(ctx) {
|
|
|
3292
3318
|
}
|
|
3293
3319
|
// Convert a pm item into the GitHub issue create/update payload, dropping
|
|
3294
3320
|
// internal provenance tags from labels and applying the optional label map.
|
|
3295
|
-
function itemToGithubPayload(item, labelMap) {
|
|
3321
|
+
function itemToGithubPayload(item, labelMap, statuses) {
|
|
3296
3322
|
// Drop our internal provenance tags from exported labels, then apply any
|
|
3297
3323
|
// user-supplied label mapping (pm tag → GitHub label). Both issue provenance
|
|
3298
3324
|
// (`gh:owner/repo#N`) and project provenance (`gh-project:owner/number#itemId`)
|
|
@@ -3303,18 +3329,18 @@ function itemToGithubPayload(item, labelMap) {
|
|
|
3303
3329
|
title: item.title ?? "(untitled)",
|
|
3304
3330
|
body: item.body || item.description || "",
|
|
3305
3331
|
labels: applyLabelMap(labels, labelMap),
|
|
3306
|
-
state: item.status
|
|
3332
|
+
state: pmStatusToGithubState(item.status, statuses),
|
|
3307
3333
|
};
|
|
3308
3334
|
}
|
|
3309
3335
|
// Build the create/update plan. `repo` (lowercased) decides which provenance
|
|
3310
3336
|
// tags count as an "already exported to THIS repo" link → update; everything
|
|
3311
3337
|
// else is a create. Pure + side-effect free so it can be unit-tested and
|
|
3312
3338
|
// printed verbatim in --dry-run.
|
|
3313
|
-
export function buildExportPlan(items, repo, labelMap) {
|
|
3339
|
+
export function buildExportPlan(items, repo, labelMap, statuses) {
|
|
3314
3340
|
const repoLc = repo?.toLowerCase();
|
|
3315
3341
|
const plan = [];
|
|
3316
3342
|
for (const item of items) {
|
|
3317
|
-
const payload = itemToGithubPayload(item, labelMap);
|
|
3343
|
+
const payload = itemToGithubPayload(item, labelMap, statuses);
|
|
3318
3344
|
let number;
|
|
3319
3345
|
if (repoLc) {
|
|
3320
3346
|
for (const tag of item.tags ?? []) {
|
|
@@ -3368,11 +3394,11 @@ export async function applyExportPlan(plan, repo, token, requestFn) {
|
|
|
3368
3394
|
throw new Error("update entry is missing its GitHub issue number");
|
|
3369
3395
|
}
|
|
3370
3396
|
if (entry.action === "update" && entry.number !== undefined) {
|
|
3371
|
-
await requestFn("PATCH", `${githubApiBase()}/repos/${repo}/issues/${entry.number}`, token, JSON.stringify({ title: p.title, body: p.body, labels: p.labels, state: p.state }));
|
|
3397
|
+
await requestFn("PATCH", `${githubApiBase()}/repos/${repo}/issues/${entry.number}`, token, JSON.stringify({ title: p.title, body: p.body, labels: uniqueGithubLabelNames(p.labels), state: p.state }));
|
|
3372
3398
|
updated++;
|
|
3373
3399
|
}
|
|
3374
3400
|
else {
|
|
3375
|
-
await requestFn("POST", `${githubApiBase()}/repos/${repo}/issues`, token, JSON.stringify({ title: p.title, body: p.body, labels: p.labels }));
|
|
3401
|
+
await requestFn("POST", `${githubApiBase()}/repos/${repo}/issues`, token, JSON.stringify({ title: p.title, body: p.body, labels: uniqueGithubLabelNames(p.labels) }));
|
|
3376
3402
|
created++;
|
|
3377
3403
|
}
|
|
3378
3404
|
}
|
|
@@ -3476,10 +3502,11 @@ async function runExport(ctx) {
|
|
|
3476
3502
|
else if (["form-ref", "formRef", "form-answers", "formAnswers", "form-bindings", "formBindings"].some(flag => optionProvided(options, flag))) {
|
|
3477
3503
|
refuseIssueForm("invalid_form_input", [{ field: "form", reason: "selection", remedy: "Pass --form with --repo and --form-ref to enforce an issue-form contract." }]);
|
|
3478
3504
|
}
|
|
3479
|
-
const
|
|
3505
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
3506
|
+
const plan = buildExportPlan(selected, repo, labelMap, workspace.statuses);
|
|
3480
3507
|
for (const [index, completion] of completions.entries()) {
|
|
3481
3508
|
plan[index].payload.body = completion.body;
|
|
3482
|
-
plan[index].payload.labels =
|
|
3509
|
+
plan[index].payload.labels = uniqueGithubLabelNames([...plan[index].payload.labels, ...completion.report.contract.labels]);
|
|
3483
3510
|
}
|
|
3484
3511
|
const creates = plan.filter((e) => e.action === "create").length;
|
|
3485
3512
|
const updates = plan.filter((e) => e.action === "update").length;
|
|
@@ -3739,11 +3766,11 @@ function isGithubBlockerDependency(target) {
|
|
|
3739
3766
|
* @param items - The complete runtime-validated item corpus.
|
|
3740
3767
|
* @returns The candidate items, in corpus order.
|
|
3741
3768
|
*/
|
|
3742
|
-
export function selectBlockerCandidates(items) {
|
|
3769
|
+
export function selectBlockerCandidates(items, statuses) {
|
|
3743
3770
|
return items.filter((item) => {
|
|
3744
|
-
if (item.status === "blocked")
|
|
3771
|
+
if (githubStatusKind(item.status, statuses) === "blocked")
|
|
3745
3772
|
return true;
|
|
3746
|
-
if (
|
|
3773
|
+
if (githubStatusIsTerminal(item.status, statuses))
|
|
3747
3774
|
return false;
|
|
3748
3775
|
return (item.dependencies.some((dep) => dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id))
|
|
3749
3776
|
|| (item.blockedBy !== undefined && isGithubBlockerDependency(item.blockedBy)));
|
|
@@ -4202,7 +4229,8 @@ async function runBlockers(ctx) {
|
|
|
4202
4229
|
}
|
|
4203
4230
|
const corpus = await readBlockerPmItems(ctx.pm_root);
|
|
4204
4231
|
const selected = requireRequestedItems(corpus, scopedIds);
|
|
4205
|
-
const
|
|
4232
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
4233
|
+
const candidates = selectBlockerCandidates(selected, workspace.statuses);
|
|
4206
4234
|
const perItem = candidates.map((item) => ({ item, references: collectBlockerReferences(item, fallbackRepo) }));
|
|
4207
4235
|
const unique = new Map();
|
|
4208
4236
|
for (const entry of perItem) {
|
|
@@ -4848,7 +4876,8 @@ async function runProjectImport(ctx) {
|
|
|
4848
4876
|
// Reading the local store is non-mutating and keeps dry-run faithful: linked
|
|
4849
4877
|
// project items must preview as updates, not misleading duplicate creates.
|
|
4850
4878
|
const pmItems = readPmItems(ctx.pm_root);
|
|
4851
|
-
const
|
|
4879
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
4880
|
+
const plan = buildProjectImportPlan(projectItems, ref, pmItems, statusMap, workspace.statuses);
|
|
4852
4881
|
let imported = 0;
|
|
4853
4882
|
let updated = 0;
|
|
4854
4883
|
let skipped = 0;
|
|
@@ -4863,11 +4892,12 @@ async function runProjectImport(ctx) {
|
|
|
4863
4892
|
continue;
|
|
4864
4893
|
}
|
|
4865
4894
|
if (entry.action === "update" && entry.pmId) {
|
|
4895
|
+
const addedTags = nativeTagAdditions(pmItems.find(item => item.id === entry.pmId).tags, entry.tags);
|
|
4866
4896
|
const updArgs = [
|
|
4867
4897
|
"--path", ctx.pm_root, "update", entry.pmId,
|
|
4868
4898
|
"--title", entry.title,
|
|
4869
4899
|
"--description", description,
|
|
4870
|
-
"--tags",
|
|
4900
|
+
...(addedTags.length ? ["--add-tags", addedTags.join(",")] : []),
|
|
4871
4901
|
"--message", `Re-imported from GitHub project ${ref.owner}/${ref.number}`,
|
|
4872
4902
|
];
|
|
4873
4903
|
if (entry.body)
|
|
@@ -4880,9 +4910,12 @@ async function runProjectImport(ctx) {
|
|
|
4880
4910
|
// A `closed` mapping cannot go through `pm update --status closed`
|
|
4881
4911
|
// (governance require_close_reason rejects it since pm-cli 2026.8.3);
|
|
4882
4912
|
// the update omits --status and a separate `pm close` records the reason.
|
|
4883
|
-
const closeAfterUpdate = entry.mappedStatus ===
|
|
4884
|
-
if (entry.mappedStatus && !closeAfterUpdate)
|
|
4913
|
+
const closeAfterUpdate = entry.mappedStatus !== undefined && canonicalGithubStatus(entry.mappedStatus, workspace.statuses) === workspace.statuses.close_status;
|
|
4914
|
+
if (entry.mappedStatus && !closeAfterUpdate) {
|
|
4885
4915
|
updArgs.push("--status", entry.mappedStatus);
|
|
4916
|
+
if (githubStatusIsTerminal(entry.mappedStatus, workspace.statuses))
|
|
4917
|
+
updArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
|
|
4918
|
+
}
|
|
4886
4919
|
const upd = pmRun(updArgs);
|
|
4887
4920
|
if (!upd.ok) {
|
|
4888
4921
|
console.error(` ${entry.title}: update failed — ${upd.stderr}`);
|
|
@@ -4904,19 +4937,21 @@ async function runProjectImport(ctx) {
|
|
|
4904
4937
|
// `require_close_reason` rejects `pm create --status closed` (pm-cli
|
|
4905
4938
|
// 2026.8.3+). Create open, then close through `pm close` with factual
|
|
4906
4939
|
// provenance. `--json` is needed to read the assigned id for the close.
|
|
4907
|
-
const
|
|
4908
|
-
const
|
|
4940
|
+
const mustClose = canonicalGithubStatus(entry.status, workspace.statuses) === workspace.statuses.close_status;
|
|
4941
|
+
const createStatus = mustClose ? workspace.statuses.open_status : entry.status;
|
|
4909
4942
|
const createArgs = [
|
|
4910
4943
|
"--path", ctx.pm_root, "create",
|
|
4911
4944
|
"--title", entry.title,
|
|
4912
4945
|
"--type", itemType,
|
|
4913
4946
|
"--status", createStatus,
|
|
4914
4947
|
"--description", description,
|
|
4915
|
-
"--tags", entry.tags
|
|
4948
|
+
"--tags", serializeImportTags(entry.tags),
|
|
4916
4949
|
"--message", `Imported from GitHub project ${ref.owner}/${ref.number}`,
|
|
4917
4950
|
];
|
|
4918
4951
|
if (entry.body)
|
|
4919
4952
|
createArgs.push("--body", entry.body);
|
|
4953
|
+
if (!mustClose && githubStatusIsTerminal(entry.status, workspace.statuses))
|
|
4954
|
+
createArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
|
|
4920
4955
|
if (mustClose)
|
|
4921
4956
|
createArgs.push("--json");
|
|
4922
4957
|
const created = pmRun(createArgs);
|
|
@@ -4991,10 +5026,10 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
4991
5026
|
if (pm?.id) {
|
|
4992
5027
|
const tag = projectItemTag(ref, itemId);
|
|
4993
5028
|
const existingTags = pm.tags;
|
|
4994
|
-
if (
|
|
5029
|
+
if (nativeTagAdditions(existingTags, [tag]).length) {
|
|
4995
5030
|
const upd = pmRun([
|
|
4996
5031
|
"--path", pmRoot, "update", pm.id,
|
|
4997
|
-
"--tags",
|
|
5032
|
+
"--add-tags", tag,
|
|
4998
5033
|
"--message", `Linked to GitHub project ${ref.owner}/${ref.number}`,
|
|
4999
5034
|
]);
|
|
5000
5035
|
if (!upd.ok)
|
|
@@ -5037,22 +5072,22 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
5037
5072
|
* @param pmRoot - Workspace root for the `pm` invocation.
|
|
5038
5073
|
* @returns The argv array.
|
|
5039
5074
|
*/
|
|
5040
|
-
export function buildPullEntryArgs(entry, pmRoot) {
|
|
5075
|
+
export function buildPullEntryArgs(entry, pmRoot, statuses) {
|
|
5041
5076
|
const reason = `GitHub project status → ${entry.toStatus}`;
|
|
5042
|
-
if (entry.toStatus === "closed") {
|
|
5077
|
+
if (canonicalGithubStatus(entry.toStatus, statuses) === (statuses?.close_status ?? "closed")) {
|
|
5043
5078
|
return ["--path", pmRoot, "close", entry.pmId, "--reason", reason];
|
|
5044
5079
|
}
|
|
5045
|
-
if (entry.toStatus
|
|
5080
|
+
if (githubStatusIsTerminal(entry.toStatus, statuses)) {
|
|
5046
5081
|
return [
|
|
5047
5082
|
"--path", pmRoot, "update", entry.pmId,
|
|
5048
|
-
"--status",
|
|
5083
|
+
"--status", canonicalGithubStatus(entry.toStatus, statuses),
|
|
5049
5084
|
"--close-reason", reason,
|
|
5050
5085
|
"--message", reason,
|
|
5051
5086
|
];
|
|
5052
5087
|
}
|
|
5053
5088
|
return [
|
|
5054
5089
|
"--path", pmRoot, "update", entry.pmId,
|
|
5055
|
-
"--status", entry.toStatus,
|
|
5090
|
+
"--status", canonicalGithubStatus(entry.toStatus, statuses),
|
|
5056
5091
|
"--message", reason,
|
|
5057
5092
|
];
|
|
5058
5093
|
}
|
|
@@ -5060,8 +5095,8 @@ export function buildPullEntryArgs(entry, pmRoot) {
|
|
|
5060
5095
|
// (records closed_at + close_reason); `canceled` keeps its distinct terminal
|
|
5061
5096
|
// state via `pm update` while recording `--close-reason`; everything else is a
|
|
5062
5097
|
// plain `pm update --status`.
|
|
5063
|
-
function applyPullEntry(entry, pmRoot) {
|
|
5064
|
-
const res = pmRun(buildPullEntryArgs(entry, pmRoot));
|
|
5098
|
+
function applyPullEntry(entry, pmRoot, statuses) {
|
|
5099
|
+
const res = pmRun(buildPullEntryArgs(entry, pmRoot, statuses));
|
|
5065
5100
|
return res.ok ? { changed: true } : { changed: false, error: res.stderr };
|
|
5066
5101
|
}
|
|
5067
5102
|
/**
|
|
@@ -5110,11 +5145,12 @@ async function runProjectSync(ctx) {
|
|
|
5110
5145
|
for (const it of scoped.selected)
|
|
5111
5146
|
if (it.id)
|
|
5112
5147
|
pmById.set(it.id, it);
|
|
5148
|
+
const workspace = await readGithubWorkspace(ctx.pm_root);
|
|
5113
5149
|
const pushPlan = doPush
|
|
5114
|
-
? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap })
|
|
5150
|
+
? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap, statuses: workspace.statuses })
|
|
5115
5151
|
: undefined;
|
|
5116
5152
|
const pullPlan = doPull
|
|
5117
|
-
? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap)
|
|
5153
|
+
? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap, workspace.statuses)
|
|
5118
5154
|
: undefined;
|
|
5119
5155
|
// Conflict resolution when applying BOTH directions: a linked item can appear
|
|
5120
5156
|
// in both plans. `--prefer pm` (default) lets push win (skip its pull entry);
|
|
@@ -5181,7 +5217,7 @@ async function runProjectSync(ctx) {
|
|
|
5181
5217
|
for (const e of pullActionable) {
|
|
5182
5218
|
if (prefer === "pm" && pushItemIds.has(e.itemId))
|
|
5183
5219
|
continue; // push wins
|
|
5184
|
-
const r = applyPullEntry(e, ctx.pm_root);
|
|
5220
|
+
const r = applyPullEntry(e, ctx.pm_root, workspace.statuses);
|
|
5185
5221
|
if (!r.changed) {
|
|
5186
5222
|
console.error(` pull ${e.pmId} "${e.title}": ${r.error}`);
|
|
5187
5223
|
pullFailed++;
|
|
@@ -5317,7 +5353,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
5317
5353
|
const defineExtension = (module) => module;
|
|
5318
5354
|
export default defineExtension({
|
|
5319
5355
|
name: "pm-github",
|
|
5320
|
-
version: "2026.10.
|
|
5356
|
+
version: "2026.10.29",
|
|
5321
5357
|
activate(api) {
|
|
5322
5358
|
// -----------------------------------------------------------------------
|
|
5323
5359
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -5577,6 +5613,52 @@ export default defineExtension({
|
|
|
5577
5613
|
});
|
|
5578
5614
|
},
|
|
5579
5615
|
});
|
|
5616
|
+
api.registerCommand({
|
|
5617
|
+
name: "github discussion capture",
|
|
5618
|
+
description: "Preview explicitly marked issue-comment spans as native Decisions. Apply only the reviewed digest; source instructions remain quoted data.",
|
|
5619
|
+
flags: [
|
|
5620
|
+
{ long: "--input", value_name: "file", description: "Strict JSON selection: repo, issue, item, selections with comment/start/end/quote/marked:true" },
|
|
5621
|
+
{ long: "--approve", value_name: "digest", description: "Apply the exact reviewed preview, re-reading every source and native target" },
|
|
5622
|
+
{ long: "--max-output-bytes", value_name: "N", description: "Complete JSON receipt budget, integer 1024..131072 (default 65536)" },
|
|
5623
|
+
],
|
|
5624
|
+
async run(ctx) {
|
|
5625
|
+
const { input, approve, maxOutputBytes } = ctx.options;
|
|
5626
|
+
if (typeof input !== "string" || approve !== undefined && typeof approve !== "string")
|
|
5627
|
+
refuseDiscussion("invalid_selection", "Supply --input with a strict JSON selection document and an optional full --approve digest.");
|
|
5628
|
+
let selection;
|
|
5629
|
+
try {
|
|
5630
|
+
selection = readLabelMigrationJson(input);
|
|
5631
|
+
}
|
|
5632
|
+
catch {
|
|
5633
|
+
refuseDiscussion("invalid_selection", "Supply a readable bounded regular JSON selection file; see pm github discussion capture --help.");
|
|
5634
|
+
}
|
|
5635
|
+
return captureGithubDiscussion(ctx.pm_root, selection, ctx.sdk, {
|
|
5636
|
+
approval: approve, maxOutputBytes: maxOutputBytes === undefined ? undefined : Number(maxOutputBytes),
|
|
5637
|
+
});
|
|
5638
|
+
},
|
|
5639
|
+
});
|
|
5640
|
+
api.registerCommand({
|
|
5641
|
+
name: "github labels plan",
|
|
5642
|
+
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.",
|
|
5643
|
+
flags: [
|
|
5644
|
+
{ long: "--input", value_name: "file", description: "Strict JSON request with repo, source IDs/names, change, filters, mappings and choices" },
|
|
5645
|
+
{ long: "--catalog", value_name: "file", description: "Complete offline catalog JSON; omit to fetch all current GitHub labels" },
|
|
5646
|
+
{ long: "--approve", value_name: "digest", description: "Check the exact reviewed plan against current labels, policy and SDK revisions; writes nothing" },
|
|
5647
|
+
{ long: "--limit", value_name: "N", description: "Affected rows per page, 1..100 (default 20); counts always cover the complete corpus" },
|
|
5648
|
+
{ long: "--offset", value_name: "N", description: "Nonnegative affected-row offset" },
|
|
5649
|
+
],
|
|
5650
|
+
async run(ctx) {
|
|
5651
|
+
const { input, catalog, approve, limit, offset } = ctx.options;
|
|
5652
|
+
if (typeof input !== "string" || catalog !== undefined && typeof catalog !== "string" || approve !== undefined && typeof approve !== "string") {
|
|
5653
|
+
throw createPmCliExpectedError("Supply --input <JSON file>, optional --catalog <JSON file> and a full --approve digest.", { exitCode: 2, context: { code: "invalid_input", required: "Use pm github labels plan --help and the documented JSON example." } });
|
|
5654
|
+
}
|
|
5655
|
+
return planGithubLabelMigration(ctx.pm_root, readLabelMigrationJson(input), {
|
|
5656
|
+
page: { limit: limit === undefined ? undefined : Number(limit), offset: offset === undefined ? undefined : Number(offset) },
|
|
5657
|
+
approval: approve,
|
|
5658
|
+
readCatalog: catalog === undefined ? undefined : async () => readLabelMigrationJson(catalog),
|
|
5659
|
+
});
|
|
5660
|
+
},
|
|
5661
|
+
});
|
|
5580
5662
|
api.registerCommand({
|
|
5581
5663
|
name: "github refresh",
|
|
5582
5664
|
description: "Refresh existing linked items from GitHub without importing new issues.",
|
|
@@ -5743,6 +5825,19 @@ export default defineExtension({
|
|
|
5743
5825
|
return report;
|
|
5744
5826
|
},
|
|
5745
5827
|
});
|
|
5828
|
+
// Receipts stay private until the workflow scans the selected diagnostic.
|
|
5829
|
+
api.registerCommand({
|
|
5830
|
+
name: "github workflow-diagnostic",
|
|
5831
|
+
description: "Select one actionable refusal from private workflow receipts. Privacy-scan before publishing the result.",
|
|
5832
|
+
flags: [
|
|
5833
|
+
{ long: "--report", value_name: "file", description: "Command JSON stdout receipt" },
|
|
5834
|
+
{ long: "--error-file", value_name: "file", description: "Command stderr receipt" },
|
|
5835
|
+
{ long: "--diff", value_name: "file", description: "Repeat-import tracker diff" },
|
|
5836
|
+
],
|
|
5837
|
+
async run(ctx) {
|
|
5838
|
+
return readWorkflowFailure(optionString(ctx.options, "report"), optionString(ctx.options, "error-file", "errorFile"), optionString(ctx.options, "diff"));
|
|
5839
|
+
},
|
|
5840
|
+
});
|
|
5746
5841
|
// Closeout uses the existing repository resolution and REST transport.
|
|
5747
5842
|
api.registerCommand({
|
|
5748
5843
|
name: "github closeout",
|
|
@@ -5765,8 +5860,12 @@ export default defineExtension({
|
|
|
5765
5860
|
const report = await runCloseout(ctx.pm_root, resolveSearchRepo(ctx.options), ctx.options, {
|
|
5766
5861
|
base: githubApiBase(), get: url => fetchJSON(url, token), next: parseNextLink,
|
|
5767
5862
|
});
|
|
5768
|
-
if (report.failed > 0)
|
|
5863
|
+
if (report.failed > 0) {
|
|
5864
|
+
// Preserve the full private receipt before the host formats or budgets its error message.
|
|
5865
|
+
if (ctx.global.json === true)
|
|
5866
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
5769
5867
|
throw new CommandError(JSON.stringify({ ...report, error: "pm github closeout: some closes failed" }), EXIT_CODE.GENERIC_FAILURE);
|
|
5868
|
+
}
|
|
5770
5869
|
return ctx.global.json === true || ctx.options.full === true ? report : compactCloseoutReport(report);
|
|
5771
5870
|
}
|
|
5772
5871
|
catch (error) {
|