pm-github 2026.6.1 → 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 +40 -1
- package/README.md +87 -9
- package/dist/index.d.ts +99 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +949 -138
- package/dist/index.js.map +1 -1
- package/manifest.json +7 -3
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
// pm-github — GitHub Issues importer for pm-cli
|
|
1
|
+
// pm-github — GitHub Issues sync (importer / exporter / sync) for pm-cli
|
|
2
|
+
//
|
|
3
|
+
// Capabilities (see manifest.json):
|
|
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)
|
|
2
10
|
import https from "node:https";
|
|
3
11
|
import { spawnSync } from "node:child_process";
|
|
4
12
|
const defineExtension = ((extension) => extension);
|
|
@@ -22,36 +30,115 @@ export function resolveGitHubToken() {
|
|
|
22
30
|
}
|
|
23
31
|
return undefined;
|
|
24
32
|
}
|
|
25
|
-
|
|
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) {
|
|
26
36
|
return new Promise((resolve, reject) => {
|
|
27
37
|
const headers = {
|
|
28
38
|
"User-Agent": "pm-github",
|
|
29
39
|
Accept: "application/vnd.github+json",
|
|
40
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
30
41
|
};
|
|
31
42
|
if (token)
|
|
32
43
|
headers.Authorization = `Bearer ${token}`;
|
|
33
|
-
|
|
44
|
+
if (payload) {
|
|
45
|
+
headers["Content-Type"] = "application/json";
|
|
46
|
+
headers["Content-Length"] = String(Buffer.byteLength(payload));
|
|
47
|
+
}
|
|
48
|
+
const req = https.request(url, { method, headers }, (res) => {
|
|
34
49
|
const status = res.statusCode ?? 0;
|
|
35
50
|
if (status >= 300 && status < 400 && res.headers.location) {
|
|
36
|
-
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
if (status !== 200) {
|
|
40
|
-
res.resume();
|
|
41
|
-
reject(new Error(`GitHub API returned HTTP ${status}`));
|
|
51
|
+
requestOnce(method, res.headers.location, token, payload).then(resolve, reject);
|
|
42
52
|
return;
|
|
43
53
|
}
|
|
44
54
|
const chunks = [];
|
|
45
55
|
res.on("data", (c) => chunks.push(c));
|
|
46
|
-
res.on("end", () =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
res.on("end", () => {
|
|
57
|
+
resolve({
|
|
58
|
+
status,
|
|
59
|
+
body: Buffer.concat(chunks).toString("utf-8"),
|
|
60
|
+
headers: res.headers,
|
|
61
|
+
linkHeader: typeof res.headers.link === "string" ? res.headers.link : undefined,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
50
64
|
});
|
|
51
65
|
req.on("error", reject);
|
|
52
|
-
req.setTimeout(30000, () => {
|
|
66
|
+
req.setTimeout(30000, () => {
|
|
67
|
+
req.destroy(new Error("request timed out after 30s"));
|
|
68
|
+
});
|
|
69
|
+
if (payload)
|
|
70
|
+
req.write(payload);
|
|
71
|
+
req.end();
|
|
53
72
|
});
|
|
54
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
|
+
}
|
|
139
|
+
function fetchJSON(url, token) {
|
|
140
|
+
return request("GET", url, token);
|
|
141
|
+
}
|
|
55
142
|
// Follow GitHub's RFC 5988 Link header so repos with more than one page of
|
|
56
143
|
// issues are fully imported instead of silently truncated at per_page.
|
|
57
144
|
export function parseNextLink(linkHeader) {
|
|
@@ -105,149 +192,873 @@ export class CommandError extends Error {
|
|
|
105
192
|
}
|
|
106
193
|
}
|
|
107
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
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Shared import core (used by both the legacy command and the importer)
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Build the GitHub issues list URL. `since`, `assignee`, `labels` and `state`
|
|
245
|
+
// are honored server-side; `milestone` is filtered client-side (the REST API
|
|
246
|
+
// keys milestones by number, not title).
|
|
247
|
+
export function buildIssuesUrl(repo, opts) {
|
|
248
|
+
let url = `https://api.github.com/repos/${repo}/issues?state=${opts.state}&per_page=100`;
|
|
249
|
+
if (opts.labels)
|
|
250
|
+
url += `&labels=${encodeURIComponent(opts.labels)}`;
|
|
251
|
+
if (opts.since)
|
|
252
|
+
url += `&since=${encodeURIComponent(opts.since)}`;
|
|
253
|
+
if (opts.assignee)
|
|
254
|
+
url += `&assignee=${encodeURIComponent(opts.assignee)}`;
|
|
255
|
+
return url;
|
|
256
|
+
}
|
|
257
|
+
async function fetchAllIssues(repo, opts, token) {
|
|
258
|
+
const issues = [];
|
|
259
|
+
let nextUrl = buildIssuesUrl(repo, opts);
|
|
260
|
+
while (nextUrl) {
|
|
261
|
+
const { body, linkHeader } = await fetchJSON(nextUrl, token);
|
|
262
|
+
let page;
|
|
263
|
+
try {
|
|
264
|
+
page = JSON.parse(body);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new Error("Invalid JSON response from GitHub.");
|
|
268
|
+
}
|
|
269
|
+
if (!Array.isArray(page)) {
|
|
270
|
+
throw new Error("Unexpected GitHub API response (expected an array of issues).");
|
|
271
|
+
}
|
|
272
|
+
issues.push(...page);
|
|
273
|
+
nextUrl = parseNextLink(linkHeader);
|
|
274
|
+
}
|
|
275
|
+
return issues;
|
|
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
|
+
}
|
|
314
|
+
// Apply the client-side filters (PRs, milestone-by-title).
|
|
315
|
+
export function applyClientFilters(issues, opts) {
|
|
316
|
+
let result = issues;
|
|
317
|
+
if (!opts.includePrs)
|
|
318
|
+
result = result.filter((i) => !i.pull_request);
|
|
319
|
+
if (opts.milestone) {
|
|
320
|
+
result = result.filter((i) => i.milestone?.title === opts.milestone);
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
export function parseImportOptions(options) {
|
|
325
|
+
// --state takes precedence; --all is the legacy shorthand for "all".
|
|
326
|
+
const stateOpt = optionString(options, "state");
|
|
327
|
+
const includeAll = optionEnabled(options, "all");
|
|
328
|
+
const state = stateOpt && ["open", "closed", "all"].includes(stateOpt)
|
|
329
|
+
? stateOpt
|
|
330
|
+
: includeAll
|
|
331
|
+
? "all"
|
|
332
|
+
: "open";
|
|
333
|
+
return {
|
|
334
|
+
state,
|
|
335
|
+
labels: optionString(options, "labels"),
|
|
336
|
+
since: optionString(options, "since"),
|
|
337
|
+
assignee: optionString(options, "assignee"),
|
|
338
|
+
milestone: optionString(options, "milestone"),
|
|
339
|
+
includePrs: optionEnabled(options, "include-prs", "includePrs"),
|
|
340
|
+
withComments: optionEnabled(options, "with-comments", "withComments"),
|
|
341
|
+
itemType: optionString(options, "type") || "Issue",
|
|
342
|
+
dryRun: optionEnabled(options, "dry-run", "dryRun"),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
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.
|
|
352
|
+
async function runImport(repoArg, pmRoot, opts) {
|
|
353
|
+
if (!repoArg || !repoArg.includes("/")) {
|
|
354
|
+
throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
|
|
355
|
+
"[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
|
|
356
|
+
"[--milestone <name>] [--include-prs] [--with-comments]", EXIT_CODE.USAGE);
|
|
357
|
+
}
|
|
358
|
+
const repo = repoArg;
|
|
359
|
+
const token = resolveGitHubToken();
|
|
360
|
+
console.error(`Fetching issues from ${repo}…${token ? "" : " (unauthenticated — 60 req/hr)"}`);
|
|
361
|
+
let fetched;
|
|
362
|
+
try {
|
|
363
|
+
fetched = await fetchAllIssues(repo, opts, token);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
const hint = !token && /HTTP 403/.test(msg)
|
|
368
|
+
? " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit (60→5000/hr) and reach private repos"
|
|
369
|
+
: "";
|
|
370
|
+
const exitCode = /HTTP 404/.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
371
|
+
throw new CommandError(`Failed to fetch issues from ${repo}: ${msg}${hint}`, exitCode);
|
|
372
|
+
}
|
|
373
|
+
const filtered = applyClientFilters(fetched, opts);
|
|
374
|
+
if (filtered.length === 0) {
|
|
375
|
+
console.error("No issues found.");
|
|
376
|
+
return { imported: 0, updated: 0, skipped: 0 };
|
|
377
|
+
}
|
|
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));
|
|
381
|
+
let imported = 0;
|
|
382
|
+
let updated = 0;
|
|
383
|
+
let skipped = 0;
|
|
384
|
+
for (const issue of filtered) {
|
|
385
|
+
const title = issue.title.trim();
|
|
386
|
+
if (!title) {
|
|
387
|
+
skipped++;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const kind = issue.pull_request ? "PR" : "issue";
|
|
391
|
+
const labels = issue.labels.map((l) => l.name).filter(Boolean);
|
|
392
|
+
const tag = provenanceTag(repo, issue.number);
|
|
393
|
+
const tags = [...labels, tag];
|
|
394
|
+
const status = mapState(issue.state);
|
|
395
|
+
const assignee = issue.assignee?.login;
|
|
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);
|
|
413
|
+
if (opts.dryRun) {
|
|
414
|
+
console.error(` [dry-run] #${issue.number} ${title} (${status}, ${labels.join(",")})`);
|
|
415
|
+
imported++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
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,
|
|
423
|
+
"--title", title,
|
|
424
|
+
"--description", description,
|
|
425
|
+
"--body", body,
|
|
426
|
+
"--tags", tags.join(","),
|
|
427
|
+
"--message", `Re-imported from GitHub #${issue.number}`,
|
|
428
|
+
];
|
|
429
|
+
if (assignee)
|
|
430
|
+
updArgs.push("--assignee", assignee);
|
|
431
|
+
if (milestone)
|
|
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;
|
|
438
|
+
}
|
|
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;
|
|
458
|
+
}
|
|
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}`);
|
|
476
|
+
skipped++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
imported++;
|
|
480
|
+
}
|
|
481
|
+
if (opts.dryRun) {
|
|
482
|
+
console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`);
|
|
483
|
+
return { dryRun: true, wouldImport: imported, wouldSkip: skipped };
|
|
484
|
+
}
|
|
485
|
+
console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
|
|
486
|
+
if (imported === 0 && updated === 0 && skipped > 0) {
|
|
487
|
+
throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
|
|
488
|
+
}
|
|
489
|
+
return { imported, updated, skipped };
|
|
490
|
+
}
|
|
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
|
+
}
|
|
518
|
+
}
|
|
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);
|
|
528
|
+
}
|
|
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 };
|
|
578
|
+
}
|
|
579
|
+
console.error(`Synced ${synced} issue(s) on ${repo}; skipped ${skipped}.`);
|
|
580
|
+
return { synced, skipped, planned: plan.length };
|
|
581
|
+
}
|
|
582
|
+
function itemToGithubPayload(item) {
|
|
583
|
+
return {
|
|
584
|
+
title: item.title ?? "(untitled)",
|
|
585
|
+
body: item.body || item.description || "",
|
|
586
|
+
// Drop our internal provenance tags from exported labels.
|
|
587
|
+
labels: (item.tags ?? []).filter((t) => !parseProvenanceTag(t)),
|
|
588
|
+
state: item.status === "closed" || item.status === "canceled" ? "closed" : "open",
|
|
589
|
+
};
|
|
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
|
+
}
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
108
776
|
// Extension
|
|
109
777
|
// ---------------------------------------------------------------------------
|
|
778
|
+
const IMPORT_FLAGS = [
|
|
779
|
+
{ long: "--all", description: "Include closed issues (shorthand for --state all)" },
|
|
780
|
+
{ long: "--state", value_name: "state", description: "Issue state: open | closed | all (default: open)" },
|
|
781
|
+
{ long: "--labels", value_name: "labels", description: "Comma-separated label filter" },
|
|
782
|
+
{ long: "--since", value_name: "iso", description: "Only issues updated after this ISO timestamp (incremental sync)" },
|
|
783
|
+
{ long: "--assignee", value_name: "login", description: "Filter by assignee login" },
|
|
784
|
+
{ long: "--milestone", value_name: "name", description: "Filter by milestone title" },
|
|
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" },
|
|
787
|
+
{ long: "--dry-run", description: "Preview without writing" },
|
|
788
|
+
{ long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
|
|
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
|
+
];
|
|
110
797
|
export default defineExtension({
|
|
111
798
|
name: "pm-github",
|
|
112
|
-
version: "2026.6.
|
|
799
|
+
version: "2026.6.3",
|
|
113
800
|
activate(api) {
|
|
114
801
|
// -----------------------------------------------------------------------
|
|
115
|
-
//
|
|
802
|
+
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
803
|
+
// -----------------------------------------------------------------------
|
|
804
|
+
api.registerItemFields([
|
|
805
|
+
{ name: "github_url", type: "string", optional: true },
|
|
806
|
+
{ name: "github_number", type: "number", optional: true },
|
|
807
|
+
{ name: "github_state", type: "string", optional: true },
|
|
808
|
+
]);
|
|
809
|
+
// -----------------------------------------------------------------------
|
|
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)
|
|
828
|
+
// -----------------------------------------------------------------------
|
|
829
|
+
api.registerImporter("github", async (ctx) => {
|
|
830
|
+
return runImport(ctx.args?.[0], ctx.pm_root, parseImportOptions(ctx.options || {}));
|
|
831
|
+
});
|
|
832
|
+
// -----------------------------------------------------------------------
|
|
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).
|
|
840
|
+
// -----------------------------------------------------------------------
|
|
841
|
+
api.registerExporter("github", async (ctx) => {
|
|
842
|
+
const options = ctx.options || {};
|
|
843
|
+
const jsonMode = ctx.global?.json === true;
|
|
844
|
+
const format = optionString(options, "format") || "json";
|
|
845
|
+
const repo = optionString(options, "repo") || ctx.args?.[0];
|
|
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.");
|
|
869
|
+
}
|
|
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++;
|
|
887
|
+
}
|
|
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 }));
|
|
890
|
+
created++;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (!jsonMode)
|
|
894
|
+
console.error(`Created ${created} and updated ${updated} issue(s) on ${repo}.`);
|
|
895
|
+
return { applied: true, created, updated, repo };
|
|
896
|
+
});
|
|
897
|
+
// -----------------------------------------------------------------------
|
|
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.
|
|
941
|
+
// -----------------------------------------------------------------------
|
|
942
|
+
api.hooks.afterCommand((ctx) => {
|
|
943
|
+
if (!process.env.PM_GITHUB_SYNC)
|
|
944
|
+
return;
|
|
945
|
+
if (!ctx.ok)
|
|
946
|
+
return;
|
|
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;
|
|
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
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
// -----------------------------------------------------------------------
|
|
1001
|
+
// command — legacy `pm gh-issues import <owner/repo>` (delegates to core)
|
|
116
1002
|
// -----------------------------------------------------------------------
|
|
117
1003
|
api.registerCommand({
|
|
118
1004
|
name: "gh-issues import",
|
|
119
|
-
description: "Fetch GitHub issues from a repo and create pm items
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
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`.",
|
|
123
1010
|
intent: "import GitHub issues as pm items",
|
|
124
1011
|
examples: [
|
|
125
1012
|
"pm gh-issues import unbraind/pm-cli",
|
|
126
1013
|
"pm gh-issues import unbraind/pm-cli --all",
|
|
127
1014
|
"pm gh-issues import unbraind/pm-cli --labels bug,enhancement",
|
|
128
|
-
"pm gh-issues import
|
|
1015
|
+
"pm gh-issues import unbraind/pm-cli --since 2026-01-01T00:00:00Z",
|
|
1016
|
+
"pm github import owner/repo --with-comments",
|
|
1017
|
+
"pm github import owner/repo --dry-run",
|
|
129
1018
|
],
|
|
130
|
-
flags:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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.",
|
|
135
1024
|
],
|
|
136
1025
|
async run(ctx) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
throw new Error("Invalid JSON response from GitHub.");
|
|
169
|
-
}
|
|
170
|
-
if (!Array.isArray(page)) {
|
|
171
|
-
throw new Error("Unexpected GitHub API response (expected an array of issues).");
|
|
172
|
-
}
|
|
173
|
-
issues.push(...page);
|
|
174
|
-
nextUrl = parseNextLink(linkHeader);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
179
|
-
const hint = !token && /HTTP 403/.test(msg)
|
|
180
|
-
? " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit (60→5000/hr) and reach private repos"
|
|
181
|
-
: "";
|
|
182
|
-
// Throw so the CLI exits non-zero on a failed fetch. A 404 maps to
|
|
183
|
-
// NOT_FOUND; everything else (403, 5xx, timeouts) is a generic failure.
|
|
184
|
-
const exitCode = /HTTP 404/.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
185
|
-
throw new CommandError(`Failed to fetch issues from ${repoArg}: ${msg}${hint}`, exitCode);
|
|
186
|
-
}
|
|
187
|
-
// Filter out PRs
|
|
188
|
-
const filtered = issues.filter((i) => !i.pull_request);
|
|
189
|
-
if (filtered.length === 0) {
|
|
190
|
-
console.error("No issues found.");
|
|
191
|
-
return { imported: 0, skipped: 0 };
|
|
192
|
-
}
|
|
193
|
-
console.error(`Found ${filtered.length} issue(s).`);
|
|
194
|
-
let imported = 0;
|
|
195
|
-
let skipped = 0;
|
|
196
|
-
for (const issue of filtered) {
|
|
197
|
-
const title = issue.title.trim();
|
|
198
|
-
if (!title) {
|
|
199
|
-
skipped++;
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
const tags = issue.labels.map((l) => l.name);
|
|
203
|
-
const status = mapState(issue.state);
|
|
204
|
-
const body = issue.body || "";
|
|
205
|
-
const description = `GH #${issue.number}: ${issue.html_url}`;
|
|
206
|
-
const assignee = issue.assignee?.login;
|
|
207
|
-
const milestone = issue.milestone?.title;
|
|
208
|
-
if (dryRun) {
|
|
209
|
-
console.error(` [dry-run] #${issue.number} ${title} (${status}, ${tags.join(",")})`);
|
|
210
|
-
imported++;
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
try {
|
|
214
|
-
const spawnArgs = [
|
|
215
|
-
"--path", ctx.pm_root,
|
|
216
|
-
"create",
|
|
217
|
-
"--title", title,
|
|
218
|
-
"--type", itemType,
|
|
219
|
-
"--status", status,
|
|
220
|
-
"--description", description,
|
|
221
|
-
"--body", body,
|
|
222
|
-
"--tags", tags.join(","),
|
|
223
|
-
"--message", `Imported from GitHub #${issue.number}`,
|
|
224
|
-
];
|
|
225
|
-
if (assignee)
|
|
226
|
-
spawnArgs.push("--assignee", assignee);
|
|
227
|
-
if (milestone)
|
|
228
|
-
spawnArgs.push("--sprint", milestone);
|
|
229
|
-
const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
|
|
230
|
-
if (result.status !== 0) {
|
|
231
|
-
throw new Error(result.stderr || "pm create failed");
|
|
232
|
-
}
|
|
233
|
-
imported++;
|
|
234
|
-
}
|
|
235
|
-
catch (err) {
|
|
236
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
237
|
-
console.error(`#${issue.number}: create failed — ${msg}`);
|
|
238
|
-
skipped++;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
if (dryRun) {
|
|
242
|
-
console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`);
|
|
243
|
-
return { dryRun: true, wouldImport: imported, wouldSkip: skipped };
|
|
1026
|
+
return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options));
|
|
1027
|
+
},
|
|
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);
|
|
244
1055
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
throw new CommandError(
|
|
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);
|
|
249
1060
|
}
|
|
250
|
-
return
|
|
1061
|
+
return report;
|
|
251
1062
|
},
|
|
252
1063
|
});
|
|
253
1064
|
},
|