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/CHANGELOG.md +34 -1
- package/README.md +55 -12
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +751 -98
- package/dist/index.js.map +1 -1
- package/manifest.json +4 -2
- package/package.json +3 -3
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
|
|
5
|
-
// importers
|
|
6
|
-
// exporters
|
|
7
|
-
// schema
|
|
8
|
-
// hooks
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, () => {
|
|
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
|
-
|
|
193
|
-
|
|
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 ${
|
|
360
|
+
console.error(`Fetching issues from ${repo}…${token ? "" : " (unauthenticated — 60 req/hr)"}`);
|
|
202
361
|
let fetched;
|
|
203
362
|
try {
|
|
204
|
-
fetched = await fetchAllIssues(
|
|
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 ${
|
|
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
|
|
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}, ${
|
|
414
|
+
console.error(` [dry-run] #${issue.number} ${title} (${status}, ${labels.join(",")})`);
|
|
239
415
|
imported++;
|
|
240
416
|
continue;
|
|
241
417
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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", `
|
|
427
|
+
"--message", `Re-imported from GitHub #${issue.number}`,
|
|
253
428
|
];
|
|
254
429
|
if (assignee)
|
|
255
|
-
|
|
430
|
+
updArgs.push("--assignee", assignee);
|
|
256
431
|
if (milestone)
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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}
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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` (
|
|
338
|
-
//
|
|
339
|
-
//
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
//
|
|
376
|
-
//
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
"
|
|
395
|
-
"
|
|
396
|
-
"
|
|
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
|