pm-github 2026.10.9 → 2026.10.14

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/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import crypto from "node:crypto";
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { spawnSync } from "node:child_process";
22
- import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
22
+ import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
23
23
  import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
24
24
  import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
25
25
  export { resolvePmDataDir } from "./gate.js";
@@ -480,6 +480,10 @@ export const EXIT_CODE = {
480
480
  GENERIC_FAILURE: 1,
481
481
  USAGE: 2,
482
482
  NOT_FOUND: 3,
483
+ // `pm github blockers`: at least one revalidated item is cleared (every
484
+ // blocker closed or merged) or partially cleared. Distinct from a failure so
485
+ // a scheduled sync can alert on a lifted blocker without reading the report.
486
+ BLOCKERS_CLEARED: 4,
483
487
  };
484
488
  /**
485
489
  * Error that carries a semantic process exit code.
@@ -1094,7 +1098,10 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1094
1098
  // are honored server-side; `milestone` is filtered client-side (the REST API
1095
1099
  // keys milestones by number, not title).
1096
1100
  export function buildIssuesUrl(repo, opts) {
1097
- let url = `${githubApiBase()}/repos/${repo}/issues?state=${opts.state}&per_page=100`;
1101
+ const pageSize = opts.limit === undefined ? 100 : Math.min(100, opts.limit + 1);
1102
+ let url = `${githubApiBase()}/repos/${repo}/issues?state=${opts.state}&per_page=${pageSize}`;
1103
+ if (opts.limit !== undefined)
1104
+ url += "&sort=updated&direction=desc";
1098
1105
  if (opts.labels)
1099
1106
  url += `&labels=${encodeURIComponent(opts.labels)}`;
1100
1107
  if (opts.since)
@@ -1105,7 +1112,8 @@ export function buildIssuesUrl(repo, opts) {
1105
1112
  }
1106
1113
  /**
1107
1114
  * Page through GitHub's issues REST endpoint, following the Link header, applying
1108
- * the import filters, and returning the full issue list.
1115
+ * the import filters, and returning the issue list. Limited runs stop after
1116
+ * retaining the cap plus one matching issue so callers can report truncation.
1109
1117
  *
1110
1118
  * @internal Exported only so the HTTP-boundary tests can drive the real client.
1111
1119
  * `stripInternal` keeps it out of the published `.d.ts`, so this is NOT a public API
@@ -1126,11 +1134,58 @@ export async function fetchAllIssues(repo, opts, token) {
1126
1134
  if (!Array.isArray(page)) {
1127
1135
  throw new Error("Unexpected GitHub API response (expected an array of issues).");
1128
1136
  }
1129
- issues.push(...page);
1137
+ const fetched = page;
1138
+ issues.push(...(opts.limit === undefined ? fetched : applyClientFilters(fetched, opts)));
1139
+ // One extra eligible issue proves truncation without draining the repo.
1140
+ if (opts.limit !== undefined && issues.length > opts.limit)
1141
+ return issues.slice(0, opts.limit + 1);
1130
1142
  nextUrl = parseNextLink(linkHeader);
1131
1143
  }
1132
1144
  return issues;
1133
1145
  }
1146
+ /** Parse, deduplicate, and validate selected issue references before any fetch. */
1147
+ export function parseIssueNumbers(repo, input) {
1148
+ const numbers = new Set();
1149
+ for (const reference of input.split(",")) {
1150
+ const match = /^(?:([^/#\s]+\/[^/#\s]+)#|#)?([1-9]\d*)$/.exec(reference.trim());
1151
+ if (!match || !Number.isSafeInteger(Number(match[2]))) {
1152
+ throw new CommandError("--issue must contain positive issue numbers, #N, or owner/repo#N references.", EXIT_CODE.USAGE);
1153
+ }
1154
+ if (match[1] && match[1].toLowerCase() !== repo.toLowerCase()) {
1155
+ throw new CommandError(`--issue reference ${reference.trim()} conflicts with repository ${repo}.`, EXIT_CODE.USAGE);
1156
+ }
1157
+ numbers.add(Number(match[2]));
1158
+ }
1159
+ return [...numbers];
1160
+ }
1161
+ /** Fetch only selected REST issue endpoints, refusing PRs and missing numbers before writes. */
1162
+ async function fetchSelectedIssues(repo, numbers, opts, token) {
1163
+ const issues = [];
1164
+ const missingIssues = [];
1165
+ for (const number of numbers) {
1166
+ let body;
1167
+ try {
1168
+ ({ body } = await fetchJSON(`${githubApiBase()}/repos/${repo}/issues/${number}`, token));
1169
+ }
1170
+ catch (err) {
1171
+ if (!/HTTP 404/.test(errorMessage(err)))
1172
+ throw err;
1173
+ if (!opts.dryRun)
1174
+ throw new Error(`GitHub issue ${repo}#${number} was not found (HTTP 404); no issues were imported.`);
1175
+ missingIssues.push(number);
1176
+ console.error(`[dry-run] Missing issue: ${repo}#${number} (HTTP 404).`);
1177
+ continue;
1178
+ }
1179
+ const issue = JSON.parse(body);
1180
+ if (issue.number !== number)
1181
+ throw new Error(`Unexpected GitHub issue response for ${repo}#${number}.`);
1182
+ if (issue.pull_request && !opts.includePrs) {
1183
+ throw new Error(`${repo}#${number} is a pull request; use --include-prs to import it. No issues were imported.`);
1184
+ }
1185
+ issues.push(issue);
1186
+ }
1187
+ return { issues, missingIssues };
1188
+ }
1134
1189
  /**
1135
1190
  * Fetch all review comments for a single issue or PR, paging through the Link header.
1136
1191
  *
@@ -1775,6 +1830,23 @@ export function applyClientFilters(issues, opts) {
1775
1830
  * @returns The normalized import options.
1776
1831
  */
1777
1832
  export function parseImportOptions(options) {
1833
+ const issue = optionString(options, "issue");
1834
+ if (optionProvided(options, "issue")) {
1835
+ if (!issue)
1836
+ throw new CommandError("--issue requires a non-empty issue list.", EXIT_CODE.USAGE);
1837
+ for (const flag of ["state", "all", "labels", "since", "assignee", "milestone", "limit"]) {
1838
+ if (optionProvided(options, flag))
1839
+ throw new CommandError(`--issue conflicts with --${flag}.`, EXIT_CODE.USAGE);
1840
+ }
1841
+ }
1842
+ let limit;
1843
+ if (optionProvided(options, "limit")) {
1844
+ const input = optionString(options, "limit");
1845
+ if (!input || !/^[1-9]\d*$/.test(input) || !Number.isSafeInteger(Number(input))) {
1846
+ throw new CommandError("--limit must be a positive integer.", EXIT_CODE.USAGE);
1847
+ }
1848
+ limit = Number(input);
1849
+ }
1778
1850
  // --state takes precedence; --all is the legacy shorthand for "all".
1779
1851
  const stateOpt = optionString(options, "state");
1780
1852
  const includeAll = optionEnabled(options, "all");
@@ -1806,6 +1878,8 @@ export function parseImportOptions(options) {
1806
1878
  commentsMode = "both";
1807
1879
  }
1808
1880
  return {
1881
+ issue,
1882
+ limit,
1809
1883
  state,
1810
1884
  labels: optionString(options, "labels"),
1811
1885
  since,
@@ -2324,14 +2398,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2324
2398
  throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
2325
2399
  "[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
2326
2400
  "[--milestone <name>] [--include-prs] [--skip-drafts] [--with-comments] " +
2327
- "[--comments-mode body|annotations|both] [--atomic]", EXIT_CODE.USAGE);
2401
+ "[--comments-mode body|annotations|both] [--atomic] [--issue <N[,N...]> | --limit <n>] " +
2402
+ "[--link-deps] [--gate] [--dry-run] [--type <type>]", EXIT_CODE.USAGE);
2328
2403
  }
2329
2404
  const repo = repoArg;
2405
+ const selectedNumbers = opts.issue === undefined ? undefined : parseIssueNumbers(repo, opts.issue);
2330
2406
  const token = (dependencies.resolveToken ?? resolveGitHubToken)();
2331
2407
  console.error(`Fetching issues from ${repo}…${token ? "" : " (unauthenticated — 60 req/hr)"}`);
2332
2408
  let fetched;
2409
+ let missingIssues = [];
2333
2410
  try {
2334
- fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token);
2411
+ if (selectedNumbers) {
2412
+ ({ issues: fetched, missingIssues } = await fetchSelectedIssues(repo, selectedNumbers, opts, token));
2413
+ }
2414
+ else {
2415
+ fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token);
2416
+ }
2335
2417
  }
2336
2418
  catch (err) {
2337
2419
  const msg = errorMessage(err);
@@ -2341,22 +2423,30 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2341
2423
  const exitCode = /HTTP 404/.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
2342
2424
  throw new CommandError(`Failed to fetch issues from ${repo}: ${msg}${hint}`, exitCode);
2343
2425
  }
2344
- const filtered = applyClientFilters(fetched, opts);
2426
+ const eligible = applyClientFilters(fetched, opts);
2427
+ const filtered = opts.limit === undefined ? eligible : eligible.slice(0, opts.limit);
2428
+ const fetchFields = {
2429
+ ...(opts.limit === undefined ? {} : { limit: opts.limit, truncated: eligible.length > opts.limit }),
2430
+ ...(missingIssues.length === 0 ? {} : { missingIssues }),
2431
+ };
2432
+ if (opts.limit !== undefined && eligible.length > opts.limit)
2433
+ console.error(`Issue list truncated at --limit ${opts.limit}.`);
2345
2434
  if (filtered.length === 0) {
2346
2435
  console.error("No issues found.");
2347
2436
  // Even a no-op gated import must prove the workspace is gateable (a Git work
2348
2437
  // tree the scanner can read), so the empty-change gate scan runs here too.
2349
2438
  const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmRoot) : undefined;
2350
- if (opts.atomic && opts.dryRun) {
2439
+ if (opts.dryRun && (opts.atomic || missingIssues.length > 0)) {
2351
2440
  return {
2352
2441
  dryRun: true,
2353
2442
  wouldImport: 0,
2354
2443
  wouldUpdate: 0,
2355
2444
  wouldSkip: 0,
2356
- atomic: true,
2445
+ ...(opts.atomic ? { atomic: true } : {}),
2446
+ ...fetchFields,
2357
2447
  };
2358
2448
  }
2359
- return { imported: 0, updated: 0, skipped: 0, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2449
+ return { imported: 0, updated: 0, skipped: 0, ...fetchFields, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2360
2450
  }
2361
2451
  console.error(`Found ${filtered.length} issue(s).`);
2362
2452
  // Build the idempotency index once up-front — including for a dry run. Skipping
@@ -2431,6 +2521,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2431
2521
  wouldUpdate: 0,
2432
2522
  wouldSkip: skipped,
2433
2523
  atomic: true,
2524
+ ...fetchFields,
2434
2525
  };
2435
2526
  }
2436
2527
  throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`, EXIT_CODE.GENERIC_FAILURE);
@@ -2455,6 +2546,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2455
2546
  atomic: true,
2456
2547
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
2457
2548
  ...gatePlanFields,
2549
+ ...fetchFields,
2458
2550
  };
2459
2551
  }
2460
2552
  const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
@@ -2501,6 +2593,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2501
2593
  skipped,
2502
2594
  atomic: true,
2503
2595
  ...depLinkResultFields(atomicDepLink),
2596
+ ...fetchFields,
2504
2597
  ...(gateReceipt ? { gate: gateReceipt } : {}),
2505
2598
  };
2506
2599
  }
@@ -2670,6 +2763,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2670
2763
  wouldSkip: skipped,
2671
2764
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
2672
2765
  ...gatePlanFields,
2766
+ ...fetchFields,
2673
2767
  };
2674
2768
  }
2675
2769
  console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
@@ -2691,7 +2785,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2691
2785
  if (opts.gate)
2692
2786
  verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), gatePlan, repo);
2693
2787
  const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2694
- return { imported, updated, skipped, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
2788
+ return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
2695
2789
  }
2696
2790
  /**
2697
2791
  * Emit the human-readable `--link-deps` summary to stderr.
@@ -3135,6 +3229,700 @@ async function runExport(ctx) {
3135
3229
  };
3136
3230
  }
3137
3231
  // ---------------------------------------------------------------------------
3232
+ // Blocker revalidation — `pm github blockers`
3233
+ // ---------------------------------------------------------------------------
3234
+ //
3235
+ // A `blocked_reason` is a static string: nothing re-reads it, so an item whose
3236
+ // named GitHub blocker has since closed stays blocked indefinitely (the
3237
+ // motivating incident: two items sat blocked for 27 days after their blocker
3238
+ // issue closed). This section revalidates blocked items against the LIVE state
3239
+ // of the GitHub issues and pull requests their blocker text names. It is
3240
+ // REPORT-ONLY by construction: it never unblocks, reopens, or edits any item;
3241
+ // the optional `--comment` appends one idempotent comment per cleared item
3242
+ // so a human — not the tool — decides what the cleared blocker means.
3243
+ /** Terminal pm lifecycle statuses: an item in one of these can no longer be blocked. */
3244
+ export const BLOCKER_TERMINAL_STATUSES = new Set(["closed", "canceled"]);
3245
+ /** How many blocker references are resolved per GraphQL request. */
3246
+ export const BLOCKER_STATES_BATCH_SIZE = 25;
3247
+ /** Shape an `owner/repo` (or `--repo`) hint must have before it resolves bare references. */
3248
+ const BLOCKER_REPO_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+$/;
3249
+ // GitHub reference forms, anchored so prose around a match cannot leak into it.
3250
+ // The prefix class excludes word characters, path separators, dots, and colons,
3251
+ // so `https://github.com/o/r/issues/N` (slash before owner) and
3252
+ // `github.com/o/r#N` never produce a second, shorter match inside a URL.
3253
+ const BLOCKER_URL_RE = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9_.-]+)\/(?:issues|pull)\/([1-9]\d{0,8})(?!\w)/gi;
3254
+ const BLOCKER_OWNER_REPO_RE = /(^|[^\w./:-])([A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+)#([1-9]\d{0,8})(?!\w)/g;
3255
+ const BLOCKER_GH_DASH_RE = /\bGH-([1-9]\d{0,8})\b/gi;
3256
+ const BLOCKER_SHORT_REPO_RE = /(^|[^\w./:-])([A-Za-z0-9_.-]+)#([1-9]\d{0,8})(?!\w)/g;
3257
+ const BLOCKER_BARE_HASH_RE = /(^|[^\w./:#-])#([1-9]\d{0,8})(?!\w)/g;
3258
+ /**
3259
+ * Mask foreign tracker locators and the `github:` scheme prefix in blocker text.
3260
+ *
3261
+ * `gitlab:`/`jira:`/`linear:` locators name blockers pm-github cannot query, so
3262
+ * the whole token is blanked to keep its `owner/repo#N` interior from parsing as
3263
+ * a GitHub reference. The `github:` scheme — the tracker's canonical external
3264
+ * dependency locator (`github:owner/repo#N`) — is replaced by a space so the
3265
+ * locator's payload parses through the ordinary `owner/repo#N` form.
3266
+ *
3267
+ * @param text - One blocker text source (blocked_reason, description, comment, …).
3268
+ * @returns The text with foreign locators blanked and the `github:` prefix removed.
3269
+ */
3270
+ function maskNonGithubLocators(text) {
3271
+ return text.replace(/\b(?:gitlab|jira|linear):[^\s]+/gi, " ").replace(/\bgithub:/gi, " ");
3272
+ }
3273
+ /**
3274
+ * Parse every GitHub reference in one text source.
3275
+ *
3276
+ * Recognized forms: full issue/PR URLs, `owner/repo#N`, `GH-N`, bare `#N`, and
3277
+ * `name#N` (a repo short name). URL and `owner/repo#N` references carry their
3278
+ * repo; `GH-N`, bare `#N`, and `name#N` do not and are resolved later against
3279
+ * the item's provenance repo or `--repo` (see {@link resolveBlockerReferences}).
3280
+ * Duplicate mentions within one text are not deduplicated here — the resolver
3281
+ * owns the stable identity — but each form's scan is independent, so one text
3282
+ * never yields the same (form, raw, number) twice.
3283
+ *
3284
+ * @param text - One blocker text source (blocked_reason, description, comment, …).
3285
+ * @returns Every recognized reference, in form order (URL, owner/repo, GH-N, short, bare).
3286
+ */
3287
+ export function parseBlockerReferences(text) {
3288
+ const masked = maskNonGithubLocators(text);
3289
+ const out = [];
3290
+ for (const m of masked.matchAll(BLOCKER_URL_RE)) {
3291
+ out.push({ repo: `${m[1].toLowerCase()}/${m[2].toLowerCase()}`, number: Number(m[3]), form: "url", raw: m[0] });
3292
+ }
3293
+ for (const m of masked.matchAll(BLOCKER_OWNER_REPO_RE)) {
3294
+ out.push({ repo: m[2].toLowerCase(), number: Number(m[3]), form: "repo_hash", raw: `${m[2]}#${m[3]}` });
3295
+ }
3296
+ for (const m of masked.matchAll(BLOCKER_GH_DASH_RE)) {
3297
+ out.push({ number: Number(m[1]), form: "gh_dash", raw: m[0].toUpperCase() });
3298
+ }
3299
+ for (const m of masked.matchAll(BLOCKER_SHORT_REPO_RE)) {
3300
+ out.push({ number: Number(m[3]), form: "short_repo_hash", raw: `${m[2]}#${m[3]}` });
3301
+ }
3302
+ for (const m of masked.matchAll(BLOCKER_BARE_HASH_RE)) {
3303
+ out.push({ number: Number(m[2]), form: "bare_hash", raw: `#${m[2]}` });
3304
+ }
3305
+ return out;
3306
+ }
3307
+ /**
3308
+ * Resolve hint-dependent references and deduplicate the result.
3309
+ *
3310
+ * `GH-N`, bare `#N`, and `name#N` references resolve against the first
3311
+ * applicable hint — the item's own `gh:owner/repo#N` provenance repo, then
3312
+ * `--repo`. A `name#N` short form resolves only when the name matches the
3313
+ * hint's repo segment (so `pm-cli#854` resolves against `unbraind/pm-cli`, but
3314
+ * `other#854` stays unresolved instead of guessing an owner). References that
3315
+ * resolve to the same `owner/repo#N` collapse to the first-seen form.
3316
+ *
3317
+ * @param parsed - References from {@link parseBlockerReferences}.
3318
+ * @param itemRepo - The item's provenance repo hint (`owner/repo`), when it has one.
3319
+ * @param fallbackRepo - The `--repo` hint, when given.
3320
+ * @returns Deduplicated references, sorted by repo then number for a deterministic report.
3321
+ */
3322
+ export function resolveBlockerReferences(parsed, itemRepo, fallbackRepo) {
3323
+ const out = [];
3324
+ const seen = new Set();
3325
+ for (const ref of parsed) {
3326
+ let repo = ref.repo;
3327
+ if (repo === undefined) {
3328
+ for (const hint of [itemRepo, fallbackRepo]) {
3329
+ if (hint === undefined || !BLOCKER_REPO_SHAPE.test(hint))
3330
+ continue;
3331
+ if (ref.form === "short_repo_hash" && hint.split("/")[1].toLowerCase() !== ref.raw.split("#")[0].toLowerCase())
3332
+ continue;
3333
+ repo = hint.toLowerCase();
3334
+ break;
3335
+ }
3336
+ }
3337
+ const key = repo === undefined
3338
+ ? (ref.form === "short_repo_hash" ? `unresolved:${ref.raw.toLowerCase()}` : `unresolved:#${ref.number}`)
3339
+ : `${repo}#${ref.number}`;
3340
+ if (seen.has(key))
3341
+ continue;
3342
+ seen.add(key);
3343
+ out.push({ key, ...(repo === undefined ? {} : { repo }), number: ref.number, form: ref.form, raw: ref.raw });
3344
+ }
3345
+ return out.sort((a, b) => `${a.repo ?? ""}#${a.number}`.localeCompare(`${b.repo ?? ""}#${b.number}`));
3346
+ }
3347
+ /**
3348
+ * Collect and resolve every GitHub reference named by one item's blocker text.
3349
+ *
3350
+ * Sources scanned, in order: external `blocked_by` dependency targets, the
3351
+ * item-level `blocked_by` field, `blocked_reason`, `description`, and every
3352
+ * stored comment. A `blocked_reason` is a static string that nothing re-reads —
3353
+ * that is exactly the staleness this command exists to surface — so comments
3354
+ * (including the latest ones) are scanned too. References matching any of the
3355
+ * item's own GitHub provenance tags are excluded: an issue cannot block itself.
3356
+ *
3357
+ * @param item - One complete item from {@link decodeBlockerPmItems}.
3358
+ * @param fallbackRepo - The `--repo` hint, when given.
3359
+ * @returns Deduplicated resolved references for the item.
3360
+ */
3361
+ export function collectBlockerReferences(item, fallbackRepo) {
3362
+ const texts = [];
3363
+ for (const dep of item.dependencies) {
3364
+ if (dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id))
3365
+ texts.push(dep.id);
3366
+ }
3367
+ if (item.blockedBy !== undefined)
3368
+ texts.push(item.blockedBy);
3369
+ if (item.blockedReason !== undefined)
3370
+ texts.push(item.blockedReason);
3371
+ texts.push(item.description);
3372
+ for (const comment of item.comments)
3373
+ texts.push(comment.text);
3374
+ const itemRepo = (() => {
3375
+ for (const tag of item.tags ?? []) {
3376
+ const provenance = parseProvenanceTag(tag);
3377
+ if (provenance && BLOCKER_REPO_SHAPE.test(provenance.repo))
3378
+ return provenance.repo;
3379
+ }
3380
+ return undefined;
3381
+ })();
3382
+ const parsed = texts.flatMap((text) => parseBlockerReferences(text));
3383
+ const ownKeys = new Set((item.tags ?? []).flatMap((tag) => {
3384
+ const provenance = parseProvenanceTag(tag);
3385
+ return provenance ? [`${provenance.repo}#${provenance.number}`] : [];
3386
+ }));
3387
+ return resolveBlockerReferences(parsed, itemRepo, fallbackRepo).filter((ref) => !ownKeys.has(ref.key));
3388
+ }
3389
+ /** Classify external dependency targets that explicitly name GitHub issues or PRs. */
3390
+ function isGithubBlockerDependency(target) {
3391
+ return isExternalDependencyReference(target) && /^(?:github:|https?:\/\/(?:www\.)?github\.com\/)/i.test(target)
3392
+ && parseBlockerReferences(target).length > 0;
3393
+ }
3394
+ /**
3395
+ * Select the items `pm github blockers` revalidates.
3396
+ *
3397
+ * Every item whose status is `blocked`, plus every non-terminal item carrying a
3398
+ * `blocked_by` dependency (or `blocked_by` field) whose target is an explicit
3399
+ * external reference — the tracker's own cross-system locator classification
3400
+ * plus an explicit GitHub locator or URL decides what counts as
3401
+ * a GitHub target, so a foreign tracker or local `blocked_by` edge to another pm item never selects the
3402
+ * item here. Terminal items are excluded: a closed item cannot be blocked.
3403
+ *
3404
+ * @param items - The complete runtime-validated item corpus.
3405
+ * @returns The candidate items, in corpus order.
3406
+ */
3407
+ export function selectBlockerCandidates(items) {
3408
+ return items.filter((item) => {
3409
+ if (item.status === "blocked")
3410
+ return true;
3411
+ if (BLOCKER_TERMINAL_STATUSES.has(item.status))
3412
+ return false;
3413
+ return (item.dependencies.some((dep) => dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id))
3414
+ || (item.blockedBy !== undefined && isGithubBlockerDependency(item.blockedBy)));
3415
+ });
3416
+ }
3417
+ /**
3418
+ * Read an optional string field from one decoded `pm list --all` row.
3419
+ *
3420
+ * Fail closed on a present-but-wrong-type value: a number or object where a
3421
+ * string belongs means the row cannot be trusted, and an unverifiable read is
3422
+ * not a whole-workspace read.
3423
+ *
3424
+ * @param value - The raw row field.
3425
+ * @param field - Field description for the refusal message.
3426
+ * @returns The string, or `undefined` when the field is absent.
3427
+ * @throws {@link CommandError} When the field is present but not a string.
3428
+ */
3429
+ function optionalCompletePmString(value, field) {
3430
+ if (value === undefined)
3431
+ return undefined;
3432
+ if (typeof value !== "string") {
3433
+ throw new CommandError(`Refusing unverifiable pm list --all output: ${field} must be a string when present.`);
3434
+ }
3435
+ return value;
3436
+ }
3437
+ /**
3438
+ * Decode the complete `pm list --all` envelope into blocker-revalidation items.
3439
+ *
3440
+ * The envelope and every base field are validated by {@link decodeCompletePmItems}
3441
+ * first (the same complete-read contract import uses), then the extra rows this
3442
+ * command consumes — `blocked_reason`, `blocked_by`, dependency rows, and
3443
+ * comment texts — are validated on top. A wrong-type extra fails closed with
3444
+ * the same refusal wording, because blocker revalidation reading a partial or
3445
+ * untrustworthy corpus would silently miss items.
3446
+ *
3447
+ * @param parsed - JSON decoded from the installed pm CLI.
3448
+ * @returns Fresh runtime-validated blocker items.
3449
+ * @throws {@link CommandError} When a receipt, count, identity, or consumed row
3450
+ * field is absent, incomplete, or contradictory.
3451
+ */
3452
+ export function decodeBlockerPmItems(parsed) {
3453
+ const base = decodeCompletePmItems(parsed);
3454
+ // No re-validation branch here: decodeCompletePmItems has already thrown unless
3455
+ // `parsed` is a record whose `items` is an array exactly as long as `base`.
3456
+ const rows = parsed.items;
3457
+ return base.map((item, index) => {
3458
+ // Downcast, not a runtime claim: the complete reader has already proven
3459
+ // id/title/status/description are strings on every row it emitted, so the
3460
+ // required-field view of BlockerPmItem holds for this object.
3461
+ const validated = item;
3462
+ const row = rows[index];
3463
+ const blockedReason = optionalCompletePmString(row.blocked_reason, `item ${item.id} blocked_reason`);
3464
+ const blockedBy = optionalCompletePmString(row.blocked_by, `item ${item.id} blocked_by`);
3465
+ const dependencies = [];
3466
+ if (row.dependencies !== undefined) {
3467
+ if (!Array.isArray(row.dependencies)) {
3468
+ throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} dependencies must be an array.`);
3469
+ }
3470
+ for (const dep of row.dependencies) {
3471
+ if (!isJsonRecord(dep) || typeof dep.id !== "string" || typeof dep.kind !== "string") {
3472
+ throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} dependency rows must carry string id and kind.`);
3473
+ }
3474
+ dependencies.push({ id: dep.id, kind: dep.kind });
3475
+ }
3476
+ }
3477
+ const comments = [];
3478
+ if (row.comments !== undefined) {
3479
+ if (!Array.isArray(row.comments)) {
3480
+ throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} comments must be an array.`);
3481
+ }
3482
+ for (const comment of row.comments) {
3483
+ if (!isJsonRecord(comment) || typeof comment.text !== "string") {
3484
+ throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} comment rows must carry string text.`);
3485
+ }
3486
+ comments.push({ text: comment.text });
3487
+ }
3488
+ }
3489
+ return {
3490
+ ...validated,
3491
+ dependencies,
3492
+ comments,
3493
+ ...(blockedReason === undefined ? {} : { blockedReason }),
3494
+ ...(blockedBy === undefined ? {} : { blockedBy }),
3495
+ };
3496
+ });
3497
+ }
3498
+ /**
3499
+ * Read every item through the complete SDK path and check its completeness certificate.
3500
+ *
3501
+ * The SDK disables extensions for the observational read. The shared item
3502
+ * decoder also validates every blocker field consumed by this command.
3503
+ *
3504
+ * @param pmRoot - Workspace or tracker root accepted by the SDK.
3505
+ * @returns The complete runtime-validated blocker item corpus.
3506
+ * @throws On unreadable, incomplete, omitted, or invalid tracker data.
3507
+ */
3508
+ export async function readBlockerPmItems(pmRoot) {
3509
+ const result = certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot, cwd: pmRoot, noExtensions: true }));
3510
+ return decodeBlockerPmItems(result);
3511
+ }
3512
+ /**
3513
+ * Build the batched GraphQL document resolving many references' live state.
3514
+ *
3515
+ * One query aliases each distinct repo (`r0: repository(owner:…, name:…)`), and
3516
+ * within it each reference twice (`iN: issue(number:…)` plus `pN:
3517
+ * pullRequest(number:…)`) — a number is either an issue or a PR and GraphQL
3518
+ * resolves the wrong one to null, so one round-trip classifies every reference
3519
+ * without guessing its kind. Repos are deduplicated and sorted, and each repo's
3520
+ * references are sorted by number, so the document is a pure function of the
3521
+ * reference SET, keeping recorded fixtures stable. The returned entries carry
3522
+ * every reference's aliases so the response can be read back without
3523
+ * re-deriving that ordering.
3524
+ *
3525
+ * @param refs - One batch's resolved references.
3526
+ * @returns The query, its repo alias order, and the per-reference alias entries.
3527
+ */
3528
+ export function buildBlockerStatesQueryPlan(refs) {
3529
+ const byRepo = new Map();
3530
+ for (const ref of refs) {
3531
+ const bucket = byRepo.get(ref.repo);
3532
+ if (bucket)
3533
+ bucket.push(ref);
3534
+ else
3535
+ byRepo.set(ref.repo, [ref]);
3536
+ }
3537
+ const repos = [...byRepo.keys()].sort();
3538
+ const entries = [];
3539
+ const blocks = repos.map((repo, repoIndex) => {
3540
+ const bucket = byRepo.get(repo).slice().sort((a, b) => a.number - b.number);
3541
+ const fields = bucket.map((ref, refIndex) => {
3542
+ entries.push({ ref, repoAlias: `r${repoIndex}`, issueAlias: `i${repoIndex}_${refIndex}`, pullAlias: `p${repoIndex}_${refIndex}` });
3543
+ return ` i${repoIndex}_${refIndex}: issue(number: ${ref.number}) { state stateReason closedAt url }\n` +
3544
+ ` p${repoIndex}_${refIndex}: pullRequest(number: ${ref.number}) { state mergedAt closedAt url }`;
3545
+ });
3546
+ const [owner, name] = repo.split("/");
3547
+ return ` r${repoIndex}: repository(owner: "${owner}", name: "${name}") {\n${fields.join("\n")}\n }`;
3548
+ });
3549
+ return { query: `query {\n${blocks.join("\n")}\n}`, repos, entries };
3550
+ }
3551
+ /**
3552
+ * Split references into fixed-size batches.
3553
+ *
3554
+ * @param refs - References to batch.
3555
+ * @param size - Maximum references per batch (at least 1).
3556
+ * @returns Consecutive slices of the input, in order.
3557
+ */
3558
+ export function chunkBlockerReferences(refs, size) {
3559
+ if (!Number.isInteger(size) || size < 1)
3560
+ throw new CommandError("Blocker batch size must be a positive integer.");
3561
+ const batches = [];
3562
+ for (let i = 0; i < refs.length; i += size)
3563
+ batches.push(refs.slice(i, i + size));
3564
+ return batches;
3565
+ }
3566
+ /**
3567
+ * Map one reference's aliased issue/pull-request nodes to its live state.
3568
+ *
3569
+ * Null nodes for both the issue and the PR field mean GitHub would not resolve
3570
+ * the reference — nonexistent, or present but invisible to the token; GitHub
3571
+ * answers both identically, so both are reported `not_found`. An issue node
3572
+ * wins over a PR node (only one of the two can exist for a number); an
3573
+ * unrecognized state string is reported as an error rather than guessed.
3574
+ *
3575
+ * @param ref - The reference the nodes answer.
3576
+ * @param issue - The aliased issue node, when GitHub resolved one.
3577
+ * @param pullRequest - The aliased pull-request node, when GitHub resolved one.
3578
+ * @returns The reference's live state.
3579
+ */
3580
+ function mapBlockerReferenceNode(ref, issue, pullRequest) {
3581
+ const base = { repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw };
3582
+ if (issue) {
3583
+ if (issue.state === "OPEN")
3584
+ return { ...base, state: "open" };
3585
+ if (issue.state === "CLOSED") {
3586
+ return {
3587
+ ...base,
3588
+ state: "closed",
3589
+ ...(issue.stateReason ? { state_reason: issue.stateReason } : {}),
3590
+ ...(issue.closedAt ? { closed_at: issue.closedAt } : {}),
3591
+ ...(issue.url ? { url: issue.url } : {}),
3592
+ };
3593
+ }
3594
+ return { ...base, state: "error", error: "Unexpected GitHub issue state." };
3595
+ }
3596
+ if (pullRequest) {
3597
+ if (pullRequest.state === "OPEN")
3598
+ return { ...base, state: "open" };
3599
+ if (pullRequest.state === "MERGED") {
3600
+ return {
3601
+ ...base,
3602
+ state: "merged",
3603
+ ...(pullRequest.mergedAt ? { merged_at: pullRequest.mergedAt } : {}),
3604
+ ...(pullRequest.closedAt ? { closed_at: pullRequest.closedAt } : {}),
3605
+ ...(pullRequest.url ? { url: pullRequest.url } : {}),
3606
+ };
3607
+ }
3608
+ if (pullRequest.state === "CLOSED") {
3609
+ return {
3610
+ ...base,
3611
+ state: "closed",
3612
+ ...(pullRequest.closedAt ? { closed_at: pullRequest.closedAt } : {}),
3613
+ ...(pullRequest.url ? { url: pullRequest.url } : {}),
3614
+ };
3615
+ }
3616
+ return { ...base, state: "error", error: "Unexpected GitHub pull request state." };
3617
+ }
3618
+ return { ...base, state: "not_found" };
3619
+ }
3620
+ /**
3621
+ * Query the live state of every resolved reference, batched.
3622
+ *
3623
+ * References are batched ({@link BLOCKER_STATES_BATCH_SIZE} per request) through
3624
+ * the shared client, auth, and rate-limit handling. A batch whose transport
3625
+ * fails (no usable data — e.g. an invalid token or a total GraphQL error) marks
3626
+ * every reference in it `error` and the run continues, so one inaccessible repo
3627
+ * cannot blind the report on the others.
3628
+ *
3629
+ * @param refs - Every resolved reference across all items.
3630
+ * @param transport - GraphQL transport (the production one closes over the token).
3631
+ * @returns Live state by {@link BlockerReference.key}.
3632
+ */
3633
+ export async function fetchBlockerReferenceStates(refs, transport) {
3634
+ const states = new Map();
3635
+ for (const batch of chunkBlockerReferences(refs, BLOCKER_STATES_BATCH_SIZE)) {
3636
+ const plan = buildBlockerStatesQueryPlan(batch);
3637
+ let response;
3638
+ try {
3639
+ response = await transport(plan.query);
3640
+ }
3641
+ catch {
3642
+ for (const ref of batch)
3643
+ states.set(ref.key, { repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw, state: "error", error: "GitHub blocker state request failed." });
3644
+ continue;
3645
+ }
3646
+ for (const entry of plan.entries) {
3647
+ const failure = response.errors?.find((error) => error.type !== "NOT_FOUND" &&
3648
+ (!error.path || (error.path[0] === entry.repoAlias &&
3649
+ (error.path.length === 1 || error.path[1] === entry.issueAlias || error.path[1] === entry.pullAlias))));
3650
+ if (failure) {
3651
+ states.set(entry.ref.key, { ...entry.ref, state: "error", error: "GitHub rejected blocker state access." });
3652
+ continue;
3653
+ }
3654
+ const repoBlock = response.data?.[entry.repoAlias];
3655
+ if (repoBlock === undefined || (repoBlock !== null && (repoBlock[entry.issueAlias] === undefined || repoBlock[entry.pullAlias] === undefined))) {
3656
+ states.set(entry.ref.key, { ...entry.ref, state: "error", error: "Incomplete GitHub blocker state response." });
3657
+ continue;
3658
+ }
3659
+ states.set(entry.ref.key, mapBlockerReferenceNode(entry.ref, repoBlock?.[entry.issueAlias], repoBlock?.[entry.pullAlias]));
3660
+ }
3661
+ }
3662
+ return states;
3663
+ }
3664
+ /**
3665
+ * The revalidation verdict over one item's reference states.
3666
+ *
3667
+ * `cleared` requires every reference closed or merged; a partially cleared item
3668
+ * (some blocker closed, some still open or unreadable) is reported as such
3669
+ * rather than cleared, because an unreadable reference can always be open. An
3670
+ * item with no references at all — a blocked_reason naming no GitHub reference —
3671
+ * is `unresolvable`: its static text can never be revalidated automatically.
3672
+ *
3673
+ * @param states - The item's reference states.
3674
+ * @returns The item's verdict.
3675
+ */
3676
+ export function blockerItemVerdict(states) {
3677
+ if (states.length === 0)
3678
+ return "unresolvable";
3679
+ let cleared = 0;
3680
+ let open = 0;
3681
+ for (const state of states) {
3682
+ if (state.state === "closed" || state.state === "merged")
3683
+ cleared++;
3684
+ else if (state.state === "open")
3685
+ open++;
3686
+ }
3687
+ if (cleared === states.length)
3688
+ return "cleared";
3689
+ if (cleared > 0)
3690
+ return "partially_cleared";
3691
+ if (open > 0)
3692
+ return "still_blocked";
3693
+ return "unresolvable";
3694
+ }
3695
+ /** Marker a blockers comment carries so re-runs can skip it (`pm-github:blockers:…`). */
3696
+ export const BLOCKERS_COMMENT_MARKER_REGEX = /<!--\s*pm-github:blockers:([A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+)#(\d+):(closed|merged):([^\s<>]+)\s*-->/g;
3697
+ /**
3698
+ * Build the idempotency marker for one cleared blocker reference.
3699
+ *
3700
+ * Identity is the reference PLUS its observed state (repo, number, closed or
3701
+ * merged, and the exact timestamp): if the blocker later reopens and closes
3702
+ * again, the changed timestamp makes the marker a new one and a fresh comment
3703
+ * is appended, while an unchanged state never repeats.
3704
+ *
3705
+ * @param repo - The blocker's `owner/repo`.
3706
+ * @param number - The blocker's number.
3707
+ * @param disposition - `closed` or `merged`.
3708
+ * @param at - The close/merge ISO timestamp.
3709
+ * @returns The marker string embedded in the comment.
3710
+ */
3711
+ export function blockerCommentMarker(repo, number, disposition, at) {
3712
+ return `<!-- pm-github:blockers:${repo}#${number}:${disposition}:${at} -->`;
3713
+ }
3714
+ /**
3715
+ * The disposition and timestamp one cleared reference is reported under.
3716
+ *
3717
+ * Shared by the comment builder and its idempotency marker so both always
3718
+ * agree on whether a reference closed or merged, and on which date.
3719
+ *
3720
+ * @param state - The cleared reference's live state.
3721
+ * @returns `closed`/`merged` plus the close or merge ISO timestamp (empty when GitHub sent none).
3722
+ */
3723
+ function blockerDispositionAndDate(state) {
3724
+ const disposition = state.state === "merged" ? "merged" : "closed";
3725
+ const at = (state.state === "merged" ? state.merged_at : state.closed_at) ?? "";
3726
+ return { disposition, at };
3727
+ }
3728
+ /**
3729
+ * Build the comment appended for one cleared blocker reference.
3730
+ *
3731
+ * Human text first (what the blocker was and when it closed, then the ask —
3732
+ * revalidate), marker last, mirroring the GitHub comment sync's layout so stored
3733
+ * comments stay scannable and machine-skippable.
3734
+ *
3735
+ * @param state - The cleared reference's live state.
3736
+ * @returns The comment text.
3737
+ */
3738
+ export function buildBlockerClearedComment(state) {
3739
+ const repo = state.repo ?? "";
3740
+ const { disposition, at } = blockerDispositionAndDate(state);
3741
+ const human = at === "" ? "an unknown date" : at.slice(0, 10);
3742
+ const marker = blockerCommentMarker(repo, state.number, disposition, at === "" ? "unknown" : at);
3743
+ return `Blocker ${repo}#${state.number} ${disposition} on ${human}; revalidate.\n\n${marker}`;
3744
+ }
3745
+ /**
3746
+ * Collect the blocker markers already present in an item's comments.
3747
+ *
3748
+ * Mirrors {@link extractSyncedCommentIds}: each stored comment's text is scanned
3749
+ * for the marker; hand-written comments simply contribute nothing.
3750
+ *
3751
+ * @param stored - The item's existing native comments.
3752
+ * @returns The set of marker strings already present.
3753
+ */
3754
+ export function extractBlockerCommentMarkers(stored) {
3755
+ const markers = new Set();
3756
+ for (const entry of stored) {
3757
+ if (!entry?.text)
3758
+ continue;
3759
+ for (const m of entry.text.matchAll(BLOCKERS_COMMENT_MARKER_REGEX)) {
3760
+ markers.add(blockerCommentMarker(m[1].toLowerCase(), Number(m[2]), m[3], m[4]));
3761
+ }
3762
+ }
3763
+ return markers;
3764
+ }
3765
+ /**
3766
+ * Append one idempotent comment per cleared item reference on its item.
3767
+ *
3768
+ * Never unblocks, reopens, or edits anything — a cleared blocker does not
3769
+ * always mean the work is unblocked, so the comment only names the closed
3770
+ * blocker and asks a human to revalidate. Idempotent across re-runs and safe
3771
+ * across concurrent runs: the per-item comment lock serializes the read-markers-then-append
3772
+ * window, and a comment whose marker is already present is skipped, so the
3773
+ * same reference state is never announced twice.
3774
+ *
3775
+ * @param pmRoot - Workspace root or pm data dir.
3776
+ * @param cleared - Cleared reference states grouped by their item id.
3777
+ * @param dryRun - Preview the comments without writing anything.
3778
+ * @returns How many comments were added, skipped as already present, and not written.
3779
+ * @internal Exported so the lock-contention and write-failure paths are directly
3780
+ * testable without racing a real process; stripped from the published declaration.
3781
+ */
3782
+ export async function appendBlockerComments(pmRoot, cleared, dryRun) {
3783
+ let added = 0;
3784
+ let skipped = 0;
3785
+ let failed = 0;
3786
+ for (const entry of cleared) {
3787
+ const acquisition = await acquireImportLock(pmRoot, entry.itemId);
3788
+ if (acquisition.status !== "acquired") {
3789
+ console.error(`${entry.itemId}: blocker comment skipped — comment-sync lock unavailable; re-run to record it`);
3790
+ failed++;
3791
+ continue;
3792
+ }
3793
+ const release = () => acquisition.lock.release();
3794
+ try {
3795
+ let existing;
3796
+ try {
3797
+ const list = await pmCommentsFn(entry.itemId, {}, { pmRoot, cwd: pmRoot });
3798
+ existing = list.comments;
3799
+ }
3800
+ catch (err) {
3801
+ console.error(`${entry.itemId}: could not read existing comments — ${errorMessage(err)}`);
3802
+ failed++;
3803
+ continue;
3804
+ }
3805
+ const markers = extractBlockerCommentMarkers(existing);
3806
+ const pending = entry.states.filter((state) => {
3807
+ const { disposition, at } = blockerDispositionAndDate(state);
3808
+ return !markers.has(blockerCommentMarker(state.repo ?? "", state.number, disposition, at === "" ? "unknown" : at));
3809
+ });
3810
+ if (pending.length === 0) {
3811
+ skipped++;
3812
+ continue;
3813
+ }
3814
+ const text = pending.map(buildBlockerClearedComment).join("\n\n");
3815
+ if (dryRun) {
3816
+ added++;
3817
+ continue;
3818
+ }
3819
+ try {
3820
+ await pmCommentsFn(entry.itemId, { add: text, author: "pm-github" }, { pmRoot, cwd: pmRoot });
3821
+ added++;
3822
+ }
3823
+ catch (err) {
3824
+ console.error(`${entry.itemId}: blocker comment failed — ${errorMessage(err)}`);
3825
+ failed++;
3826
+ }
3827
+ }
3828
+ finally {
3829
+ release();
3830
+ }
3831
+ }
3832
+ return { added, skipped, failed };
3833
+ }
3834
+ /**
3835
+ * Run `pm github blockers`: revalidate blocked items against the live state of their GitHub blockers.
3836
+ *
3837
+ * Reads the complete corpus through the SDK with a completeness certificate, selects blocked items plus non-terminal items with an external GitHub
3838
+ * `blocked_by` dependency, parses their blocker text, queries each reference's
3839
+ * live state in batched GraphQL, and reports a verdict per item. REPORT-ONLY:
3840
+ * nothing is unblocked or edited; `--comment` only appends idempotent comments.
3841
+ * Exit codes: 0 when nothing cleared, {@link EXIT_CODE.BLOCKERS_CLEARED} when
3842
+ * something cleared or partially cleared, {@link EXIT_CODE.USAGE} for usage or a
3843
+ * missing token, {@link EXIT_CODE.GENERIC_FAILURE} when any reference state query
3844
+ * or any requested comment failed (takes precedence over 4).
3845
+ *
3846
+ * @param ctx - The host command-handler context.
3847
+ * @returns The full report for the host renderer (JSON/TOON).
3848
+ * @throws {@link CommandError} For usage or requested-item selection errors.
3849
+ */
3850
+ async function runBlockers(ctx) {
3851
+ const options = ctx.options;
3852
+ const fallbackRepo = optionString(options, "repo");
3853
+ if (fallbackRepo !== undefined && !BLOCKER_REPO_SHAPE.test(fallbackRepo)) {
3854
+ throw new CommandError("Usage: pm github blockers [--repo owner/repo] [--ids pm-1,pm-2] [--comment] [--dry-run] " +
3855
+ "(--repo must be owner/repo)", EXIT_CODE.USAGE);
3856
+ }
3857
+ const scopedIds = optionCsv(options, "ids");
3858
+ if (optionProvided(options, "ids") && scopedIds.length === 0) {
3859
+ throw new CommandError("--ids requires at least one pm item id (comma-separated), e.g. --ids pm-123,pm-456.", EXIT_CODE.USAGE);
3860
+ }
3861
+ const withComments = optionEnabled(options, "comment");
3862
+ const dryRun = optionEnabled(options, "dry-run", "dryRun");
3863
+ const token = resolveGitHubToken();
3864
+ if (!token) {
3865
+ throw new CommandError("pm github blockers reads blocker state from GitHub and needs a token " +
3866
+ "(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
3867
+ }
3868
+ const corpus = await readBlockerPmItems(ctx.pm_root);
3869
+ const selected = requireRequestedItems(corpus, scopedIds);
3870
+ const candidates = selectBlockerCandidates(selected);
3871
+ const perItem = candidates.map((item) => ({ item, references: collectBlockerReferences(item, fallbackRepo) }));
3872
+ const unique = new Map();
3873
+ for (const entry of perItem) {
3874
+ for (const ref of entry.references) {
3875
+ if (ref.repo !== undefined && !unique.has(ref.key)) {
3876
+ unique.set(ref.key, { key: ref.key, repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw });
3877
+ }
3878
+ }
3879
+ }
3880
+ const stateByKey = await fetchBlockerReferenceStates([...unique.values()], (query) => githubGraphQLResponse(token, query, {}));
3881
+ const queried = [...stateByKey.values()];
3882
+ const items = perItem.map(({ item, references }) => {
3883
+ const refStates = references.map((ref) => stateByKey.get(ref.key)
3884
+ ?? { repo: ref.repo, number: ref.number, form: ref.form, raw: ref.raw, state: "unresolved" });
3885
+ return {
3886
+ id: item.id,
3887
+ status: item.status,
3888
+ verdict: blockerItemVerdict(refStates),
3889
+ references: refStates,
3890
+ };
3891
+ });
3892
+ const counts = {
3893
+ cleared: items.filter((entry) => entry.verdict === "cleared").length,
3894
+ partially_cleared: items.filter((entry) => entry.verdict === "partially_cleared").length,
3895
+ still_blocked: items.filter((entry) => entry.verdict === "still_blocked").length,
3896
+ unresolvable: items.filter((entry) => entry.verdict === "unresolvable").length,
3897
+ };
3898
+ let comment;
3899
+ if (withComments) {
3900
+ const cleared = items
3901
+ .filter((entry) => entry.verdict === "cleared")
3902
+ .map((entry) => ({
3903
+ itemId: entry.id,
3904
+ states: entry.references,
3905
+ }));
3906
+ const receipt = await appendBlockerComments(ctx.pm_root, cleared, dryRun);
3907
+ comment = { requested: true, dry_run: dryRun, ...receipt };
3908
+ }
3909
+ const report = {
3910
+ command: "github blockers",
3911
+ generated_at: new Date().toISOString(),
3912
+ items_read: selected.length,
3913
+ candidates: candidates.length,
3914
+ items,
3915
+ counts,
3916
+ ...(comment === undefined ? {} : { comment }),
3917
+ };
3918
+ const failed = queried.some((state) => state.state === "error") || (comment?.failed ?? 0) > 0;
3919
+ if (failed)
3920
+ return { ...report, exit_code: EXIT_CODE.GENERIC_FAILURE };
3921
+ if (counts.cleared > 0 || counts.partially_cleared > 0)
3922
+ return { ...report, exit_code: EXIT_CODE.BLOCKERS_CLEARED };
3923
+ return report;
3924
+ }
3925
+ // ---------------------------------------------------------------------------
3138
3926
  // Search provider — reach GitHub from `pm search` for imported items
3139
3927
  // ---------------------------------------------------------------------------
3140
3928
  //
@@ -3314,6 +4102,10 @@ function graphqlUrl() {
3314
4102
  // below intentionally return a partial error for the wrong owner type while the
3315
4103
  // right one still resolves; we only throw when there is NO usable data.
3316
4104
  async function githubGraphQL(token, query, variables) {
4105
+ return (await githubGraphQLResponse(token, query, variables)).data;
4106
+ }
4107
+ /** Shared GraphQL client retaining partial errors for reference-level classification. */
4108
+ async function githubGraphQLResponse(token, query, variables) {
3317
4109
  if (!token) {
3318
4110
  throw new CommandError("GitHub GraphQL requires a token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
3319
4111
  }
@@ -3330,7 +4122,7 @@ async function githubGraphQL(token, query, variables) {
3330
4122
  const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
3331
4123
  throw new CommandError(`GitHub GraphQL error${messages ? `: ${messages}` : ` (HTTP ${res.status})`}.`);
3332
4124
  }
3333
- return parsed.data;
4125
+ return parsed;
3334
4126
  }
3335
4127
  const STATUS_FIELD_GQL = `
3336
4128
  statusField: field(name: "Status") {
@@ -4103,6 +4895,8 @@ export function isMutatingGithubCommand(command, options) {
4103
4895
  // Extension
4104
4896
  // ---------------------------------------------------------------------------
4105
4897
  const IMPORT_FLAGS = [
4898
+ { long: "--issue", value_name: "N[,N...]", description: "Fetch exactly these issue numbers, #N, or same-repo owner/repo#N references; conflicts with --state/--all/--labels/--since/--assignee/--milestone/--limit. Missing numbers fail before writes (reported by --dry-run); PRs require --include-prs" },
4899
+ { long: "--limit", value_name: "n", description: "Positive list-mode issue cap, newest-updated first; reports limit and truncated when more exist (one extra matching issue is fetched to check). Invalid with --issue" },
4106
4900
  { long: "--all", description: "Include closed issues (shorthand for --state all)" },
4107
4901
  { long: "--state", value_name: "state", description: "Issue state: open | closed | all (default: open)" },
4108
4902
  { long: "--labels", value_name: "labels", description: "Comma-separated label filter" },
@@ -4142,6 +4936,12 @@ const GATE_FLAGS = [
4142
4936
  const VALIDATE_FLAGS = [
4143
4937
  { long: "--repo", value_name: "owner/repo", description: "Also check this repo is accessible with the resolved token" },
4144
4938
  ];
4939
+ const BLOCKERS_FLAGS = [
4940
+ { long: "--repo", value_name: "owner/repo", description: "Fallback repo for bare `#N`, `GH-N`, and `name#N` references that carry no owner (after the item's own gh: provenance repo)" },
4941
+ { long: "--ids", value_name: "pm-1,pm-2", description: "Only revalidate these pm item IDs (comma-separated)" },
4942
+ { long: "--comment", description: "Append one idempotent pm comment per cleared item (never unblocks or edits the item; re-runs skip an already-announced reference state)" },
4943
+ { long: "--dry-run", description: "With --comment: preview the comments without writing anything (the report itself never mutates)" },
4944
+ ];
4145
4945
  const PROJECT_LIST_FLAGS = [
4146
4946
  { long: "--owner", value_name: "login", description: "GitHub user or org login (or pass positionally)" },
4147
4947
  ];
@@ -4176,7 +4976,7 @@ const PROJECT_SYNC_FLAGS = [
4176
4976
  const defineExtension = (module) => module;
4177
4977
  export default defineExtension({
4178
4978
  name: "pm-github",
4179
- version: "2026.10.9",
4979
+ version: "2026.10.14",
4180
4980
  activate(api) {
4181
4981
  // -----------------------------------------------------------------------
4182
4982
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -4235,6 +5035,8 @@ export default defineExtension({
4235
5035
  { name: "owner/repo", required: true, description: "GitHub repository to import" },
4236
5036
  ],
4237
5037
  examples: [
5038
+ "pm github import owner/repo --issue 189",
5039
+ "pm github import owner/repo --limit 10",
4238
5040
  "pm github import unbraind/pm-cli",
4239
5041
  "pm github import owner/repo --since 7d",
4240
5042
  "pm github import owner/repo --include-comments",
@@ -4399,6 +5201,8 @@ export default defineExtension({
4399
5201
  ],
4400
5202
  examples: [
4401
5203
  "pm gh-issues import unbraind/pm-cli",
5204
+ "pm gh-issues import owner/repo --issue 189",
5205
+ "pm gh-issues import owner/repo --limit 10",
4402
5206
  "pm gh-issues import unbraind/pm-cli --all",
4403
5207
  "pm gh-issues import unbraind/pm-cli --labels bug,enhancement",
4404
5208
  "pm gh-issues import unbraind/pm-cli --since 2026-01-01T00:00:00Z",
@@ -4497,6 +5301,45 @@ export default defineExtension({
4497
5301
  },
4498
5302
  });
4499
5303
  // -----------------------------------------------------------------------
5304
+ // command — `pm github blockers` (revalidate blocked items against the
5305
+ // live state of their GitHub blockers). REPORT-ONLY: it never unblocks,
5306
+ // reopens, or edits any item; --comment only appends idempotent comments.
5307
+ // Exit codes: 0 nothing cleared; 4 something cleared or partially cleared
5308
+ // (so CI and the scheduled sync can alert); 2 usage/missing token;
5309
+ // 1 failure (any reference state query or requested comment failed, which
5310
+ // takes precedence over 4).
5311
+ // -----------------------------------------------------------------------
5312
+ api.registerCommand({
5313
+ name: "github blockers",
5314
+ description: "Revalidate blocked items against the live state of the GitHub issues and pull requests their " +
5315
+ "blocked_reason, description, comments, and external blocked_by dependencies name " +
5316
+ "(owner/repo#N, bare #N vs the item's repo or --repo, full issue/PR URLs, GH-N). " +
5317
+ "Queries current state (open, closed with reason, merged, not found) in batched GraphQL and reports a " +
5318
+ "verdict per item: cleared, partially_cleared, still_blocked, or unresolvable. NEVER auto-unblocks. " +
5319
+ "Read-only; --comment appends one idempotent pm comment per cleared item. " +
5320
+ "Exits 4 when something is cleared so CI and the scheduled sync can alert; use --json for machine output.",
5321
+ intent: "revalidate blocked items against the live state of their GitHub blockers",
5322
+ examples: [
5323
+ "pm github blockers",
5324
+ "pm github blockers --json",
5325
+ "pm github blockers --repo unbraind/pm-cli",
5326
+ "pm github blockers --ids pm-123,pm-456 --json",
5327
+ "pm github blockers --comment",
5328
+ "pm github blockers --comment --dry-run",
5329
+ ],
5330
+ flags: BLOCKERS_FLAGS,
5331
+ failure_hints: [
5332
+ "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`; blockers reads live GitHub state.",
5333
+ "Pass --repo owner/repo so bare `#N`, `GH-N`, and `name#N` references resolve; imported items resolve against their own provenance repo.",
5334
+ "Exit code 4 means at least one blocked item has a cleared or partially cleared blocker; nothing was changed — revalidate the item by hand.",
5335
+ "--comment never unblocks: it only appends one idempotent comment per cleared item.",
5336
+ "Use --ids <pm-1,pm-2> to scope revalidation; unknown IDs fail fast.",
5337
+ ],
5338
+ async run(ctx) {
5339
+ return runBlockers(ctx);
5340
+ },
5341
+ });
5342
+ // -----------------------------------------------------------------------
4500
5343
  // command — `pm github project list <owner>` (discover ProjectsV2)
4501
5344
  // -----------------------------------------------------------------------
4502
5345
  api.registerCommand({