pm-github 2026.10.4 → 2026.10.8
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 +16 -0
- package/README.md +94 -2
- package/dist/gate.d.ts +269 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +873 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +126 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +511 -180
- package/dist/index.js.map +1 -1
- package/dist/projects.d.ts +11 -0
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +17 -7
- package/dist/projects.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +25 -12
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Capabilities (see manifest.json):
|
|
4
4
|
// commands — `pm gh-issues import` (legacy) + `pm github sync` +
|
|
5
|
+
// `pm github gate` (fail-closed pre-push privacy gate) +
|
|
5
6
|
// `pm github project list|fields|import|sync` (Projects v2)
|
|
6
7
|
// importers — `pm github import <owner/repo>` (idempotent native import)
|
|
7
8
|
// exporters — `pm github export` (render pm items as a GitHub-issues payload)
|
|
@@ -20,7 +21,9 @@ import path from "node:path";
|
|
|
20
21
|
import { spawnSync } from "node:child_process";
|
|
21
22
|
import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
|
|
22
23
|
import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
|
|
23
|
-
import {
|
|
24
|
+
import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
|
|
25
|
+
export { resolvePmDataDir } from "./gate.js";
|
|
26
|
+
import { buildProjectImportPlan, buildProjectPullPlan, buildProjectPushPlan, parseProjectItemTag, parseProjectRef, parseStatusMap, parseAssignmentMap, projectItemTag, } from "./projects.js";
|
|
24
27
|
const COMMENTS_MODES = ["body", "annotations", "both"];
|
|
25
28
|
// Resolve a GitHub token so the importer is not stuck on the 60 req/hr
|
|
26
29
|
// unauthenticated quota and can read private repos. Order: explicit env vars,
|
|
@@ -29,20 +32,19 @@ export function resolveGitHubToken() {
|
|
|
29
32
|
const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
30
33
|
if (envToken && envToken.trim())
|
|
31
34
|
return envToken.trim();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// gh not installed — fall back to unauthenticated requests.
|
|
35
|
+
// spawnSync never throws for a missing executable: it reports ENOENT through
|
|
36
|
+
// `result.error`/`status: null` (verified against the Node subprocess
|
|
37
|
+
// contract), and the arguments here are fixed and valid, so there is no
|
|
38
|
+
// throwable failure left — the status check below is the whole boundary.
|
|
39
|
+
const result = spawnSync("gh", ["auth", "token"], { encoding: "utf-8", env: { ...process.env } });
|
|
40
|
+
if (result.status === 0) {
|
|
41
|
+
const token = result.stdout.trim();
|
|
42
|
+
if (token)
|
|
43
|
+
return token;
|
|
42
44
|
}
|
|
43
45
|
return undefined;
|
|
44
46
|
}
|
|
45
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
46
48
|
/**
|
|
47
49
|
* Decide whether the Authorization token may be forwarded across a redirect.
|
|
48
50
|
*
|
|
@@ -117,6 +119,7 @@ export function githubApiBase() {
|
|
|
117
119
|
* token only to same-origin targets via {@link sameOrigin}.
|
|
118
120
|
*/
|
|
119
121
|
function requestOnce(method, url, token, payload, redirectsLeft = 5) {
|
|
122
|
+
let deadline;
|
|
120
123
|
return new Promise((resolve, reject) => {
|
|
121
124
|
const headers = {
|
|
122
125
|
"User-Agent": "pm-github",
|
|
@@ -135,7 +138,7 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
|
|
|
135
138
|
const target = new URL(url);
|
|
136
139
|
const transport = target.protocol === "http:" ? http : https;
|
|
137
140
|
const req = transport.request(target, { method, headers }, (res) => {
|
|
138
|
-
const status = res.statusCode
|
|
141
|
+
const status = res.statusCode; // Node guarantees an Integer statusCode on every ClientResponse.
|
|
139
142
|
if (status >= 300 && status < 400 && res.headers.location) {
|
|
140
143
|
// Drain the redirect response so the socket is returned to the pool.
|
|
141
144
|
res.resume();
|
|
@@ -173,13 +176,19 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
|
|
|
173
176
|
});
|
|
174
177
|
});
|
|
175
178
|
req.on("error", reject);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
// A wall-clock deadline also bounds redirects and stalled response bodies.
|
|
180
|
+
// Bun's ClientRequest socket timeout can fail to fire on a silent server.
|
|
181
|
+
deadline = setTimeout(() => {
|
|
182
|
+
const error = new Error("request timed out after 30s");
|
|
183
|
+
// Reject before destroy: Bun can synchronously emit response end during
|
|
184
|
+
// destruction, which must never turn a truncated timed-out body into success.
|
|
185
|
+
reject(error);
|
|
186
|
+
req.destroy(error);
|
|
187
|
+
}, 30000);
|
|
179
188
|
if (payload)
|
|
180
189
|
req.write(payload);
|
|
181
190
|
req.end();
|
|
182
|
-
});
|
|
191
|
+
}).finally(() => clearTimeout(deadline));
|
|
183
192
|
}
|
|
184
193
|
/**
|
|
185
194
|
* Compute how long (ms) to wait before retrying a rate-limited or transient
|
|
@@ -269,7 +278,22 @@ export function formatRateLimit(info) {
|
|
|
269
278
|
}
|
|
270
279
|
// Decide whether a failed HTTP response is worth retrying: 429, any 5xx, or a
|
|
271
280
|
// 403 that is actually a primary/secondary rate-limit wall (remaining=0).
|
|
272
|
-
|
|
281
|
+
//
|
|
282
|
+
/**
|
|
283
|
+
* Decide whether a failed HTTP response is worth retrying.
|
|
284
|
+
*
|
|
285
|
+
* Retries 429, any 5xx, and a 403 that is actually a primary/secondary
|
|
286
|
+
* rate-limit wall (remaining=0 or a Retry-After). Mixed-case header keys are
|
|
287
|
+
* honored defensively: Node's HTTP client lowercases response names, but the
|
|
288
|
+
* shared headers record also serves boundary tests that pass other shapes.
|
|
289
|
+
*
|
|
290
|
+
* @internal Exported only so the HTTP-boundary tests can drive the real
|
|
291
|
+
* decision. `stripInternal` keeps it out of the published `.d.ts`.
|
|
292
|
+
* @param status - The HTTP status code of the failed response.
|
|
293
|
+
* @param headers - The response headers to read the rate-limit hints from.
|
|
294
|
+
* @returns True when the request should be retried with backoff.
|
|
295
|
+
*/
|
|
296
|
+
export function isRetryableStatus(status, headers) {
|
|
273
297
|
if (status === 429)
|
|
274
298
|
return true;
|
|
275
299
|
if (status >= 500)
|
|
@@ -355,7 +379,7 @@ export function optionString(options, ...keys) {
|
|
|
355
379
|
}
|
|
356
380
|
// Whether an option key was explicitly provided (even if empty/falsey).
|
|
357
381
|
export function optionProvided(options, ...keys) {
|
|
358
|
-
return keys.some((k) => Object.
|
|
382
|
+
return keys.some((k) => Object.hasOwn(options, k));
|
|
359
383
|
}
|
|
360
384
|
// Parse a `--since` value into an ISO timestamp the GitHub `since` query param
|
|
361
385
|
// accepts. Accepts either an ISO 8601 timestamp (passed through, invalid date
|
|
@@ -394,20 +418,7 @@ export function parseSince(value, nowMs = Date.now()) {
|
|
|
394
418
|
export function parseLabelMap(options, ...keys) {
|
|
395
419
|
const lookup = keys.length > 0 ? keys : ["label-map", "labelMap"];
|
|
396
420
|
const raw = optionCsv(options, ...lookup);
|
|
397
|
-
|
|
398
|
-
return undefined;
|
|
399
|
-
const map = new Map();
|
|
400
|
-
for (const entry of raw) {
|
|
401
|
-
const eq = entry.indexOf("=");
|
|
402
|
-
if (eq <= 0)
|
|
403
|
-
continue; // need a non-empty "from" before the '='
|
|
404
|
-
const from = entry.slice(0, eq).trim();
|
|
405
|
-
const to = entry.slice(eq + 1).trim();
|
|
406
|
-
if (!from || !to)
|
|
407
|
-
continue;
|
|
408
|
-
map.set(from, to);
|
|
409
|
-
}
|
|
410
|
-
return map.size > 0 ? map : undefined;
|
|
421
|
+
return parseAssignmentMap(raw);
|
|
411
422
|
}
|
|
412
423
|
// Apply a label translation table to a list of labels. Labels with a mapping
|
|
413
424
|
// are replaced; unmapped labels pass through unchanged. Two source labels that
|
|
@@ -761,7 +772,7 @@ export function readPmItems(pmRoot, platform = process.platform, pmPackageRoot =
|
|
|
761
772
|
command = process.execPath;
|
|
762
773
|
args = [cliEntry, ...listArgs];
|
|
763
774
|
}
|
|
764
|
-
const result = spawnSync(command, args, { encoding: "utf-8", maxBuffer });
|
|
775
|
+
const result = spawnSync(command, args, { encoding: "utf-8", maxBuffer, env: { ...process.env } });
|
|
765
776
|
// A buffer overrun kills the child with status null and no stderr, so name the
|
|
766
777
|
// real cause instead of reporting an unexplained failure.
|
|
767
778
|
if (result.error) {
|
|
@@ -846,6 +857,10 @@ export function indexByProvenance(items) {
|
|
|
846
857
|
}
|
|
847
858
|
return index;
|
|
848
859
|
}
|
|
860
|
+
/** Render thrown values consistently across subprocess, SDK, and HTTP boundaries. */
|
|
861
|
+
function errorMessage(error) {
|
|
862
|
+
return error instanceof Error ? error.message : String(error);
|
|
863
|
+
}
|
|
849
864
|
// ---------------------------------------------------------------------------
|
|
850
865
|
// Atomic GitHub issue import (pm-cli >= 2026.7.20 commitItemMutations)
|
|
851
866
|
// ---------------------------------------------------------------------------
|
|
@@ -889,11 +904,15 @@ export async function resolveCommitItemMutations(importSdk) {
|
|
|
889
904
|
return cachedCommitItemMutations;
|
|
890
905
|
}
|
|
891
906
|
async function resolveAtomicSdkFunctions(opts) {
|
|
892
|
-
const needsSdk = !opts.commitItemMutations || !opts.normalizeItemId || !opts.readSettings;
|
|
893
907
|
return {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
908
|
+
// Each `??` fallback runs only when the injected option is absent, so the
|
|
909
|
+
// SDK function is always the value handed onward; a separate `needsSdk ?`
|
|
910
|
+
// branch inside the fallback can never select anything else (when every
|
|
911
|
+
// option is injected the fallbacks do not run at all), so the SDK binding
|
|
912
|
+
// is passed directly.
|
|
913
|
+
commitItemMutations: opts.commitItemMutations ?? assertSdkFunction(sdkCommitItemMutations, "commitItemMutations"),
|
|
914
|
+
normalizeItemId: opts.normalizeItemId ?? assertSdkFunction(sdkNormalizeItemId, "normalizeItemId"),
|
|
915
|
+
readSettings: opts.readSettings ?? assertSdkFunction(sdkReadSettings, "readSettings"),
|
|
897
916
|
};
|
|
898
917
|
}
|
|
899
918
|
/**
|
|
@@ -1058,7 +1077,7 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
|
1058
1077
|
};
|
|
1059
1078
|
}
|
|
1060
1079
|
catch (err) {
|
|
1061
|
-
const msg =
|
|
1080
|
+
const msg = errorMessage(err);
|
|
1062
1081
|
if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
|
|
1063
1082
|
throw new CommandError(`Atomic GitHub import failed and compensation was incomplete. The tracker may contain partially applied state; retry the same import to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
|
|
1064
1083
|
}
|
|
@@ -1132,10 +1151,10 @@ export async function fetchComments(issue, repo, token) {
|
|
|
1132
1151
|
page = JSON.parse(body);
|
|
1133
1152
|
}
|
|
1134
1153
|
catch {
|
|
1135
|
-
|
|
1154
|
+
throw new CommandError("GitHub comments response is not valid JSON.");
|
|
1136
1155
|
}
|
|
1137
1156
|
if (!Array.isArray(page))
|
|
1138
|
-
|
|
1157
|
+
throw new CommandError("GitHub comments response must be an array.");
|
|
1139
1158
|
comments.push(...page);
|
|
1140
1159
|
nextUrl = parseNextLink(linkHeader);
|
|
1141
1160
|
}
|
|
@@ -1204,28 +1223,6 @@ export function composeBody(issue, comments) {
|
|
|
1204
1223
|
export const IMPORT_LOCK_TTL_MS_DEFAULT = 5 * 60_000;
|
|
1205
1224
|
/** Default total budget a caller waits to acquire a contended comment-sync lock before giving up and skipping that item's sync. */
|
|
1206
1225
|
export const IMPORT_LOCK_WAIT_MS_DEFAULT = 30_000;
|
|
1207
|
-
/**
|
|
1208
|
-
* Resolve the pm data dir from the `pmRoot` a command handler receives.
|
|
1209
|
-
*
|
|
1210
|
-
* The host may hand either the workspace root (the dir containing `.agents/pm`)
|
|
1211
|
-
* or the data dir itself — the pm CLI accepts both for `--path`. This returns
|
|
1212
|
-
* the dir that actually holds `settings.json` and `locks/`, defaulting to
|
|
1213
|
-
* `pmRoot` unchanged when no nested `.agents/pm` exists.
|
|
1214
|
-
*
|
|
1215
|
-
* @param pmRoot - The path supplied by the extension host.
|
|
1216
|
-
* @returns The resolved pm data directory.
|
|
1217
|
-
*/
|
|
1218
|
-
export function resolvePmDataDir(pmRoot) {
|
|
1219
|
-
const nested = path.join(pmRoot, ".agents", "pm");
|
|
1220
|
-
try {
|
|
1221
|
-
if (fs.statSync(nested).isDirectory())
|
|
1222
|
-
return nested;
|
|
1223
|
-
}
|
|
1224
|
-
catch {
|
|
1225
|
-
// Not the workspace-root form — assume pmRoot already is the data dir.
|
|
1226
|
-
}
|
|
1227
|
-
return pmRoot;
|
|
1228
|
-
}
|
|
1229
1226
|
/**
|
|
1230
1227
|
* Absolute lock file path for one item's comment-sync critical section.
|
|
1231
1228
|
*
|
|
@@ -1274,13 +1271,17 @@ function readImportLockPayload(lockPath) {
|
|
|
1274
1271
|
*
|
|
1275
1272
|
* Signals the PID with signal 0 (no effect); `EPERM` means the process exists
|
|
1276
1273
|
* but belongs to another user (still alive), while `ESRCH` means it is gone.
|
|
1277
|
-
* Non-
|
|
1274
|
+
* Non-positive PIDs are treated as not-alive. The sole production caller already
|
|
1275
|
+
* rejects non-numeric PIDs before calling, so this function does not re-check the type.
|
|
1278
1276
|
*
|
|
1279
|
-
* @
|
|
1277
|
+
* @internal Exported so the lock tests can drive the PID boundary directly.
|
|
1278
|
+
* `stripInternal` keeps it out of the published declarations.
|
|
1279
|
+
* @param pid - The recorded owner PID. Callers pass a positive integer; non-positive
|
|
1280
|
+
* values are rejected here so a direct call cannot signal the process group.
|
|
1280
1281
|
* @returns True when the PID names a live process.
|
|
1281
1282
|
*/
|
|
1282
|
-
function isLockOwnerAlive(pid) {
|
|
1283
|
-
if (
|
|
1283
|
+
export function isLockOwnerAlive(pid) {
|
|
1284
|
+
if (pid <= 0)
|
|
1284
1285
|
return false;
|
|
1285
1286
|
try {
|
|
1286
1287
|
process.kill(pid, 0);
|
|
@@ -1444,7 +1445,7 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
|
|
|
1444
1445
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
1445
1446
|
}
|
|
1446
1447
|
catch (err) {
|
|
1447
|
-
const msg =
|
|
1448
|
+
const msg = errorMessage(err);
|
|
1448
1449
|
console.error(`pm-github: comment-sync lock unavailable (${msg}) — proceeding without cross-process serialization`);
|
|
1449
1450
|
return { status: "degraded" };
|
|
1450
1451
|
}
|
|
@@ -1513,11 +1514,13 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
|
|
|
1513
1514
|
}
|
|
1514
1515
|
const code = err?.code;
|
|
1515
1516
|
if (code !== "EEXIST") {
|
|
1516
|
-
const msg =
|
|
1517
|
+
const msg = errorMessage(err);
|
|
1517
1518
|
console.error(`pm-github: comment-sync lock failed (${msg}) — proceeding without cross-process serialization`);
|
|
1518
1519
|
return { status: "degraded" };
|
|
1519
1520
|
}
|
|
1520
|
-
// The lock exists
|
|
1521
|
+
// The lock exists for open(O_EXCL) but cannot be stat'd (it vanished, or
|
|
1522
|
+
// it is a broken symlink). Retry, but honor the wait budget: a persistent
|
|
1523
|
+
// stat failure must not busy-spin the process until an external killer.
|
|
1521
1524
|
let existing;
|
|
1522
1525
|
let mtimeMs = Number.NaN;
|
|
1523
1526
|
try {
|
|
@@ -1525,6 +1528,10 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
|
|
|
1525
1528
|
existing = readImportLockPayload(lockPath);
|
|
1526
1529
|
}
|
|
1527
1530
|
catch {
|
|
1531
|
+
const vanishedForMs = Date.now() - startedAt;
|
|
1532
|
+
if (vanishedForMs >= waitMs)
|
|
1533
|
+
return { status: "contended" };
|
|
1534
|
+
await sleep(Math.min(backoffMs, Math.max(1, waitMs - vanishedForMs)));
|
|
1528
1535
|
continue;
|
|
1529
1536
|
}
|
|
1530
1537
|
const staleReason = importLockStaleReason(lockPath, existing, mtimeMs, ttlMs);
|
|
@@ -1661,24 +1668,26 @@ export function parseCreatedItemId(stdout) {
|
|
|
1661
1668
|
*
|
|
1662
1669
|
* Idempotent: comments already present (matched by their marker id) are skipped,
|
|
1663
1670
|
* so re-running import never duplicates. Each GitHub comment becomes one pm
|
|
1664
|
-
* comment authored by the GitHub login. Failures are
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1671
|
+
* comment authored by the GitHub login. Failures are reported in the returned
|
|
1672
|
+
* `failed` count and logged, never thrown: a contended or unavailable lock, an
|
|
1673
|
+
* unreadable existing-comment collection, and each refused add all count every
|
|
1674
|
+
* comment that did not land (see {@link acquireImportLock}). Gated callers turn
|
|
1675
|
+
* a non-zero `failed` count into a fail-closed refusal; ungated ones continue.
|
|
1667
1676
|
*
|
|
1668
1677
|
* @param itemId - The pm item to append comments to.
|
|
1669
1678
|
* @param comments - The GitHub comments to sync.
|
|
1670
1679
|
* @param pmRoot - Workspace root or pm data dir.
|
|
1671
1680
|
* @param issueNumber - The source issue number (for log prefixes).
|
|
1672
|
-
* @returns How many comments were added
|
|
1681
|
+
* @returns How many comments were added, skipped as duplicates, and not written.
|
|
1673
1682
|
*/
|
|
1674
1683
|
export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot, issueNumber) {
|
|
1675
1684
|
if (comments.length === 0)
|
|
1676
|
-
return { added: 0, skipped: 0 };
|
|
1685
|
+
return { added: 0, skipped: 0, failed: 0 };
|
|
1677
1686
|
const acquisition = await acquireImportLock(pmRoot, itemId);
|
|
1678
1687
|
if (acquisition.status === "contended") {
|
|
1679
1688
|
console.error(`#${issueNumber}: comment sync for ${itemId} skipped — another import holds the ` +
|
|
1680
1689
|
`comment-sync lock; re-run import to pick up the comments`);
|
|
1681
|
-
return { added: 0, skipped: 0 };
|
|
1690
|
+
return { added: 0, skipped: 0, failed: comments.length };
|
|
1682
1691
|
}
|
|
1683
1692
|
const release = acquisition.status === "acquired" ? () => acquisition.lock.release() : () => { };
|
|
1684
1693
|
try {
|
|
@@ -1688,13 +1697,14 @@ export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot,
|
|
|
1688
1697
|
existing = list.comments;
|
|
1689
1698
|
}
|
|
1690
1699
|
catch (err) {
|
|
1691
|
-
const msg =
|
|
1700
|
+
const msg = errorMessage(err);
|
|
1692
1701
|
console.error(`#${issueNumber}: could not read existing comments for ${itemId} — ${msg}`);
|
|
1693
|
-
return { added: 0, skipped: 0 };
|
|
1702
|
+
return { added: 0, skipped: 0, failed: comments.length };
|
|
1694
1703
|
}
|
|
1695
1704
|
const synced = extractSyncedCommentIds(existing);
|
|
1696
1705
|
let added = 0;
|
|
1697
1706
|
let skipped = 0;
|
|
1707
|
+
let failed = 0;
|
|
1698
1708
|
for (const c of comments) {
|
|
1699
1709
|
if (synced.has(c.id)) {
|
|
1700
1710
|
skipped++;
|
|
@@ -1706,11 +1716,12 @@ export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot,
|
|
|
1706
1716
|
added++;
|
|
1707
1717
|
}
|
|
1708
1718
|
catch (err) {
|
|
1709
|
-
const msg =
|
|
1719
|
+
const msg = errorMessage(err);
|
|
1710
1720
|
console.error(`#${issueNumber}: comment ${c.id} sync failed — ${msg}`);
|
|
1721
|
+
failed++;
|
|
1711
1722
|
}
|
|
1712
1723
|
}
|
|
1713
|
-
return { added, skipped };
|
|
1724
|
+
return { added, skipped, failed };
|
|
1714
1725
|
}
|
|
1715
1726
|
finally {
|
|
1716
1727
|
release();
|
|
@@ -1808,6 +1819,7 @@ export function parseImportOptions(options) {
|
|
|
1808
1819
|
dryRun: optionEnabled(options, "dry-run", "dryRun"),
|
|
1809
1820
|
atomic: optionEnabled(options, "atomic"),
|
|
1810
1821
|
linkDeps: optionEnabled(options, "link-deps", "linkDeps"),
|
|
1822
|
+
gate: optionEnabled(options, "gate"),
|
|
1811
1823
|
};
|
|
1812
1824
|
}
|
|
1813
1825
|
// Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
|
|
@@ -1815,7 +1827,7 @@ export function parseImportOptions(options) {
|
|
|
1815
1827
|
// error-handling shape.
|
|
1816
1828
|
function pmRun(args) {
|
|
1817
1829
|
const maxBuffer = pmJsonMaxBuffer();
|
|
1818
|
-
const result = spawnSync("pm", args, { encoding: "utf-8", maxBuffer });
|
|
1830
|
+
const result = spawnSync("pm", args, { encoding: "utf-8", maxBuffer, env: { ...process.env } });
|
|
1819
1831
|
return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" };
|
|
1820
1832
|
}
|
|
1821
1833
|
// Strip fenced (```…```) and inline (`…`) code so `#123` mentions inside code
|
|
@@ -2037,7 +2049,7 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
|
|
|
2037
2049
|
linked: 0,
|
|
2038
2050
|
unresolved: 0,
|
|
2039
2051
|
orderingCycleWarnings: [],
|
|
2040
|
-
failures: [`dependency linking skipped: ${
|
|
2052
|
+
failures: [`dependency linking skipped: ${errorMessage(err)}`],
|
|
2041
2053
|
};
|
|
2042
2054
|
}
|
|
2043
2055
|
const provenance = buildProvenanceIndexFromMetadata(before);
|
|
@@ -2070,7 +2082,7 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
|
|
|
2070
2082
|
}
|
|
2071
2083
|
}
|
|
2072
2084
|
catch (err) {
|
|
2073
|
-
failures.push(`ordering-cycle advisory skipped: ${
|
|
2085
|
+
failures.push(`ordering-cycle advisory skipped: ${errorMessage(err)}`);
|
|
2074
2086
|
}
|
|
2075
2087
|
}
|
|
2076
2088
|
return { linked, unresolved, orderingCycleWarnings: [...warnings], failures };
|
|
@@ -2108,7 +2120,10 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
|
|
|
2108
2120
|
comments = await fetchIssueComments(issue, repo, token);
|
|
2109
2121
|
}
|
|
2110
2122
|
catch (err) {
|
|
2111
|
-
const msg =
|
|
2123
|
+
const msg = errorMessage(err);
|
|
2124
|
+
if (opts.gate)
|
|
2125
|
+
throw new CommandError(`pm github gate: comments for issue #${issue.number} could not be read.`);
|
|
2126
|
+
// The gated case throws above, so this diagnostic always names the plain path.
|
|
2112
2127
|
console.error(`#${issue.number}: failed to fetch comments — ${msg}`);
|
|
2113
2128
|
}
|
|
2114
2129
|
}
|
|
@@ -2132,14 +2147,172 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
|
|
|
2132
2147
|
match,
|
|
2133
2148
|
};
|
|
2134
2149
|
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Verify that a gated import's plan accounts for every fetched issue exactly once.
|
|
2152
|
+
*
|
|
2153
|
+
* An import that silently drops an issue (or plans one twice) would leave the
|
|
2154
|
+
* tracker out of sync with GitHub while reporting success, and the sync PR
|
|
2155
|
+
* would then publish an incomplete state. The check is fail-closed: every
|
|
2156
|
+
* filtered issue number must appear in the prepared entries or the explicit
|
|
2157
|
+
* skip list exactly once, nothing outside the fetched set may appear, and the
|
|
2158
|
+
* counts must reconcile. A violation throws {@link CommandError} before any
|
|
2159
|
+
* mutation happens.
|
|
2160
|
+
*
|
|
2161
|
+
* @param issues - The filtered issues the fetch delivered.
|
|
2162
|
+
* @param prepared - The plan entries built from them.
|
|
2163
|
+
* @param skippedNumbers - Issue numbers the plan explicitly skips.
|
|
2164
|
+
* @returns The reconciled counts on success.
|
|
2165
|
+
*/
|
|
2166
|
+
export function verifyImportPlanCompleteness(issues, prepared, skippedNumbers) {
|
|
2167
|
+
const fetched = new Set(issues.map((issue) => issue.number));
|
|
2168
|
+
const planned = new Set();
|
|
2169
|
+
let duplicates = 0;
|
|
2170
|
+
for (const entry of prepared) {
|
|
2171
|
+
if (planned.has(entry.issueNumber))
|
|
2172
|
+
duplicates++;
|
|
2173
|
+
planned.add(entry.issueNumber);
|
|
2174
|
+
}
|
|
2175
|
+
const skippedSet = new Set(skippedNumbers);
|
|
2176
|
+
const unaccounted = [...fetched].filter((number) => !planned.has(number) && !skippedSet.has(number));
|
|
2177
|
+
const foreign = [...planned, ...skippedNumbers].filter((number) => !fetched.has(number));
|
|
2178
|
+
const countsReconcile = prepared.length + skippedNumbers.length === issues.length;
|
|
2179
|
+
const overlapping = skippedNumbers.some((number) => planned.has(number));
|
|
2180
|
+
const duplicateFetch = fetched.size !== issues.length;
|
|
2181
|
+
const duplicateSkip = skippedSet.size !== skippedNumbers.length;
|
|
2182
|
+
if (overlapping || duplicateFetch || duplicateSkip || duplicates > 0 || unaccounted.length > 0 || foreign.length > 0 || !countsReconcile) {
|
|
2183
|
+
throw new CommandError(`pm github gate: import plan is incomplete — fetched ${issues.length}, planned ${prepared.length}, ` +
|
|
2184
|
+
`skipped ${skippedNumbers.length}; duplicates ${duplicates}, unaccounted [${unaccounted.join(", ")}], ` +
|
|
2185
|
+
`out-of-plan [${foreign.join(", ")}]. No item was written.`, EXIT_CODE.GENERIC_FAILURE);
|
|
2186
|
+
}
|
|
2187
|
+
return { fetched: issues.length, planned: prepared.length, skipped: skippedNumbers.length };
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Verify that a gated import's plan keeps a second run a no-op via provenance tags.
|
|
2191
|
+
*
|
|
2192
|
+
* Idempotency is only real when every re-import lands on exactly one item: a
|
|
2193
|
+
* corpus with two items sharing a `gh:owner/repo#N` tag updates one and lets the
|
|
2194
|
+
* other drift, and a plan entry targeting a second entry's item would write the
|
|
2195
|
+
* same item twice. The check fails closed on duplicate provenance tags in the
|
|
2196
|
+
* existing corpus, on plan entries that would create a new item without its
|
|
2197
|
+
* provenance tag, and on two plan entries resolving to the same existing item.
|
|
2198
|
+
*
|
|
2199
|
+
* @param existing - The complete existing pm item corpus.
|
|
2200
|
+
* @param prepared - The plan entries about to be written.
|
|
2201
|
+
* @param repo - The `owner/repo` being imported from.
|
|
2202
|
+
* @returns The provenance idempotency receipts on success.
|
|
2203
|
+
*/
|
|
2204
|
+
export function verifyImportIdempotency(existing, prepared, repo) {
|
|
2205
|
+
const tagOwners = new Map();
|
|
2206
|
+
const duplicateTags = new Set();
|
|
2207
|
+
for (const item of existing) {
|
|
2208
|
+
if (!item.id)
|
|
2209
|
+
continue;
|
|
2210
|
+
for (const tag of item.tags ?? []) {
|
|
2211
|
+
const parsed = parseProvenanceTag(tag);
|
|
2212
|
+
if (!parsed)
|
|
2213
|
+
continue;
|
|
2214
|
+
const key = `${parsed.repo}#${parsed.number}`;
|
|
2215
|
+
const prior = tagOwners.get(key);
|
|
2216
|
+
if (prior && prior !== item.id)
|
|
2217
|
+
duplicateTags.add(key);
|
|
2218
|
+
else
|
|
2219
|
+
tagOwners.set(key, item.id);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
const matchedIds = [];
|
|
2223
|
+
let untaggedNewEntries = 0;
|
|
2224
|
+
for (const entry of prepared) {
|
|
2225
|
+
if (entry.match?.id) {
|
|
2226
|
+
matchedIds.push(entry.match.id);
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
if (!entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
|
|
2230
|
+
untaggedNewEntries++;
|
|
2231
|
+
}
|
|
2232
|
+
const conflictingTargets = matchedIds.length - new Set(matchedIds).size;
|
|
2233
|
+
if (duplicateTags.size > 0 || untaggedNewEntries > 0 || conflictingTargets > 0) {
|
|
2234
|
+
throw new CommandError(`pm github gate: import plan is not idempotent — ${duplicateTags.size} duplicate provenance tag(s) ` +
|
|
2235
|
+
`in the existing corpus, ${untaggedNewEntries} new entr(y/ies) without a provenance tag, ` +
|
|
2236
|
+
`${conflictingTargets} conflicting target(s). No item was written.`, EXIT_CODE.GENERIC_FAILURE);
|
|
2237
|
+
}
|
|
2238
|
+
return {
|
|
2239
|
+
provenance_indexed: tagOwners.size,
|
|
2240
|
+
matched_by_provenance: matchedIds.length,
|
|
2241
|
+
new_entries: prepared.length - matchedIds.length,
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* Verify the persisted corpus actually contains every issue in the written plan.
|
|
2246
|
+
*
|
|
2247
|
+
* A recovered SDK journal alone is insufficient evidence after local files are
|
|
2248
|
+
* reset or removed. Require unique durable provenance before a caller may push.
|
|
2249
|
+
*
|
|
2250
|
+
* @param existing - Complete post-write item corpus.
|
|
2251
|
+
* @param prepared - Every entry the verified plan required.
|
|
2252
|
+
* @param repo - Source repository whose provenance must be present.
|
|
2253
|
+
* @returns Number of durable issue identities verified.
|
|
2254
|
+
*/
|
|
2255
|
+
export function verifyImportedProvenance(existing, prepared, repo) {
|
|
2256
|
+
const index = indexByProvenance(existing);
|
|
2257
|
+
const persisted = prepared.map(entry => ({ ...entry, match: index.get(`${repo.toLowerCase()}#${entry.issueNumber}`) }));
|
|
2258
|
+
if (persisted.some(entry => !entry.match?.id))
|
|
2259
|
+
throw new CommandError("pm github gate: persisted corpus does not account for the complete import plan. Nothing may be pushed.");
|
|
2260
|
+
verifyImportIdempotency(existing, persisted, repo);
|
|
2261
|
+
return persisted.length;
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Execute the real tracker gate with the shared fail-closed command error boundary.
|
|
2265
|
+
*
|
|
2266
|
+
* @internal Exported to verify collaborator failures against real Git fixtures.
|
|
2267
|
+
* @param input - Tracker root and optional gate input dependencies.
|
|
2268
|
+
* @returns A completed gate report; unexpected errors are redacted.
|
|
2269
|
+
*/
|
|
2270
|
+
export function runCommandTrackerGate(input) {
|
|
2271
|
+
try {
|
|
2272
|
+
return runTrackerGate(input);
|
|
2273
|
+
}
|
|
2274
|
+
catch (error) {
|
|
2275
|
+
throw new CommandError(error instanceof GateInputError ? error.message : "pm github gate: scanner error; scan did not complete.", EXIT_CODE.GENERIC_FAILURE);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Run the post-write tracker privacy gate and turn its outcome into a receipt.
|
|
2280
|
+
*
|
|
2281
|
+
* Runs the real gate over the proposed tracker change the import just wrote,
|
|
2282
|
+
* emits the human-readable report to stderr, and throws {@link CommandError}
|
|
2283
|
+
* with the findings (rule + item + field + hash — never the matched content)
|
|
2284
|
+
* when the verdict is fail, so a caller higher in the pipeline (a sync workflow)
|
|
2285
|
+
* never reaches its commit/push step on a gated failure. Gate input failures
|
|
2286
|
+
* (not a Git work tree, unreadable change, malformed allowlist) fail identically.
|
|
2287
|
+
*
|
|
2288
|
+
* @param pmRoot - Workspace root or pm data dir the import wrote to.
|
|
2289
|
+
* @returns The pass receipt, embedded into the import result.
|
|
2290
|
+
*/
|
|
2291
|
+
function gateImportWrites(pmRoot) {
|
|
2292
|
+
const report = runCommandTrackerGate({ pmRoot });
|
|
2293
|
+
const lines = formatGateReport(report);
|
|
2294
|
+
console.error(lines.join("\n"));
|
|
2295
|
+
if (report.verdict === "fail") {
|
|
2296
|
+
throw new CommandError(JSON.stringify({ ...report, error: "pm github gate: FAIL" }), EXIT_CODE.GENERIC_FAILURE);
|
|
2297
|
+
}
|
|
2298
|
+
return {
|
|
2299
|
+
verdict: report.verdict,
|
|
2300
|
+
scanned_files: report.scanned_files,
|
|
2301
|
+
findings: report.findings.length,
|
|
2302
|
+
allowlisted: report.allowlisted,
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2135
2305
|
/**
|
|
2136
2306
|
* Run the full GitHub issue import flow.
|
|
2137
2307
|
*
|
|
2138
2308
|
* Idempotent: items already linked (by provenance tag) to a fetched issue are
|
|
2139
2309
|
* UPDATEd; new issues are created. Honors `--atomic` (one crash-resumable
|
|
2140
2310
|
* transaction) versus the per-item `pm` mutation path, optional `--link-deps`,
|
|
2141
|
-
* and `--dry-run`.
|
|
2142
|
-
*
|
|
2311
|
+
* and `--dry-run`. With `--gate` the plan is verified (completeness + provenance
|
|
2312
|
+
* idempotency) BEFORE any mutation, and the proposed tracker change is scanned
|
|
2313
|
+
* by the fail-closed privacy gate AFTER it — a gated failure exits non-zero so
|
|
2314
|
+
* a caller never commits or pushes the change. Returns a structured result and
|
|
2315
|
+
* throws {@link CommandError} (with a semantic exit code) on failure.
|
|
2143
2316
|
*
|
|
2144
2317
|
* @param repoArg - The `owner/repo` to import from.
|
|
2145
2318
|
* @param pmRoot - Workspace root or pm data dir.
|
|
@@ -2161,7 +2334,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2161
2334
|
fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token);
|
|
2162
2335
|
}
|
|
2163
2336
|
catch (err) {
|
|
2164
|
-
const msg =
|
|
2337
|
+
const msg = errorMessage(err);
|
|
2165
2338
|
const hint = !token && /HTTP 403/.test(msg)
|
|
2166
2339
|
? " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit (60→5000/hr) and reach private repos"
|
|
2167
2340
|
: "";
|
|
@@ -2171,6 +2344,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2171
2344
|
const filtered = applyClientFilters(fetched, opts);
|
|
2172
2345
|
if (filtered.length === 0) {
|
|
2173
2346
|
console.error("No issues found.");
|
|
2347
|
+
// Even a no-op gated import must prove the workspace is gateable (a Git work
|
|
2348
|
+
// tree the scanner can read), so the empty-change gate scan runs here too.
|
|
2349
|
+
const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmRoot) : undefined;
|
|
2174
2350
|
if (opts.atomic && opts.dryRun) {
|
|
2175
2351
|
return {
|
|
2176
2352
|
dryRun: true,
|
|
@@ -2180,7 +2356,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2180
2356
|
atomic: true,
|
|
2181
2357
|
};
|
|
2182
2358
|
}
|
|
2183
|
-
return { imported: 0, updated: 0, skipped: 0 };
|
|
2359
|
+
return { imported: 0, updated: 0, skipped: 0, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
|
|
2184
2360
|
}
|
|
2185
2361
|
console.error(`Found ${filtered.length} issue(s).`);
|
|
2186
2362
|
// Build the idempotency index once up-front — including for a dry run. Skipping
|
|
@@ -2188,20 +2364,64 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2188
2364
|
// reported "would import N, skip 0" where the real run performs updates for
|
|
2189
2365
|
// already-linked issues. A preview that overstates creates reads as "this will
|
|
2190
2366
|
// duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
|
|
2191
|
-
const
|
|
2367
|
+
const existingItems = (dependencies.readItems ?? readPmItems)(pmRoot);
|
|
2368
|
+
const existing = indexByProvenance(existingItems);
|
|
2192
2369
|
let imported = 0;
|
|
2193
2370
|
let updated = 0;
|
|
2194
2371
|
let skipped = 0;
|
|
2195
|
-
|
|
2196
|
-
|
|
2372
|
+
// Planned native comments the write phase could not land (contended lock,
|
|
2373
|
+
// refused add, unparsable created id). Ungated runs warn and continue; gated
|
|
2374
|
+
// runs add this to the plan-divergence refusal at the end.
|
|
2375
|
+
let commentWriteFailures = 0;
|
|
2376
|
+
// Gated pipeline, phase 1 — plan BEFORE any mutation. The same prepare step
|
|
2377
|
+
// the write paths use builds every plan entry up front, so completeness
|
|
2378
|
+
// (every fetched issue accounted for exactly once) and idempotency (every
|
|
2379
|
+
// re-import lands on exactly one provenance-tagged item) are proven while
|
|
2380
|
+
// the tracker is still untouched. Both checks throw before any write.
|
|
2381
|
+
let gatePlan;
|
|
2382
|
+
let gateCompleteness;
|
|
2383
|
+
let gateIdempotency;
|
|
2384
|
+
let gatePlanByNumber;
|
|
2385
|
+
if (opts.gate || opts.atomic) {
|
|
2386
|
+
const planned = [];
|
|
2387
|
+
const skippedNumbers = [];
|
|
2197
2388
|
for (const issue of filtered) {
|
|
2198
2389
|
const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
|
|
2199
2390
|
if (!entry) {
|
|
2200
|
-
|
|
2391
|
+
skippedNumbers.push(issue.number);
|
|
2201
2392
|
continue;
|
|
2202
2393
|
}
|
|
2203
|
-
|
|
2394
|
+
planned.push(entry);
|
|
2395
|
+
}
|
|
2396
|
+
if (opts.gate) {
|
|
2397
|
+
gateCompleteness = verifyImportPlanCompleteness(filtered, planned, skippedNumbers);
|
|
2398
|
+
gateIdempotency = verifyImportIdempotency(existingItems, planned, repo);
|
|
2204
2399
|
}
|
|
2400
|
+
gatePlan = planned;
|
|
2401
|
+
gatePlanByNumber = new Map(planned.map((entry) => [entry.issueNumber, entry]));
|
|
2402
|
+
skipped = skippedNumbers.length;
|
|
2403
|
+
if (opts.gate && !opts.dryRun) {
|
|
2404
|
+
const preview = runCommandTrackerGate({ pmRoot, plannedItems: planned.map(entry => ({
|
|
2405
|
+
itemId: entry.match?.id ?? `github-issue-${entry.issueNumber}`,
|
|
2406
|
+
fields: { title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
|
|
2407
|
+
assignee: entry.assignee, sprint: entry.milestone,
|
|
2408
|
+
comments: entry.syncAnnotations ? entry.comments.map(comment => ({
|
|
2409
|
+
author: comment.user?.login, text: comment.body, created_at: comment.created_at,
|
|
2410
|
+
})) : [] },
|
|
2411
|
+
})) });
|
|
2412
|
+
if (preview.verdict === "fail")
|
|
2413
|
+
throw new CommandError(JSON.stringify({ ...preview, error: "pm github gate: FAIL before write" }));
|
|
2414
|
+
}
|
|
2415
|
+
if (opts.gate)
|
|
2416
|
+
console.error(`pm github gate: plan verified — ${gateCompleteness.planned} planned, ${gateCompleteness.skipped} skipped, ` +
|
|
2417
|
+
`${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.new_entries} new.`);
|
|
2418
|
+
}
|
|
2419
|
+
const gatePlanFields = opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {};
|
|
2420
|
+
if (opts.atomic) {
|
|
2421
|
+
// The gated pipeline already prepared every entry (and counted its skips)
|
|
2422
|
+
// during the pre-mutation plan phase; reuse that plan verbatim so the write
|
|
2423
|
+
// phase cannot diverge from the verified plan.
|
|
2424
|
+
const prepared = [...gatePlan];
|
|
2205
2425
|
if (prepared.length === 0) {
|
|
2206
2426
|
if (opts.dryRun) {
|
|
2207
2427
|
console.error(`[dry-run] Atomic plan would import 0, update 0, skip ${skipped}.`);
|
|
@@ -2220,7 +2440,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2220
2440
|
updated = prepared.length - imported;
|
|
2221
2441
|
for (const entry of prepared) {
|
|
2222
2442
|
const action = entry.match?.id ? "update" : "import";
|
|
2223
|
-
console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${entry.title} (${entry.status})`);
|
|
2443
|
+
console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${opts.gate ? "(gated title)" : entry.title} (${entry.status})`);
|
|
2224
2444
|
}
|
|
2225
2445
|
console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`);
|
|
2226
2446
|
if (opts.linkDeps) {
|
|
@@ -2234,17 +2454,29 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2234
2454
|
wouldSkip: skipped,
|
|
2235
2455
|
atomic: true,
|
|
2236
2456
|
...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
|
|
2457
|
+
...gatePlanFields,
|
|
2237
2458
|
};
|
|
2238
2459
|
}
|
|
2239
2460
|
const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
|
|
2461
|
+
if (opts.gate)
|
|
2462
|
+
verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), prepared, repo);
|
|
2463
|
+
// Planned native comments are part of the verified plan: a contended lock,
|
|
2464
|
+
// a refused add, or a committed entry whose created id is missing from the
|
|
2465
|
+
// post-commit routing map all leave planned comments unwritten, so they are
|
|
2466
|
+
// counted and gated runs fail closed below instead of exiting clean.
|
|
2467
|
+
let commentWriteFailures = 0;
|
|
2240
2468
|
for (const entry of prepared) {
|
|
2241
2469
|
if (!entry.syncAnnotations)
|
|
2242
2470
|
continue;
|
|
2243
2471
|
const itemId = result.itemIds.get(entry.issueNumber);
|
|
2244
2472
|
if (itemId) {
|
|
2245
|
-
await syncGithubCommentsToAnnotations(itemId, entry.comments, pmRoot, entry.issueNumber);
|
|
2473
|
+
commentWriteFailures += (await syncGithubCommentsToAnnotations(itemId, entry.comments, pmRoot, entry.issueNumber)).failed;
|
|
2474
|
+
}
|
|
2475
|
+
else {
|
|
2476
|
+
commentWriteFailures += entry.comments.length;
|
|
2246
2477
|
}
|
|
2247
2478
|
}
|
|
2479
|
+
failClosedOnPlanDivergence(opts.gate, skipped, skipped, commentWriteFailures);
|
|
2248
2480
|
if (result.recovered) {
|
|
2249
2481
|
console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
|
|
2250
2482
|
}
|
|
@@ -2257,6 +2489,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2257
2489
|
reportDepLink(atomicDepLink);
|
|
2258
2490
|
// itemIds is an internal post-commit routing map for native comments. Maps
|
|
2259
2491
|
// serialize as `{}` in JSON, so keep it out of the public command result.
|
|
2492
|
+
// Gated pipeline, phase 2 — the writes are on disk but nothing is committed
|
|
2493
|
+
// or pushed; scan the proposed tracker change now and fail closed.
|
|
2494
|
+
const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
|
|
2260
2495
|
return {
|
|
2261
2496
|
transactionId: result.transactionId,
|
|
2262
2497
|
recovered: result.recovered,
|
|
@@ -2266,12 +2501,21 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2266
2501
|
skipped,
|
|
2267
2502
|
atomic: true,
|
|
2268
2503
|
...depLinkResultFields(atomicDepLink),
|
|
2504
|
+
...(gateReceipt ? { gate: gateReceipt } : {}),
|
|
2269
2505
|
};
|
|
2270
2506
|
}
|
|
2271
2507
|
for (const issue of filtered) {
|
|
2272
|
-
|
|
2508
|
+
// With --gate, the pre-mutation plan phase already prepared (or explicitly
|
|
2509
|
+
// skipped) every issue; reuse the verified plan instead of re-preparing so
|
|
2510
|
+
// the write phase cannot diverge from it.
|
|
2511
|
+
const prepared = gatePlanByNumber
|
|
2512
|
+
? gatePlanByNumber.get(issue.number)
|
|
2513
|
+
: await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
|
|
2273
2514
|
if (!prepared) {
|
|
2274
|
-
|
|
2515
|
+
// A gated run counted its skips during the verified plan phase; only the
|
|
2516
|
+
// ungated path counts them here.
|
|
2517
|
+
if (!gatePlanByNumber)
|
|
2518
|
+
skipped++;
|
|
2275
2519
|
continue;
|
|
2276
2520
|
}
|
|
2277
2521
|
const labels = issue.labels.map((l) => l.name).filter(Boolean);
|
|
@@ -2295,8 +2539,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2295
2539
|
};
|
|
2296
2540
|
if (opts.dryRun) {
|
|
2297
2541
|
const action = match?.id ? "update" : "import";
|
|
2298
|
-
const metadata = labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
|
|
2299
|
-
console.error(` [dry-run] #${issue.number} ${action}: ${title} (${metadata})`);
|
|
2542
|
+
const metadata = !opts.gate && labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
|
|
2543
|
+
console.error(` [dry-run] #${issue.number} ${action}: ${opts.gate ? "(gated title)" : title} (${metadata})`);
|
|
2300
2544
|
if (match?.id)
|
|
2301
2545
|
updated++;
|
|
2302
2546
|
else
|
|
@@ -2344,7 +2588,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2344
2588
|
}
|
|
2345
2589
|
}
|
|
2346
2590
|
if (syncAnnotations) {
|
|
2347
|
-
await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number);
|
|
2591
|
+
commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
|
|
2348
2592
|
}
|
|
2349
2593
|
updated++;
|
|
2350
2594
|
continue;
|
|
@@ -2401,10 +2645,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2401
2645
|
}
|
|
2402
2646
|
if (syncAnnotations) {
|
|
2403
2647
|
if (createdId) {
|
|
2404
|
-
await syncGithubCommentsToAnnotations(createdId, comments, pmRoot, issue.number);
|
|
2648
|
+
commentWriteFailures += (await syncGithubCommentsToAnnotations(createdId, comments, pmRoot, issue.number)).failed;
|
|
2405
2649
|
}
|
|
2406
2650
|
else {
|
|
2651
|
+
// Without the created id the planned comments cannot be written; the
|
|
2652
|
+
// gated plan-divergence refusal below turns that into a fail-closed exit
|
|
2653
|
+
// while ungated runs keep today's warn-and-continue behaviour.
|
|
2407
2654
|
console.error(`#${issue.number}: could not parse created item id — comments not synced`);
|
|
2655
|
+
commentWriteFailures += comments.length;
|
|
2408
2656
|
}
|
|
2409
2657
|
}
|
|
2410
2658
|
imported++;
|
|
@@ -2420,19 +2668,30 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
2420
2668
|
wouldImport: imported,
|
|
2421
2669
|
wouldUpdate: updated,
|
|
2422
2670
|
wouldSkip: skipped,
|
|
2423
|
-
...(opts.atomic ? { atomic: true } : {}),
|
|
2424
2671
|
...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
|
|
2672
|
+
...gatePlanFields,
|
|
2425
2673
|
};
|
|
2426
2674
|
}
|
|
2427
2675
|
console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
|
|
2428
2676
|
if (imported === 0 && updated === 0 && skipped > 0) {
|
|
2429
2677
|
throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
|
|
2430
2678
|
}
|
|
2679
|
+
// A gated import is all-or-nothing in effect: a planned write that failed
|
|
2680
|
+
// (an update, close or reopen reconciliation) or a planned native comment
|
|
2681
|
+
// that was not written leaves the tracker different from the verified plan,
|
|
2682
|
+
// and provenance alone cannot see that, because the item still carries its
|
|
2683
|
+
// tag. Fail closed before anything can be pushed.
|
|
2684
|
+
failClosedOnPlanDivergence(opts.gate, gateCompleteness?.skipped ?? skipped, skipped, commentWriteFailures);
|
|
2431
2685
|
const depLink = opts.linkDeps
|
|
2432
2686
|
? await linkImportedDependencies(repo, filtered, pmRoot, dependencies)
|
|
2433
2687
|
: undefined;
|
|
2434
2688
|
reportDepLink(depLink);
|
|
2435
|
-
|
|
2689
|
+
// Gated pipeline, phase 2 — the writes are on disk but nothing is committed or
|
|
2690
|
+
// pushed; scan the proposed tracker change now and fail closed.
|
|
2691
|
+
if (opts.gate)
|
|
2692
|
+
verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), gatePlan, repo);
|
|
2693
|
+
const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
|
|
2694
|
+
return { imported, updated, skipped, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
|
|
2436
2695
|
}
|
|
2437
2696
|
/**
|
|
2438
2697
|
* Emit the human-readable `--link-deps` summary to stderr.
|
|
@@ -2469,12 +2728,50 @@ function depLinkResultFields(result) {
|
|
|
2469
2728
|
...(result.failures.length > 0 ? { dependencyLinkFailures: result.failures } : {}),
|
|
2470
2729
|
};
|
|
2471
2730
|
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Fail a gated import when the writes diverged from the verified plan.
|
|
2733
|
+
*
|
|
2734
|
+
* A gated import is all-or-nothing in effect: a planned item write that failed
|
|
2735
|
+
* (an update, close or reopen reconciliation), a planned native comment that
|
|
2736
|
+
* was not written, or a comment collection left unreachable by an unparsable
|
|
2737
|
+
* created id all leave the tracker different from the verified plan — and
|
|
2738
|
+
* provenance alone cannot see that, because the item still carries its tag and
|
|
2739
|
+
* missing content cannot leak anything into the post-write privacy scan. Both
|
|
2740
|
+
* the non-atomic and the atomic import path funnel their divergence here and
|
|
2741
|
+
* fail closed before anything can be pushed; ungated imports already warned.
|
|
2742
|
+
*
|
|
2743
|
+
* @param gate - Whether the import runs gated; ungated runs never throw here.
|
|
2744
|
+
* @param plannedSkipped - Issues the verified plan already accounted as skipped.
|
|
2745
|
+
* @param actualSkipped - Issues the write phase actually skipped.
|
|
2746
|
+
* @param commentFailures - Planned native comments that were not written.
|
|
2747
|
+
* @throws CommandError when any planned write or comment failed on a gated run.
|
|
2748
|
+
*/
|
|
2749
|
+
function failClosedOnPlanDivergence(gate, plannedSkipped, actualSkipped, commentFailures) {
|
|
2750
|
+
const failed = actualSkipped - plannedSkipped + commentFailures;
|
|
2751
|
+
if (!gate || failed <= 0)
|
|
2752
|
+
return;
|
|
2753
|
+
throw new CommandError(`pm github gate: ${failed} planned write(s) failed; the tracker does not match the verified plan. Nothing may be pushed.`);
|
|
2754
|
+
}
|
|
2472
2755
|
// ---------------------------------------------------------------------------
|
|
2473
2756
|
// Sync core — push pm status changes back to GitHub (close / reopen)
|
|
2474
2757
|
// ---------------------------------------------------------------------------
|
|
2475
2758
|
function pmStatusToGithubState(status) {
|
|
2476
2759
|
return status === "closed" || status === "canceled" ? "closed" : "open";
|
|
2477
2760
|
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Require every requested item before a remote sync or export can begin.
|
|
2763
|
+
*
|
|
2764
|
+
* @param items - Complete local corpus.
|
|
2765
|
+
* @param requested - Selected ids, or an empty list for the complete corpus.
|
|
2766
|
+
* @returns Matched items in corpus order.
|
|
2767
|
+
* @throws CommandError when any requested identity is absent.
|
|
2768
|
+
*/
|
|
2769
|
+
function requireRequestedItems(items, requested) {
|
|
2770
|
+
const scoped = scopeItemsByIds(items, requested.length > 0 ? requested : undefined);
|
|
2771
|
+
if (scoped.missing.length > 0)
|
|
2772
|
+
throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
|
|
2773
|
+
return scoped.selected;
|
|
2774
|
+
}
|
|
2478
2775
|
// Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
|
|
2479
2776
|
// a create-or-update entry keyed by the issue's provenance tag.
|
|
2480
2777
|
export function planSync(items, repo) {
|
|
@@ -2504,8 +2801,8 @@ export function planSync(items, repo) {
|
|
|
2504
2801
|
// Command handler for `pm github sync`: preview or apply the pm → GitHub issue
|
|
2505
2802
|
// sync plan, scoped by --ids and honoring --dry-run / --apply.
|
|
2506
2803
|
async function runSync(ctx) {
|
|
2507
|
-
const options = ctx.options
|
|
2508
|
-
const repo = optionString(options, "repo") || ctx.args
|
|
2804
|
+
const options = ctx.options;
|
|
2805
|
+
const repo = optionString(options, "repo") || ctx.args[0];
|
|
2509
2806
|
const dryRun = optionEnabled(options, "dry-run", "dryRun");
|
|
2510
2807
|
const idsProvided = optionProvided(options, "ids");
|
|
2511
2808
|
const scopedIds = optionCsv(options, "ids");
|
|
@@ -2521,12 +2818,8 @@ async function runSync(ctx) {
|
|
|
2521
2818
|
throw new CommandError("pm github sync needs a GitHub token to mutate issues " +
|
|
2522
2819
|
"(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
|
|
2523
2820
|
}
|
|
2524
|
-
const
|
|
2525
|
-
const
|
|
2526
|
-
if (scoped.missing.length > 0) {
|
|
2527
|
-
throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
|
|
2528
|
-
}
|
|
2529
|
-
const plan = planSync(scoped.selected, repo);
|
|
2821
|
+
const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
|
|
2822
|
+
const plan = planSync(selected, repo);
|
|
2530
2823
|
if (plan.length === 0) {
|
|
2531
2824
|
const scopeNote = scopedIds.length > 0 ? ` from --ids (${scopedIds.join(", ")})` : "";
|
|
2532
2825
|
console.error(`No pm items${scopeNote} linked to ${repo} ` +
|
|
@@ -2548,7 +2841,7 @@ async function runSync(ctx) {
|
|
|
2548
2841
|
current = JSON.parse(body);
|
|
2549
2842
|
}
|
|
2550
2843
|
catch (err) {
|
|
2551
|
-
const msg =
|
|
2844
|
+
const msg = errorMessage(err);
|
|
2552
2845
|
console.error(`#${entry.number}: could not read upstream state — ${msg}`);
|
|
2553
2846
|
skipped++;
|
|
2554
2847
|
continue;
|
|
@@ -2568,7 +2861,7 @@ async function runSync(ctx) {
|
|
|
2568
2861
|
synced++;
|
|
2569
2862
|
}
|
|
2570
2863
|
catch (err) {
|
|
2571
|
-
const msg =
|
|
2864
|
+
const msg = errorMessage(err);
|
|
2572
2865
|
console.error(`#${entry.number}: PATCH failed — ${msg}`);
|
|
2573
2866
|
skipped++;
|
|
2574
2867
|
}
|
|
@@ -2675,7 +2968,7 @@ export async function applyExportPlan(plan, repo, token, requestFn) {
|
|
|
2675
2968
|
catch (err) {
|
|
2676
2969
|
// Isolate the failure: record it and keep going so one bad item never
|
|
2677
2970
|
// abandons the items that follow it (and that may already be writable).
|
|
2678
|
-
const msg =
|
|
2971
|
+
const msg = errorMessage(err);
|
|
2679
2972
|
const label = entry.action === "update" && entry.number !== undefined
|
|
2680
2973
|
? `#${entry.number}`
|
|
2681
2974
|
: entry.id ?? `"${p.title}"`;
|
|
@@ -2746,10 +3039,10 @@ export function applyOutcomeError(plan, result, repo) {
|
|
|
2746
3039
|
* duplicated; `--label-map` translates pm tags to GitHub labels.
|
|
2747
3040
|
*/
|
|
2748
3041
|
async function runExport(ctx) {
|
|
2749
|
-
const options = ctx.options
|
|
2750
|
-
const jsonMode = ctx.global
|
|
3042
|
+
const options = ctx.options;
|
|
3043
|
+
const jsonMode = ctx.global.json === true;
|
|
2751
3044
|
const format = optionString(options, "format") || "json";
|
|
2752
|
-
const repo = optionString(options, "repo") || ctx.args
|
|
3045
|
+
const repo = optionString(options, "repo") || ctx.args[0];
|
|
2753
3046
|
const apply = exportWillApply(options);
|
|
2754
3047
|
const idsProvided = optionProvided(options, "ids");
|
|
2755
3048
|
const scopedIds = optionCsv(options, "ids");
|
|
@@ -2757,12 +3050,8 @@ async function runExport(ctx) {
|
|
|
2757
3050
|
if (idsProvided && scopedIds.length === 0) {
|
|
2758
3051
|
throw new CommandError("--ids requires at least one pm item id (comma-separated), e.g. --ids pm-123,pm-456.", EXIT_CODE.USAGE);
|
|
2759
3052
|
}
|
|
2760
|
-
const
|
|
2761
|
-
const
|
|
2762
|
-
if (scoped.missing.length > 0) {
|
|
2763
|
-
throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
|
|
2764
|
-
}
|
|
2765
|
-
const plan = buildExportPlan(scoped.selected, repo, labelMap);
|
|
3053
|
+
const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
|
|
3054
|
+
const plan = buildExportPlan(selected, repo, labelMap);
|
|
2766
3055
|
const creates = plan.filter((e) => e.action === "create").length;
|
|
2767
3056
|
const updates = plan.filter((e) => e.action === "update").length;
|
|
2768
3057
|
if (!apply) {
|
|
@@ -2787,7 +3076,7 @@ async function runExport(ctx) {
|
|
|
2787
3076
|
console.error(JSON.stringify(plan, null, 2));
|
|
2788
3077
|
}
|
|
2789
3078
|
const scopeNote = scopedIds.length > 0
|
|
2790
|
-
? ` Scoped to ${
|
|
3079
|
+
? ` Scoped to ${selected.length} item(s) via --ids.`
|
|
2791
3080
|
: "";
|
|
2792
3081
|
const labelNote = labelMap && labelMap.size > 0
|
|
2793
3082
|
? ` Label map applied (${labelMap.size} mapping(s)).`
|
|
@@ -2898,7 +3187,7 @@ export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
|
|
|
2898
3187
|
// (0, 1] so hits clear pm's default score threshold.
|
|
2899
3188
|
hits.push({
|
|
2900
3189
|
id: item.id,
|
|
2901
|
-
score:
|
|
3190
|
+
score: rank / matchedNumbers.length,
|
|
2902
3191
|
matched_fields: [`github:${repoLc}#${number}`],
|
|
2903
3192
|
});
|
|
2904
3193
|
rank--;
|
|
@@ -2925,13 +3214,8 @@ export function resolveSearchRepo(options) {
|
|
|
2925
3214
|
}
|
|
2926
3215
|
// Detect whether the `gh` CLI is installed and runnable on PATH.
|
|
2927
3216
|
function detectGhCli() {
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
return r.status === 0;
|
|
2931
|
-
}
|
|
2932
|
-
catch {
|
|
2933
|
-
return false;
|
|
2934
|
-
}
|
|
3217
|
+
const r = spawnSync("gh", ["--version"], { encoding: "utf-8", env: { ...process.env } });
|
|
3218
|
+
return r.status === 0;
|
|
2935
3219
|
}
|
|
2936
3220
|
// Report which token source is active: `env` (GITHUB_TOKEN/GH_TOKEN), `gh` (gh
|
|
2937
3221
|
// auth), or `none`.
|
|
@@ -2944,8 +3228,8 @@ function detectTokenSource() {
|
|
|
2944
3228
|
// Command handler for `pm github validate`: checks token source, gh CLI, rate
|
|
2945
3229
|
// limit, and the reachable issue counts for the configured repo.
|
|
2946
3230
|
async function runValidate(ctx) {
|
|
2947
|
-
const options = ctx.options
|
|
2948
|
-
const repo = optionString(options, "repo") || ctx.args
|
|
3231
|
+
const options = ctx.options;
|
|
3232
|
+
const repo = optionString(options, "repo") || ctx.args[0];
|
|
2949
3233
|
const gh_cli = detectGhCli();
|
|
2950
3234
|
const token_source = detectTokenSource();
|
|
2951
3235
|
const token = resolveGitHubToken();
|
|
@@ -2974,7 +3258,7 @@ async function runValidate(ctx) {
|
|
|
2974
3258
|
report.repo = repo;
|
|
2975
3259
|
try {
|
|
2976
3260
|
const res = await fetchJSON(`${githubApiBase()}/repos/${repo}`, token);
|
|
2977
|
-
report.repo_accessible =
|
|
3261
|
+
report.repo_accessible = true;
|
|
2978
3262
|
report.repo_status = res.status;
|
|
2979
3263
|
const rate = parseRateLimit(res.headers);
|
|
2980
3264
|
if (rate.remaining !== undefined)
|
|
@@ -2992,18 +3276,14 @@ async function runValidate(ctx) {
|
|
|
2992
3276
|
(token ? "" : " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise it (60→5000/hr)") +
|
|
2993
3277
|
".");
|
|
2994
3278
|
}
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
}
|
|
2998
|
-
else {
|
|
2999
|
-
report.ok = false;
|
|
3000
|
-
report.messages.push(`Repo ${repo} returned HTTP ${res.status}.`);
|
|
3001
|
-
}
|
|
3279
|
+
// fetchJSON only resolves for 2xx; every other status throws into the catch
|
|
3280
|
+
// below, so a returned response is accessible by construction.
|
|
3281
|
+
report.messages.push(`Repo ${repo} is accessible (HTTP ${res.status}).`);
|
|
3002
3282
|
}
|
|
3003
3283
|
catch (err) {
|
|
3004
3284
|
report.ok = false;
|
|
3005
3285
|
report.repo_accessible = false;
|
|
3006
|
-
report.messages.push(`Repo ${repo} check failed: ${
|
|
3286
|
+
report.messages.push(`Repo ${repo} check failed: ${errorMessage(err)}`);
|
|
3007
3287
|
}
|
|
3008
3288
|
}
|
|
3009
3289
|
}
|
|
@@ -3199,7 +3479,7 @@ async function fetchProjectItems(projectId, token) {
|
|
|
3199
3479
|
*/
|
|
3200
3480
|
async function gqlAddDraft(projectId, title, body, token) {
|
|
3201
3481
|
const q = `mutation($p:ID!,$t:String!,$b:String){ addProjectV2DraftIssue(input:{projectId:$p,title:$t,body:$b}){ projectItem{ id } } }`;
|
|
3202
|
-
const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body
|
|
3482
|
+
const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body });
|
|
3203
3483
|
const id = d.addProjectV2DraftIssue?.projectItem?.id;
|
|
3204
3484
|
if (!id)
|
|
3205
3485
|
throw new CommandError("addProjectV2DraftIssue returned no item id.");
|
|
@@ -3335,8 +3615,8 @@ export async function listOwnerProjectsV2Nodes(owner, graphQL) {
|
|
|
3335
3615
|
* @param ctx - The command-handler context.
|
|
3336
3616
|
*/
|
|
3337
3617
|
async function runProjectList(ctx) {
|
|
3338
|
-
const options = ctx.options
|
|
3339
|
-
const owner = optionString(options, "owner") || ctx.args
|
|
3618
|
+
const options = ctx.options;
|
|
3619
|
+
const owner = optionString(options, "owner") || ctx.args[0];
|
|
3340
3620
|
if (!owner) {
|
|
3341
3621
|
throw new CommandError("Usage: pm github project list <owner> (a GitHub user or org login)", EXIT_CODE.USAGE);
|
|
3342
3622
|
}
|
|
@@ -3349,7 +3629,7 @@ async function runProjectList(ctx) {
|
|
|
3349
3629
|
closed: !!n.closed,
|
|
3350
3630
|
description: n.shortDescription ?? undefined,
|
|
3351
3631
|
}));
|
|
3352
|
-
if (ctx.global
|
|
3632
|
+
if (ctx.global.json !== true) {
|
|
3353
3633
|
if (projects.length === 0) {
|
|
3354
3634
|
console.error(`No Projects v2 found for ${owner} (or none accessible with the resolved token).`);
|
|
3355
3635
|
}
|
|
@@ -3362,6 +3642,22 @@ async function runProjectList(ctx) {
|
|
|
3362
3642
|
}
|
|
3363
3643
|
return { owner, projects };
|
|
3364
3644
|
}
|
|
3645
|
+
/**
|
|
3646
|
+
* Require a valid owner/number board reference before a project command can run.
|
|
3647
|
+
*
|
|
3648
|
+
* Resolves positional and --project forms identically for fields, import, and
|
|
3649
|
+
* sync; a malformed reference exits before any GraphQL request or mutation.
|
|
3650
|
+
*
|
|
3651
|
+
* @param ctx - The host command context.
|
|
3652
|
+
* @param usage - Command-specific recovery instruction.
|
|
3653
|
+
* @returns The parsed project identity.
|
|
3654
|
+
*/
|
|
3655
|
+
function requireProjectRef(ctx, usage) {
|
|
3656
|
+
const ref = parseProjectRef(optionString(ctx.options, "project") || ctx.args[0]);
|
|
3657
|
+
if (!ref)
|
|
3658
|
+
throw new CommandError(usage, EXIT_CODE.USAGE);
|
|
3659
|
+
return ref;
|
|
3660
|
+
}
|
|
3365
3661
|
// --- project fields --------------------------------------------------------
|
|
3366
3662
|
/**
|
|
3367
3663
|
* Command handler for `pm github project fields`: show a board's fields.
|
|
@@ -3372,11 +3668,8 @@ async function runProjectList(ctx) {
|
|
|
3372
3668
|
* @param ctx - The command-handler context.
|
|
3373
3669
|
*/
|
|
3374
3670
|
async function runProjectFields(ctx) {
|
|
3375
|
-
const options = ctx.options
|
|
3376
|
-
const ref =
|
|
3377
|
-
if (!ref) {
|
|
3378
|
-
throw new CommandError("Usage: pm github project fields <owner/number> (e.g. pm github project fields unbraind/5)", EXIT_CODE.USAGE);
|
|
3379
|
-
}
|
|
3671
|
+
const options = ctx.options;
|
|
3672
|
+
const ref = requireProjectRef(ctx, "Usage: pm github project fields <owner/number> (e.g. pm github project fields unbraind/5)");
|
|
3380
3673
|
const token = resolveGitHubToken();
|
|
3381
3674
|
const meta = await resolveProject(ref, token);
|
|
3382
3675
|
const q = `
|
|
@@ -3393,7 +3686,7 @@ async function runProjectFields(ctx) {
|
|
|
3393
3686
|
type: f.dataType ?? f.__typename,
|
|
3394
3687
|
options: Array.isArray(f.options) ? f.options.map((o) => o.name) : undefined,
|
|
3395
3688
|
}));
|
|
3396
|
-
if (ctx.global
|
|
3689
|
+
if (ctx.global.json !== true) {
|
|
3397
3690
|
console.error(`Project ${ref.owner}/${ref.number} — ${meta.title} (${meta.ownerType})`);
|
|
3398
3691
|
console.error(` ${meta.url}`);
|
|
3399
3692
|
console.error(` Status field: ${meta.statusField ? meta.statusField.options.map((o) => o.name).join(" | ") : "(none — pushes cannot set status)"}`);
|
|
@@ -3415,11 +3708,8 @@ async function runProjectFields(ctx) {
|
|
|
3415
3708
|
* @param ctx - The command-handler context.
|
|
3416
3709
|
*/
|
|
3417
3710
|
async function runProjectImport(ctx) {
|
|
3418
|
-
const options = ctx.options
|
|
3419
|
-
const ref =
|
|
3420
|
-
if (!ref) {
|
|
3421
|
-
throw new CommandError("Usage: pm github project import <owner/number> [--dry-run] [--status-map pm=Option,...] [--type <type>]", EXIT_CODE.USAGE);
|
|
3422
|
-
}
|
|
3711
|
+
const options = ctx.options;
|
|
3712
|
+
const ref = requireProjectRef(ctx, "Usage: pm github project import <owner/number> [--dry-run] [--status-map pm=Option,...] [--type <type>]");
|
|
3423
3713
|
const dryRun = optionEnabled(options, "dry-run", "dryRun");
|
|
3424
3714
|
const itemType = optionString(options, "type") || "Task";
|
|
3425
3715
|
const statusMap = parseStatusMap(optionCsv(options, "status-map", "statusMap"));
|
|
@@ -3554,19 +3844,16 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
3554
3844
|
let itemId = entry.itemId;
|
|
3555
3845
|
if (entry.action === "add-draft") {
|
|
3556
3846
|
const pm = pmById.get(entry.pmId);
|
|
3557
|
-
itemId = await gqlAddDraft(meta.id, entry.title, pm
|
|
3847
|
+
itemId = await gqlAddDraft(meta.id, entry.title, pm.body || pm.description, token);
|
|
3558
3848
|
}
|
|
3559
3849
|
else if (entry.action === "add-issue") {
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
}
|
|
3850
|
+
// The private executor receives only buildProjectPushPlan entries. Its
|
|
3851
|
+
// add-issue arm always sets both coordinates from a parsed provenance tag.
|
|
3563
3852
|
const contentId = await gqlResolveIssueNodeId(entry.issueRepo, entry.issueNumber, token);
|
|
3564
3853
|
if (!contentId)
|
|
3565
3854
|
return { changed: false, error: `could not resolve node id for ${entry.issueRepo}#${entry.issueNumber}` };
|
|
3566
3855
|
itemId = await gqlAddIssue(meta.id, contentId, token);
|
|
3567
3856
|
}
|
|
3568
|
-
if (!itemId)
|
|
3569
|
-
return { changed: false, error: "no project item id to act on" };
|
|
3570
3857
|
if (entry.targetOptionId && meta.statusField) {
|
|
3571
3858
|
await gqlSetStatus(meta.id, itemId, meta.statusField.id, entry.targetOptionId, token);
|
|
3572
3859
|
}
|
|
@@ -3575,7 +3862,7 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
3575
3862
|
const pm = pmById.get(entry.pmId);
|
|
3576
3863
|
if (pm?.id) {
|
|
3577
3864
|
const tag = projectItemTag(ref, itemId);
|
|
3578
|
-
const existingTags = pm.tags
|
|
3865
|
+
const existingTags = pm.tags;
|
|
3579
3866
|
if (!existingTags.includes(tag)) {
|
|
3580
3867
|
const upd = pmRun([
|
|
3581
3868
|
"--path", pmRoot, "update", pm.id,
|
|
@@ -3589,7 +3876,7 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
3589
3876
|
return { changed: true };
|
|
3590
3877
|
}
|
|
3591
3878
|
catch (err) {
|
|
3592
|
-
return { changed: false, error:
|
|
3879
|
+
return { changed: false, error: errorMessage(err) };
|
|
3593
3880
|
}
|
|
3594
3881
|
}
|
|
3595
3882
|
// Build the pm CLI argv that applies one pull (project → pm) status transition.
|
|
@@ -3659,11 +3946,8 @@ function applyPullEntry(entry, pmRoot) {
|
|
|
3659
3946
|
* @param ctx - The command-handler context.
|
|
3660
3947
|
*/
|
|
3661
3948
|
async function runProjectSync(ctx) {
|
|
3662
|
-
const options = ctx.options
|
|
3663
|
-
const ref =
|
|
3664
|
-
if (!ref) {
|
|
3665
|
-
throw new CommandError("Usage: pm github project sync <owner/number> [--push|--pull] [--apply] [--ids pm-1,..] [--status-map pm=Option,..] [--no-add-missing] [--prefer pm|github]", EXIT_CODE.USAGE);
|
|
3666
|
-
}
|
|
3949
|
+
const options = ctx.options;
|
|
3950
|
+
const ref = requireProjectRef(ctx, "Usage: pm github project sync <owner/number> [--push|--pull] [--apply] [--ids pm-1,..] [--status-map pm=Option,..] [--no-add-missing] [--prefer pm|github]");
|
|
3667
3951
|
const wantPush = optionEnabled(options, "push");
|
|
3668
3952
|
const wantPull = optionEnabled(options, "pull");
|
|
3669
3953
|
const dryRunFlag = optionEnabled(options, "dry-run", "dryRun");
|
|
@@ -3732,7 +4016,7 @@ async function runProjectSync(ctx) {
|
|
|
3732
4016
|
for (const e of pullActionable)
|
|
3733
4017
|
console.error(` ${e.pmId} "${e.title}": ${e.fromStatus} → ${e.toStatus}`);
|
|
3734
4018
|
for (const s of pullPlan.statusSkipped) {
|
|
3735
|
-
console.error(` [skip] item ${s.itemId}: board status "${s.optionName
|
|
4019
|
+
console.error(` [skip] item ${s.itemId}: board status "${s.optionName}" maps to no pm status`);
|
|
3736
4020
|
}
|
|
3737
4021
|
if (pullActionable.length === 0)
|
|
3738
4022
|
console.error(" (nothing to pull)");
|
|
@@ -3749,7 +4033,7 @@ async function runProjectSync(ctx) {
|
|
|
3749
4033
|
let pushFailed = 0;
|
|
3750
4034
|
if (applyPush && pushPlan) {
|
|
3751
4035
|
for (const e of pushActionable) {
|
|
3752
|
-
if (prefer === "github" && e.action === "set-status" && pullItemIds.has(e.itemId
|
|
4036
|
+
if (prefer === "github" && e.action === "set-status" && pullItemIds.has(e.itemId)) {
|
|
3753
4037
|
continue; // pull wins for this linked item
|
|
3754
4038
|
}
|
|
3755
4039
|
const r = await applyPushEntry(e, meta, ref, pmById, ctx.pm_root, token);
|
|
@@ -3832,6 +4116,7 @@ const IMPORT_FLAGS = [
|
|
|
3832
4116
|
{ long: "--comments-mode", value_name: "body|annotations|both", description: "How to persist fetched GitHub comments: `body` (default, embed in item body), `annotations` (sync to the pm item's native comments collection), or `both`. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id)" },
|
|
3833
4117
|
{ long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" },
|
|
3834
4118
|
{ long: "--link-deps", description: "After import, map dependency references in issue bodies (`Blocked by #N`, `Depends on owner/repo#N`, `Blocks #N`) to pm dependency edges between the linked items. Idempotent; skips self- and unresolved references; ordering cycles are reported (via the SDK ordering-cycle advisory), not rejected" },
|
|
4119
|
+
{ long: "--gate", description: "Fail-closed privacy gate: verify plan completeness and provenance-tag idempotency before the write, then scan the proposed tracker change for credentials, personal data, and host paths after it; any finding (or unreadable scan input) fails the import so nothing is ever pushed" },
|
|
3835
4120
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
3836
4121
|
{ long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
|
|
3837
4122
|
];
|
|
@@ -3850,6 +4135,10 @@ const SYNC_FLAGS = [
|
|
|
3850
4135
|
{ long: "--ids", value_name: "pm-1,pm-2", description: "Only sync these pm item IDs (comma-separated)" },
|
|
3851
4136
|
{ long: "--dry-run", description: "Preview the close/reopen plan without mutating GitHub" },
|
|
3852
4137
|
];
|
|
4138
|
+
const GATE_FLAGS = [
|
|
4139
|
+
{ long: "--diff", value_name: "file", description: "Scan an explicit unified diff file instead of the staged/working tracker change (fail closed when unreadable)" },
|
|
4140
|
+
{ long: "--allowlist", value_name: "file", description: "Explicit content-hash allowlist of reviewed false positives (default: `<repo root>/.pm-github-gate-allowlist.json`; a missing EXPLICIT file fails, a missing default is an empty allowlist)" },
|
|
4141
|
+
];
|
|
3853
4142
|
const VALIDATE_FLAGS = [
|
|
3854
4143
|
{ long: "--repo", value_name: "owner/repo", description: "Also check this repo is accessible with the resolved token" },
|
|
3855
4144
|
];
|
|
@@ -3887,7 +4176,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
3887
4176
|
const defineExtension = (module) => module;
|
|
3888
4177
|
export default defineExtension({
|
|
3889
4178
|
name: "pm-github",
|
|
3890
|
-
version: "2026.10.
|
|
4179
|
+
version: "2026.10.8",
|
|
3891
4180
|
activate(api) {
|
|
3892
4181
|
// -----------------------------------------------------------------------
|
|
3893
4182
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -3923,7 +4212,7 @@ export default defineExtension({
|
|
|
3923
4212
|
"github project sync",
|
|
3924
4213
|
],
|
|
3925
4214
|
run: (ctx) => {
|
|
3926
|
-
if (isMutatingGithubCommand(ctx.command, ctx.options
|
|
4215
|
+
if (isMutatingGithubCommand(ctx.command, ctx.options)) {
|
|
3927
4216
|
if (!resolveGitHubToken()) {
|
|
3928
4217
|
console.error("[pm-github preflight] this github command mutates remote state but no GitHub " +
|
|
3929
4218
|
"token is resolvable (GITHUB_TOKEN/GH_TOKEN or `gh auth login`). It will fail.");
|
|
@@ -3936,7 +4225,7 @@ export default defineExtension({
|
|
|
3936
4225
|
// importer — `pm github import <owner/repo>` (idempotent native pipeline)
|
|
3937
4226
|
// -----------------------------------------------------------------------
|
|
3938
4227
|
api.registerImporter("github", async (ctx) => {
|
|
3939
|
-
return runImport(ctx.args
|
|
4228
|
+
return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options));
|
|
3940
4229
|
}, {
|
|
3941
4230
|
description: "Fetch GitHub issues from a repo and create/update pm items (idempotent " +
|
|
3942
4231
|
"on re-import via the `gh:owner/repo#N` provenance tag). Skips pull " +
|
|
@@ -4003,7 +4292,7 @@ export default defineExtension({
|
|
|
4003
4292
|
api.registerSearchProvider({
|
|
4004
4293
|
name: "github",
|
|
4005
4294
|
async query(qctx) {
|
|
4006
|
-
const repo = resolveSearchRepo(qctx.options
|
|
4295
|
+
const repo = resolveSearchRepo(qctx.options);
|
|
4007
4296
|
if (!repo)
|
|
4008
4297
|
return [];
|
|
4009
4298
|
const token = resolveGitHubToken();
|
|
@@ -4042,10 +4331,10 @@ export default defineExtension({
|
|
|
4042
4331
|
if (ctx.command !== "close" && ctx.command !== "update")
|
|
4043
4332
|
return;
|
|
4044
4333
|
// Only nudge for items that are actually linked to GitHub.
|
|
4045
|
-
const id = ctx.args
|
|
4334
|
+
const id = ctx.args[0];
|
|
4046
4335
|
if (!id || !ctx.pm_root)
|
|
4047
4336
|
return;
|
|
4048
|
-
const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8", maxBuffer: pmJsonMaxBuffer() });
|
|
4337
|
+
const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8", maxBuffer: pmJsonMaxBuffer(), env: { ...process.env } });
|
|
4049
4338
|
if (res.status !== 0)
|
|
4050
4339
|
return;
|
|
4051
4340
|
let repo;
|
|
@@ -4131,6 +4420,48 @@ export default defineExtension({
|
|
|
4131
4420
|
},
|
|
4132
4421
|
});
|
|
4133
4422
|
// -----------------------------------------------------------------------
|
|
4423
|
+
// command — `pm github gate` (fail-closed privacy gate over the proposed
|
|
4424
|
+
// tracker change). Runs BEFORE any push in a sync pipeline; a fail verdict
|
|
4425
|
+
// or an unreadable scan input exits non-zero so nothing downstream commits
|
|
4426
|
+
// or pushes. Findings name rule + item + field + content hash — never the
|
|
4427
|
+
// matched content.
|
|
4428
|
+
// -----------------------------------------------------------------------
|
|
4429
|
+
api.registerCommand({
|
|
4430
|
+
name: "github gate",
|
|
4431
|
+
description: "Fail-closed privacy gate over the proposed tracker change (the staged/working/untracked diff under the resolved pm tracker path, or an explicit --diff file). Fails on credentials (GitHub/npm/AWS/Slack/OpenAI/Anthropic tokens, generic bearers, private-key blocks, high-entropy secret assignments), personal data (non-noreply emails, phone numbers), and host paths (absolute local filesystem paths, home-directory usernames). Reviewed false positives are allowlisted by content hash. Unreadable input or a scanner error fails. Read-only; never mutates anything. Use --json for machine output.",
|
|
4432
|
+
intent: "verify a proposed tracker change before any public branch push",
|
|
4433
|
+
examples: [
|
|
4434
|
+
"pm github gate",
|
|
4435
|
+
"pm github gate --json",
|
|
4436
|
+
"pm github gate --diff pm-sync.patch",
|
|
4437
|
+
"pm github gate --allowlist .pm-github-gate-allowlist.json",
|
|
4438
|
+
],
|
|
4439
|
+
flags: GATE_FLAGS,
|
|
4440
|
+
failure_hints: [
|
|
4441
|
+
"Run inside the repository holding the pm tracker, or pass --diff <file> for an explicit unified diff.",
|
|
4442
|
+
"A finding names the rule, item, field, and the sha256 of the matched content; allowlist that hash in .pm-github-gate-allowlist.json with a written reason after review.",
|
|
4443
|
+
"The gate scans only added lines — removed content cannot publish anything new.",
|
|
4444
|
+
"pm github import --gate composes this gate into an import: plan completeness and provenance idempotency are verified before the write, the scan runs after it.",
|
|
4445
|
+
],
|
|
4446
|
+
async run(ctx) {
|
|
4447
|
+
const diffFile = optionString(ctx.options, "diff");
|
|
4448
|
+
const allowlistFile = optionString(ctx.options, "allowlist");
|
|
4449
|
+
const report = runCommandTrackerGate({
|
|
4450
|
+
pmRoot: ctx.pm_root,
|
|
4451
|
+
...(diffFile ? { diffFile } : {}),
|
|
4452
|
+
...(allowlistFile ? { allowlistFile } : {}),
|
|
4453
|
+
});
|
|
4454
|
+
if (ctx.global.json !== true) {
|
|
4455
|
+
for (const line of formatGateReport(report))
|
|
4456
|
+
console.error(line);
|
|
4457
|
+
}
|
|
4458
|
+
if (report.verdict === "fail") {
|
|
4459
|
+
throw new CommandError(JSON.stringify({ ...report, error: "pm github gate: FAIL" }), EXIT_CODE.GENERIC_FAILURE);
|
|
4460
|
+
}
|
|
4461
|
+
return report;
|
|
4462
|
+
},
|
|
4463
|
+
});
|
|
4464
|
+
// -----------------------------------------------------------------------
|
|
4134
4465
|
// command — `pm github validate` (diagnostics: gh/token/repo reachability)
|
|
4135
4466
|
// -----------------------------------------------------------------------
|
|
4136
4467
|
api.registerCommand({
|
|
@@ -4152,7 +4483,7 @@ export default defineExtension({
|
|
|
4152
4483
|
],
|
|
4153
4484
|
async run(ctx) {
|
|
4154
4485
|
const report = await runValidate(ctx);
|
|
4155
|
-
const jsonMode = ctx.global
|
|
4486
|
+
const jsonMode = ctx.global.json === true;
|
|
4156
4487
|
if (!jsonMode) {
|
|
4157
4488
|
for (const line of report.messages)
|
|
4158
4489
|
console.error(line);
|