pm-github 2026.6.2 → 2026.6.4

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