pm-github 2026.6.2 → 2026.6.3

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
@@ -1,11 +1,12 @@
1
- // pm-github — GitHub Issues importer/exporter for pm-cli
1
+ // pm-github — GitHub Issues sync (importer / exporter / sync) for pm-cli
2
2
  //
3
3
  // Capabilities (see manifest.json):
4
- // commands — `pm gh-issues import` (legacy, full-featured)
5
- // importers — `pm github import <owner/repo>` (native import pipeline)
6
- // exporters — `pm github export` (render pm items as a GitHub-issues payload)
7
- // schema — declares github_url / github_number / github_state item fields
8
- // hooks — afterCommand: opt-in sync hints for github-linked items
4
+ // commands — `pm gh-issues import` (legacy) + `pm github sync`
5
+ // importers — `pm github import <owner/repo>` (idempotent native import)
6
+ // exporters — `pm github export` (render pm items as a GitHub-issues payload)
7
+ // schema — declares github_url / github_number / github_state item fields
8
+ // hooks — afterCommand: actionable sync hint for github-linked items
9
+ // preflight — local guard for mutating github commands (token presence)
9
10
  import https from "node:https";
10
11
  import { spawnSync } from "node:child_process";
11
12
  const defineExtension = ((extension) => extension);
@@ -29,11 +30,14 @@ export function resolveGitHubToken() {
29
30
  }
30
31
  return undefined;
31
32
  }
32
- function request(method, url, token, payload) {
33
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
34
+ // One low-level request, no retry/backoff (that lives in `request`).
35
+ function requestOnce(method, url, token, payload) {
33
36
  return new Promise((resolve, reject) => {
34
37
  const headers = {
35
38
  "User-Agent": "pm-github",
36
39
  Accept: "application/vnd.github+json",
40
+ "X-GitHub-Api-Version": "2022-11-28",
37
41
  };
38
42
  if (token)
39
43
  headers.Authorization = `Bearer ${token}`;
@@ -44,30 +48,94 @@ function request(method, url, token, payload) {
44
48
  const req = https.request(url, { method, headers }, (res) => {
45
49
  const status = res.statusCode ?? 0;
46
50
  if (status >= 300 && status < 400 && res.headers.location) {
47
- request(method, res.headers.location, token, payload).then(resolve, reject);
51
+ requestOnce(method, res.headers.location, token, payload).then(resolve, reject);
48
52
  return;
49
53
  }
50
54
  const chunks = [];
51
55
  res.on("data", (c) => chunks.push(c));
52
56
  res.on("end", () => {
53
- const body = Buffer.concat(chunks).toString("utf-8");
54
- if (status < 200 || status >= 300) {
55
- reject(new Error(`GitHub API returned HTTP ${status}`));
56
- return;
57
- }
58
57
  resolve({
59
- body,
58
+ status,
59
+ body: Buffer.concat(chunks).toString("utf-8"),
60
+ headers: res.headers,
60
61
  linkHeader: typeof res.headers.link === "string" ? res.headers.link : undefined,
61
62
  });
62
63
  });
63
64
  });
64
65
  req.on("error", reject);
65
- req.setTimeout(30000, () => { req.destroy(); reject(new Error("timeout")); });
66
+ req.setTimeout(30000, () => {
67
+ req.destroy(new Error("request timed out after 30s"));
68
+ });
66
69
  if (payload)
67
70
  req.write(payload);
68
71
  req.end();
69
72
  });
70
73
  }
74
+ // How long (ms) to wait before retrying a rate-limited / transient response.
75
+ // Honors Retry-After (seconds) and the primary-rate-limit reset window
76
+ // (X-RateLimit-Remaining: 0 + X-RateLimit-Reset epoch), then falls back to
77
+ // exponential backoff. Capped so we never hang a CLI run indefinitely.
78
+ export function computeBackoffMs(headers, attempt, nowMs = Date.now()) {
79
+ const get = (k) => {
80
+ const v = headers[k] ?? headers[k.toLowerCase()];
81
+ return Array.isArray(v) ? v[0] : v;
82
+ };
83
+ const cap = 60_000;
84
+ const retryAfter = get("retry-after");
85
+ if (retryAfter) {
86
+ const secs = Number(retryAfter);
87
+ if (Number.isFinite(secs) && secs >= 0)
88
+ return Math.min(secs * 1000, cap);
89
+ }
90
+ const remaining = get("x-ratelimit-remaining");
91
+ const reset = get("x-ratelimit-reset");
92
+ if (remaining === "0" && reset) {
93
+ const resetMs = Number(reset) * 1000;
94
+ if (Number.isFinite(resetMs)) {
95
+ const wait = resetMs - nowMs;
96
+ if (wait > 0)
97
+ return Math.min(wait + 1000, cap);
98
+ }
99
+ }
100
+ // Exponential backoff: 1s, 2s, 4s … capped.
101
+ return Math.min(1000 * 2 ** attempt, cap);
102
+ }
103
+ function isRetryableStatus(status, headers) {
104
+ if (status === 429)
105
+ return true;
106
+ if (status >= 500)
107
+ return true;
108
+ // Secondary/primary rate limit surfaces as 403 with remaining=0.
109
+ if (status === 403) {
110
+ const v = headers["x-ratelimit-remaining"] ?? headers["X-RateLimit-Remaining"];
111
+ const remaining = Array.isArray(v) ? v[0] : v;
112
+ if (remaining === "0")
113
+ return true;
114
+ if (headers["retry-after"] ?? headers["Retry-After"])
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+ // Request with rate-limit/backoff handling. Retries on 429/5xx and GitHub
120
+ // rate-limit 403s, honoring Retry-After / X-RateLimit-Reset. Throws on a
121
+ // non-retryable error status so callers can map it to a semantic exit code.
122
+ async function request(method, url, token, payload, maxRetries = 4) {
123
+ let attempt = 0;
124
+ for (;;) {
125
+ const res = await requestOnce(method, url, token, payload);
126
+ if (res.status >= 200 && res.status < 300)
127
+ return res;
128
+ if (attempt < maxRetries && isRetryableStatus(res.status, res.headers)) {
129
+ const wait = computeBackoffMs(res.headers, attempt);
130
+ console.error(`GitHub returned HTTP ${res.status}; retrying in ${Math.round(wait / 1000)}s ` +
131
+ `(attempt ${attempt + 1}/${maxRetries})…`);
132
+ await sleep(wait);
133
+ attempt++;
134
+ continue;
135
+ }
136
+ throw new Error(`GitHub API returned HTTP ${res.status}`);
137
+ }
138
+ }
71
139
  function fetchJSON(url, token) {
72
140
  return request("GET", url, token);
73
141
  }
@@ -124,6 +192,53 @@ export class CommandError extends Error {
124
192
  }
125
193
  }
126
194
  // ---------------------------------------------------------------------------
195
+ // Provenance — link a pm item back to a specific GitHub issue
196
+ // ---------------------------------------------------------------------------
197
+ // Provenance lives in a machine-parseable tag (`gh:owner/repo#123`) AND in the
198
+ // declared schema fields/description. The tag is the idempotency key: it
199
+ // round-trips losslessly through `pm create --tags` / `pm list --json`, so a
200
+ // re-import can find the existing item and UPDATE it instead of duplicating.
201
+ export function provenanceTag(repo, issueNumber) {
202
+ return `gh:${repo.toLowerCase()}#${issueNumber}`;
203
+ }
204
+ export function parseProvenanceTag(tag) {
205
+ const m = /^gh:([^#\s]+)#(\d+)$/.exec(tag.trim());
206
+ if (!m)
207
+ return undefined;
208
+ return { repo: m[1].toLowerCase(), number: Number(m[2]) };
209
+ }
210
+ function readPmItems(pmRoot) {
211
+ // `list-all` (NOT `list`) so CLOSED items are included: `pm list` returns only
212
+ // active items, which would make the idempotency index miss every closed
213
+ // issue and re-create it as a DUPLICATE on re-import. `--full --include-body`
214
+ // so tags and body survive the read instead of the brief projection.
215
+ const result = spawnSync("pm", ["--path", pmRoot, "--json", "list-all", "--full", "--include-body", "--limit", "10000"], { encoding: "utf-8" });
216
+ if (result.status !== 0) {
217
+ throw new CommandError(result.stderr || "pm list-all failed");
218
+ }
219
+ try {
220
+ const parsed = JSON.parse(result.stdout);
221
+ const items = Array.isArray(parsed) ? parsed : parsed.items ?? parsed.results ?? [];
222
+ return items;
223
+ }
224
+ catch {
225
+ throw new CommandError("Could not parse `pm list-all --json` output.");
226
+ }
227
+ }
228
+ // Index existing pm items by their GitHub provenance tag for O(1) idempotent
229
+ // matching on re-import.
230
+ export function indexByProvenance(items) {
231
+ const index = new Map();
232
+ for (const item of items) {
233
+ for (const tag of item.tags ?? []) {
234
+ const p = parseProvenanceTag(tag);
235
+ if (p && item.id)
236
+ index.set(`${p.repo}#${p.number}`, item);
237
+ }
238
+ }
239
+ return index;
240
+ }
241
+ // ---------------------------------------------------------------------------
127
242
  // Shared import core (used by both the legacy command and the importer)
128
243
  // ---------------------------------------------------------------------------
129
244
  // Build the GitHub issues list URL. `since`, `assignee`, `labels` and `state`
@@ -159,6 +274,43 @@ async function fetchAllIssues(repo, opts, token) {
159
274
  }
160
275
  return issues;
161
276
  }
277
+ async function fetchComments(issue, repo, token) {
278
+ if (!issue.comments || issue.comments <= 0)
279
+ return [];
280
+ const comments = [];
281
+ let nextUrl = issue.comments_url ||
282
+ `https://api.github.com/repos/${repo}/issues/${issue.number}/comments?per_page=100`;
283
+ while (nextUrl) {
284
+ const { body, linkHeader } = await fetchJSON(nextUrl, token);
285
+ let page;
286
+ try {
287
+ page = JSON.parse(body);
288
+ }
289
+ catch {
290
+ break;
291
+ }
292
+ if (!Array.isArray(page))
293
+ break;
294
+ comments.push(...page);
295
+ nextUrl = parseNextLink(linkHeader);
296
+ }
297
+ return comments;
298
+ }
299
+ // Compose the pm item body for an issue, optionally appending its comments.
300
+ export function composeBody(issue, comments) {
301
+ let body = issue.body || "";
302
+ if (comments.length > 0) {
303
+ const rendered = comments
304
+ .map((c) => {
305
+ const who = c.user?.login ?? "unknown";
306
+ const when = c.created_at ? ` (${c.created_at})` : "";
307
+ return `> **@${who}**${when}\n>\n${(c.body || "").split("\n").map((l) => `> ${l}`).join("\n")}`;
308
+ })
309
+ .join("\n\n");
310
+ body = `${body}\n\n---\n\n### GitHub comments (${comments.length})\n\n${rendered}`.trim();
311
+ }
312
+ return body;
313
+ }
162
314
  // Apply the client-side filters (PRs, milestone-by-title).
163
315
  export function applyClientFilters(issues, opts) {
164
316
  let result = issues;
@@ -185,23 +337,30 @@ export function parseImportOptions(options) {
185
337
  assignee: optionString(options, "assignee"),
186
338
  milestone: optionString(options, "milestone"),
187
339
  includePrs: optionEnabled(options, "include-prs", "includePrs"),
340
+ withComments: optionEnabled(options, "with-comments", "withComments"),
188
341
  itemType: optionString(options, "type") || "Issue",
189
342
  dryRun: optionEnabled(options, "dry-run", "dryRun"),
190
343
  };
191
344
  }
192
- // Run the full import flow. Returns a structured result; throws CommandError
193
- // (with a semantic exitCode) on failure so the CLI exits non-zero exactly once.
345
+ function pmRun(args) {
346
+ const result = spawnSync("pm", args, { encoding: "utf-8" });
347
+ return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" };
348
+ }
349
+ // Run the full import flow. Idempotent: items already linked (provenance tag)
350
+ // to a fetched issue are UPDATEd; new issues are created. Returns a structured
351
+ // result; throws CommandError (with a semantic exitCode) on failure.
194
352
  async function runImport(repoArg, pmRoot, opts) {
195
353
  if (!repoArg || !repoArg.includes("/")) {
196
354
  throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
197
355
  "[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
198
- "[--milestone <name>] [--include-prs]", EXIT_CODE.USAGE);
356
+ "[--milestone <name>] [--include-prs] [--with-comments]", EXIT_CODE.USAGE);
199
357
  }
358
+ const repo = repoArg;
200
359
  const token = resolveGitHubToken();
201
- console.error(`Fetching issues from ${repoArg}…${token ? "" : " (unauthenticated — 60 req/hr)"}`);
360
+ console.error(`Fetching issues from ${repo}…${token ? "" : " (unauthenticated — 60 req/hr)"}`);
202
361
  let fetched;
203
362
  try {
204
- fetched = await fetchAllIssues(repoArg, opts, token);
363
+ fetched = await fetchAllIssues(repo, opts, token);
205
364
  }
206
365
  catch (err) {
207
366
  const msg = err instanceof Error ? err.message : String(err);
@@ -209,15 +368,18 @@ async function runImport(repoArg, pmRoot, opts) {
209
368
  ? " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit (60→5000/hr) and reach private repos"
210
369
  : "";
211
370
  const exitCode = /HTTP 404/.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
212
- throw new CommandError(`Failed to fetch issues from ${repoArg}: ${msg}${hint}`, exitCode);
371
+ throw new CommandError(`Failed to fetch issues from ${repo}: ${msg}${hint}`, exitCode);
213
372
  }
214
373
  const filtered = applyClientFilters(fetched, opts);
215
374
  if (filtered.length === 0) {
216
375
  console.error("No issues found.");
217
- return { imported: 0, skipped: 0 };
376
+ return { imported: 0, updated: 0, skipped: 0 };
218
377
  }
219
378
  console.error(`Found ${filtered.length} issue(s).`);
379
+ // Build the idempotency index once up-front.
380
+ const existing = opts.dryRun ? new Map() : indexByProvenance(readPmItems(pmRoot));
220
381
  let imported = 0;
382
+ let updated = 0;
221
383
  let skipped = 0;
222
384
  for (const issue of filtered) {
223
385
  const title = issue.title.trim();
@@ -226,81 +388,390 @@ async function runImport(repoArg, pmRoot, opts) {
226
388
  continue;
227
389
  }
228
390
  const kind = issue.pull_request ? "PR" : "issue";
229
- const tags = issue.labels.map((l) => l.name);
391
+ const labels = issue.labels.map((l) => l.name).filter(Boolean);
392
+ const tag = provenanceTag(repo, issue.number);
393
+ const tags = [...labels, tag];
230
394
  const status = mapState(issue.state);
231
- const body = issue.body || "";
232
- // Persist GitHub provenance in the description so it survives round-trips
233
- // and powers `pm github export` + the afterCommand sync hint.
234
- const description = `GH ${kind} #${issue.number}: ${issue.html_url}`;
235
395
  const assignee = issue.assignee?.login;
236
396
  const milestone = issue.milestone?.title;
397
+ const key = `${repo.toLowerCase()}#${issue.number}`;
398
+ const match = existing.get(key);
399
+ // GitHub provenance lives in the description (human-readable) and the
400
+ // declared schema fields (github_url/github_number/github_state).
401
+ const description = `GH ${kind} #${issue.number}: ${issue.html_url}`;
402
+ let comments = [];
403
+ if (opts.withComments) {
404
+ try {
405
+ comments = await fetchComments(issue, repo, token);
406
+ }
407
+ catch (err) {
408
+ const msg = err instanceof Error ? err.message : String(err);
409
+ console.error(`#${issue.number}: failed to fetch comments — ${msg}`);
410
+ }
411
+ }
412
+ const body = composeBody(issue, comments);
237
413
  if (opts.dryRun) {
238
- console.error(` [dry-run] #${issue.number} ${title} (${status}, ${tags.join(",")})`);
414
+ console.error(` [dry-run] #${issue.number} ${title} (${status}, ${labels.join(",")})`);
239
415
  imported++;
240
416
  continue;
241
417
  }
242
- try {
243
- const spawnArgs = [
244
- "--path", pmRoot,
245
- "create",
418
+ if (match?.id) {
419
+ // Idempotent update — never duplicate. Status transitions go through the
420
+ // proper command (close requires a reason; reopen via update --status).
421
+ const updArgs = [
422
+ "--path", pmRoot, "update", match.id,
246
423
  "--title", title,
247
- "--type", opts.itemType,
248
- "--status", status,
249
424
  "--description", description,
250
425
  "--body", body,
251
426
  "--tags", tags.join(","),
252
- "--message", `Imported from GitHub #${issue.number}`,
427
+ "--message", `Re-imported from GitHub #${issue.number}`,
253
428
  ];
254
429
  if (assignee)
255
- spawnArgs.push("--assignee", assignee);
430
+ updArgs.push("--assignee", assignee);
256
431
  if (milestone)
257
- spawnArgs.push("--sprint", milestone);
258
- const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
259
- if (result.status !== 0) {
260
- throw new Error(result.stderr || "pm create failed");
432
+ updArgs.push("--sprint", milestone);
433
+ const upd = pmRun(updArgs);
434
+ if (!upd.ok) {
435
+ console.error(`#${issue.number}: update failed — ${upd.stderr}`);
436
+ skipped++;
437
+ continue;
261
438
  }
262
- imported++;
439
+ // Reconcile status separately.
440
+ if (status === "closed" && match.status !== "closed") {
441
+ const close = pmRun(["--path", pmRoot, "close", match.id, "--reason", `GitHub issue #${issue.number} closed`]);
442
+ if (!close.ok) {
443
+ console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
444
+ skipped++;
445
+ continue;
446
+ }
447
+ }
448
+ else if (status === "open" && match.status === "closed") {
449
+ const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
450
+ if (!reopen.ok) {
451
+ console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
452
+ skipped++;
453
+ continue;
454
+ }
455
+ }
456
+ updated++;
457
+ continue;
263
458
  }
264
- catch (err) {
265
- const msg = err instanceof Error ? err.message : String(err);
266
- console.error(`#${issue.number}: create failed — ${msg}`);
459
+ const createArgs = [
460
+ "--path", pmRoot, "create",
461
+ "--title", title,
462
+ "--type", opts.itemType,
463
+ "--status", status,
464
+ "--description", description,
465
+ "--body", body,
466
+ "--tags", tags.join(","),
467
+ "--message", `Imported from GitHub #${issue.number}`,
468
+ ];
469
+ if (assignee)
470
+ createArgs.push("--assignee", assignee);
471
+ if (milestone)
472
+ createArgs.push("--sprint", milestone);
473
+ const created = pmRun(createArgs);
474
+ if (!created.ok) {
475
+ console.error(`#${issue.number}: create failed — ${created.stderr}`);
267
476
  skipped++;
477
+ continue;
268
478
  }
479
+ imported++;
269
480
  }
270
481
  if (opts.dryRun) {
271
482
  console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`);
272
483
  return { dryRun: true, wouldImport: imported, wouldSkip: skipped };
273
484
  }
274
- console.error(`Imported ${imported} issue(s), skipped ${skipped}.`);
275
- if (imported === 0 && skipped > 0) {
485
+ console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
486
+ if (imported === 0 && updated === 0 && skipped > 0) {
276
487
  throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
277
488
  }
278
- return { imported, skipped };
489
+ return { imported, updated, skipped };
279
490
  }
280
- function readPmItems(pmRoot) {
281
- // `--full --include-body` so tags and body survive the export instead of the
282
- // brief projection (which omits them).
283
- const result = spawnSync("pm", ["--path", pmRoot, "--json", "list", "--full", "--include-body", "--limit", "10000"], { encoding: "utf-8" });
284
- if (result.status !== 0) {
285
- throw new CommandError(result.stderr || "pm list failed");
491
+ // ---------------------------------------------------------------------------
492
+ // Sync core — push pm status changes back to GitHub (close / reopen)
493
+ // ---------------------------------------------------------------------------
494
+ function pmStatusToGithubState(status) {
495
+ return status === "closed" || status === "canceled" ? "closed" : "open";
496
+ }
497
+ export function planSync(items, repo) {
498
+ const plan = [];
499
+ const repoLc = repo.toLowerCase();
500
+ for (const item of items) {
501
+ if (!item.id)
502
+ continue;
503
+ for (const tag of item.tags ?? []) {
504
+ const p = parseProvenanceTag(tag);
505
+ if (!p || p.repo !== repoLc)
506
+ continue;
507
+ const desired = pmStatusToGithubState(item.status);
508
+ plan.push({
509
+ id: item.id,
510
+ number: p.number,
511
+ title: item.title ?? "(untitled)",
512
+ // `from` is unknown without a fetch; the planner records desired state
513
+ // and the executor only PATCHes when GitHub disagrees.
514
+ from: desired === "open" ? "closed" : "open",
515
+ to: desired,
516
+ });
517
+ }
286
518
  }
287
- try {
288
- const parsed = JSON.parse(result.stdout);
289
- const items = Array.isArray(parsed) ? parsed : parsed.items ?? parsed.results ?? [];
290
- return items;
519
+ return plan;
520
+ }
521
+ async function runSync(ctx) {
522
+ const options = ctx.options || {};
523
+ const repo = optionString(options, "repo") || ctx.args?.[0];
524
+ const dryRun = optionEnabled(options, "dry-run", "dryRun");
525
+ if (!repo || !repo.includes("/")) {
526
+ throw new CommandError("Usage: pm github sync --repo <owner/repo> [--dry-run] " +
527
+ "(pushes pm item status to the linked GitHub issue: close/reopen)", EXIT_CODE.USAGE);
291
528
  }
292
- catch {
293
- throw new CommandError("Could not parse `pm list --json` output.");
529
+ const token = resolveGitHubToken();
530
+ if (!token && !dryRun) {
531
+ throw new CommandError("pm github sync needs a GitHub token to mutate issues " +
532
+ "(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
533
+ }
534
+ const items = readPmItems(ctx.pm_root);
535
+ const plan = planSync(items, repo);
536
+ if (plan.length === 0) {
537
+ console.error(`No pm items linked to ${repo} (no \`gh:${repo.toLowerCase()}#N\` provenance tags).`);
538
+ return { synced: 0, skipped: 0, planned: 0 };
539
+ }
540
+ let synced = 0;
541
+ let skipped = 0;
542
+ for (const entry of plan) {
543
+ // Fetch current state so we only PATCH on a genuine divergence.
544
+ let current;
545
+ try {
546
+ const { body } = await fetchJSON(`https://api.github.com/repos/${repo}/issues/${entry.number}`, token);
547
+ current = JSON.parse(body);
548
+ }
549
+ catch (err) {
550
+ const msg = err instanceof Error ? err.message : String(err);
551
+ console.error(`#${entry.number}: could not read upstream state — ${msg}`);
552
+ skipped++;
553
+ continue;
554
+ }
555
+ if (current.state === entry.to) {
556
+ skipped++;
557
+ continue;
558
+ }
559
+ if (dryRun) {
560
+ console.error(` [dry-run] #${entry.number} "${entry.title}": ${current.state} → ${entry.to}`);
561
+ synced++;
562
+ continue;
563
+ }
564
+ try {
565
+ await request("PATCH", `https://api.github.com/repos/${repo}/issues/${entry.number}`, token, JSON.stringify({ state: entry.to }));
566
+ console.error(`#${entry.number} "${entry.title}": ${current.state} → ${entry.to}`);
567
+ synced++;
568
+ }
569
+ catch (err) {
570
+ const msg = err instanceof Error ? err.message : String(err);
571
+ console.error(`#${entry.number}: PATCH failed — ${msg}`);
572
+ skipped++;
573
+ }
574
+ }
575
+ if (dryRun) {
576
+ console.error(`[dry-run] Would update ${synced} issue(s) on ${repo}; ${skipped} already in sync/failed.`);
577
+ return { dryRun: true, wouldSync: synced, skipped, planned: plan.length };
294
578
  }
579
+ console.error(`Synced ${synced} issue(s) on ${repo}; skipped ${skipped}.`);
580
+ return { synced, skipped, planned: plan.length };
295
581
  }
296
582
  function itemToGithubPayload(item) {
297
583
  return {
298
584
  title: item.title ?? "(untitled)",
299
585
  body: item.body || item.description || "",
300
- labels: item.tags ?? [],
586
+ // Drop our internal provenance tags from exported labels.
587
+ labels: (item.tags ?? []).filter((t) => !parseProvenanceTag(t)),
301
588
  state: item.status === "closed" || item.status === "canceled" ? "closed" : "open",
302
589
  };
303
590
  }
591
+ // Build the create/update plan. `repo` (lowercased) decides which provenance
592
+ // tags count as an "already exported to THIS repo" link → update; everything
593
+ // else is a create. Pure + side-effect free so it can be unit-tested and
594
+ // printed verbatim in --dry-run.
595
+ export function buildExportPlan(items, repo) {
596
+ const repoLc = repo?.toLowerCase();
597
+ const plan = [];
598
+ for (const item of items) {
599
+ const payload = itemToGithubPayload(item);
600
+ let number;
601
+ if (repoLc) {
602
+ for (const tag of item.tags ?? []) {
603
+ const p = parseProvenanceTag(tag);
604
+ if (p && p.repo === repoLc) {
605
+ number = p.number;
606
+ break;
607
+ }
608
+ }
609
+ }
610
+ plan.push({
611
+ id: item.id,
612
+ action: number === undefined ? "create" : "update",
613
+ ...(number === undefined ? {} : { number }),
614
+ payload,
615
+ });
616
+ }
617
+ return plan;
618
+ }
619
+ // Export is SAFE BY DEFAULT: it only performs real GitHub writes when the user
620
+ // explicitly opts in (--apply / --no-dry-run, or the legacy --push alias) AND
621
+ // has not also passed --dry-run (which always wins). Anything else is a
622
+ // preview that prints the plan without touching GitHub.
623
+ export function exportWillApply(options) {
624
+ if (optionEnabled(options, "dry-run", "dryRun"))
625
+ return false;
626
+ if (optionEnabled(options, "no-dry-run", "noDryRun"))
627
+ return true;
628
+ return optionEnabled(options, "apply", "push");
629
+ }
630
+ // ---------------------------------------------------------------------------
631
+ // Search provider — reach GitHub from `pm search` for imported items
632
+ // ---------------------------------------------------------------------------
633
+ //
634
+ // The pm search runtime maps a provider's hits back to LOCAL item documents by
635
+ // id and DROPS any hit whose id is not a local item (see
636
+ // normalizeExtensionProviderHits in @unbrained/pm-cli). So this provider cannot
637
+ // surface arbitrary remote issues; instead it asks GitHub which issues in the
638
+ // repo match the query, then returns hits for the pm items that are already
639
+ // imported from those issues (matched by the `gh:repo#N` provenance tag).
640
+ // Semantics: "find my imported pm items whose upstream GitHub issue matches Q".
641
+ // Build the GitHub Search-API URL for issues in a repo matching the free-text
642
+ // query. Restricted to `type:issue repo:<repo>` so it never leaks across repos.
643
+ export function buildSearchUrl(repo, query) {
644
+ const q = `${query} repo:${repo} type:issue`;
645
+ return `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&per_page=100`;
646
+ }
647
+ // Map GitHub search results to local pm-item hits. For every returned issue
648
+ // number we look up the pm item carrying the matching `gh:repo#N` provenance
649
+ // tag; only locally-present items become hits (the runtime would drop the rest
650
+ // anyway). Score is GitHub's relevance score, normalized into a sane range.
651
+ export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
652
+ const repoLc = repo.toLowerCase();
653
+ const hits = [];
654
+ const seen = new Set();
655
+ let rank = matchedNumbers.length;
656
+ for (const number of matchedNumbers) {
657
+ const item = itemsByProvenance.get(`${repoLc}#${number}`);
658
+ if (!item?.id || seen.has(item.id)) {
659
+ rank--;
660
+ continue;
661
+ }
662
+ seen.add(item.id);
663
+ // Preserve GitHub's ranking: earlier results score higher. Normalize to
664
+ // (0, 1] so hits clear pm's default score threshold.
665
+ hits.push({
666
+ id: item.id,
667
+ score: matchedNumbers.length > 0 ? rank / matchedNumbers.length : 1,
668
+ matched_fields: [`github:${repoLc}#${number}`],
669
+ });
670
+ rank--;
671
+ }
672
+ return hits;
673
+ }
674
+ // Resolve the search target repo: an explicit option wins, then the
675
+ // PM_GITHUB_REPO env var (so a workspace can pin its upstream).
676
+ export function resolveSearchRepo(options) {
677
+ const opt = optionString(options, "repo", "github-repo", "githubRepo");
678
+ if (opt && opt.includes("/"))
679
+ return opt;
680
+ const env = process.env.PM_GITHUB_REPO;
681
+ if (env && env.includes("/"))
682
+ return env.trim();
683
+ return undefined;
684
+ }
685
+ function detectGhCli() {
686
+ try {
687
+ const r = spawnSync("gh", ["--version"], { encoding: "utf-8" });
688
+ return r.status === 0;
689
+ }
690
+ catch {
691
+ return false;
692
+ }
693
+ }
694
+ function detectTokenSource() {
695
+ if ((process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim())
696
+ return "env";
697
+ const token = resolveGitHubToken();
698
+ return token ? "gh" : "none";
699
+ }
700
+ async function runValidate(ctx) {
701
+ const options = ctx.options || {};
702
+ const repo = optionString(options, "repo") || ctx.args?.[0];
703
+ const gh_cli = detectGhCli();
704
+ const token_source = detectTokenSource();
705
+ const token = resolveGitHubToken();
706
+ const report = {
707
+ ok: true,
708
+ gh_cli,
709
+ token: Boolean(token),
710
+ token_source,
711
+ messages: [],
712
+ };
713
+ if (!token) {
714
+ report.messages.push("No GitHub token resolvable (GITHUB_TOKEN/GH_TOKEN or `gh auth login`); " +
715
+ "reads are capped at 60 req/hr and private repos are unreachable.");
716
+ }
717
+ else {
718
+ report.messages.push(`GitHub token resolved via ${token_source === "env" ? "environment" : "gh CLI"}.`);
719
+ }
720
+ if (!gh_cli)
721
+ report.messages.push("`gh` CLI not found on PATH (optional; only used to borrow a token).");
722
+ if (repo) {
723
+ if (!repo.includes("/")) {
724
+ report.ok = false;
725
+ report.messages.push(`Invalid --repo "${repo}" (expected owner/repo).`);
726
+ }
727
+ else {
728
+ report.repo = repo;
729
+ try {
730
+ const res = await fetchJSON(`https://api.github.com/repos/${repo}`, token);
731
+ report.repo_accessible = res.status >= 200 && res.status < 300;
732
+ report.repo_status = res.status;
733
+ const rem = res.headers["x-ratelimit-remaining"] ?? res.headers["X-RateLimit-Remaining"];
734
+ const remStr = Array.isArray(rem) ? rem[0] : rem;
735
+ if (remStr !== undefined)
736
+ report.rate_limit_remaining = Number(remStr);
737
+ if (report.repo_accessible) {
738
+ report.messages.push(`Repo ${repo} is accessible (HTTP ${res.status}).`);
739
+ }
740
+ else {
741
+ report.ok = false;
742
+ report.messages.push(`Repo ${repo} returned HTTP ${res.status}.`);
743
+ }
744
+ }
745
+ catch (err) {
746
+ report.ok = false;
747
+ report.repo_accessible = false;
748
+ report.messages.push(`Repo ${repo} check failed: ${err instanceof Error ? err.message : String(err)}`);
749
+ }
750
+ }
751
+ }
752
+ else {
753
+ report.messages.push("No --repo given; skipped repo accessibility check.");
754
+ }
755
+ return report;
756
+ }
757
+ // ---------------------------------------------------------------------------
758
+ // Preflight — local guard for mutating github commands (no network in-hook)
759
+ // ---------------------------------------------------------------------------
760
+ // Returns true if the command/args describe a github operation that will MUTATE
761
+ // state (a write import, an export --push, or a non-dry-run sync).
762
+ export function isMutatingGithubCommand(command, options) {
763
+ const cmd = (command || "").toLowerCase();
764
+ const dryRun = optionEnabled(options, "dry-run", "dryRun");
765
+ if (cmd === "github sync")
766
+ return !dryRun;
767
+ // Export is dry-run by default; it only mutates with an explicit apply
768
+ // (--apply / --no-dry-run / legacy --push) AND no --dry-run override.
769
+ if (cmd === "github export")
770
+ return exportWillApply(options);
771
+ if (cmd === "github import" || cmd === "gh-issues import")
772
+ return !dryRun;
773
+ return false;
774
+ }
304
775
  // ---------------------------------------------------------------------------
305
776
  // Extension
306
777
  // ---------------------------------------------------------------------------
@@ -312,12 +783,20 @@ const IMPORT_FLAGS = [
312
783
  { long: "--assignee", value_name: "login", description: "Filter by assignee login" },
313
784
  { long: "--milestone", value_name: "name", description: "Filter by milestone title" },
314
785
  { long: "--include-prs", description: "Include pull requests (default: skip PRs)" },
786
+ { long: "--with-comments", description: "Fetch issue comments and append them to the item body" },
315
787
  { long: "--dry-run", description: "Preview without writing" },
316
788
  { long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
317
789
  ];
790
+ const SYNC_FLAGS = [
791
+ { long: "--repo", value_name: "owner/repo", description: "Target GitHub repo (required)" },
792
+ { long: "--dry-run", description: "Preview the close/reopen plan without mutating GitHub" },
793
+ ];
794
+ const VALIDATE_FLAGS = [
795
+ { long: "--repo", value_name: "owner/repo", description: "Also check this repo is accessible with the resolved token" },
796
+ ];
318
797
  export default defineExtension({
319
798
  name: "pm-github",
320
- version: "2026.6.2",
799
+ version: "2026.6.3",
321
800
  activate(api) {
322
801
  // -----------------------------------------------------------------------
323
802
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -328,86 +807,260 @@ export default defineExtension({
328
807
  { name: "github_state", type: "string", optional: true },
329
808
  ]);
330
809
  // -----------------------------------------------------------------------
331
- // importer — `pm github import <owner/repo>` (native import pipeline)
810
+ // preflight — safe, local guard for mutating github commands.
811
+ // Runs before pm core commands; it does NOT make network calls (that would
812
+ // be a surprise side effect on every command) and cannot hard-block (the
813
+ // runtime swallows preflight throws). It only surfaces a clear, early
814
+ // warning when a github mutation is requested without a resolvable token;
815
+ // the authoritative validation + non-zero exit lives in the handlers.
816
+ // -----------------------------------------------------------------------
817
+ api.registerPreflight((ctx) => {
818
+ if (isMutatingGithubCommand(ctx.command, ctx.options || {})) {
819
+ if (!resolveGitHubToken()) {
820
+ console.error("[pm-github preflight] this github command mutates remote state but no GitHub " +
821
+ "token is resolvable (GITHUB_TOKEN/GH_TOKEN or `gh auth login`). It will fail.");
822
+ }
823
+ }
824
+ return {};
825
+ });
826
+ // -----------------------------------------------------------------------
827
+ // importer — `pm github import <owner/repo>` (idempotent native pipeline)
332
828
  // -----------------------------------------------------------------------
333
829
  api.registerImporter("github", async (ctx) => {
334
830
  return runImport(ctx.args?.[0], ctx.pm_root, parseImportOptions(ctx.options || {}));
335
831
  });
336
832
  // -----------------------------------------------------------------------
337
- // exporter — `pm github export` (render pm items as a GitHub-issues payload)
338
- // Default: print JSON payload (or markdown with --format md). With --push
339
- // AND a token AND --repo <owner/repo>, create the issues on GitHub.
833
+ // exporter — `pm github export` (pm items → GitHub issues)
834
+ // SAFE BY DEFAULT: previews the create/update plan and writes NOTHING.
835
+ // Real writes happen only with --apply (or --no-dry-run / legacy --push)
836
+ // AND a token AND --repo <owner/repo>. With --repo, items already linked to
837
+ // an issue in that repo (via the `gh:repo#N` provenance tag) are UPDATEd
838
+ // (upsert) rather than duplicated. --json returns the plan object; we never
839
+ // write our own stdout in JSON mode (pm renders the return value).
340
840
  // -----------------------------------------------------------------------
341
841
  api.registerExporter("github", async (ctx) => {
342
842
  const options = ctx.options || {};
843
+ const jsonMode = ctx.global?.json === true;
343
844
  const format = optionString(options, "format") || "json";
344
- const items = readPmItems(ctx.pm_root);
345
- const payloads = items.map(itemToGithubPayload);
346
- const push = optionEnabled(options, "push");
347
845
  const repo = optionString(options, "repo") || ctx.args?.[0];
348
- if (push) {
349
- const token = resolveGitHubToken();
350
- if (!token) {
351
- throw new CommandError("--push requires a GitHub token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
846
+ const apply = exportWillApply(options);
847
+ const items = readPmItems(ctx.pm_root);
848
+ const plan = buildExportPlan(items, repo);
849
+ const creates = plan.filter((e) => e.action === "create").length;
850
+ const updates = plan.filter((e) => e.action === "update").length;
851
+ if (!apply) {
852
+ // Dry-run (default). Emit the plan; in JSON mode return it silently.
853
+ if (!jsonMode) {
854
+ if (format === "md" || format === "markdown") {
855
+ const md = plan
856
+ .map((e) => {
857
+ const head = e.action === "update" ? `## [update #${e.number}] ${e.payload.title}` : `## [create] ${e.payload.title}`;
858
+ return `${head}\n\n${e.payload.body}\n\n_labels: ${e.payload.labels.join(", ")} · state: ${e.payload.state}_\n`;
859
+ })
860
+ .join("\n");
861
+ console.log(md);
862
+ }
863
+ else {
864
+ console.log(JSON.stringify(plan, null, 2));
865
+ }
866
+ console.error(`[dry-run] Would create ${creates} and update ${updates} issue(s)` +
867
+ `${repo ? ` on ${repo}` : " (no --repo: all treated as create)"}. ` +
868
+ "Re-run with --apply --repo <owner/repo> to write to GitHub.");
352
869
  }
353
- if (!repo || !repo.includes("/")) {
354
- throw new CommandError("--push requires --repo <owner/repo>.", EXIT_CODE.USAGE);
870
+ return { dry_run: true, plan, would_create: creates, would_update: updates, repo };
871
+ }
872
+ // --apply path: real writes. Require token + repo.
873
+ const token = resolveGitHubToken();
874
+ if (!token) {
875
+ throw new CommandError("--apply requires a GitHub token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
876
+ }
877
+ if (!repo || !repo.includes("/")) {
878
+ throw new CommandError("--apply requires --repo <owner/repo>.", EXIT_CODE.USAGE);
879
+ }
880
+ let created = 0;
881
+ let updated = 0;
882
+ for (const entry of plan) {
883
+ const p = entry.payload;
884
+ if (entry.action === "update" && entry.number !== undefined) {
885
+ await request("PATCH", `https://api.github.com/repos/${repo}/issues/${entry.number}`, token, JSON.stringify({ title: p.title, body: p.body, labels: p.labels, state: p.state }));
886
+ updated++;
355
887
  }
356
- let created = 0;
357
- for (const payload of payloads) {
358
- await request("POST", `https://api.github.com/repos/${repo}/issues`, token, JSON.stringify({ title: payload.title, body: payload.body, labels: payload.labels }));
888
+ else {
889
+ await request("POST", `https://api.github.com/repos/${repo}/issues`, token, JSON.stringify({ title: p.title, body: p.body, labels: p.labels }));
359
890
  created++;
360
891
  }
361
- console.error(`Created ${created} issue(s) on ${repo}.`);
362
- return { pushed: true, created };
363
- }
364
- if (format === "md" || format === "markdown") {
365
- const md = payloads
366
- .map((p) => `## ${p.title}\n\n${p.body}\n\n_labels: ${p.labels.join(", ")} · state: ${p.state}_\n`)
367
- .join("\n");
368
- console.log(md);
369
- return { exported: payloads.length, format: "markdown" };
370
892
  }
371
- console.log(JSON.stringify(payloads, null, 2));
372
- return { exported: payloads.length, format: "json" };
893
+ if (!jsonMode)
894
+ console.error(`Created ${created} and updated ${updated} issue(s) on ${repo}.`);
895
+ return { applied: true, created, updated, repo };
373
896
  });
374
897
  // -----------------------------------------------------------------------
375
- // hooks — opt-in sync reminder for github-linked items
376
- // Safe + no network: only emits a hint, and only when PM_GITHUB_SYNC is set.
898
+ // search — reach GitHub from `pm search` for imported items.
899
+ // Guarded by a capability check so it is a no-op on runtimes that predate
900
+ // search providers. The provider asks GitHub which issues in the configured
901
+ // repo match the query, then returns hits for the LOCAL pm items imported
902
+ // from those issues (the runtime drops hits that aren't local documents).
903
+ // Activates in semantic/hybrid mode: `pm search "<q>" --semantic`.
904
+ // -----------------------------------------------------------------------
905
+ if (typeof api.registerSearchProvider === "function") {
906
+ api.registerSearchProvider({
907
+ name: "github",
908
+ async query(qctx) {
909
+ const repo = resolveSearchRepo(qctx.options || {});
910
+ if (!repo)
911
+ return [];
912
+ const token = resolveGitHubToken();
913
+ let matchedNumbers;
914
+ try {
915
+ const { body } = await fetchJSON(buildSearchUrl(repo, qctx.query), token);
916
+ const parsed = JSON.parse(body);
917
+ matchedNumbers = (parsed.items ?? [])
918
+ .map((i) => i.number)
919
+ .filter((n) => typeof n === "number");
920
+ }
921
+ catch {
922
+ // Network/parse failure → no remote hits; pm degrades to keyword.
923
+ return [];
924
+ }
925
+ // Map remote matches back to local items via provenance tags. Prefer
926
+ // the runtime-provided documents (already the current corpus); fall
927
+ // back to a fresh read if absent.
928
+ const docs = Array.isArray(qctx.documents)
929
+ ? qctx.documents.map((d) => (d?.metadata ? d.metadata : d))
930
+ : readPmItems(qctx.pm_root || ".agents/pm");
931
+ const index = indexByProvenance(docs);
932
+ return mapSearchHits(matchedNumbers, repo, index);
933
+ },
934
+ });
935
+ }
936
+ // -----------------------------------------------------------------------
937
+ // hooks — actionable sync reminder for github-linked items.
938
+ // Safe + no network: only emits a hint, gated on PM_GITHUB_SYNC, and names
939
+ // the exact command to run. Triggers only when a github-linked item (one
940
+ // carrying a `gh:owner/repo#N` provenance tag) is closed/reopened.
377
941
  // -----------------------------------------------------------------------
378
942
  api.hooks.afterCommand((ctx) => {
379
943
  if (!process.env.PM_GITHUB_SYNC)
380
944
  return;
381
945
  if (!ctx.ok)
382
946
  return;
383
- if (ctx.command === "close" || ctx.command === "update") {
384
- console.error("[pm-github] item changed — if it is linked to a GitHub issue, " +
385
- "remember to sync the upstream issue (PM_GITHUB_SYNC hint).");
947
+ if (ctx.command !== "close" && ctx.command !== "update")
948
+ return;
949
+ // Only nudge for items that are actually linked to GitHub.
950
+ const id = ctx.args?.[0];
951
+ if (!id || !ctx.pm_root)
952
+ return;
953
+ const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8" });
954
+ if (res.status !== 0)
955
+ return;
956
+ let repo;
957
+ try {
958
+ const item = JSON.parse(res.stdout);
959
+ for (const tag of item?.tags ?? []) {
960
+ const p = parseProvenanceTag(String(tag));
961
+ if (p) {
962
+ repo = p.repo;
963
+ break;
964
+ }
965
+ }
966
+ }
967
+ catch {
968
+ return;
386
969
  }
970
+ if (!repo)
971
+ return;
972
+ console.error(`[pm-github] ${id} is linked to ${repo}; run \`pm github sync --repo ${repo}\` ` +
973
+ "to push this status change upstream (or --dry-run to preview).");
974
+ });
975
+ // -----------------------------------------------------------------------
976
+ // command — `pm github sync` (push pm status → GitHub close/reopen)
977
+ // -----------------------------------------------------------------------
978
+ api.registerCommand({
979
+ name: "github sync",
980
+ description: "Push pm item status changes back to GitHub: close/reopen the linked " +
981
+ "issue (matched by the `gh:owner/repo#N` provenance tag) to match the pm " +
982
+ "item's status. Requires a GitHub token and explicit --repo. Use --dry-run " +
983
+ "to preview the plan without mutating anything.",
984
+ intent: "sync pm item status to the linked GitHub issue state",
985
+ examples: [
986
+ "pm github sync --repo unbraind/pm-cli --dry-run",
987
+ "pm github sync --repo unbraind/pm-cli",
988
+ ],
989
+ flags: SYNC_FLAGS,
990
+ failure_hints: [
991
+ "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` (sync mutates remote issues).",
992
+ "Pass --repo <owner/repo> explicitly; sync never guesses the target repo.",
993
+ "Items must carry a `gh:owner/repo#N` tag — import with `pm github import` first.",
994
+ "Use --dry-run to preview the close/reopen plan before pushing.",
995
+ ],
996
+ async run(ctx) {
997
+ return runSync(ctx);
998
+ },
387
999
  });
388
1000
  // -----------------------------------------------------------------------
389
1001
  // command — legacy `pm gh-issues import <owner/repo>` (delegates to core)
390
1002
  // -----------------------------------------------------------------------
391
1003
  api.registerCommand({
392
1004
  name: "gh-issues import",
393
- description: "Fetch GitHub issues from a repo and create pm items. " +
394
- "Skips pull requests by default. Uses GITHUB_TOKEN/GH_TOKEN (or the " +
395
- "authenticated gh CLI) when available for 5000 req/hr and private repos; " +
396
- "falls back to the unauthenticated API (60 req/hr). " +
397
- "Equivalent to `pm github import`.",
1005
+ description: "Fetch GitHub issues from a repo and create/update pm items (idempotent " +
1006
+ "on re-import via the `gh:owner/repo#N` provenance tag). Skips pull " +
1007
+ "requests by default. Uses GITHUB_TOKEN/GH_TOKEN (or the authenticated " +
1008
+ "gh CLI) when available for 5000 req/hr and private repos; falls back to " +
1009
+ "the unauthenticated API (60 req/hr). Equivalent to `pm github import`.",
398
1010
  intent: "import GitHub issues as pm items",
399
1011
  examples: [
400
1012
  "pm gh-issues import unbraind/pm-cli",
401
1013
  "pm gh-issues import unbraind/pm-cli --all",
402
1014
  "pm gh-issues import unbraind/pm-cli --labels bug,enhancement",
403
1015
  "pm gh-issues import unbraind/pm-cli --since 2026-01-01T00:00:00Z",
1016
+ "pm github import owner/repo --with-comments",
404
1017
  "pm github import owner/repo --dry-run",
405
1018
  ],
406
1019
  flags: IMPORT_FLAGS,
1020
+ failure_hints: [
1021
+ "Pass <owner/repo>, e.g. `pm gh-issues import unbraind/pm-cli`.",
1022
+ "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for private repos / 5000 req/hr.",
1023
+ "Re-running is safe: existing items are updated, not duplicated.",
1024
+ ],
407
1025
  async run(ctx) {
408
1026
  return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options));
409
1027
  },
410
1028
  });
1029
+ // -----------------------------------------------------------------------
1030
+ // command — `pm github validate` (diagnostics: gh/token/repo reachability)
1031
+ // -----------------------------------------------------------------------
1032
+ api.registerCommand({
1033
+ name: "github validate",
1034
+ description: "Diagnose the GitHub integration: whether the `gh` CLI is present, " +
1035
+ "whether a token is resolvable (and from where), and—if --repo is " +
1036
+ "given—whether that repo is accessible with the resolved token. " +
1037
+ "Read-only; never mutates anything. Use --json for machine output.",
1038
+ intent: "check gh/token availability and repo accessibility",
1039
+ examples: [
1040
+ "pm github validate",
1041
+ "pm github validate --repo unbraind/pm-cli",
1042
+ "pm github validate --repo unbraind/pm-cli --json",
1043
+ ],
1044
+ flags: VALIDATE_FLAGS,
1045
+ failure_hints: [
1046
+ "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit and reach private repos.",
1047
+ "Pass --repo <owner/repo> to verify a specific repo is reachable.",
1048
+ ],
1049
+ async run(ctx) {
1050
+ const report = await runValidate(ctx);
1051
+ const jsonMode = ctx.global?.json === true;
1052
+ if (!jsonMode) {
1053
+ for (const line of report.messages)
1054
+ console.error(line);
1055
+ }
1056
+ if (!report.ok) {
1057
+ // Surface a non-zero exit for scripts; the report still returns so
1058
+ // --json consumers get structured detail.
1059
+ throw new CommandError(report.messages.join(" "), report.repo && report.repo_accessible === false ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE);
1060
+ }
1061
+ return report;
1062
+ },
1063
+ });
411
1064
  },
412
1065
  });
413
1066
  //# sourceMappingURL=index.js.map