pm-github 2026.8.7 → 2026.8.15

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
@@ -43,10 +43,16 @@ export function resolveGitHubToken() {
43
43
  return undefined;
44
44
  }
45
45
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
- // Decide whether the Authorization token may be forwarded to a redirect target.
47
- // Only same-origin (scheme + host + port) redirects keep the credential; any
48
- // origin change drops it to avoid leaking the token. Exported for
49
- // unit testing.
46
+ /**
47
+ * Decide whether the Authorization token may be forwarded across a redirect.
48
+ *
49
+ * Only same-origin (scheme + host + port) redirects keep the credential; any
50
+ * origin change drops it so the bearer token can never leak to a third party.
51
+ *
52
+ * @param fromUrl - The URL the redirect originated from.
53
+ * @param toUrl - The redirect target URL.
54
+ * @returns True when both URLs share an origin.
55
+ */
50
56
  export function sameOrigin(fromUrl, toUrl) {
51
57
  try {
52
58
  return new URL(fromUrl).origin.toLowerCase() === new URL(toUrl).origin.toLowerCase();
@@ -102,9 +108,14 @@ export function githubApiBase() {
102
108
  // Strip a trailing slash so `${base}/repos/...` cannot produce `//repos/...`.
103
109
  return raw.replace(/\/+$/, "");
104
110
  }
105
- // One low-level request, no retry/backoff (that lives in `request`). Follows up
106
- // to `redirectsLeft` redirects; a cycle or an over-long chain rejects instead of
107
- // overflowing the stack, and the token is only forwarded to same-host targets.
111
+ /**
112
+ * One low-level HTTP request, with no retry or backoff.
113
+ *
114
+ * That orchestration lives in the surrounding {@link request} wrapper. This
115
+ * function follows up to `redirectsLeft` redirects, rejecting on a cycle or an
116
+ * over-long chain rather than overflowing the stack, and forwards the bearer
117
+ * token only to same-origin targets via {@link sameOrigin}.
118
+ */
108
119
  function requestOnce(method, url, token, payload, redirectsLeft = 5) {
109
120
  return new Promise((resolve, reject) => {
110
121
  const headers = {
@@ -170,10 +181,20 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
170
181
  req.end();
171
182
  });
172
183
  }
173
- // How long (ms) to wait before retrying a rate-limited / transient response.
174
- // Honors Retry-After (seconds) and the primary-rate-limit reset window
175
- // (X-RateLimit-Remaining: 0 + X-RateLimit-Reset epoch), then falls back to
176
- // exponential backoff. Capped so we never hang a CLI run indefinitely.
184
+ /**
185
+ * Compute how long (ms) to wait before retrying a rate-limited or transient
186
+ * response.
187
+ *
188
+ * Honors `Retry-After` (seconds) and the primary rate-limit reset window
189
+ * (`X-RateLimit-Remaining: 0` + `X-RateLimit-Reset` epoch), then falls back to
190
+ * exponential backoff. The result is capped so a CLI run is never hung
191
+ * indefinitely.
192
+ *
193
+ * @param headers - Response headers to read the retry hints from.
194
+ * @param attempt - Zero-based retry index feeding the exponential backoff floor.
195
+ * @param nowMs - Current epoch ms, used to measure the reset window's remaining time.
196
+ * @returns The capped wait in milliseconds.
197
+ */
177
198
  export function computeBackoffMs(headers, attempt, nowMs = Date.now()) {
178
199
  const get = (k) => {
179
200
  const v = headers[k] ?? headers[k.toLowerCase()];
@@ -449,7 +470,17 @@ export const EXIT_CODE = {
449
470
  USAGE: 2,
450
471
  NOT_FOUND: 3,
451
472
  };
473
+ /**
474
+ * Error that carries a semantic process exit code.
475
+ *
476
+ * pm's command runtime treats a thrown error as a cleanly handled non-zero exit
477
+ * only when it exposes a numeric `exitCode`; a plain `Error` instead falls
478
+ * through to the "unhandled" path, which re-invokes the handler (doubling side
479
+ * effects such as a second GitHub fetch) and exits with a generic code. Throwing
480
+ * this routes a failure to a clean, single exit at the chosen code.
481
+ */
452
482
  export class CommandError extends Error {
483
+ /** Numeric exit code the runtime propagates to the shell (one of {@link EXIT_CODE}). */
453
484
  exitCode;
454
485
  constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
455
486
  super(message);
@@ -460,24 +491,46 @@ export class CommandError extends Error {
460
491
  // ---------------------------------------------------------------------------
461
492
  // Provenance — link a pm item back to a specific GitHub issue
462
493
  // ---------------------------------------------------------------------------
463
- // Provenance lives in a machine-parseable tag (`gh:owner/repo#123`) AND in the
464
- // declared schema fields/description. The tag is the idempotency key: it
465
- // round-trips losslessly through `pm create --tags` / `pm list --json`, so a
466
- // re-import can find the existing item and UPDATE it instead of duplicating.
494
+ /**
495
+ * Build the `gh:owner/repo#N` provenance tag linking a pm item to a GitHub issue.
496
+ *
497
+ * The tag is the idempotency key: it round-trips losslessly through
498
+ * `pm create --tags` / `pm list --json`, so a re-import can find the existing
499
+ * item and UPDATE it instead of duplicating. Provenance also rides on declared
500
+ * schema fields and the description, but the tag is what matching keys on.
501
+ *
502
+ * @param repo - `owner/repo`, lowercased into the tag.
503
+ * @param issueNumber - The issue/PR number.
504
+ * @returns The provenance tag string.
505
+ */
467
506
  export function provenanceTag(repo, issueNumber) {
468
507
  return `gh:${repo.toLowerCase()}#${issueNumber}`;
469
508
  }
470
- // Parse a `gh:owner/repo#number` provenance tag into its repo + issue number;
471
- // returns undefined for non-provenance tags.
509
+ /**
510
+ * Parse a `gh:owner/repo#N` provenance tag back into its repo and issue number.
511
+ *
512
+ * Returns `undefined` for anything that is not a provenance tag, so a caller
513
+ * scanning a tag list can skip foreign tags without a try/catch.
514
+ *
515
+ * @param tag - The candidate tag string.
516
+ * @returns The parsed repo (lowercased) and number, or `undefined`.
517
+ */
472
518
  export function parseProvenanceTag(tag) {
473
519
  const m = /^gh:([^#\s]+)#(\d+)$/.exec(tag.trim());
474
520
  if (!m)
475
521
  return undefined;
476
522
  return { repo: m[1].toLowerCase(), number: Number(m[2]) };
477
523
  }
478
- // Surface the GitHub issue author as a machine-readable tag (`github_author:login`)
479
- // — consistent with how provenance rides on tags. Returns undefined when the
480
- // API did not include a usable login (so we never emit an empty tag).
524
+ /**
525
+ * Build the `github_author:login` tag recording who opened a GitHub issue.
526
+ *
527
+ * Mirrors how provenance rides on tags so the author survives a round-trip.
528
+ * Returns `undefined` when the API supplied no usable login, so an empty tag is
529
+ * never emitted.
530
+ *
531
+ * @param issue - The issue whose author to tag.
532
+ * @returns The author tag, or `undefined`.
533
+ */
481
534
  export function authorTag(issue) {
482
535
  const login = issue.user?.login?.trim();
483
536
  if (!login)
@@ -937,11 +990,19 @@ export function composeBody(issue, comments) {
937
990
  // reports "degraded" and proceeds unlocked with a warning, matching the
938
991
  // pre-lock best-effort behavior.
939
992
  export const IMPORT_LOCK_TTL_MS_DEFAULT = 5 * 60_000;
993
+ /** Default total budget a caller waits to acquire a contended comment-sync lock before giving up and skipping that item's sync. */
940
994
  export const IMPORT_LOCK_WAIT_MS_DEFAULT = 30_000;
941
- // Resolve the pm data dir (the dir holding settings.json and locks/) from the
942
- // pmRoot the extension host hands the command, which may be either the
943
- // workspace root (the dir containing .agents/pm) or the data dir itself — the
944
- // pm CLI accepts both for --path, and tests pass the workspace root.
995
+ /**
996
+ * Resolve the pm data dir from the `pmRoot` a command handler receives.
997
+ *
998
+ * The host may hand either the workspace root (the dir containing `.agents/pm`)
999
+ * or the data dir itself — the pm CLI accepts both for `--path`. This returns
1000
+ * the dir that actually holds `settings.json` and `locks/`, defaulting to
1001
+ * `pmRoot` unchanged when no nested `.agents/pm` exists.
1002
+ *
1003
+ * @param pmRoot - The path supplied by the extension host.
1004
+ * @returns The resolved pm data directory.
1005
+ */
945
1006
  export function resolvePmDataDir(pmRoot) {
946
1007
  const nested = path.join(pmRoot, ".agents", "pm");
947
1008
  try {
@@ -953,13 +1014,31 @@ export function resolvePmDataDir(pmRoot) {
953
1014
  }
954
1015
  return pmRoot;
955
1016
  }
956
- // Absolute lock file path for one item's comment-sync critical section. The
957
- // item id is sanitized defensively (pm ids are already filename-safe, but the
958
- // lock path must never become a traversal vector if that ever changes).
1017
+ /**
1018
+ * Absolute lock file path for one item's comment-sync critical section.
1019
+ *
1020
+ * The item id is sanitized defensively: pm ids are already filename-safe, but
1021
+ * the lock path must never become a directory-traversal vector if that ever
1022
+ * changes.
1023
+ *
1024
+ * @param pmRoot - Workspace root or pm data dir.
1025
+ * @param itemId - The pm item id to serialize.
1026
+ * @returns Absolute path under the data dir's `locks/` directory.
1027
+ */
959
1028
  export function importCommentSyncLockPath(pmRoot, itemId) {
960
1029
  const safe = itemId.replace(/[^A-Za-z0-9._-]/g, "_");
961
1030
  return path.join(resolvePmDataDir(pmRoot), "locks", `pm-github.comment-sync.${safe}.lock`);
962
1031
  }
1032
+ /**
1033
+ * Read and parse a lock file's payload.
1034
+ *
1035
+ * Returns `undefined` when the file is absent or holds non-JSON / non-object
1036
+ * content (e.g. caught mid-write then abandoned), so the caller treats an
1037
+ * unreadable lock as owner-less rather than crashing.
1038
+ *
1039
+ * @param lockPath - Absolute lock file path.
1040
+ * @returns The parsed payload, or `undefined`.
1041
+ */
963
1042
  function readImportLockPayload(lockPath) {
964
1043
  let raw;
965
1044
  try {
@@ -978,6 +1057,16 @@ function readImportLockPayload(lockPath) {
978
1057
  return undefined;
979
1058
  }
980
1059
  }
1060
+ /**
1061
+ * Whether the process holding a lock is still running.
1062
+ *
1063
+ * Signals the PID with signal 0 (no effect); `EPERM` means the process exists
1064
+ * but belongs to another user (still alive), while `ESRCH` means it is gone.
1065
+ * Non-numeric or non-positive PIDs are treated as not-alive.
1066
+ *
1067
+ * @param pid - The recorded owner PID (untyped at the call site).
1068
+ * @returns True when the PID names a live process.
1069
+ */
981
1070
  function isLockOwnerAlive(pid) {
982
1071
  if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
983
1072
  return false;
@@ -1005,6 +1094,22 @@ function isLockOwnerAlive(pid) {
1005
1094
  // same-pid payload that is legitimately ours (in-process contention → wait)
1006
1095
  // from a leftover written by a dead process whose PID we inherited (→ break).
1007
1096
  const heldImportLocks = new Set();
1097
+ /**
1098
+ * Decide whether a held lock is stale and why.
1099
+ *
1100
+ * Returns a human-readable reason when the lock should be broken, or
1101
+ * `undefined` when it is still legitimately held. A lock is stale only when its
1102
+ * owner is provably gone: the recorded PID is dead, or it equals our own PID on
1103
+ * a path this process does not hold (a recycled PID). A LIVE owner is never
1104
+ * age-broken; only a missing/unparseable payload falls back to the TTL, aged
1105
+ * from `created_at` or the file mtime.
1106
+ *
1107
+ * @param lockPath - Absolute lock file path (also tracked in {@link heldImportLocks}).
1108
+ * @param payload - Parsed lock payload, or `undefined` when unreadable.
1109
+ * @param mtimeMs - File mtime, the age fallback base.
1110
+ * @param ttlMs - Maximum age before an owner-less lock is considered stale.
1111
+ * @returns The staleness reason, or `undefined` when the lock is live.
1112
+ */
1008
1113
  function importLockStaleReason(lockPath, payload, mtimeMs, ttlMs) {
1009
1114
  const pid = payload?.pid;
1010
1115
  if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) {
@@ -1028,20 +1133,29 @@ function importLockStaleReason(lockPath, payload, mtimeMs, ttlMs) {
1028
1133
  const IMPORT_LOCK_INITIAL_BACKOFF_MS = 25;
1029
1134
  const IMPORT_LOCK_MAX_BACKOFF_MS = 200;
1030
1135
  const IMPORT_LOCK_MAX_STALE_BREAKS = 3;
1031
- // A breaker's critical section is a handful of syscalls (re-stat, re-read,
1032
- // unlink) — a crashed breaker's sidecar goes stale in seconds, not minutes.
1136
+ /**
1137
+ * Staleness TTL for a breaker election sidecar, in ms.
1138
+ *
1139
+ * A breaker's critical section is a handful of syscalls (re-stat, re-read,
1140
+ * unlink), so a crashed breaker's sidecar goes stale in seconds, not minutes;
1141
+ * this short window lets a contender clear and re-run a dead election.
1142
+ */
1033
1143
  export const IMPORT_LOCK_BREAKER_TTL_MS = 10_000;
1034
- // Break a stale lock under a breaker election so the unlink can never race
1035
- // another breaker: two contenders may both judge the same file stale, but
1036
- // without mutual exclusion the slower one would execute its unlink AFTER the
1037
- // winner already re-created a fresh, live lock — silently unlinking that
1038
- // replacement and letting both proceed (the exact double-acquire this module
1039
- // exists to prevent). The election is an O_EXCL sidecar (`<lock>.break`):
1040
- // only its single winner may unlink, and it re-verifies staleness under the
1041
- // mutex first, so a lock that became live since the caller's check survives.
1042
- // Returns true when the stale lock was removed (caller should retry acquire
1043
- // immediately), false when the election was lost or the lock is live again
1044
- // (caller should fall through to the normal wait/backoff).
1144
+ /**
1145
+ * Break a stale lock under a breaker election.
1146
+ *
1147
+ * Two contenders may both judge the same file stale; without mutual exclusion
1148
+ * the slower unlink would remove a fresh, live lock the winner had just
1149
+ * re-created — the exact double-acquire this module exists to prevent. The
1150
+ * election is an O_EXCL sidecar (`<lock>.break`): only its single winner may
1151
+ * unlink, and it re-verifies staleness under that mutex first, so a lock that
1152
+ * became live since the caller's check survives.
1153
+ *
1154
+ * @param lockPath - Absolute path of the lock judged stale.
1155
+ * @param ttlMs - Staleness TTL handed to {@link importLockStaleReason} on re-check.
1156
+ * @param reason - Human-readable staleness reason, surfaced in the break warning.
1157
+ * @returns True when the stale lock was removed (retry acquire now); false when the election was lost or the lock is live again.
1158
+ */
1045
1159
  function breakStaleImportLock(lockPath, ttlMs, reason) {
1046
1160
  const breakerPath = `${lockPath}.break`;
1047
1161
  let bfd;
@@ -1096,9 +1210,19 @@ function breakStaleImportLock(lockPath, ttlMs, reason) {
1096
1210
  }
1097
1211
  }
1098
1212
  }
1099
- // Acquire the cross-process comment-sync lock for one item. Never throws:
1100
- // every failure mode maps to one of the three acquisition statuses so the
1101
- // caller can decide how to degrade. `ttlMs`/`waitMs` are injectable for tests.
1213
+ /**
1214
+ * Acquire the cross-process comment-sync lock for one item.
1215
+ *
1216
+ * Never throws: every failure mode maps to one of the three acquisition
1217
+ * statuses (`acquired`, `contended`, `degraded`) so the caller decides how to
1218
+ * degrade. `ttlMs`/`waitMs` are injectable so the wait and staleness windows
1219
+ * can be exercised without real timing in tests.
1220
+ *
1221
+ * @param pmRoot - Workspace root or pm data dir passed to {@link resolvePmDataDir}.
1222
+ * @param itemId - The pm item whose comment critical section is serialized.
1223
+ * @param opts - Optional overrides for the staleness TTL and wait budget.
1224
+ * @returns The acquisition outcome.
1225
+ */
1102
1226
  export async function acquireImportLock(pmRoot, itemId, opts = {}) {
1103
1227
  const ttlMs = opts.ttlMs ?? IMPORT_LOCK_TTL_MS_DEFAULT;
1104
1228
  const waitMs = opts.waitMs ?? IMPORT_LOCK_WAIT_MS_DEFAULT;
@@ -1221,18 +1345,35 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
1221
1345
  // the GitHub comment id (`<!-- pm-github:comment:N -->`). On re-import the
1222
1346
  // already-synced ids are read back and skipped, so re-running import never
1223
1347
  // duplicates a comment.
1224
- // Hidden HTML comment marker carrying the GitHub comment id. HTML comments are
1225
- // invisible in rendered markdown but survive `pm comments` storage verbatim.
1348
+ /**
1349
+ * Matches the hidden HTML-comment marker carrying a synced GitHub comment id.
1350
+ *
1351
+ * HTML comments are invisible in rendered markdown but survive `pm comments`
1352
+ * storage verbatim, so the id embedded in the marker is the stable dedupe key.
1353
+ */
1226
1354
  export const COMMENT_MARKER_REGEX = /<!--\s*pm-github:comment:(\d+)\s*-->/;
1227
- // Build the text for a single native pm comment from a GitHub comment. The
1228
- // marker is appended so re-sync can de-duplicate on the GitHub comment id.
1355
+ /**
1356
+ * Build the text for one native pm comment from a GitHub comment.
1357
+ *
1358
+ * Appends the marker so a later re-sync can de-duplicate on the GitHub comment
1359
+ * id (see {@link COMMENT_MARKER_REGEX}).
1360
+ *
1361
+ * @param comment - The GitHub comment to render.
1362
+ * @returns The comment text with the trailing id marker.
1363
+ */
1229
1364
  export function buildCommentText(comment) {
1230
1365
  const body = (comment.body || "").trim() || "(empty comment)";
1231
1366
  return `${body}\n\n<!-- pm-github:comment:${comment.id} -->`;
1232
1367
  }
1233
- // Read the GitHub comment ids already synced into an item's native comments,
1234
- // by scanning each stored comment's text for the stable marker. Returns the set
1235
- // of synced ids (empty when none match, e.g. for hand-written pm comments).
1368
+ /**
1369
+ * Collect the GitHub comment ids already synced into an item's native comments.
1370
+ *
1371
+ * Scans each stored comment's text for the stable marker. Returns the set of
1372
+ * synced ids (empty when none match, e.g. for hand-written pm comments).
1373
+ *
1374
+ * @param stored - The item's existing native comments (each may carry marker text).
1375
+ * @returns The set of already-synced GitHub comment ids.
1376
+ */
1236
1377
  export function extractSyncedCommentIds(stored) {
1237
1378
  const ids = new Set();
1238
1379
  for (const entry of stored) {
@@ -1263,6 +1404,17 @@ export function extractSyncedCommentIds(stored) {
1263
1404
  //
1264
1405
  // Returns undefined when the output genuinely cannot be parsed, so callers fall
1265
1406
  // back to a safe skip-with-warning instead of crashing the whole import.
1407
+ /**
1408
+ * Extract the newly-created item id from `pm create --json` stdout.
1409
+ *
1410
+ * The CLI emits a FLAT envelope (`{ "id": "pm-xxxx", … }`) with no `item`
1411
+ * wrapper, so this reads `parsed.id` directly. Returns `undefined` when the
1412
+ * output genuinely cannot be parsed, so callers skip-with-warning instead of
1413
+ * crashing the whole import.
1414
+ *
1415
+ * @param stdout - Raw `pm create --json` output.
1416
+ * @returns The created item id, or `undefined`.
1417
+ */
1266
1418
  export function parseCreatedItemId(stdout) {
1267
1419
  try {
1268
1420
  const parsed = JSON.parse(stdout);
@@ -1292,6 +1444,21 @@ export function parseCreatedItemId(stdout) {
1292
1444
  // (never run unlocked, so a wedged peer can cost a sync — recoverable on the
1293
1445
  // next import — but never cause a duplicate); if the lock mechanism itself is
1294
1446
  // unavailable the sync proceeds unlocked as before, also with a warning.
1447
+ /**
1448
+ * Sync GitHub issue comments into a pm item's native comments collection.
1449
+ *
1450
+ * Idempotent: comments already present (matched by their marker id) are skipped,
1451
+ * so re-running import never duplicates. Each GitHub comment becomes one pm
1452
+ * comment authored by the GitHub login. Failures are logged and never abort the
1453
+ * import; a contended or unavailable lock degrades gracefully (see
1454
+ * {@link acquireImportLock}).
1455
+ *
1456
+ * @param itemId - The pm item to append comments to.
1457
+ * @param comments - The GitHub comments to sync.
1458
+ * @param pmRoot - Workspace root or pm data dir.
1459
+ * @param issueNumber - The source issue number (for log prefixes).
1460
+ * @returns How many comments were added and how many skipped as duplicates.
1461
+ */
1295
1462
  export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot, issueNumber) {
1296
1463
  if (comments.length === 0)
1297
1464
  return { added: 0, skipped: 0 };
@@ -1337,12 +1504,29 @@ export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot,
1337
1504
  release();
1338
1505
  }
1339
1506
  }
1340
- // A draft PR is an issue that is both a pull request and flagged `draft: true`.
1341
- // (Plain issues are never drafts.)
1507
+ /**
1508
+ * Whether a GitHub issue node is a draft pull request.
1509
+ *
1510
+ * A draft PR is an issue that is BOTH a pull request and flagged `draft: true`;
1511
+ * plain issues are never drafts.
1512
+ *
1513
+ * @param issue - The issue node to test.
1514
+ * @returns True only for draft pull requests.
1515
+ */
1342
1516
  export function isDraftPr(issue) {
1343
1517
  return Boolean(issue.pull_request) && issue.draft === true;
1344
1518
  }
1345
- // Apply the client-side filters (PRs, drafts, milestone-by-title).
1519
+ /**
1520
+ * Apply the client-side import filters the REST API cannot express.
1521
+ *
1522
+ * Drops pull requests (unless `includePrs`), draft PRs (when `skipDrafts`), and
1523
+ * items whose milestone title does not match (the API keys milestones by number,
1524
+ * not title). Returns the surviving issues in input order.
1525
+ *
1526
+ * @param issues - Fetched issues to narrow.
1527
+ * @param opts - Import options carrying the filter flags.
1528
+ * @returns The filtered issue list.
1529
+ */
1346
1530
  export function applyClientFilters(issues, opts) {
1347
1531
  let result = issues;
1348
1532
  if (!opts.includePrs)
@@ -1356,8 +1540,17 @@ export function applyClientFilters(issues, opts) {
1356
1540
  }
1357
1541
  return result;
1358
1542
  }
1359
- // Normalize CLI options into ImportOptions (state filter, labels, assignee,
1360
- // milestone, since-window, PR inclusion, draft skipping, comment fetching).
1543
+ /**
1544
+ * Normalize the raw CLI flag bag into a typed {@link ImportOptions}.
1545
+ *
1546
+ * Resolves the state filter, labels, assignee, milestone, the `--since` window,
1547
+ * PR inclusion, draft skipping, and the comment-fetching mode, throwing a
1548
+ * {@link CommandError} with a usage code on a malformed `--since` or
1549
+ * `--comments-mode`.
1550
+ *
1551
+ * @param options - The raw option object from the command handler.
1552
+ * @returns The normalized import options.
1553
+ */
1361
1554
  export function parseImportOptions(options) {
1362
1555
  // --state takes precedence; --all is the legacy shorthand for "all".
1363
1556
  const stateOpt = optionString(options, "state");
@@ -1430,6 +1623,16 @@ const DEP_REF_STICKY = /(?:([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+))?#(\d+)/y;
1430
1623
  // ("#1, #2 and #3"). Sticky and can match empty, so the ref loop always
1431
1624
  // terminates: the next sticky ref match is what decides whether to continue.
1432
1625
  const DEP_GLUE_STICKY = /[\s,]*(?:and\b[\s,]*)?/y;
1626
+ /**
1627
+ * Map a dependency phrase captured from an issue body to its pm edge kind.
1628
+ *
1629
+ * Collapses spacing/hyphen variants and returns both the canonical
1630
+ * {@link DepRefKind} and a normalized human phrase ("blocks", "depends on", or
1631
+ * "blocked by") for audit messages.
1632
+ *
1633
+ * @param raw - The raw phrase matched by the dependency-phrase regex.
1634
+ * @returns The resolved kind and normalized phrase.
1635
+ */
1433
1636
  function phraseToKind(raw) {
1434
1637
  const compact = raw.toLowerCase().replace(/[\s-]/g, "");
1435
1638
  if (compact === "blocks")
@@ -1548,12 +1751,17 @@ export function countDependencyRefCandidates(repo, issues) {
1548
1751
  n += parseDependencyReferences(issue.body ?? "", repoLc).length;
1549
1752
  return n;
1550
1753
  }
1551
- // Build the default `DepLinkSdk` from the top-level SDK imports so the
1552
- // --link-deps second pass never needs a dynamic import. `listAllItemMetadata`
1553
- // returns full `ItemMetadata` objects; we project to the `DepLinkSnapshotItem`
1554
- // structural subset the link pass actually reads. The SDK's cycle detector
1555
- // reads only id/tags/dependencies off each item; `DepLinkSnapshotItem` is that
1556
- // structural subset, so the cast on `collectNewOrderingCycleWarnings` is sound.
1754
+ /**
1755
+ * Build the default SDK-backed dependency-link collaborators.
1756
+ *
1757
+ * Wires the `--link-deps` second pass to the top-level SDK imports so it never
1758
+ * needs a dynamic import. `listAllItemMetadata` returns full `ItemMetadata`
1759
+ * objects, projected here to the {@link DepLinkSnapshotItem} structural subset
1760
+ * the link pass reads; the SDK cycle detector reads only id/tags/dependencies,
1761
+ * so the cast on `collectNewOrderingCycleWarnings` is sound.
1762
+ *
1763
+ * @returns The default SDK collaborators.
1764
+ */
1557
1765
  function defaultDepLinkSdk() {
1558
1766
  return {
1559
1767
  listAllItemMetadata: async (pmRoot) => {
@@ -1567,6 +1775,17 @@ function defaultDepLinkSdk() {
1567
1775
  collectNewOrderingCycleWarnings: sdkCollectNewOrderingCycleWarnings,
1568
1776
  };
1569
1777
  }
1778
+ /**
1779
+ * Default edge applier: spawn `pm update --dep` for one resolved edge.
1780
+ *
1781
+ * Records the source issue and the matching phrase in the mutation message so
1782
+ * the resulting dependency is auditable. Returns the ok/stderr shape every edge
1783
+ * applier shares, never throwing.
1784
+ *
1785
+ * @param edge - The resolved workspace edge to write.
1786
+ * @param pmRoot - Workspace root for the `pm` invocation.
1787
+ * @returns Whether the write succeeded and any stderr.
1788
+ */
1570
1789
  function defaultApplyDependencyLink(edge, pmRoot) {
1571
1790
  const res = pmRun([
1572
1791
  "--path", pmRoot, "update", edge.sourceId,
@@ -1644,6 +1863,21 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
1644
1863
  }
1645
1864
  return { linked, unresolved, orderingCycleWarnings: [...warnings], failures };
1646
1865
  }
1866
+ /**
1867
+ * Render one GitHub issue into the import-ready desired state.
1868
+ *
1869
+ * Computes the title, status, provenance/author tags, description, body (with
1870
+ * optional embedded comments), and the comment list for native sync. Returns
1871
+ * `undefined` for an issue with no usable title so the caller counts it skipped.
1872
+ *
1873
+ * @param issue - The fetched issue to render.
1874
+ * @param repo - `owner/repo`, baked into the provenance tag.
1875
+ * @param opts - Import options governing body/comments.
1876
+ * @param token - GitHub token for the comment fetch.
1877
+ * @param match - The existing pm item linked to this issue, if any (drives update vs create).
1878
+ * @param fetchIssueComments - Injectable comment fetcher (defaults to {@link fetchComments}).
1879
+ * @returns The prepared import entry, or `undefined` when the issue has no title.
1880
+ */
1647
1881
  async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
1648
1882
  const title = issue.title.trim();
1649
1883
  if (!title)
@@ -1686,9 +1920,20 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
1686
1920
  match,
1687
1921
  };
1688
1922
  }
1689
- // Run the full import flow. Idempotent: items already linked (provenance tag)
1690
- // to a fetched issue are UPDATEd; new issues are created. Returns a structured
1691
- // result; throws CommandError (with a semantic exitCode) on failure.
1923
+ /**
1924
+ * Run the full GitHub issue import flow.
1925
+ *
1926
+ * Idempotent: items already linked (by provenance tag) to a fetched issue are
1927
+ * UPDATEd; new issues are created. Honors `--atomic` (one crash-resumable
1928
+ * transaction) versus the per-item `pm` mutation path, optional `--link-deps`,
1929
+ * and `--dry-run`. Returns a structured result and throws {@link CommandError}
1930
+ * (with a semantic exit code) on failure.
1931
+ *
1932
+ * @param repoArg - The `owner/repo` to import from.
1933
+ * @param pmRoot - Workspace root or pm data dir.
1934
+ * @param opts - Normalized import options.
1935
+ * @param deps - Injectable collaborators (token resolver, fetchers, etc.) for tests.
1936
+ */
1692
1937
  export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
1693
1938
  if (!repoArg || !repoArg.includes("/")) {
1694
1939
  throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
@@ -1976,8 +2221,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
1976
2221
  reportDepLink(depLink);
1977
2222
  return { imported, updated, skipped, ...depLinkResultFields(depLink) };
1978
2223
  }
1979
- // Emit the human-readable --link-deps summary to stderr (import writes its
1980
- // progress there; stdout is reserved for the structured result).
2224
+ /**
2225
+ * Emit the human-readable `--link-deps` summary to stderr.
2226
+ *
2227
+ * Import progress is written to stderr so stdout stays reserved for the
2228
+ * structured result; this prints the linked-edge count, unresolved refs, and
2229
+ * any ordering-cycle warnings or per-edge failures.
2230
+ */
1981
2231
  function reportDepLink(result) {
1982
2232
  if (!result)
1983
2233
  return;
@@ -1990,8 +2240,12 @@ function reportDepLink(result) {
1990
2240
  for (const failure of result.failures)
1991
2241
  console.error(` ✗ link failed: ${failure}`);
1992
2242
  }
1993
- // The public, JSON-serializable slice of a --link-deps result. Kept flat and
1994
- // omitted entirely when the pass did not run so the default result is unchanged.
2243
+ /**
2244
+ * The public, JSON-serializable slice of a `--link-deps` result.
2245
+ *
2246
+ * Kept flat and omitted entirely when the pass did not run, so the default
2247
+ * import result is unchanged.
2248
+ */
1995
2249
  function depLinkResultFields(result) {
1996
2250
  if (!result)
1997
2251
  return {};
@@ -2174,13 +2428,15 @@ export function exportWillApply(options) {
2174
2428
  return true;
2175
2429
  return optionEnabled(options, "apply", "push");
2176
2430
  }
2177
- // Apply a (already-built) export plan to GitHub, one issue at a time.
2178
- //
2179
- // CRITICAL: each create/update is isolated. A single failed write (e.g. a 422
2180
- // for a label that does not exist on the repo) is recorded and the loop
2181
- // CONTINUES with the remaining items — it never abandons the rest of the batch.
2182
- // This mirrors the per-item isolation already used by the import/sync paths.
2183
- // Pure aside from the injected `requestFn`, so it is directly unit-testable.
2431
+ /**
2432
+ * Apply an already-built export plan to GitHub, one issue at a time.
2433
+ *
2434
+ * Each create/update is isolated: a single failed write (e.g. a 422 for a label
2435
+ * that does not exist on the repo) is recorded and the loop CONTINUES with the
2436
+ * remaining items — it never abandons the rest of the batch. Pure aside from the
2437
+ * injected `requestFn`, so the per-item isolation is directly unit-testable
2438
+ * without real network I/O.
2439
+ */
2184
2440
  export async function applyExportPlan(plan, repo, token, requestFn) {
2185
2441
  let created = 0;
2186
2442
  let updated = 0;
@@ -2235,6 +2491,19 @@ export async function applyExportPlan(plan, repo, token, requestFn) {
2235
2491
  // - any creates/updates landed (partial or full) → success (per-item
2236
2492
  // failures are already reported; the batch still wrote real changes)
2237
2493
  // - non-empty plan, nothing written, >=1 failure → GENERIC_FAILURE
2494
+ /**
2495
+ * Decide the exit status of a completed `export --apply` batch.
2496
+ *
2497
+ * Per-item continuation is intentional (one bad item never aborts the batch),
2498
+ * but the batch as a whole must still report honestly: a non-empty plan that
2499
+ * wrote NOTHING while recording at least one failure is a total failure, so it
2500
+ * returns a {@link CommandError} to throw; otherwise `undefined` (exit 0).
2501
+ *
2502
+ * @param plan - The plan that was applied.
2503
+ * @param result - What the apply loop actually wrote.
2504
+ * @param repo - Target repo, included in the failure message.
2505
+ * @returns A `CommandError` to throw on total failure, else `undefined`.
2506
+ */
2238
2507
  export function applyOutcomeError(plan, result, repo) {
2239
2508
  if (plan.length > 0 && result.created === 0 && result.updated === 0 && result.failed > 0) {
2240
2509
  return new CommandError(`All ${result.failed} item(s) failed to apply to ${repo}; ` +
@@ -2255,6 +2524,14 @@ export function applyOutcomeError(plan, result, repo) {
2255
2524
  // stdout in JSON mode (pm renders the return value). Used by both the
2256
2525
  // `registerExporter("github", ...)` entry point and the `pm github export`
2257
2526
  // command so the surface stays consistent.
2527
+ /**
2528
+ * Shared handler for the `pm github export` exporter and command.
2529
+ *
2530
+ * Export is SAFE BY DEFAULT: it previews the create/update plan and writes
2531
+ * nothing unless the user opts in with `--apply` (plus a token and `--repo`).
2532
+ * Items already linked to an issue in `--repo` are upserted rather than
2533
+ * duplicated; `--label-map` translates pm tags to GitHub labels.
2534
+ */
2258
2535
  async function runExport(ctx) {
2259
2536
  const options = ctx.options || {};
2260
2537
  const jsonMode = ctx.global?.json === true;
@@ -2366,16 +2643,32 @@ async function runExport(ctx) {
2366
2643
  // repo match the query, then returns hits for the pm items that are already
2367
2644
  // imported from those issues (matched by the `gh:repo#N` provenance tag).
2368
2645
  // Semantics: "find my imported pm items whose upstream GitHub issue matches Q".
2369
- // Build the GitHub Search-API URL for issues in a repo matching the free-text
2370
- // query. Restricted to `type:issue repo:<repo>` so it never leaks across repos.
2646
+ /**
2647
+ * Build the GitHub Search-API URL for issues in one repo matching a free-text query.
2648
+ *
2649
+ * Restricted to `type:issue repo:<repo>` so a search never leaks across repos.
2650
+ *
2651
+ * @param repo - The `owner/repo` to search within.
2652
+ * @param query - Free-text query string.
2653
+ * @returns The encoded search URL.
2654
+ */
2371
2655
  export function buildSearchUrl(repo, query) {
2372
2656
  const q = `${query} repo:${repo} type:issue`;
2373
2657
  return `${githubApiBase()}/search/issues?q=${encodeURIComponent(q)}&per_page=100`;
2374
2658
  }
2375
- // Map GitHub search results to local pm-item hits. For every returned issue
2376
- // number we look up the pm item carrying the matching `gh:repo#N` provenance
2377
- // tag; only locally-present items become hits (the runtime would drop the rest
2378
- // anyway). Score is GitHub's relevance score, normalized into a sane range.
2659
+ /**
2660
+ * Map GitHub search result numbers to local pm-item hits.
2661
+ *
2662
+ * For every returned issue number, looks up the pm item carrying the matching
2663
+ * `gh:repo#N` provenance tag; only locally-present items become hits (the
2664
+ * runtime would drop the rest anyway). Score preserves GitHub's ranking,
2665
+ * normalized into `(0, 1]` so hits clear pm's default threshold.
2666
+ *
2667
+ * @param matchedNumbers - Issue numbers GitHub returned, best-first.
2668
+ * @param repo - The repo the search ran against.
2669
+ * @param itemsByProvenance - Local items keyed `owner/repo#N`.
2670
+ * @returns One hit per locally-present matching item.
2671
+ */
2379
2672
  export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
2380
2673
  const repoLc = repo.toLowerCase();
2381
2674
  const hits = [];
@@ -2399,8 +2692,15 @@ export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
2399
2692
  }
2400
2693
  return hits;
2401
2694
  }
2402
- // Resolve the search target repo: an explicit option wins, then the
2403
- // PM_GITHUB_REPO env var (so a workspace can pin its upstream).
2695
+ /**
2696
+ * Resolve the search target repo for the GitHub search provider.
2697
+ *
2698
+ * An explicit option wins, then the `PM_GITHUB_REPO` env var so a workspace can
2699
+ * pin its upstream. Returns `undefined` when neither yields an `owner/repo`.
2700
+ *
2701
+ * @param options - The raw option object from the search context.
2702
+ * @returns The resolved `owner/repo`, or `undefined`.
2703
+ */
2404
2704
  export function resolveSearchRepo(options) {
2405
2705
  const opt = optionString(options, "repo", "github-repo", "githubRepo");
2406
2706
  if (opt && opt.includes("/"))
@@ -2543,9 +2843,18 @@ const STATUS_FIELD_GQL = `
2543
2843
  statusField: field(name: "Status") {
2544
2844
  ... on ProjectV2SingleSelectField { id name options { id name } }
2545
2845
  }`;
2546
- // Resolve owner/number → project id + Status field. The owner may be a user or
2547
- // an org and GraphQL requires us to pick, so we ask both in one request and use
2548
- // whichever resolves.
2846
+ /**
2847
+ * Resolve a project reference into its GraphQL id and Status field.
2848
+ *
2849
+ * The owner may be a user or an org, and GraphQL requires us to pick which
2850
+ * owner-type field to query, so this asks both in one request and keeps
2851
+ * whichever connection resolves. Throws {@link CommandError} (NOT_FOUND) when
2852
+ * neither resolves (wrong owner, wrong number, or missing project scope).
2853
+ *
2854
+ * @param ref - The `owner/number` board reference.
2855
+ * @param token - GitHub token (the GraphQL call requires one).
2856
+ * @returns The board metadata including its Status field, if any.
2857
+ */
2549
2858
  async function resolveProject(ref, token) {
2550
2859
  const query = `
2551
2860
  query($owner:String!,$number:Int!){
@@ -2565,9 +2874,16 @@ async function resolveProject(ref, token) {
2565
2874
  const statusField = sf && sf.id ? { id: sf.id, name: sf.name, options: sf.options ?? [] } : undefined;
2566
2875
  return { id: node.id, title: node.title ?? "", url: node.url ?? "", ownerType, statusField };
2567
2876
  }
2568
- // Normalize a raw GraphQL project-item node into the ProjectItem model,
2569
- // tolerating null/undefined nodes and missing content (defensive against
2570
- // partial GraphQL errors / inaccessible items).
2877
+ /**
2878
+ * Normalize a raw GraphQL project-item node into the {@link ProjectItem} model.
2879
+ *
2880
+ * Tolerates null/undefined nodes and missing content, returning a placeholder
2881
+ * `Unknown` content for redacted or unmodeled item types so they are counted but
2882
+ * never mutated.
2883
+ *
2884
+ * @param n - The raw GraphQL node (may be null).
2885
+ * @returns The normalized project item.
2886
+ */
2571
2887
  function normalizeProjectItemNode(n) {
2572
2888
  const c = n?.content ?? { __typename: "Unknown" };
2573
2889
  const tn = c.__typename;
@@ -2609,7 +2925,17 @@ function normalizeProjectItemNode(n) {
2609
2925
  content,
2610
2926
  };
2611
2927
  }
2612
- // Fetch every item on the board (paginated, 100/page).
2928
+ /**
2929
+ * Fetch every item on a board, paging through the connection at 100/page.
2930
+ *
2931
+ * Walks the GraphQL cursor until `hasNextPage` is false, normalizing each node
2932
+ * via {@link normalizeProjectItemNode}. Stops cleanly when a page reports no
2933
+ * connection so the loop never truncates silently nor spins on a missing cursor.
2934
+ *
2935
+ * @param projectId - GraphQL node id of the board.
2936
+ * @param token - GitHub token.
2937
+ * @returns Every item on the board, in page order.
2938
+ */
2613
2939
  async function fetchProjectItems(projectId, token) {
2614
2940
  const query = `
2615
2941
  query($id:ID!,$cursor:String){
@@ -2649,7 +2975,15 @@ async function fetchProjectItems(projectId, token) {
2649
2975
  }
2650
2976
  return items;
2651
2977
  }
2652
- // Add a DraftIssue to a Projects v2 board and return the new project item id.
2978
+ /**
2979
+ * Add a draft issue to a Projects v2 board and return the new project-item id.
2980
+ *
2981
+ * @param projectId - GraphQL node id of the board.
2982
+ * @param title - Draft issue title.
2983
+ * @param body - Draft issue body (empty string when absent).
2984
+ * @param token - GitHub token.
2985
+ * @returns The newly created project-item id.
2986
+ */
2653
2987
  async function gqlAddDraft(projectId, title, body, token) {
2654
2988
  const q = `mutation($p:ID!,$t:String!,$b:String){ addProjectV2DraftIssue(input:{projectId:$p,title:$t,body:$b}){ projectItem{ id } } }`;
2655
2989
  const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body ?? "" });
@@ -2658,8 +2992,14 @@ async function gqlAddDraft(projectId, title, body, token) {
2658
2992
  throw new CommandError("addProjectV2DraftIssue returned no item id.");
2659
2993
  return id;
2660
2994
  }
2661
- // Link an existing repository issue/PR to a Projects v2 board and return the
2662
- // new project item id.
2995
+ /**
2996
+ * Link an existing repo issue/PR to a board and return the new project-item id.
2997
+ *
2998
+ * @param projectId - GraphQL node id of the board.
2999
+ * @param contentId - GraphQL node id of the issue/PR to attach.
3000
+ * @param token - GitHub token.
3001
+ * @returns The newly created project-item id.
3002
+ */
2663
3003
  async function gqlAddIssue(projectId, contentId, token) {
2664
3004
  const q = `mutation($p:ID!,$c:ID!){ addProjectV2ItemById(input:{projectId:$p,contentId:$c}){ item{ id } } }`;
2665
3005
  const d = await githubGraphQL(token, q, { p: projectId, c: contentId });
@@ -2673,8 +3013,14 @@ async function gqlSetStatus(projectId, itemId, fieldId, optionId, token) {
2673
3013
  const q = `mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){ updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f,value:{singleSelectOptionId:$o}}){ projectV2Item{ id } } }`;
2674
3014
  await githubGraphQL(token, q, { p: projectId, i: itemId, f: fieldId, o: optionId });
2675
3015
  }
2676
- // Resolve the GraphQL node id of a repo's issue or PR by number, returning
2677
- // undefined when the item is not found or the repo ref is malformed.
3016
+ /**
3017
+ * Resolve the GraphQL node id of a repo's issue or PR by number.
3018
+ *
3019
+ * @param repo - `owner/repo` to look up in.
3020
+ * @param number - The issue/PR number.
3021
+ * @param token - GitHub token.
3022
+ * @returns The node id, or `undefined` when not found or the repo ref is malformed.
3023
+ */
2678
3024
  async function gqlResolveIssueNodeId(repo, number, token) {
2679
3025
  const [owner, name] = repo.split("/");
2680
3026
  if (!owner || !name)
@@ -2683,8 +3029,6 @@ async function gqlResolveIssueNodeId(repo, number, token) {
2683
3029
  const d = await githubGraphQL(token, q, { o: owner, n: name, num: number });
2684
3030
  return d.repository?.issueOrPullRequest?.id ?? undefined;
2685
3031
  }
2686
- // Compose a human-readable + provenance-bearing description for an imported
2687
- // project item (parallels the issue-import description).
2688
3032
  /** Build a factual close reason for a project-import item entering `closed`. */
2689
3033
  function projectImportCloseReason(ref, c) {
2690
3034
  if (c.repo && typeof c.number === "number") {
@@ -2692,6 +3036,17 @@ function projectImportCloseReason(ref, c) {
2692
3036
  }
2693
3037
  return `GitHub project ${ref.owner}/${ref.number} item closed`;
2694
3038
  }
3039
+ /**
3040
+ * Compose a human-readable, provenance-bearing description for an imported
3041
+ * project item.
3042
+ *
3043
+ * Parallels the issue-import description: joins the board ref, item kind, issue
3044
+ * link, URL, and state into one summary string written to the pm item.
3045
+ *
3046
+ * @param ref - The board the item came from.
3047
+ * @param c - The item's resolved content.
3048
+ * @returns The composed description line.
3049
+ */
2695
3050
  function projectItemDescription(ref, c) {
2696
3051
  const parts = [`GH project item ${ref.owner}/${ref.number}`];
2697
3052
  if (c.typename === "DraftIssue")
@@ -2731,12 +3086,18 @@ export async function collectProjectsV2Pages(fetchPage) {
2731
3086
  }
2732
3087
  return out;
2733
3088
  }
2734
- // Resolve + paginate the projectsV2 connection for a user-or-org owner using an
2735
- // injected GraphQL transport. A login is either a user or an organization,
2736
- // never both; querying both in one request lets us resolve the owner type
2737
- // without a round-trip, then we keep paginating the connection that actually
2738
- // exists so owners with more than 50/100 projects are fully listed instead of
2739
- // silently truncated (Greptile 2006f478). Returns raw connection nodes.
3089
+ /**
3090
+ * Resolve and paginate the `projectsV2` connection for a user-or-org owner.
3091
+ *
3092
+ * A login is either a user or an organization, never both; querying both in one
3093
+ * request resolves the owner type without an extra round-trip, then this keeps
3094
+ * paginating the connection that actually exists so owners with more than 100
3095
+ * projects are fully listed instead of silently truncated.
3096
+ *
3097
+ * @param owner - The user or org login.
3098
+ * @param graphQL - Injectable transport (query + variables → data), for tests.
3099
+ * @returns Every project-summary node for the owner.
3100
+ */
2740
3101
  export async function listOwnerProjectsV2Nodes(owner, graphQL) {
2741
3102
  let ownerType = null;
2742
3103
  return collectProjectsV2Pages(async (cursor) => {
@@ -2755,6 +3116,11 @@ export async function listOwnerProjectsV2Nodes(owner, graphQL) {
2755
3116
  return ownerType === "organization" ? d.organization?.projectsV2 : d.user?.projectsV2;
2756
3117
  });
2757
3118
  }
3119
+ /**
3120
+ * Command handler for `pm github project list`: list a user/org's Projects v2.
3121
+ *
3122
+ * @param ctx - The command-handler context.
3123
+ */
2758
3124
  async function runProjectList(ctx) {
2759
3125
  const options = ctx.options || {};
2760
3126
  const owner = optionString(options, "owner") || ctx.args?.[0];
@@ -2784,6 +3150,14 @@ async function runProjectList(ctx) {
2784
3150
  return { owner, projects };
2785
3151
  }
2786
3152
  // --- project fields --------------------------------------------------------
3153
+ /**
3154
+ * Command handler for `pm github project fields`: show a board's fields.
3155
+ *
3156
+ * Resolves the board, then lists its fields and (for the Status single-select)
3157
+ * its option names so the caller can build a `--status-map`.
3158
+ *
3159
+ * @param ctx - The command-handler context.
3160
+ */
2787
3161
  async function runProjectFields(ctx) {
2788
3162
  const options = ctx.options || {};
2789
3163
  const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
@@ -2818,6 +3192,15 @@ async function runProjectFields(ctx) {
2818
3192
  return { project: meta, fields };
2819
3193
  }
2820
3194
  // --- project import --------------------------------------------------------
3195
+ /**
3196
+ * Command handler for `pm github project import`: import board items as pm items.
3197
+ *
3198
+ * Idempotent: items already linked (by project tag or wrapped issue) are updated,
3199
+ * the rest are created. Status is refreshed only from an explicit, resolvable
3200
+ * board mapping (never a guess), honoring the no-data-loss invariant.
3201
+ *
3202
+ * @param ctx - The command-handler context.
3203
+ */
2821
3204
  async function runProjectImport(ctx) {
2822
3205
  const options = ctx.options || {};
2823
3206
  const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
@@ -2938,8 +3321,21 @@ async function runProjectImport(ctx) {
2938
3321
  return { imported, updated, skipped, project: `${ref.owner}/${ref.number}`, planned: plan.length };
2939
3322
  }
2940
3323
  // --- project sync (bidirectional) ------------------------------------------
2941
- // Push a pm item's status onto its (existing) board item, tolerating a missing
2942
- // target option. Returns true on a real change.
3324
+ /**
3325
+ * Push one pm item's status onto its board item.
3326
+ *
3327
+ * Tolerates a missing target option (writes the item but skips the Status set).
3328
+ * Returns whether a real change was made and an optional error string so the
3329
+ * caller can continue the batch per-item.
3330
+ *
3331
+ * @param entry - The push-plan entry to execute.
3332
+ * @param meta - The board metadata (id, Status field).
3333
+ * @param ref - The board reference (for the provenance tag).
3334
+ * @param pmById - Local pm items keyed by id (for body lookup and tagging).
3335
+ * @param pmRoot - Workspace root for the `pm` tag write.
3336
+ * @param token - GitHub token for the GraphQL calls.
3337
+ * @returns Whether the item changed, plus an optional error message.
3338
+ */
2943
3339
  async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
2944
3340
  try {
2945
3341
  let itemId = entry.itemId;
@@ -3001,6 +3397,18 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
3001
3397
  // and fail for terminal→canceled transitions. Recording `--close-reason` on the
3002
3398
  // `pm update` keeps the distinction AND the lifecycle metadata, and works for
3003
3399
  // active→canceled and terminal→canceled alike.
3400
+ /**
3401
+ * Build the `pm` CLI argv that applies one pull (project → pm) status transition.
3402
+ *
3403
+ * Pure so the pm lifecycle contract is unit-testable without spawning `pm`:
3404
+ * `closed` goes through `pm close`; `canceled` keeps its distinct terminal state
3405
+ * via `pm update --status canceled` with `--close-reason`; everything else is a
3406
+ * plain `pm update --status`. See the long note above for the pm CLI contracts.
3407
+ *
3408
+ * @param entry - The pull-plan entry to render argv for.
3409
+ * @param pmRoot - Workspace root for the `pm` invocation.
3410
+ * @returns The argv array.
3411
+ */
3004
3412
  export function buildPullEntryArgs(entry, pmRoot) {
3005
3413
  const reason = `GitHub project status → ${entry.toStatus}`;
3006
3414
  if (entry.toStatus === "closed") {
@@ -3028,8 +3436,15 @@ function applyPullEntry(entry, pmRoot) {
3028
3436
  const res = pmRun(buildPullEntryArgs(entry, pmRoot));
3029
3437
  return res.ok ? { changed: true } : { changed: false, error: res.stderr };
3030
3438
  }
3031
- // Command handler for `pm github project sync`: preview (default) or apply the
3032
- // bidirectional Projects v2 sync plan, honoring --push/--pull/--apply/--ids.
3439
+ /**
3440
+ * Command handler for `pm github project sync`: preview or apply the
3441
+ * bidirectional Projects v2 sync plan.
3442
+ *
3443
+ * Honors `--push`/`--pull` (default previews both), `--apply`, `--ids`, and the
3444
+ * `--prefer` conflict winner when both directions touch the same linked item.
3445
+ *
3446
+ * @param ctx - The command-handler context.
3447
+ */
3033
3448
  async function runProjectSync(ctx) {
3034
3449
  const options = ctx.options || {};
3035
3450
  const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
@@ -3158,8 +3573,16 @@ async function runProjectSync(ctx) {
3158
3573
  }
3159
3574
  return result;
3160
3575
  }
3161
- // Report whether a github subcommand mutates GitHub (for the host CLI's
3162
- // "mutating command" guard), accounting for --dry-run / --apply overrides.
3576
+ /**
3577
+ * Report whether a github subcommand will mutate GitHub.
3578
+ *
3579
+ * Feeds the host CLI's "mutating command" guard, accounting for the `--dry-run`
3580
+ * and `--apply` overrides that turn an otherwise-mutating command into a preview.
3581
+ *
3582
+ * @param command - The subcommand string (e.g. `github sync`).
3583
+ * @param options - The raw option object from the command handler.
3584
+ * @returns True when the command will perform real GitHub writes.
3585
+ */
3163
3586
  export function isMutatingGithubCommand(command, options) {
3164
3587
  const cmd = (command || "").toLowerCase();
3165
3588
  const dryRun = optionEnabled(options, "dry-run", "dryRun");
@@ -3251,7 +3674,7 @@ const PROJECT_SYNC_FLAGS = [
3251
3674
  const defineExtension = (module) => module;
3252
3675
  export default defineExtension({
3253
3676
  name: "pm-github",
3254
- version: "2026.8.7",
3677
+ version: "2026.8.15",
3255
3678
  activate(api) {
3256
3679
  // -----------------------------------------------------------------------
3257
3680
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -3265,21 +3688,36 @@ export default defineExtension({
3265
3688
  { name: "github_updated_at", type: "string", optional: true },
3266
3689
  ]);
3267
3690
  // -----------------------------------------------------------------------
3268
- // preflight — safe, local guard for mutating github commands.
3269
- // Runs before pm core commands; it does NOT make network calls (that would
3270
- // be a surprise side effect on every command) and cannot hard-block (the
3271
- // runtime swallows preflight throws). It only surfaces a clear, early
3272
- // warning when a github mutation is requested without a resolvable token;
3273
- // the authoritative validation + non-zero exit lives in the handlers.
3691
+ // preflight — safe, local guard for mutating github commands. Scoped to
3692
+ // the command paths pm-github owns (the mutating github/gh-issues paths
3693
+ // isMutatingGithubCommand recognizes) so it cannot contend with another
3694
+ // package's preflight override; an unscoped (global) override collides
3695
+ // pairwise with every other installed package's override (pm health reports
3696
+ // extension_preflight_override_collision). It runs before those commands;
3697
+ // it does NOT make network calls (that would be a surprise side effect on
3698
+ // every command) and cannot hard-block (the runtime swallows preflight
3699
+ // throws). It only surfaces a clear, early warning when a github mutation
3700
+ // is requested without a resolvable token; the authoritative validation +
3701
+ // non-zero exit lives in the handlers.
3274
3702
  // -----------------------------------------------------------------------
3275
- api.registerPreflight((ctx) => {
3276
- if (isMutatingGithubCommand(ctx.command, ctx.options || {})) {
3277
- if (!resolveGitHubToken()) {
3278
- console.error("[pm-github preflight] this github command mutates remote state but no GitHub " +
3279
- "token is resolvable (GITHUB_TOKEN/GH_TOKEN or `gh auth login`). It will fail.");
3703
+ api.registerPreflight({
3704
+ commands: [
3705
+ "github sync",
3706
+ "github export",
3707
+ "github import",
3708
+ "gh-issues import",
3709
+ "github project import",
3710
+ "github project sync",
3711
+ ],
3712
+ run: (ctx) => {
3713
+ if (isMutatingGithubCommand(ctx.command, ctx.options || {})) {
3714
+ if (!resolveGitHubToken()) {
3715
+ console.error("[pm-github preflight] this github command mutates remote state but no GitHub " +
3716
+ "token is resolvable (GITHUB_TOKEN/GH_TOKEN or `gh auth login`). It will fail.");
3717
+ }
3280
3718
  }
3281
- }
3282
- return {};
3719
+ return {};
3720
+ },
3283
3721
  });
3284
3722
  // -----------------------------------------------------------------------
3285
3723
  // importer — `pm github import <owner/repo>` (idempotent native pipeline)