pm-github 2026.7.22 → 2026.7.25
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 +21 -0
- package/README.md +44 -3
- package/dist/index.d.ts +80 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +344 -13
- package/dist/index.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -444,11 +444,23 @@ export function scopeItemsByIds(items, ids) {
|
|
|
444
444
|
// Read every pm item (active + closed) via `pm list-all --full --include-body`
|
|
445
445
|
// so the idempotency index never misses closed issues and re-creates them.
|
|
446
446
|
function readPmItems(pmRoot) {
|
|
447
|
+
const maxBuffer = pmJsonMaxBuffer();
|
|
447
448
|
// `list-all` (NOT `list`) so CLOSED items are included: `pm list` returns only
|
|
448
449
|
// active items, which would make the idempotency index miss every closed
|
|
449
450
|
// issue and re-create it as a DUPLICATE on re-import. `--full --include-body`
|
|
450
451
|
// so tags and body survive the read instead of the brief projection.
|
|
451
|
-
const result = spawnSync("pm", ["--path", pmRoot, "--json", "list-all", "--full", "--include-body", "--limit", "10000"], { encoding: "utf-8" });
|
|
452
|
+
const result = spawnSync("pm", ["--path", pmRoot, "--json", "list-all", "--full", "--include-body", "--limit", "10000"], { encoding: "utf-8", maxBuffer });
|
|
453
|
+
// A buffer overrun kills the child with status null and no stderr, so name the
|
|
454
|
+
// real cause instead of reporting an unexplained failure.
|
|
455
|
+
if (result.error) {
|
|
456
|
+
const code = result.error.code;
|
|
457
|
+
if (code === "ENOBUFS") {
|
|
458
|
+
throw new CommandError(`pm list-all output exceeded the ${maxBuffer} byte read buffer. ` +
|
|
459
|
+
"The workspace is larger than this read limit; narrow the import " +
|
|
460
|
+
"(for example --labels or --since) or raise the PM_JSON_MAX_BUFFER env var.");
|
|
461
|
+
}
|
|
462
|
+
throw new CommandError(`pm list-all failed: ${result.error.message}`);
|
|
463
|
+
}
|
|
452
464
|
if (result.status !== 0) {
|
|
453
465
|
throw new CommandError(result.stderr || "pm list-all failed");
|
|
454
466
|
}
|
|
@@ -477,6 +489,23 @@ export function indexByProvenance(items) {
|
|
|
477
489
|
// ---------------------------------------------------------------------------
|
|
478
490
|
// Atomic GitHub issue import (pm-cli >= 2026.7.20 commitItemMutations)
|
|
479
491
|
// ---------------------------------------------------------------------------
|
|
492
|
+
// Node's spawnSync defaults to a 1 MiB stdout cap. A mature tracker's full JSON
|
|
493
|
+
// dump (`pm list-all --full --include-body`) passes that at a few hundred items,
|
|
494
|
+
// and the child is then killed with ENOBUFS, status null and EMPTY stderr — which
|
|
495
|
+
// surfaced as a bare "pm list-all failed" with nothing to diagnose. Reproduced on
|
|
496
|
+
// a real 443-item workspace at 1,052,859 bytes. 64 MiB matches the cap the sibling
|
|
497
|
+
// pm packages settled on (pm-changelog, pm-context, pm-brief).
|
|
498
|
+
/** Read-buffer cap for `pm` output, in bytes. 64 MiB by default; override with the
|
|
499
|
+
* `PM_JSON_MAX_BUFFER` env var. Resolved per call so the override takes effect
|
|
500
|
+
* without an import-order dependency. Invalid or non-positive values fall back to
|
|
501
|
+
* the default rather than silently disabling the guard. */
|
|
502
|
+
function pmJsonMaxBuffer() {
|
|
503
|
+
// Number(), not parseInt(): parseInt("64MiB") silently yields 64, which would
|
|
504
|
+
// impose a 64-BYTE cap and break every ordinary read while appearing to honor
|
|
505
|
+
// the documented invalid-value fallback. Number() rejects the whole string.
|
|
506
|
+
const raw = Number(process.env.PM_JSON_MAX_BUFFER);
|
|
507
|
+
return Number.isSafeInteger(raw) && raw > 0 ? raw : 64 * 1024 * 1024;
|
|
508
|
+
}
|
|
480
509
|
const ATOMIC_IMPORT_PREFIX = "github-import-";
|
|
481
510
|
let cachedCommitItemMutations;
|
|
482
511
|
function assertSdkFunction(fn, exportName) {
|
|
@@ -1257,15 +1286,263 @@ export function parseImportOptions(options) {
|
|
|
1257
1286
|
itemType: optionString(options, "type") || "Issue",
|
|
1258
1287
|
dryRun: optionEnabled(options, "dry-run", "dryRun"),
|
|
1259
1288
|
atomic: optionEnabled(options, "atomic"),
|
|
1289
|
+
linkDeps: optionEnabled(options, "link-deps", "linkDeps"),
|
|
1260
1290
|
};
|
|
1261
1291
|
}
|
|
1262
1292
|
// Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
|
|
1263
1293
|
// result (ok = exit code 0). Centralizes every pm mutation so callers share one
|
|
1264
1294
|
// error-handling shape.
|
|
1265
1295
|
function pmRun(args) {
|
|
1266
|
-
const
|
|
1296
|
+
const maxBuffer = pmJsonMaxBuffer();
|
|
1297
|
+
const result = spawnSync("pm", args, { encoding: "utf-8", maxBuffer });
|
|
1267
1298
|
return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" };
|
|
1268
1299
|
}
|
|
1300
|
+
// Strip fenced (```…```) and inline (`…`) code so `#123` mentions inside code
|
|
1301
|
+
// samples never masquerade as declared dependencies.
|
|
1302
|
+
function stripCodeSpans(body) {
|
|
1303
|
+
return body
|
|
1304
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
1305
|
+
.replace(/`[^`]*`/g, " ");
|
|
1306
|
+
}
|
|
1307
|
+
// A dependency phrase at a word boundary, plus trailing separator. Global +
|
|
1308
|
+
// case-insensitive; each match anchors where the reference run begins.
|
|
1309
|
+
const DEP_PHRASE_RE = /\b(blocked[\s-]?by|depends[\s-]?on|blocks)\b[\s:]*/gi;
|
|
1310
|
+
// One `owner/repo#N` or bare `#N` reference, matched STICKILY (`y`) so it only
|
|
1311
|
+
// succeeds at the exact scan position — no scanning ahead, no backtracking.
|
|
1312
|
+
const DEP_REF_STICKY = /(?:([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+))?#(\d+)/y;
|
|
1313
|
+
// Comma/whitespace glue with an optional "and" between consecutive refs
|
|
1314
|
+
// ("#1, #2 and #3"). Sticky and can match empty, so the ref loop always
|
|
1315
|
+
// terminates: the next sticky ref match is what decides whether to continue.
|
|
1316
|
+
const DEP_GLUE_STICKY = /[\s,]*(?:and\b[\s,]*)?/y;
|
|
1317
|
+
function phraseToKind(raw) {
|
|
1318
|
+
const compact = raw.toLowerCase().replace(/[\s-]/g, "");
|
|
1319
|
+
if (compact === "blocks")
|
|
1320
|
+
return { kind: "blocks", phrase: "blocks" };
|
|
1321
|
+
if (compact === "dependson")
|
|
1322
|
+
return { kind: "blocked_by", phrase: "depends on" };
|
|
1323
|
+
return { kind: "blocked_by", phrase: "blocked by" };
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Parse dependency references from one issue body. Pure. Bare `#N` refs resolve
|
|
1327
|
+
* against `sourceRepo` (lowercased); `owner/repo#N` refs keep their explicit
|
|
1328
|
+
* repo (lowercased). References are de-duplicated within the body by
|
|
1329
|
+
* repo+number+kind so a body repeating a link yields one ref.
|
|
1330
|
+
*
|
|
1331
|
+
* Scanning is linear: each dependency phrase is found once, then references are
|
|
1332
|
+
* consumed one at a time with sticky (`y`) regexes anchored at the exact scan
|
|
1333
|
+
* position. There is no nested quantifier or look-ahead over unbounded input,
|
|
1334
|
+
* so the parser cannot backtrack catastrophically on adversarial bodies.
|
|
1335
|
+
*/
|
|
1336
|
+
export function parseDependencyReferences(body, sourceRepo) {
|
|
1337
|
+
if (!body)
|
|
1338
|
+
return [];
|
|
1339
|
+
const text = stripCodeSpans(body);
|
|
1340
|
+
const repoLc = sourceRepo.toLowerCase();
|
|
1341
|
+
const out = [];
|
|
1342
|
+
const seen = new Set();
|
|
1343
|
+
for (const m of text.matchAll(DEP_PHRASE_RE)) {
|
|
1344
|
+
const { kind, phrase } = phraseToKind(m[1]);
|
|
1345
|
+
let pos = m.index + m[0].length;
|
|
1346
|
+
// Consume the run of references immediately following the phrase.
|
|
1347
|
+
for (;;) {
|
|
1348
|
+
DEP_REF_STICKY.lastIndex = pos;
|
|
1349
|
+
const ref = DEP_REF_STICKY.exec(text);
|
|
1350
|
+
if (!ref)
|
|
1351
|
+
break;
|
|
1352
|
+
const repo = (ref[1] ? ref[1] : repoLc).toLowerCase();
|
|
1353
|
+
const number = Number.parseInt(ref[2], 10);
|
|
1354
|
+
if (Number.isSafeInteger(number) && number > 0) {
|
|
1355
|
+
const key = `${repo}#${number}|${kind}`;
|
|
1356
|
+
if (!seen.has(key)) {
|
|
1357
|
+
seen.add(key);
|
|
1358
|
+
out.push({ repo, number, kind, phrase });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
pos = DEP_REF_STICKY.lastIndex;
|
|
1362
|
+
// Skip separator glue; if no further ref follows, the next iteration's
|
|
1363
|
+
// sticky match fails at `pos` and the run ends.
|
|
1364
|
+
DEP_GLUE_STICKY.lastIndex = pos;
|
|
1365
|
+
DEP_GLUE_STICKY.exec(text);
|
|
1366
|
+
pos = DEP_GLUE_STICKY.lastIndex;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return out;
|
|
1370
|
+
}
|
|
1371
|
+
/** Build a `repo#number` → pm item id index from an item-metadata snapshot. */
|
|
1372
|
+
export function buildProvenanceIndexFromMetadata(items) {
|
|
1373
|
+
const index = new Map();
|
|
1374
|
+
for (const item of items) {
|
|
1375
|
+
if (!item.id)
|
|
1376
|
+
continue;
|
|
1377
|
+
for (const tag of item.tags ?? []) {
|
|
1378
|
+
const p = parseProvenanceTag(tag);
|
|
1379
|
+
// First writer wins: a provenance tag is a 1:1 issue↔item link, and the
|
|
1380
|
+
// import guarantees uniqueness; guarding keeps a hand-edited duplicate
|
|
1381
|
+
// deterministic rather than order-dependent.
|
|
1382
|
+
if (p && !index.has(`${p.repo}#${p.number}`)) {
|
|
1383
|
+
index.set(`${p.repo}#${p.number}`, item.id);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return index;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Resolve parsed references into concrete workspace edges. Skips references
|
|
1391
|
+
* whose source or target issue is not present in the workspace (counted as
|
|
1392
|
+
* `unresolved`), self-references, and duplicates. Pure.
|
|
1393
|
+
*/
|
|
1394
|
+
export function planDependencyLinks(repo, issues, provenance) {
|
|
1395
|
+
const repoLc = repo.toLowerCase();
|
|
1396
|
+
const edges = [];
|
|
1397
|
+
const seen = new Set();
|
|
1398
|
+
let unresolved = 0;
|
|
1399
|
+
for (const issue of issues) {
|
|
1400
|
+
const sourceKey = `${repoLc}#${issue.number}`;
|
|
1401
|
+
const sourceId = provenance.get(sourceKey);
|
|
1402
|
+
// Source not in the workspace (e.g. its own import was skipped): its edges
|
|
1403
|
+
// have nowhere to attach; leave them for a later re-run rather than count
|
|
1404
|
+
// them as unresolved targets.
|
|
1405
|
+
if (!sourceId)
|
|
1406
|
+
continue;
|
|
1407
|
+
for (const ref of parseDependencyReferences(issue.body ?? "", repoLc)) {
|
|
1408
|
+
const targetKey = `${ref.repo}#${ref.number}`;
|
|
1409
|
+
if (targetKey === sourceKey)
|
|
1410
|
+
continue; // self-reference by provenance
|
|
1411
|
+
const targetId = provenance.get(targetKey);
|
|
1412
|
+
if (!targetId) {
|
|
1413
|
+
unresolved++;
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (targetId === sourceId)
|
|
1417
|
+
continue; // self-reference by resolved id
|
|
1418
|
+
const dedupeKey = `${sourceId}|${targetId}|${ref.kind}`;
|
|
1419
|
+
if (seen.has(dedupeKey))
|
|
1420
|
+
continue;
|
|
1421
|
+
seen.add(dedupeKey);
|
|
1422
|
+
edges.push({ sourceId, targetId, kind: ref.kind, sourceIssue: issue.number, phrase: ref.phrase });
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return { edges, unresolved };
|
|
1426
|
+
}
|
|
1427
|
+
/** Count candidate references across issues without resolving them (dry-run preview). */
|
|
1428
|
+
export function countDependencyRefCandidates(repo, issues) {
|
|
1429
|
+
const repoLc = repo.toLowerCase();
|
|
1430
|
+
let n = 0;
|
|
1431
|
+
for (const issue of issues)
|
|
1432
|
+
n += parseDependencyReferences(issue.body ?? "", repoLc).length;
|
|
1433
|
+
return n;
|
|
1434
|
+
}
|
|
1435
|
+
// Lazy-load the two SDK primitives --link-deps needs. Mirrors loadPmComments /
|
|
1436
|
+
// loadAtomicSdk: a missing export degrades to a clear upgrade error instead of
|
|
1437
|
+
// killing extension load on older hosts.
|
|
1438
|
+
async function loadDepLinkSdk() {
|
|
1439
|
+
let mod;
|
|
1440
|
+
try {
|
|
1441
|
+
mod = (await import("@unbrained/pm-cli/sdk"));
|
|
1442
|
+
}
|
|
1443
|
+
catch (err) {
|
|
1444
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1445
|
+
throw new CommandError(`--link-deps requires @unbrained/pm-cli>=2026.7.20, but the SDK could not be imported: ${msg}.`, EXIT_CODE.USAGE);
|
|
1446
|
+
}
|
|
1447
|
+
const listFn = mod.listAllItemMetadata;
|
|
1448
|
+
const collectFn = mod.collectNewOrderingCycleWarnings;
|
|
1449
|
+
if (typeof listFn !== "function" || typeof collectFn !== "function") {
|
|
1450
|
+
throw new CommandError("--link-deps requires @unbrained/pm-cli>=2026.7.20 with the SDK exports " +
|
|
1451
|
+
"listAllItemMetadata + collectNewOrderingCycleWarnings. Upgrade @unbrained/pm-cli.", EXIT_CODE.USAGE);
|
|
1452
|
+
}
|
|
1453
|
+
return {
|
|
1454
|
+
listAllItemMetadata: async (pmRoot) => {
|
|
1455
|
+
const raw = (await listFn(pmRoot));
|
|
1456
|
+
return raw.map((i) => ({
|
|
1457
|
+
id: String(i.id),
|
|
1458
|
+
tags: Array.isArray(i.tags) ? i.tags : [],
|
|
1459
|
+
dependencies: Array.isArray(i.dependencies)
|
|
1460
|
+
? i.dependencies
|
|
1461
|
+
: undefined,
|
|
1462
|
+
}));
|
|
1463
|
+
},
|
|
1464
|
+
// The SDK's cycle detector reads only id/tags/dependencies off each item;
|
|
1465
|
+
// DepLinkSnapshotItem is that structural subset, so the cast is sound.
|
|
1466
|
+
collectNewOrderingCycleWarnings: collectFn,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function defaultApplyDependencyLink(edge, pmRoot) {
|
|
1470
|
+
const res = pmRun([
|
|
1471
|
+
"--path", pmRoot, "update", edge.sourceId,
|
|
1472
|
+
"--dep", `id=${edge.targetId},kind=${edge.kind}`,
|
|
1473
|
+
"--message", `Linked from GitHub #${edge.sourceIssue} body ("${edge.phrase}" reference)`,
|
|
1474
|
+
]);
|
|
1475
|
+
return { ok: res.ok, stderr: res.stderr };
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* The `--link-deps` second pass. Snapshots the workspace, resolves body
|
|
1479
|
+
* references to edges, applies them idempotently, then re-snapshots and asks the
|
|
1480
|
+
* SDK which ordering cycles the batch newly introduced. Never throws for an
|
|
1481
|
+
* individual edge; a resolution/SDK failure surfaces through the returned
|
|
1482
|
+
* `failures`/warnings so the import result stays truthful.
|
|
1483
|
+
*/
|
|
1484
|
+
export async function linkImportedDependencies(repo, issues, pmRoot, deps = {}) {
|
|
1485
|
+
const applyEdge = deps.applyDependencyLink ?? defaultApplyDependencyLink;
|
|
1486
|
+
const failures = [];
|
|
1487
|
+
// Resolve the SDK-backed helpers and take the pre-edge snapshot. This runs
|
|
1488
|
+
// AFTER the import has already committed, so a failure here (a host whose CLI
|
|
1489
|
+
// predates the required SDK exports, or an unreadable workspace) must never
|
|
1490
|
+
// reject and discard the successful import result — degrade to a reported
|
|
1491
|
+
// failure and skip linking, honoring the "a failure never fails the import"
|
|
1492
|
+
// contract for infra errors, not just per-edge errors.
|
|
1493
|
+
let listMeta;
|
|
1494
|
+
let collect;
|
|
1495
|
+
let before;
|
|
1496
|
+
try {
|
|
1497
|
+
const needsSdk = !deps.listItemMetadata || !deps.collectOrderingCycleWarnings;
|
|
1498
|
+
const sdk = needsSdk ? await loadDepLinkSdk() : undefined;
|
|
1499
|
+
listMeta = deps.listItemMetadata ?? ((r) => sdk.listAllItemMetadata(r));
|
|
1500
|
+
collect = deps.collectOrderingCycleWarnings ?? sdk.collectNewOrderingCycleWarnings;
|
|
1501
|
+
before = await listMeta(pmRoot);
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
return {
|
|
1505
|
+
linked: 0,
|
|
1506
|
+
unresolved: 0,
|
|
1507
|
+
orderingCycleWarnings: [],
|
|
1508
|
+
failures: [`dependency linking skipped: ${err instanceof Error ? err.message : String(err)}`],
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
const provenance = buildProvenanceIndexFromMetadata(before);
|
|
1512
|
+
const { edges, unresolved } = planDependencyLinks(repo, issues, provenance);
|
|
1513
|
+
if (edges.length === 0) {
|
|
1514
|
+
return { linked: 0, unresolved, orderingCycleWarnings: [], failures };
|
|
1515
|
+
}
|
|
1516
|
+
const changedSources = new Set();
|
|
1517
|
+
let linked = 0;
|
|
1518
|
+
for (const edge of edges) {
|
|
1519
|
+
const res = applyEdge(edge, pmRoot);
|
|
1520
|
+
if (res.ok) {
|
|
1521
|
+
linked++;
|
|
1522
|
+
changedSources.add(edge.sourceId);
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
failures.push(`#${edge.sourceIssue} ${edge.sourceId} —(${edge.kind})→ ${edge.targetId}: ${res.stderr.trim()}`);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
// The ordering-cycle advisory is likewise best-effort: a re-snapshot or
|
|
1529
|
+
// detector failure must not discard the edges already written, so it degrades
|
|
1530
|
+
// to a reported note rather than throwing.
|
|
1531
|
+
const warnings = new Set();
|
|
1532
|
+
if (changedSources.size > 0) {
|
|
1533
|
+
try {
|
|
1534
|
+
const after = await listMeta(pmRoot);
|
|
1535
|
+
for (const id of changedSources) {
|
|
1536
|
+
for (const w of collect(before, after, id))
|
|
1537
|
+
warnings.add(w);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
catch (err) {
|
|
1541
|
+
failures.push(`ordering-cycle advisory skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return { linked, unresolved, orderingCycleWarnings: [...warnings], failures };
|
|
1545
|
+
}
|
|
1269
1546
|
async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
|
|
1270
1547
|
const title = issue.title.trim();
|
|
1271
1548
|
if (!title)
|
|
@@ -1347,11 +1624,12 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1347
1624
|
return { imported: 0, updated: 0, skipped: 0 };
|
|
1348
1625
|
}
|
|
1349
1626
|
console.error(`Found ${filtered.length} issue(s).`);
|
|
1350
|
-
// Build the idempotency index once up-front.
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1627
|
+
// Build the idempotency index once up-front — including for a dry run. Skipping
|
|
1628
|
+
// it on a non-atomic dry run made every issue look like a create, so the preview
|
|
1629
|
+
// reported "would import N, skip 0" where the real run performs updates for
|
|
1630
|
+
// already-linked issues. A preview that overstates creates reads as "this will
|
|
1631
|
+
// duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
|
|
1632
|
+
const existing = indexByProvenance((dependencies.readItems ?? readPmItems)(pmRoot));
|
|
1355
1633
|
let imported = 0;
|
|
1356
1634
|
let updated = 0;
|
|
1357
1635
|
let skipped = 0;
|
|
@@ -1386,12 +1664,17 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1386
1664
|
console.error(` [dry-run][atomic] #${entry.issueNumber} ${action} ${entry.title} (${entry.status})`);
|
|
1387
1665
|
}
|
|
1388
1666
|
console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`);
|
|
1667
|
+
if (opts.linkDeps) {
|
|
1668
|
+
console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
|
|
1669
|
+
`resolution + edge writes run only on a real import.`);
|
|
1670
|
+
}
|
|
1389
1671
|
return {
|
|
1390
1672
|
dryRun: true,
|
|
1391
1673
|
wouldImport: imported,
|
|
1392
1674
|
wouldUpdate: updated,
|
|
1393
1675
|
wouldSkip: skipped,
|
|
1394
1676
|
atomic: true,
|
|
1677
|
+
...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
|
|
1395
1678
|
};
|
|
1396
1679
|
}
|
|
1397
1680
|
const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
|
|
@@ -1409,6 +1692,10 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1409
1692
|
else {
|
|
1410
1693
|
console.error(`Atomically imported ${result.imported} new, updated ${result.updated} existing, skipped ${skipped}.`);
|
|
1411
1694
|
}
|
|
1695
|
+
const atomicDepLink = opts.linkDeps
|
|
1696
|
+
? await linkImportedDependencies(repo, filtered, pmRoot, dependencies)
|
|
1697
|
+
: undefined;
|
|
1698
|
+
reportDepLink(atomicDepLink);
|
|
1412
1699
|
// itemIds is an internal post-commit routing map for native comments. Maps
|
|
1413
1700
|
// serialize as `{}` in JSON, so keep it out of the public command result.
|
|
1414
1701
|
return {
|
|
@@ -1419,6 +1706,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1419
1706
|
...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}),
|
|
1420
1707
|
skipped,
|
|
1421
1708
|
atomic: true,
|
|
1709
|
+
...depLinkResultFields(atomicDepLink),
|
|
1422
1710
|
};
|
|
1423
1711
|
}
|
|
1424
1712
|
for (const issue of filtered) {
|
|
@@ -1430,8 +1718,12 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1430
1718
|
const labels = issue.labels.map((l) => l.name).filter(Boolean);
|
|
1431
1719
|
const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, } = prepared;
|
|
1432
1720
|
if (opts.dryRun) {
|
|
1433
|
-
|
|
1434
|
-
|
|
1721
|
+
const action = match?.id ? "update" : "import";
|
|
1722
|
+
console.error(` [dry-run] #${issue.number} ${action} ${title} (${status}, ${labels.join(",")})`);
|
|
1723
|
+
if (match?.id)
|
|
1724
|
+
updated++;
|
|
1725
|
+
else
|
|
1726
|
+
imported++;
|
|
1435
1727
|
continue;
|
|
1436
1728
|
}
|
|
1437
1729
|
if (match?.id) {
|
|
@@ -1514,19 +1806,55 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1514
1806
|
imported++;
|
|
1515
1807
|
}
|
|
1516
1808
|
if (opts.dryRun) {
|
|
1517
|
-
console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`);
|
|
1809
|
+
console.error(`[dry-run] Would import ${imported}, update ${updated}, skip ${skipped}.`);
|
|
1810
|
+
if (opts.linkDeps) {
|
|
1811
|
+
console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
|
|
1812
|
+
`resolution + edge writes run only on a real import.`);
|
|
1813
|
+
}
|
|
1518
1814
|
return {
|
|
1519
1815
|
dryRun: true,
|
|
1520
1816
|
wouldImport: imported,
|
|
1817
|
+
wouldUpdate: updated,
|
|
1521
1818
|
wouldSkip: skipped,
|
|
1522
1819
|
...(opts.atomic ? { atomic: true } : {}),
|
|
1820
|
+
...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
|
|
1523
1821
|
};
|
|
1524
1822
|
}
|
|
1525
1823
|
console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
|
|
1526
1824
|
if (imported === 0 && updated === 0 && skipped > 0) {
|
|
1527
1825
|
throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
|
|
1528
1826
|
}
|
|
1529
|
-
|
|
1827
|
+
const depLink = opts.linkDeps
|
|
1828
|
+
? await linkImportedDependencies(repo, filtered, pmRoot, dependencies)
|
|
1829
|
+
: undefined;
|
|
1830
|
+
reportDepLink(depLink);
|
|
1831
|
+
return { imported, updated, skipped, ...depLinkResultFields(depLink) };
|
|
1832
|
+
}
|
|
1833
|
+
// Emit the human-readable --link-deps summary to stderr (import writes its
|
|
1834
|
+
// progress there; stdout is reserved for the structured result).
|
|
1835
|
+
function reportDepLink(result) {
|
|
1836
|
+
if (!result)
|
|
1837
|
+
return;
|
|
1838
|
+
const parts = [`linked ${result.linked} dependency edge(s)`];
|
|
1839
|
+
if (result.unresolved > 0)
|
|
1840
|
+
parts.push(`${result.unresolved} unresolved ref(s)`);
|
|
1841
|
+
console.error(`--link-deps: ${parts.join(", ")}.`);
|
|
1842
|
+
for (const warning of result.orderingCycleWarnings)
|
|
1843
|
+
console.error(` ⚠ ${warning}`);
|
|
1844
|
+
for (const failure of result.failures)
|
|
1845
|
+
console.error(` ✗ link failed: ${failure}`);
|
|
1846
|
+
}
|
|
1847
|
+
// The public, JSON-serializable slice of a --link-deps result. Kept flat and
|
|
1848
|
+
// omitted entirely when the pass did not run so the default result is unchanged.
|
|
1849
|
+
function depLinkResultFields(result) {
|
|
1850
|
+
if (!result)
|
|
1851
|
+
return {};
|
|
1852
|
+
return {
|
|
1853
|
+
linkedDependencies: result.linked,
|
|
1854
|
+
unresolvedDependencyRefs: result.unresolved,
|
|
1855
|
+
orderingCycleWarnings: result.orderingCycleWarnings,
|
|
1856
|
+
...(result.failures.length > 0 ? { dependencyLinkFailures: result.failures } : {}),
|
|
1857
|
+
};
|
|
1530
1858
|
}
|
|
1531
1859
|
// ---------------------------------------------------------------------------
|
|
1532
1860
|
// Sync core — push pm status changes back to GitHub (close / reopen)
|
|
@@ -2675,6 +3003,7 @@ const IMPORT_FLAGS = [
|
|
|
2675
3003
|
{ long: "--include-comments", description: "Alias for --with-comments" },
|
|
2676
3004
|
{ 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)" },
|
|
2677
3005
|
{ 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" },
|
|
3006
|
+
{ 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" },
|
|
2678
3007
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
2679
3008
|
{ long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
|
|
2680
3009
|
];
|
|
@@ -2721,7 +3050,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
2721
3050
|
];
|
|
2722
3051
|
export default defineExtension({
|
|
2723
3052
|
name: "pm-github",
|
|
2724
|
-
version: "2026.7.
|
|
3053
|
+
version: "2026.7.25",
|
|
2725
3054
|
activate(api) {
|
|
2726
3055
|
// -----------------------------------------------------------------------
|
|
2727
3056
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -2770,6 +3099,7 @@ export default defineExtension({
|
|
|
2770
3099
|
"pm github import owner/repo --include-comments",
|
|
2771
3100
|
"pm github import owner/repo --comments-mode annotations",
|
|
2772
3101
|
"pm github import owner/repo --atomic",
|
|
3102
|
+
"pm github import owner/repo --link-deps",
|
|
2773
3103
|
"pm github import owner/repo --dry-run",
|
|
2774
3104
|
],
|
|
2775
3105
|
flags: IMPORT_FLAGS,
|
|
@@ -2865,7 +3195,7 @@ export default defineExtension({
|
|
|
2865
3195
|
const id = ctx.args?.[0];
|
|
2866
3196
|
if (!id || !ctx.pm_root)
|
|
2867
3197
|
return;
|
|
2868
|
-
const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8" });
|
|
3198
|
+
const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8", maxBuffer: pmJsonMaxBuffer() });
|
|
2869
3199
|
if (res.status !== 0)
|
|
2870
3200
|
return;
|
|
2871
3201
|
let repo;
|
|
@@ -2936,6 +3266,7 @@ export default defineExtension({
|
|
|
2936
3266
|
"pm github import owner/repo --with-comments",
|
|
2937
3267
|
"pm github import owner/repo --comments-mode annotations",
|
|
2938
3268
|
"pm github import owner/repo --atomic",
|
|
3269
|
+
"pm github import owner/repo --link-deps",
|
|
2939
3270
|
"pm github import owner/repo --dry-run",
|
|
2940
3271
|
],
|
|
2941
3272
|
flags: IMPORT_FLAGS,
|