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