pm-github 2026.7.11 → 2026.7.13
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 +21 -0
- package/README.md +54 -1
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +821 -8
- package/dist/index.js.map +1 -1
- package/dist/projects.d.ts +142 -0
- package/dist/projects.d.ts.map +1 -0
- package/dist/projects.js +445 -0
- package/dist/projects.js.map +1 -0
- package/manifest.json +2 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// pm-github — GitHub Issues
|
|
1
|
+
// pm-github — GitHub Issues + Projects v2 sync for pm-cli
|
|
2
2
|
//
|
|
3
3
|
// Capabilities (see manifest.json):
|
|
4
|
-
// commands — `pm gh-issues import` (legacy) + `pm github sync`
|
|
4
|
+
// commands — `pm gh-issues import` (legacy) + `pm github sync` +
|
|
5
|
+
// `pm github project list|fields|import|sync` (Projects v2)
|
|
5
6
|
// importers — `pm github import <owner/repo>` (idempotent native import)
|
|
6
7
|
// exporters — `pm github export` (render pm items as a GitHub-issues payload)
|
|
7
8
|
// schema — declares github_url / github_number / github_state /
|
|
8
9
|
// github_author / github_created_at / github_updated_at item fields
|
|
9
10
|
// hooks — afterCommand: actionable sync hint for github-linked items
|
|
10
11
|
// preflight — local guard for mutating github commands (token presence)
|
|
12
|
+
//
|
|
13
|
+
// Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2
|
|
14
|
+
// section below and the pure plan/mapping logic in ./projects.ts).
|
|
11
15
|
import https from "node:https";
|
|
12
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { buildProjectImportPlan, buildProjectPullPlan, buildProjectPushPlan, parseProjectItemTag, parseProjectRef, parseStatusMap, projectItemTag, } from "./projects.js";
|
|
13
18
|
const defineExtension = ((extension) => extension);
|
|
14
19
|
// Resolve a GitHub token so the importer is not stuck on the 60 req/hr
|
|
15
20
|
// unauthenticated quota and can read private repos. Order: explicit env vars,
|
|
@@ -32,8 +37,22 @@ export function resolveGitHubToken() {
|
|
|
32
37
|
return undefined;
|
|
33
38
|
}
|
|
34
39
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
-
//
|
|
36
|
-
|
|
40
|
+
// Decide whether the Authorization token may be forwarded to a redirect target.
|
|
41
|
+
// Only same-origin (scheme + host + port) redirects keep the credential; any
|
|
42
|
+
// origin change drops it to avoid leaking the token. Exported for
|
|
43
|
+
// unit testing.
|
|
44
|
+
export function sameOrigin(fromUrl, toUrl) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(fromUrl).origin.toLowerCase() === new URL(toUrl).origin.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// One low-level request, no retry/backoff (that lives in `request`). Follows up
|
|
53
|
+
// to `redirectsLeft` redirects; a cycle or an over-long chain rejects instead of
|
|
54
|
+
// overflowing the stack, and the token is only forwarded to same-host targets.
|
|
55
|
+
function requestOnce(method, url, token, payload, redirectsLeft = 5) {
|
|
37
56
|
return new Promise((resolve, reject) => {
|
|
38
57
|
const headers = {
|
|
39
58
|
"User-Agent": "pm-github",
|
|
@@ -49,7 +68,24 @@ function requestOnce(method, url, token, payload) {
|
|
|
49
68
|
const req = https.request(url, { method, headers }, (res) => {
|
|
50
69
|
const status = res.statusCode ?? 0;
|
|
51
70
|
if (status >= 300 && status < 400 && res.headers.location) {
|
|
52
|
-
|
|
71
|
+
// Drain the redirect response so the socket is returned to the pool.
|
|
72
|
+
res.resume();
|
|
73
|
+
if (redirectsLeft <= 0) {
|
|
74
|
+
reject(new Error(`too many redirects following ${url}`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Resolve the (possibly relative) Location against the current URL, and
|
|
78
|
+
// only carry the token forward on a same-host redirect.
|
|
79
|
+
let target;
|
|
80
|
+
try {
|
|
81
|
+
target = new URL(res.headers.location, url).toString();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
reject(new Error(`invalid redirect Location from ${url}`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const forwardToken = sameOrigin(url, target) ? token : undefined;
|
|
88
|
+
requestOnce(method, target, forwardToken, payload, redirectsLeft - 1).then(resolve, reject);
|
|
53
89
|
return;
|
|
54
90
|
}
|
|
55
91
|
const chunks = [];
|
|
@@ -101,6 +137,8 @@ export function computeBackoffMs(headers, attempt, nowMs = Date.now()) {
|
|
|
101
137
|
// Exponential backoff: 1s, 2s, 4s … capped.
|
|
102
138
|
return Math.min(1000 * 2 ** attempt, cap);
|
|
103
139
|
}
|
|
140
|
+
// Read GitHub's x-ratelimit-* headers into a structured snapshot, reporting a
|
|
141
|
+
// low-remaining warning once the budget drops below `lowThreshold`.
|
|
104
142
|
export function parseRateLimit(headers, lowThreshold = 10) {
|
|
105
143
|
// Node lowercases response header names, but be defensive: scan
|
|
106
144
|
// case-insensitively so a mixed-case key (e.g. from a different runtime or a
|
|
@@ -146,6 +184,8 @@ export function formatRateLimit(info) {
|
|
|
146
184
|
}
|
|
147
185
|
return `GitHub API quota: ${info.remaining}${limitPart} remaining${resetPart}`;
|
|
148
186
|
}
|
|
187
|
+
// Decide whether a failed HTTP response is worth retrying: 429, any 5xx, or a
|
|
188
|
+
// 403 that is actually a primary/secondary rate-limit wall (remaining=0).
|
|
149
189
|
function isRetryableStatus(status, headers) {
|
|
150
190
|
if (status === 429)
|
|
151
191
|
return true;
|
|
@@ -182,6 +222,7 @@ async function request(method, url, token, payload, maxRetries = 4) {
|
|
|
182
222
|
throw new Error(`GitHub API returned HTTP ${res.status}`);
|
|
183
223
|
}
|
|
184
224
|
}
|
|
225
|
+
// Fetch a single GitHub REST endpoint and return { body, linkHeader }.
|
|
185
226
|
function fetchJSON(url, token) {
|
|
186
227
|
return request("GET", url, token);
|
|
187
228
|
}
|
|
@@ -197,6 +238,8 @@ export function parseNextLink(linkHeader) {
|
|
|
197
238
|
}
|
|
198
239
|
return undefined;
|
|
199
240
|
}
|
|
241
|
+
// Map a GitHub issue/PR state (+ optional stateReason) onto a pm status,
|
|
242
|
+
// preserving `not_planned` closures as `canceled` rather than `closed`.
|
|
200
243
|
export function mapState(state, stateReason) {
|
|
201
244
|
if (state === "closed" && stateReason === "not_planned")
|
|
202
245
|
return "canceled";
|
|
@@ -210,6 +253,8 @@ export function optionEnabled(options, ...keys) {
|
|
|
210
253
|
return v === true || v === "true" || v === "1";
|
|
211
254
|
});
|
|
212
255
|
}
|
|
256
|
+
// Read the first non-empty trimmed string option under any of the given
|
|
257
|
+
// (kebab- or camel-case) keys; returns undefined when none are set.
|
|
213
258
|
export function optionString(options, ...keys) {
|
|
214
259
|
for (const k of keys) {
|
|
215
260
|
const v = options[k];
|
|
@@ -353,6 +398,8 @@ export class CommandError extends Error {
|
|
|
353
398
|
export function provenanceTag(repo, issueNumber) {
|
|
354
399
|
return `gh:${repo.toLowerCase()}#${issueNumber}`;
|
|
355
400
|
}
|
|
401
|
+
// Parse a `gh:owner/repo#number` provenance tag into its repo + issue number;
|
|
402
|
+
// returns undefined for non-provenance tags.
|
|
356
403
|
export function parseProvenanceTag(tag) {
|
|
357
404
|
const m = /^gh:([^#\s]+)#(\d+)$/.exec(tag.trim());
|
|
358
405
|
if (!m)
|
|
@@ -382,6 +429,8 @@ export function scopeItemsByIds(items, ids) {
|
|
|
382
429
|
const missing = ids.filter((id) => !found.has(id));
|
|
383
430
|
return { selected, missing };
|
|
384
431
|
}
|
|
432
|
+
// Read every pm item (active + closed) via `pm list-all --full --include-body`
|
|
433
|
+
// so the idempotency index never misses closed issues and re-creates them.
|
|
385
434
|
function readPmItems(pmRoot) {
|
|
386
435
|
// `list-all` (NOT `list`) so CLOSED items are included: `pm list` returns only
|
|
387
436
|
// active items, which would make the idempotency index miss every closed
|
|
@@ -429,6 +478,8 @@ export function buildIssuesUrl(repo, opts) {
|
|
|
429
478
|
url += `&assignee=${encodeURIComponent(opts.assignee)}`;
|
|
430
479
|
return url;
|
|
431
480
|
}
|
|
481
|
+
// Page through GitHub's issues REST endpoint, following the Link header,
|
|
482
|
+
// applying the import filters, and returning the full issue list.
|
|
432
483
|
async function fetchAllIssues(repo, opts, token) {
|
|
433
484
|
const issues = [];
|
|
434
485
|
let nextUrl = buildIssuesUrl(repo, opts);
|
|
@@ -449,6 +500,7 @@ async function fetchAllIssues(repo, opts, token) {
|
|
|
449
500
|
}
|
|
450
501
|
return issues;
|
|
451
502
|
}
|
|
503
|
+
// Fetch all review comments for a single issue/PR, paging through Link.
|
|
452
504
|
async function fetchComments(issue, repo, token) {
|
|
453
505
|
if (!issue.comments || issue.comments <= 0)
|
|
454
506
|
return [];
|
|
@@ -505,6 +557,8 @@ export function applyClientFilters(issues, opts) {
|
|
|
505
557
|
}
|
|
506
558
|
return result;
|
|
507
559
|
}
|
|
560
|
+
// Normalize CLI options into ImportOptions (state filter, labels, assignee,
|
|
561
|
+
// milestone, since-window, PR inclusion, draft skipping, comment fetching).
|
|
508
562
|
export function parseImportOptions(options) {
|
|
509
563
|
// --state takes precedence; --all is the legacy shorthand for "all".
|
|
510
564
|
const stateOpt = optionString(options, "state");
|
|
@@ -532,6 +586,9 @@ export function parseImportOptions(options) {
|
|
|
532
586
|
dryRun: optionEnabled(options, "dry-run", "dryRun"),
|
|
533
587
|
};
|
|
534
588
|
}
|
|
589
|
+
// Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
|
|
590
|
+
// result (ok = exit code 0). Centralizes every pm mutation so callers share one
|
|
591
|
+
// error-handling shape.
|
|
535
592
|
function pmRun(args) {
|
|
536
593
|
const result = spawnSync("pm", args, { encoding: "utf-8" });
|
|
537
594
|
return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" };
|
|
@@ -692,6 +749,8 @@ async function runImport(repoArg, pmRoot, opts) {
|
|
|
692
749
|
function pmStatusToGithubState(status) {
|
|
693
750
|
return status === "closed" || status === "canceled" ? "closed" : "open";
|
|
694
751
|
}
|
|
752
|
+
// Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
|
|
753
|
+
// a create-or-update entry keyed by the issue's provenance tag.
|
|
695
754
|
export function planSync(items, repo) {
|
|
696
755
|
const plan = [];
|
|
697
756
|
const repoLc = repo.toLowerCase();
|
|
@@ -716,6 +775,8 @@ export function planSync(items, repo) {
|
|
|
716
775
|
}
|
|
717
776
|
return plan;
|
|
718
777
|
}
|
|
778
|
+
// Command handler for `pm github sync`: preview or apply the pm → GitHub issue
|
|
779
|
+
// sync plan, scoped by --ids and honoring --dry-run / --apply.
|
|
719
780
|
async function runSync(ctx) {
|
|
720
781
|
const options = ctx.options || {};
|
|
721
782
|
const repo = optionString(options, "repo") || ctx.args?.[0];
|
|
@@ -799,10 +860,15 @@ async function runSync(ctx) {
|
|
|
799
860
|
console.error(`Synced ${synced} issue(s) on ${repo}; skipped ${skipped}.`);
|
|
800
861
|
return { synced, skipped, planned: plan.length, ...(scopedIds.length > 0 ? { scoped_ids: scopedIds } : {}) };
|
|
801
862
|
}
|
|
863
|
+
// Convert a pm item into the GitHub issue create/update payload, dropping
|
|
864
|
+
// internal provenance tags from labels and applying the optional label map.
|
|
802
865
|
function itemToGithubPayload(item, labelMap) {
|
|
803
866
|
// Drop our internal provenance tags from exported labels, then apply any
|
|
804
|
-
// user-supplied label mapping (pm tag → GitHub label).
|
|
805
|
-
|
|
867
|
+
// user-supplied label mapping (pm tag → GitHub label). Both issue provenance
|
|
868
|
+
// (`gh:owner/repo#N`) and project provenance (`gh-project:owner/number#itemId`)
|
|
869
|
+
// are stripped, but only via strict anchored matching — user labels that
|
|
870
|
+
// merely contain similar text (e.g. `gh-project-notes`) are preserved.
|
|
871
|
+
const labels = (item.tags ?? []).filter((t) => !parseProvenanceTag(t) && !parseProjectItemTag(t));
|
|
806
872
|
return {
|
|
807
873
|
title: item.title ?? "(untitled)",
|
|
808
874
|
body: item.body || item.description || "",
|
|
@@ -1079,6 +1145,7 @@ export function resolveSearchRepo(options) {
|
|
|
1079
1145
|
return env.trim();
|
|
1080
1146
|
return undefined;
|
|
1081
1147
|
}
|
|
1148
|
+
// Detect whether the `gh` CLI is installed and runnable on PATH.
|
|
1082
1149
|
function detectGhCli() {
|
|
1083
1150
|
try {
|
|
1084
1151
|
const r = spawnSync("gh", ["--version"], { encoding: "utf-8" });
|
|
@@ -1088,12 +1155,16 @@ function detectGhCli() {
|
|
|
1088
1155
|
return false;
|
|
1089
1156
|
}
|
|
1090
1157
|
}
|
|
1158
|
+
// Report which token source is active: `env` (GITHUB_TOKEN/GH_TOKEN), `gh` (gh
|
|
1159
|
+
// auth), or `none`.
|
|
1091
1160
|
function detectTokenSource() {
|
|
1092
1161
|
if ((process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim())
|
|
1093
1162
|
return "env";
|
|
1094
1163
|
const token = resolveGitHubToken();
|
|
1095
1164
|
return token ? "gh" : "none";
|
|
1096
1165
|
}
|
|
1166
|
+
// Command handler for `pm github validate`: checks token source, gh CLI, rate
|
|
1167
|
+
// limit, and the reachable issue counts for the configured repo.
|
|
1097
1168
|
async function runValidate(ctx) {
|
|
1098
1169
|
const options = ctx.options || {};
|
|
1099
1170
|
const repo = optionString(options, "repo") || ctx.args?.[0];
|
|
@@ -1168,6 +1239,616 @@ async function runValidate(ctx) {
|
|
|
1168
1239
|
// ---------------------------------------------------------------------------
|
|
1169
1240
|
// Returns true if the command/args describe a github operation that will MUTATE
|
|
1170
1241
|
// state (a write import, an export --push, or a non-dry-run sync).
|
|
1242
|
+
// ===========================================================================
|
|
1243
|
+
// GitHub Projects v2 — GraphQL client, operations, and command handlers.
|
|
1244
|
+
// Projects v2 is a GraphQL-only API; this section speaks it via the shared
|
|
1245
|
+
// `request` infrastructure (retry/backoff/rate-limit) already defined above.
|
|
1246
|
+
// The pure plan/mapping logic lives in ./projects.ts for unit-testability.
|
|
1247
|
+
// ===========================================================================
|
|
1248
|
+
const GRAPHQL_URL = "https://api.github.com/graphql";
|
|
1249
|
+
// One GraphQL round-trip. GraphQL reports business errors as HTTP 200 with an
|
|
1250
|
+
// `errors` array, so we surface those explicitly. The combined user+org queries
|
|
1251
|
+
// below intentionally return a partial error for the wrong owner type while the
|
|
1252
|
+
// right one still resolves; we only throw when there is NO usable data.
|
|
1253
|
+
async function githubGraphQL(token, query, variables) {
|
|
1254
|
+
if (!token) {
|
|
1255
|
+
throw new CommandError("GitHub GraphQL requires a token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
|
|
1256
|
+
}
|
|
1257
|
+
const payload = JSON.stringify({ query, variables });
|
|
1258
|
+
const res = await request("POST", GRAPHQL_URL, token, payload);
|
|
1259
|
+
let parsed;
|
|
1260
|
+
try {
|
|
1261
|
+
parsed = JSON.parse(res.body);
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
throw new CommandError(`GitHub GraphQL returned an unparseable response (HTTP ${res.status}).`);
|
|
1265
|
+
}
|
|
1266
|
+
if (parsed.data === undefined || parsed.data === null) {
|
|
1267
|
+
const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
|
|
1268
|
+
throw new CommandError(`GitHub GraphQL error${messages ? `: ${messages}` : ` (HTTP ${res.status})`}.`);
|
|
1269
|
+
}
|
|
1270
|
+
return parsed.data;
|
|
1271
|
+
}
|
|
1272
|
+
const STATUS_FIELD_GQL = `
|
|
1273
|
+
statusField: field(name: "Status") {
|
|
1274
|
+
... on ProjectV2SingleSelectField { id name options { id name } }
|
|
1275
|
+
}`;
|
|
1276
|
+
// Resolve owner/number → project id + Status field. The owner may be a user or
|
|
1277
|
+
// an org and GraphQL requires us to pick, so we ask both in one request and use
|
|
1278
|
+
// whichever resolves.
|
|
1279
|
+
async function resolveProject(ref, token) {
|
|
1280
|
+
const query = `
|
|
1281
|
+
query($owner:String!,$number:Int!){
|
|
1282
|
+
user(login:$owner){ projectV2(number:$number){ id title url ${STATUS_FIELD_GQL} } }
|
|
1283
|
+
organization(login:$owner){ projectV2(number:$number){ id title url ${STATUS_FIELD_GQL} } }
|
|
1284
|
+
}`;
|
|
1285
|
+
const data = await githubGraphQL(token, query, { owner: ref.owner, number: ref.number });
|
|
1286
|
+
const userNode = data.user?.projectV2;
|
|
1287
|
+
const orgNode = data.organization?.projectV2;
|
|
1288
|
+
const node = userNode ?? orgNode;
|
|
1289
|
+
if (!node) {
|
|
1290
|
+
throw new CommandError(`Project ${ref.owner}/${ref.number} not found or not accessible with the resolved token ` +
|
|
1291
|
+
"(need `project`/`read:project` scope; set GITHUB_TOKEN/GH_TOKEN or `gh auth login`).", EXIT_CODE.NOT_FOUND);
|
|
1292
|
+
}
|
|
1293
|
+
const ownerType = userNode ? "user" : "organization";
|
|
1294
|
+
const sf = node.statusField;
|
|
1295
|
+
const statusField = sf && sf.id ? { id: sf.id, name: sf.name, options: sf.options ?? [] } : undefined;
|
|
1296
|
+
return { id: node.id, title: node.title ?? "", url: node.url ?? "", ownerType, statusField };
|
|
1297
|
+
}
|
|
1298
|
+
// Normalize a raw GraphQL project-item node into the ProjectItem model,
|
|
1299
|
+
// tolerating null/undefined nodes and missing content (defensive against
|
|
1300
|
+
// partial GraphQL errors / inaccessible items).
|
|
1301
|
+
function normalizeProjectItemNode(n) {
|
|
1302
|
+
const c = n?.content ?? {};
|
|
1303
|
+
const tn = c.__typename;
|
|
1304
|
+
let content;
|
|
1305
|
+
if (tn === "DraftIssue") {
|
|
1306
|
+
content = { typename: "DraftIssue", title: c.title ?? "", body: c.body ?? undefined };
|
|
1307
|
+
}
|
|
1308
|
+
else if (tn === "Issue") {
|
|
1309
|
+
content = {
|
|
1310
|
+
typename: "Issue",
|
|
1311
|
+
title: c.title ?? "",
|
|
1312
|
+
number: c.number,
|
|
1313
|
+
url: c.url,
|
|
1314
|
+
state: typeof c.state === "string" ? c.state.toLowerCase() : undefined,
|
|
1315
|
+
stateReason: typeof c.stateReason === "string" ? c.stateReason.toLowerCase() : null,
|
|
1316
|
+
repo: c.repository?.nameWithOwner,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
else if (tn === "PullRequest") {
|
|
1320
|
+
content = {
|
|
1321
|
+
typename: "PullRequest",
|
|
1322
|
+
title: c.title ?? "",
|
|
1323
|
+
number: c.number,
|
|
1324
|
+
url: c.url,
|
|
1325
|
+
state: typeof c.state === "string" ? c.state.toLowerCase() : undefined,
|
|
1326
|
+
repo: c.repository?.nameWithOwner,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
// Redacted or an item type we do not model — carry a placeholder so it is
|
|
1331
|
+
// counted but never mutated.
|
|
1332
|
+
content = { typename: "Unknown", title: "" };
|
|
1333
|
+
}
|
|
1334
|
+
const sv = n?.fieldValueByName;
|
|
1335
|
+
return {
|
|
1336
|
+
id: n?.id ?? "",
|
|
1337
|
+
statusOptionId: sv?.optionId ?? undefined,
|
|
1338
|
+
statusName: sv?.name ?? undefined,
|
|
1339
|
+
content,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
// Fetch every item on the board (paginated, 100/page).
|
|
1343
|
+
async function fetchProjectItems(projectId, token) {
|
|
1344
|
+
const query = `
|
|
1345
|
+
query($id:ID!,$cursor:String){
|
|
1346
|
+
node(id:$id){ ... on ProjectV2 {
|
|
1347
|
+
items(first:100, after:$cursor){
|
|
1348
|
+
pageInfo{ hasNextPage endCursor }
|
|
1349
|
+
nodes{
|
|
1350
|
+
id
|
|
1351
|
+
fieldValueByName(name:"Status"){ ... on ProjectV2ItemFieldSingleSelectValue{ name optionId } }
|
|
1352
|
+
content{
|
|
1353
|
+
__typename
|
|
1354
|
+
... on DraftIssue { title body }
|
|
1355
|
+
... on Issue { number title url state stateReason repository{ nameWithOwner } }
|
|
1356
|
+
... on PullRequest { number title url state repository{ nameWithOwner } }
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}}
|
|
1361
|
+
}`;
|
|
1362
|
+
const items = [];
|
|
1363
|
+
let cursor;
|
|
1364
|
+
for (;;) {
|
|
1365
|
+
const data = await githubGraphQL(token, query, { id: projectId, cursor: cursor ?? null });
|
|
1366
|
+
const conn = data.node?.items;
|
|
1367
|
+
if (!conn)
|
|
1368
|
+
break;
|
|
1369
|
+
for (const n of conn.nodes ?? []) {
|
|
1370
|
+
if (n)
|
|
1371
|
+
items.push(normalizeProjectItemNode(n));
|
|
1372
|
+
}
|
|
1373
|
+
if (conn.pageInfo?.hasNextPage && conn.pageInfo.endCursor) {
|
|
1374
|
+
cursor = conn.pageInfo.endCursor;
|
|
1375
|
+
}
|
|
1376
|
+
else {
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return items;
|
|
1381
|
+
}
|
|
1382
|
+
// Add a DraftIssue to a Projects v2 board and return the new project item id.
|
|
1383
|
+
async function gqlAddDraft(projectId, title, body, token) {
|
|
1384
|
+
const q = `mutation($p:ID!,$t:String!,$b:String){ addProjectV2DraftIssue(input:{projectId:$p,title:$t,body:$b}){ projectItem{ id } } }`;
|
|
1385
|
+
const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body ?? "" });
|
|
1386
|
+
const id = d.addProjectV2DraftIssue?.projectItem?.id;
|
|
1387
|
+
if (!id)
|
|
1388
|
+
throw new CommandError("addProjectV2DraftIssue returned no item id.");
|
|
1389
|
+
return id;
|
|
1390
|
+
}
|
|
1391
|
+
// Link an existing repository issue/PR to a Projects v2 board and return the
|
|
1392
|
+
// new project item id.
|
|
1393
|
+
async function gqlAddIssue(projectId, contentId, token) {
|
|
1394
|
+
const q = `mutation($p:ID!,$c:ID!){ addProjectV2ItemById(input:{projectId:$p,contentId:$c}){ item{ id } } }`;
|
|
1395
|
+
const d = await githubGraphQL(token, q, { p: projectId, c: contentId });
|
|
1396
|
+
const id = d.addProjectV2ItemById?.item?.id;
|
|
1397
|
+
if (!id)
|
|
1398
|
+
throw new CommandError("addProjectV2ItemById returned no item id.");
|
|
1399
|
+
return id;
|
|
1400
|
+
}
|
|
1401
|
+
// Set a project item's single-select Status field to the given option id.
|
|
1402
|
+
async function gqlSetStatus(projectId, itemId, fieldId, optionId, token) {
|
|
1403
|
+
const q = `mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){ updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f,value:{singleSelectOptionId:$o}}){ projectV2Item{ id } } }`;
|
|
1404
|
+
await githubGraphQL(token, q, { p: projectId, i: itemId, f: fieldId, o: optionId });
|
|
1405
|
+
}
|
|
1406
|
+
// Resolve the GraphQL node id of a repo's issue or PR by number, returning
|
|
1407
|
+
// undefined when the item is not found or the repo ref is malformed.
|
|
1408
|
+
async function gqlResolveIssueNodeId(repo, number, token) {
|
|
1409
|
+
const [owner, name] = repo.split("/");
|
|
1410
|
+
if (!owner || !name)
|
|
1411
|
+
return undefined;
|
|
1412
|
+
const q = `query($o:String!,$n:String!,$num:Int!){ repository(owner:$o,name:$n){ issueOrPullRequest(number:$num){ ... on Issue { id } ... on PullRequest { id } } } }`;
|
|
1413
|
+
const d = await githubGraphQL(token, q, { o: owner, n: name, num: number });
|
|
1414
|
+
return d.repository?.issueOrPullRequest?.id ?? undefined;
|
|
1415
|
+
}
|
|
1416
|
+
// Compose a human-readable + provenance-bearing description for an imported
|
|
1417
|
+
// project item (parallels the issue-import description).
|
|
1418
|
+
function projectItemDescription(ref, c) {
|
|
1419
|
+
const parts = [`GH project item ${ref.owner}/${ref.number}`];
|
|
1420
|
+
if (c.typename === "DraftIssue")
|
|
1421
|
+
parts.push("· draft issue");
|
|
1422
|
+
if (c.repo && typeof c.number === "number")
|
|
1423
|
+
parts.push(`· ${c.repo}#${c.number}`);
|
|
1424
|
+
if (c.url)
|
|
1425
|
+
parts.push(`· ${c.url}`);
|
|
1426
|
+
if (c.state)
|
|
1427
|
+
parts.push(`· state ${c.state}`);
|
|
1428
|
+
return parts.join(" ");
|
|
1429
|
+
}
|
|
1430
|
+
// Paginate a GitHub Projects v2 connection until pageInfo.hasNextPage is false.
|
|
1431
|
+
// `fetchPage` returns the connection object (nodes + pageInfo) for the given
|
|
1432
|
+
// cursor and may return null/undefined to stop early. Pure over the injected
|
|
1433
|
+
// fetcher so the multi-page contract (no silent truncation, cursor threading)
|
|
1434
|
+
// is unit-testable without network I/O. Mirrors the fetchProjectItems loop.
|
|
1435
|
+
export async function collectProjectsV2Pages(fetchPage) {
|
|
1436
|
+
const out = [];
|
|
1437
|
+
let cursor;
|
|
1438
|
+
for (;;) {
|
|
1439
|
+
const conn = await fetchPage(cursor);
|
|
1440
|
+
if (!conn)
|
|
1441
|
+
break;
|
|
1442
|
+
for (const n of conn.nodes ?? [])
|
|
1443
|
+
if (n)
|
|
1444
|
+
out.push(n);
|
|
1445
|
+
// Continue only when GitHub explicitly reports more pages AND gives us a
|
|
1446
|
+
// cursor; otherwise stop so we never silently truncate by paging past the
|
|
1447
|
+
// end, nor loop forever on a missing endCursor.
|
|
1448
|
+
if (conn.pageInfo?.hasNextPage && conn.pageInfo?.endCursor) {
|
|
1449
|
+
cursor = conn.pageInfo.endCursor;
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return out;
|
|
1456
|
+
}
|
|
1457
|
+
// Resolve + paginate the projectsV2 connection for a user-or-org owner using an
|
|
1458
|
+
// injected GraphQL transport. A login is either a user or an organization,
|
|
1459
|
+
// never both; querying both in one request lets us resolve the owner type
|
|
1460
|
+
// without a round-trip, then we keep paginating the connection that actually
|
|
1461
|
+
// exists so owners with more than 50/100 projects are fully listed instead of
|
|
1462
|
+
// silently truncated (Greptile 2006f478). Returns raw connection nodes.
|
|
1463
|
+
export async function listOwnerProjectsV2Nodes(owner, graphQL) {
|
|
1464
|
+
let ownerType = null;
|
|
1465
|
+
return collectProjectsV2Pages(async (cursor) => {
|
|
1466
|
+
const q = `
|
|
1467
|
+
query($owner:String!,$cursor:String){
|
|
1468
|
+
user(login:$owner){ projectsV2(first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){ pageInfo{ hasNextPage endCursor } nodes{ number title url closed shortDescription } } }
|
|
1469
|
+
organization(login:$owner){ projectsV2(first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){ pageInfo{ hasNextPage endCursor } nodes{ number title url closed shortDescription } } }
|
|
1470
|
+
}`;
|
|
1471
|
+
const d = await graphQL(q, { owner, cursor: cursor ?? null });
|
|
1472
|
+
if (ownerType === null) {
|
|
1473
|
+
if (d.user?.projectsV2)
|
|
1474
|
+
ownerType = "user";
|
|
1475
|
+
else if (d.organization?.projectsV2)
|
|
1476
|
+
ownerType = "organization";
|
|
1477
|
+
}
|
|
1478
|
+
return ownerType === "organization" ? d.organization?.projectsV2 : d.user?.projectsV2;
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
async function runProjectList(ctx) {
|
|
1482
|
+
const options = ctx.options || {};
|
|
1483
|
+
const owner = optionString(options, "owner") || ctx.args?.[0];
|
|
1484
|
+
if (!owner) {
|
|
1485
|
+
throw new CommandError("Usage: pm github project list <owner> (a GitHub user or org login)", EXIT_CODE.USAGE);
|
|
1486
|
+
}
|
|
1487
|
+
const token = resolveGitHubToken();
|
|
1488
|
+
const nodes = await listOwnerProjectsV2Nodes(owner, (q, vars) => githubGraphQL(token, q, vars));
|
|
1489
|
+
const projects = nodes.map((n) => ({
|
|
1490
|
+
number: n.number,
|
|
1491
|
+
title: n.title ?? "",
|
|
1492
|
+
url: n.url ?? "",
|
|
1493
|
+
closed: !!n.closed,
|
|
1494
|
+
description: n.shortDescription ?? undefined,
|
|
1495
|
+
}));
|
|
1496
|
+
if (ctx.global?.json !== true) {
|
|
1497
|
+
if (projects.length === 0) {
|
|
1498
|
+
console.error(`No Projects v2 found for ${owner} (or none accessible with the resolved token).`);
|
|
1499
|
+
}
|
|
1500
|
+
else {
|
|
1501
|
+
console.error(`Projects for ${owner}:`);
|
|
1502
|
+
for (const p of projects) {
|
|
1503
|
+
console.error(` #${p.number} ${p.closed ? "[closed] " : ""}${p.title} ${p.url}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return { owner, projects };
|
|
1508
|
+
}
|
|
1509
|
+
// --- project fields --------------------------------------------------------
|
|
1510
|
+
async function runProjectFields(ctx) {
|
|
1511
|
+
const options = ctx.options || {};
|
|
1512
|
+
const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
|
|
1513
|
+
if (!ref) {
|
|
1514
|
+
throw new CommandError("Usage: pm github project fields <owner/number> (e.g. pm github project fields unbraind/5)", EXIT_CODE.USAGE);
|
|
1515
|
+
}
|
|
1516
|
+
const token = resolveGitHubToken();
|
|
1517
|
+
const meta = await resolveProject(ref, token);
|
|
1518
|
+
const q = `
|
|
1519
|
+
query($id:ID!){ node(id:$id){ ... on ProjectV2 {
|
|
1520
|
+
fields(first:50){ nodes{
|
|
1521
|
+
__typename
|
|
1522
|
+
... on ProjectV2FieldCommon { name dataType }
|
|
1523
|
+
... on ProjectV2SingleSelectField { name options{ name } }
|
|
1524
|
+
} }
|
|
1525
|
+
}}}`;
|
|
1526
|
+
const d = await githubGraphQL(token, q, { id: meta.id });
|
|
1527
|
+
const fields = (d.node?.fields?.nodes ?? []).filter(Boolean).map((f) => ({
|
|
1528
|
+
name: f.name,
|
|
1529
|
+
type: f.dataType ?? f.__typename,
|
|
1530
|
+
options: Array.isArray(f.options) ? f.options.map((o) => o.name) : undefined,
|
|
1531
|
+
}));
|
|
1532
|
+
if (ctx.global?.json !== true) {
|
|
1533
|
+
console.error(`Project ${ref.owner}/${ref.number} — ${meta.title} (${meta.ownerType})`);
|
|
1534
|
+
console.error(` ${meta.url}`);
|
|
1535
|
+
console.error(` Status field: ${meta.statusField ? meta.statusField.options.map((o) => o.name).join(" | ") : "(none — pushes cannot set status)"}`);
|
|
1536
|
+
console.error(" Fields:");
|
|
1537
|
+
for (const f of fields) {
|
|
1538
|
+
console.error(` ${f.name} (${f.type})${f.options ? `: ${f.options.join(", ")}` : ""}`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return { project: meta, fields };
|
|
1542
|
+
}
|
|
1543
|
+
// --- project import --------------------------------------------------------
|
|
1544
|
+
async function runProjectImport(ctx) {
|
|
1545
|
+
const options = ctx.options || {};
|
|
1546
|
+
const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
|
|
1547
|
+
if (!ref) {
|
|
1548
|
+
throw new CommandError("Usage: pm github project import <owner/number> [--dry-run] [--status-map pm=Option,...] [--type <type>]", EXIT_CODE.USAGE);
|
|
1549
|
+
}
|
|
1550
|
+
const dryRun = optionEnabled(options, "dry-run", "dryRun");
|
|
1551
|
+
const itemType = optionString(options, "type") || "Task";
|
|
1552
|
+
const statusMap = parseStatusMap(optionCsv(options, "status-map", "statusMap"));
|
|
1553
|
+
const token = resolveGitHubToken();
|
|
1554
|
+
const meta = await resolveProject(ref, token);
|
|
1555
|
+
const projectItems = await fetchProjectItems(meta.id, token);
|
|
1556
|
+
console.error(`Found ${projectItems.length} item(s) on ${ref.owner}/${ref.number} — ${meta.title}.`);
|
|
1557
|
+
// Reading the local store is non-mutating and keeps dry-run faithful: linked
|
|
1558
|
+
// project items must preview as updates, not misleading duplicate creates.
|
|
1559
|
+
const pmItems = readPmItems(ctx.pm_root);
|
|
1560
|
+
const plan = buildProjectImportPlan(projectItems, ref, pmItems, statusMap);
|
|
1561
|
+
let imported = 0;
|
|
1562
|
+
let updated = 0;
|
|
1563
|
+
let skipped = 0;
|
|
1564
|
+
for (const entry of plan) {
|
|
1565
|
+
const description = projectItemDescription(ref, entry.content);
|
|
1566
|
+
if (dryRun) {
|
|
1567
|
+
console.error(` [dry-run] ${entry.action} "${entry.title}" (${entry.status})`);
|
|
1568
|
+
if (entry.action === "create")
|
|
1569
|
+
imported++;
|
|
1570
|
+
else
|
|
1571
|
+
updated++;
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
if (entry.action === "update" && entry.pmId) {
|
|
1575
|
+
const updArgs = [
|
|
1576
|
+
"--path", ctx.pm_root, "update", entry.pmId,
|
|
1577
|
+
"--title", entry.title,
|
|
1578
|
+
"--description", description,
|
|
1579
|
+
"--tags", entry.tags.join(","),
|
|
1580
|
+
"--message", `Re-imported from GitHub project ${ref.owner}/${ref.number}`,
|
|
1581
|
+
];
|
|
1582
|
+
if (entry.body)
|
|
1583
|
+
updArgs.push("--body", entry.body);
|
|
1584
|
+
// Refresh the mapped pm status alongside the other fields — but ONLY when
|
|
1585
|
+
// the board Status mapped to a known pm status. An unknown mapping is
|
|
1586
|
+
// skipped (no --status) so we never overwrite a real pm state with a guess
|
|
1587
|
+
// (no data loss). `entry.status` carries a fallback for the create path;
|
|
1588
|
+
// `entry.mappedStatus` is set only for an explicit, resolvable mapping.
|
|
1589
|
+
if (entry.mappedStatus)
|
|
1590
|
+
updArgs.push("--status", entry.mappedStatus);
|
|
1591
|
+
const upd = pmRun(updArgs);
|
|
1592
|
+
if (!upd.ok) {
|
|
1593
|
+
console.error(` ${entry.title}: update failed — ${upd.stderr}`);
|
|
1594
|
+
skipped++;
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
updated++;
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
const createArgs = [
|
|
1601
|
+
"--path", ctx.pm_root, "create",
|
|
1602
|
+
"--title", entry.title,
|
|
1603
|
+
"--type", itemType,
|
|
1604
|
+
"--status", entry.status,
|
|
1605
|
+
"--description", description,
|
|
1606
|
+
"--tags", entry.tags.join(","),
|
|
1607
|
+
"--message", `Imported from GitHub project ${ref.owner}/${ref.number}`,
|
|
1608
|
+
];
|
|
1609
|
+
if (entry.body)
|
|
1610
|
+
createArgs.push("--body", entry.body);
|
|
1611
|
+
const created = pmRun(createArgs);
|
|
1612
|
+
if (!created.ok) {
|
|
1613
|
+
console.error(` ${entry.title}: create failed — ${created.stderr}`);
|
|
1614
|
+
skipped++;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
imported++;
|
|
1618
|
+
}
|
|
1619
|
+
if (dryRun) {
|
|
1620
|
+
console.error(`[dry-run] Would import ${imported}, update ${updated}.`);
|
|
1621
|
+
return { dryRun: true, project: `${ref.owner}/${ref.number}`, wouldImport: imported, wouldUpdate: updated, planned: plan.length };
|
|
1622
|
+
}
|
|
1623
|
+
console.error(`Imported ${imported} new, updated ${updated}, skipped ${skipped}.`);
|
|
1624
|
+
if (imported === 0 && updated === 0 && skipped > 0) {
|
|
1625
|
+
throw new CommandError(`Imported 0 project item(s); ${skipped} failed.`);
|
|
1626
|
+
}
|
|
1627
|
+
return { imported, updated, skipped, project: `${ref.owner}/${ref.number}`, planned: plan.length };
|
|
1628
|
+
}
|
|
1629
|
+
// --- project sync (bidirectional) ------------------------------------------
|
|
1630
|
+
// Push a pm item's status onto its (existing) board item, tolerating a missing
|
|
1631
|
+
// target option. Returns true on a real change.
|
|
1632
|
+
async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
1633
|
+
try {
|
|
1634
|
+
let itemId = entry.itemId;
|
|
1635
|
+
if (entry.action === "add-draft") {
|
|
1636
|
+
const pm = pmById.get(entry.pmId);
|
|
1637
|
+
itemId = await gqlAddDraft(meta.id, entry.title, pm?.body || pm?.description, token);
|
|
1638
|
+
}
|
|
1639
|
+
else if (entry.action === "add-issue") {
|
|
1640
|
+
if (!entry.issueRepo || typeof entry.issueNumber !== "number") {
|
|
1641
|
+
return { changed: false, error: "add-issue entry missing issue coordinates" };
|
|
1642
|
+
}
|
|
1643
|
+
const contentId = await gqlResolveIssueNodeId(entry.issueRepo, entry.issueNumber, token);
|
|
1644
|
+
if (!contentId)
|
|
1645
|
+
return { changed: false, error: `could not resolve node id for ${entry.issueRepo}#${entry.issueNumber}` };
|
|
1646
|
+
itemId = await gqlAddIssue(meta.id, contentId, token);
|
|
1647
|
+
}
|
|
1648
|
+
if (!itemId)
|
|
1649
|
+
return { changed: false, error: "no project item id to act on" };
|
|
1650
|
+
if (entry.targetOptionId && meta.statusField) {
|
|
1651
|
+
await gqlSetStatus(meta.id, itemId, meta.statusField.id, entry.targetOptionId, token);
|
|
1652
|
+
}
|
|
1653
|
+
// Ensure the pm item carries the project provenance tag so future syncs are
|
|
1654
|
+
// idempotent (never strips existing tags; only adds the missing one).
|
|
1655
|
+
const pm = pmById.get(entry.pmId);
|
|
1656
|
+
if (pm?.id) {
|
|
1657
|
+
const tag = projectItemTag(ref, itemId);
|
|
1658
|
+
const existingTags = pm.tags ?? [];
|
|
1659
|
+
if (!existingTags.includes(tag)) {
|
|
1660
|
+
const upd = pmRun([
|
|
1661
|
+
"--path", pmRoot, "update", pm.id,
|
|
1662
|
+
"--tags", [...existingTags, tag].join(","),
|
|
1663
|
+
"--message", `Linked to GitHub project ${ref.owner}/${ref.number}`,
|
|
1664
|
+
]);
|
|
1665
|
+
if (!upd.ok)
|
|
1666
|
+
return { changed: true, error: `linked but tag write failed — ${upd.stderr}` };
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return { changed: true };
|
|
1670
|
+
}
|
|
1671
|
+
catch (err) {
|
|
1672
|
+
return { changed: false, error: err instanceof Error ? err.message : String(err) };
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
// Build the pm CLI argv that applies one pull (project → pm) status transition.
|
|
1676
|
+
// Pure so the lifecycle contract is unit-testable without spawning `pm`.
|
|
1677
|
+
//
|
|
1678
|
+
// pm CLI contracts (verified against pm 2026.7.11):
|
|
1679
|
+
// - `pm close` records `closed_at` + `close_reason` and moves the item to the
|
|
1680
|
+
// terminal `closed` state, but REFUSES on already-terminal items ("use
|
|
1681
|
+
// --force to close again"). It only ever produces `closed`, never `canceled`.
|
|
1682
|
+
// - `canceled` is a DISTINCT terminal state set via `pm update --status
|
|
1683
|
+
// canceled`; `pm update --status canceled` is permitted on both active and
|
|
1684
|
+
// terminal items, and `--close-reason` records the lifecycle rationale.
|
|
1685
|
+
// `pm list-canceled` projects `close_reason` (not `closed_at`), so the close
|
|
1686
|
+
// reason is the lifecycle metadata that must be recorded for `canceled`.
|
|
1687
|
+
//
|
|
1688
|
+
// Routing `canceled` through `pm close` (as one review suggestion proposed)
|
|
1689
|
+
// would conflate `canceled` with `closed`, lose the distinct terminal state,
|
|
1690
|
+
// and fail for terminal→canceled transitions. Recording `--close-reason` on the
|
|
1691
|
+
// `pm update` keeps the distinction AND the lifecycle metadata, and works for
|
|
1692
|
+
// active→canceled and terminal→canceled alike.
|
|
1693
|
+
export function buildPullEntryArgs(entry, pmRoot) {
|
|
1694
|
+
const reason = `GitHub project status → ${entry.toStatus}`;
|
|
1695
|
+
if (entry.toStatus === "closed") {
|
|
1696
|
+
return ["--path", pmRoot, "close", entry.pmId, "--reason", reason];
|
|
1697
|
+
}
|
|
1698
|
+
if (entry.toStatus === "canceled") {
|
|
1699
|
+
return [
|
|
1700
|
+
"--path", pmRoot, "update", entry.pmId,
|
|
1701
|
+
"--status", "canceled",
|
|
1702
|
+
"--close-reason", reason,
|
|
1703
|
+
"--message", reason,
|
|
1704
|
+
];
|
|
1705
|
+
}
|
|
1706
|
+
return [
|
|
1707
|
+
"--path", pmRoot, "update", entry.pmId,
|
|
1708
|
+
"--status", entry.toStatus,
|
|
1709
|
+
"--message", reason,
|
|
1710
|
+
];
|
|
1711
|
+
}
|
|
1712
|
+
// Pull a board status onto its pm item. `closed` goes through `pm close`
|
|
1713
|
+
// (records closed_at + close_reason); `canceled` keeps its distinct terminal
|
|
1714
|
+
// state via `pm update` while recording `--close-reason`; everything else is a
|
|
1715
|
+
// plain `pm update --status`.
|
|
1716
|
+
function applyPullEntry(entry, pmRoot) {
|
|
1717
|
+
const res = pmRun(buildPullEntryArgs(entry, pmRoot));
|
|
1718
|
+
return res.ok ? { changed: true } : { changed: false, error: res.stderr };
|
|
1719
|
+
}
|
|
1720
|
+
// Command handler for `pm github project sync`: preview (default) or apply the
|
|
1721
|
+
// bidirectional Projects v2 sync plan, honoring --push/--pull/--apply/--ids.
|
|
1722
|
+
async function runProjectSync(ctx) {
|
|
1723
|
+
const options = ctx.options || {};
|
|
1724
|
+
const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
|
|
1725
|
+
if (!ref) {
|
|
1726
|
+
throw new CommandError("Usage: pm github project sync <owner/number> [--push|--pull] [--apply] [--ids pm-1,..] [--status-map pm=Option,..] [--no-add-missing] [--prefer pm|github]", EXIT_CODE.USAGE);
|
|
1727
|
+
}
|
|
1728
|
+
const wantPush = optionEnabled(options, "push");
|
|
1729
|
+
const wantPull = optionEnabled(options, "pull");
|
|
1730
|
+
const dryRunFlag = optionEnabled(options, "dry-run", "dryRun");
|
|
1731
|
+
const apply = optionEnabled(options, "apply") && !dryRunFlag;
|
|
1732
|
+
const addMissing = !optionEnabled(options, "no-add-missing", "noAddMissing");
|
|
1733
|
+
const prefer = (optionString(options, "prefer") || "pm").toLowerCase() === "github" ? "github" : "pm";
|
|
1734
|
+
const statusMap = parseStatusMap(optionCsv(options, "status-map", "statusMap"));
|
|
1735
|
+
const idsProvided = optionProvided(options, "ids");
|
|
1736
|
+
const scopedIds = optionCsv(options, "ids");
|
|
1737
|
+
if (idsProvided && scopedIds.length === 0) {
|
|
1738
|
+
throw new CommandError("--ids requires at least one pm item id (comma-separated).", EXIT_CODE.USAGE);
|
|
1739
|
+
}
|
|
1740
|
+
// Direction resolution. Preview (no --apply) shows BOTH plans unless one is
|
|
1741
|
+
// explicitly requested. --apply defaults to push (never mutates pm silently).
|
|
1742
|
+
const previewBoth = !wantPush && !wantPull;
|
|
1743
|
+
const doPush = previewBoth || wantPush;
|
|
1744
|
+
const doPull = previewBoth || wantPull;
|
|
1745
|
+
const applyPush = apply && (wantPush || (!wantPush && !wantPull));
|
|
1746
|
+
const applyPull = apply && wantPull;
|
|
1747
|
+
const token = resolveGitHubToken();
|
|
1748
|
+
if (!token) {
|
|
1749
|
+
throw new CommandError("pm github project sync needs a GitHub token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
|
|
1750
|
+
}
|
|
1751
|
+
const meta = await resolveProject(ref, token);
|
|
1752
|
+
const projectItems = await fetchProjectItems(meta.id, token);
|
|
1753
|
+
const allPm = readPmItems(ctx.pm_root);
|
|
1754
|
+
const scoped = scopeItemsByIds(allPm, scopedIds.length > 0 ? scopedIds : undefined);
|
|
1755
|
+
if (scoped.missing.length > 0) {
|
|
1756
|
+
throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
|
|
1757
|
+
}
|
|
1758
|
+
const pmById = new Map();
|
|
1759
|
+
for (const it of scoped.selected)
|
|
1760
|
+
if (it.id)
|
|
1761
|
+
pmById.set(it.id, it);
|
|
1762
|
+
const pushPlan = doPush
|
|
1763
|
+
? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap })
|
|
1764
|
+
: undefined;
|
|
1765
|
+
const pullPlan = doPull
|
|
1766
|
+
? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap)
|
|
1767
|
+
: undefined;
|
|
1768
|
+
// Conflict resolution when applying BOTH directions: a linked item can appear
|
|
1769
|
+
// in both plans. `--prefer pm` (default) lets push win (skip its pull entry);
|
|
1770
|
+
// `--prefer github` lets pull win (skip its push set-status entry). Adds are
|
|
1771
|
+
// always safe (new items are never in the pull plan).
|
|
1772
|
+
const pushItemIds = new Set((pushPlan?.entries ?? []).filter((e) => e.action === "set-status").map((e) => e.itemId));
|
|
1773
|
+
const pullItemIds = new Set((pullPlan?.entries ?? []).map((e) => e.itemId));
|
|
1774
|
+
const pushActionable = (pushPlan?.entries ?? []).filter((e) => e.action !== "noop");
|
|
1775
|
+
const pullActionable = pullPlan?.entries ?? [];
|
|
1776
|
+
if (!apply) {
|
|
1777
|
+
if (pushPlan) {
|
|
1778
|
+
console.error(`[dry-run] push (pm → project ${ref.owner}/${ref.number}):`);
|
|
1779
|
+
for (const e of pushActionable) {
|
|
1780
|
+
const detail = e.action === "set-status" ? `${e.currentOptionName ?? "(none)"} → ${e.targetOptionName}`
|
|
1781
|
+
: e.action === "add-draft" ? `add draft${e.targetOptionName ? ` @ ${e.targetOptionName}` : ""}`
|
|
1782
|
+
: `add issue ${e.issueRepo}#${e.issueNumber}${e.targetOptionName ? ` @ ${e.targetOptionName}` : ""}`;
|
|
1783
|
+
console.error(` ${e.pmId} "${e.title}": ${detail}`);
|
|
1784
|
+
}
|
|
1785
|
+
for (const s of pushPlan.statusSkipped) {
|
|
1786
|
+
console.error(` [skip] ${s.pmId} "${s.title}": pm status "${s.status}" maps to no board option`);
|
|
1787
|
+
}
|
|
1788
|
+
if (pushActionable.length === 0)
|
|
1789
|
+
console.error(" (nothing to push)");
|
|
1790
|
+
}
|
|
1791
|
+
if (pullPlan) {
|
|
1792
|
+
console.error(`[dry-run] pull (project ${ref.owner}/${ref.number} → pm):`);
|
|
1793
|
+
for (const e of pullActionable)
|
|
1794
|
+
console.error(` ${e.pmId} "${e.title}": ${e.fromStatus} → ${e.toStatus}`);
|
|
1795
|
+
for (const s of pullPlan.statusSkipped) {
|
|
1796
|
+
console.error(` [skip] item ${s.itemId}: board status "${s.optionName ?? "(none)"}" maps to no pm status`);
|
|
1797
|
+
}
|
|
1798
|
+
if (pullActionable.length === 0)
|
|
1799
|
+
console.error(" (nothing to pull)");
|
|
1800
|
+
}
|
|
1801
|
+
console.error("Preview only — pass --apply with --push and/or --pull to write.");
|
|
1802
|
+
return {
|
|
1803
|
+
dryRun: true,
|
|
1804
|
+
project: `${ref.owner}/${ref.number}`,
|
|
1805
|
+
push: pushPlan ? { actionable: pushActionable.length, statusSkipped: pushPlan.statusSkipped.length } : undefined,
|
|
1806
|
+
pull: pullPlan ? { actionable: pullActionable.length, statusSkipped: pullPlan.statusSkipped.length } : undefined,
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
let pushed = 0;
|
|
1810
|
+
let pushFailed = 0;
|
|
1811
|
+
if (applyPush && pushPlan) {
|
|
1812
|
+
for (const e of pushActionable) {
|
|
1813
|
+
if (prefer === "github" && e.action === "set-status" && pullItemIds.has(e.itemId ?? "")) {
|
|
1814
|
+
continue; // pull wins for this linked item
|
|
1815
|
+
}
|
|
1816
|
+
const r = await applyPushEntry(e, meta, ref, pmById, ctx.pm_root, token);
|
|
1817
|
+
if (r.error && !r.changed) {
|
|
1818
|
+
console.error(` push ${e.pmId} "${e.title}": ${r.error}`);
|
|
1819
|
+
pushFailed++;
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
if (r.error)
|
|
1823
|
+
console.error(` push ${e.pmId}: ${r.error}`);
|
|
1824
|
+
pushed++;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
let pulled = 0;
|
|
1828
|
+
let pullFailed = 0;
|
|
1829
|
+
if (applyPull && pullPlan) {
|
|
1830
|
+
for (const e of pullActionable) {
|
|
1831
|
+
if (prefer === "pm" && pushItemIds.has(e.itemId))
|
|
1832
|
+
continue; // push wins
|
|
1833
|
+
const r = applyPullEntry(e, ctx.pm_root);
|
|
1834
|
+
if (!r.changed) {
|
|
1835
|
+
console.error(` pull ${e.pmId} "${e.title}": ${r.error}`);
|
|
1836
|
+
pullFailed++;
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
pulled++;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
console.error(`Sync complete on ${ref.owner}/${ref.number}: pushed ${pushed}${pushFailed ? ` (${pushFailed} failed)` : ""}, ` +
|
|
1843
|
+
`pulled ${pulled}${pullFailed ? ` (${pullFailed} failed)` : ""}.`);
|
|
1844
|
+
const result = { project: `${ref.owner}/${ref.number}`, pushed, pushFailed, pulled, pullFailed, prefer };
|
|
1845
|
+
if (pushFailed + pullFailed > 0 && pushed + pulled === 0) {
|
|
1846
|
+
throw new CommandError(`Sync wrote nothing; ${pushFailed + pullFailed} operation(s) failed.`);
|
|
1847
|
+
}
|
|
1848
|
+
return result;
|
|
1849
|
+
}
|
|
1850
|
+
// Report whether a github subcommand mutates GitHub (for the host CLI's
|
|
1851
|
+
// "mutating command" guard), accounting for --dry-run / --apply overrides.
|
|
1171
1852
|
export function isMutatingGithubCommand(command, options) {
|
|
1172
1853
|
const cmd = (command || "").toLowerCase();
|
|
1173
1854
|
const dryRun = optionEnabled(options, "dry-run", "dryRun");
|
|
@@ -1179,6 +1860,12 @@ export function isMutatingGithubCommand(command, options) {
|
|
|
1179
1860
|
return exportWillApply(options);
|
|
1180
1861
|
if (cmd === "github import" || cmd === "gh-issues import")
|
|
1181
1862
|
return !dryRun;
|
|
1863
|
+
if (cmd === "github project import")
|
|
1864
|
+
return !dryRun;
|
|
1865
|
+
// Project sync is dry-run by default; it only mutates with --apply (and no
|
|
1866
|
+
// --dry-run override).
|
|
1867
|
+
if (cmd === "github project sync")
|
|
1868
|
+
return optionEnabled(options, "apply") && !dryRun;
|
|
1182
1869
|
return false;
|
|
1183
1870
|
}
|
|
1184
1871
|
// ---------------------------------------------------------------------------
|
|
@@ -1216,9 +1903,32 @@ const SYNC_FLAGS = [
|
|
|
1216
1903
|
const VALIDATE_FLAGS = [
|
|
1217
1904
|
{ long: "--repo", value_name: "owner/repo", description: "Also check this repo is accessible with the resolved token" },
|
|
1218
1905
|
];
|
|
1906
|
+
const PROJECT_LIST_FLAGS = [
|
|
1907
|
+
{ long: "--owner", value_name: "login", description: "GitHub user or org login (or pass positionally)" },
|
|
1908
|
+
];
|
|
1909
|
+
const PROJECT_FIELDS_FLAGS = [
|
|
1910
|
+
{ long: "--project", value_name: "owner/number", description: "Project reference (or pass positionally, e.g. unbraind/5)" },
|
|
1911
|
+
];
|
|
1912
|
+
const PROJECT_IMPORT_FLAGS = [
|
|
1913
|
+
{ long: "--project", value_name: "owner/number", description: "Project reference (or pass positionally)" },
|
|
1914
|
+
{ long: "--dry-run", description: "Preview without writing pm items" },
|
|
1915
|
+
{ long: "--type", value_name: "type", description: "pm item type for created items (default: Task)" },
|
|
1916
|
+
{ long: "--status-map", value_name: "pm=Option,...", description: "Map board Status options to pm statuses, e.g. in_progress=Doing,closed=Shipped (inverted for import)" },
|
|
1917
|
+
];
|
|
1918
|
+
const PROJECT_SYNC_FLAGS = [
|
|
1919
|
+
{ long: "--project", value_name: "owner/number", description: "Project reference (or pass positionally)" },
|
|
1920
|
+
{ long: "--push", description: "pm → project: add missing items and set their Status" },
|
|
1921
|
+
{ long: "--pull", description: "project → pm: update pm item status from the board" },
|
|
1922
|
+
{ long: "--apply", description: "Write changes (default is a safe dry-run preview of both directions)" },
|
|
1923
|
+
{ long: "--ids", value_name: "pm-1,pm-2", description: "Only sync these pm item IDs (comma-separated)" },
|
|
1924
|
+
{ long: "--status-map", value_name: "pm=Option,...", description: "Map pm status to a board Status option, e.g. in_progress=Doing,closed=Shipped" },
|
|
1925
|
+
{ long: "--no-add-missing", description: "Push: only reconcile status of already-linked items; never add new board items" },
|
|
1926
|
+
{ long: "--prefer", value_name: "pm|github", description: "Conflict winner when applying both directions (default: pm)" },
|
|
1927
|
+
{ long: "--dry-run", description: "Preview only (always wins over --apply)" },
|
|
1928
|
+
];
|
|
1219
1929
|
export default defineExtension({
|
|
1220
1930
|
name: "pm-github",
|
|
1221
|
-
version: "2026.7.
|
|
1931
|
+
version: "2026.7.13",
|
|
1222
1932
|
activate(api) {
|
|
1223
1933
|
// -----------------------------------------------------------------------
|
|
1224
1934
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -1475,6 +2185,109 @@ export default defineExtension({
|
|
|
1475
2185
|
return report;
|
|
1476
2186
|
},
|
|
1477
2187
|
});
|
|
2188
|
+
// -----------------------------------------------------------------------
|
|
2189
|
+
// command — `pm github project list <owner>` (discover ProjectsV2)
|
|
2190
|
+
// -----------------------------------------------------------------------
|
|
2191
|
+
api.registerCommand({
|
|
2192
|
+
name: "github project list",
|
|
2193
|
+
description: "List the GitHub Projects v2 owned by a user or org (number, title, url, " +
|
|
2194
|
+
"closed state). Read-only. Use --json for machine output. Needs a token " +
|
|
2195
|
+
"with `project`/`read:project` scope for private projects.",
|
|
2196
|
+
intent: "discover GitHub Projects v2 for an owner",
|
|
2197
|
+
arguments: [{ name: "owner", required: false, description: "GitHub user or org login" }],
|
|
2198
|
+
examples: ["pm github project list unbraind", "pm github project list unbraind --json"],
|
|
2199
|
+
flags: PROJECT_LIST_FLAGS,
|
|
2200
|
+
failure_hints: [
|
|
2201
|
+
"Pass an owner login, e.g. `pm github project list unbraind`.",
|
|
2202
|
+
"Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` (private projects need `project` scope).",
|
|
2203
|
+
],
|
|
2204
|
+
async run(ctx) {
|
|
2205
|
+
return runProjectList(ctx);
|
|
2206
|
+
},
|
|
2207
|
+
});
|
|
2208
|
+
// -----------------------------------------------------------------------
|
|
2209
|
+
// command — `pm github project fields <owner/number>` (introspect schema)
|
|
2210
|
+
// -----------------------------------------------------------------------
|
|
2211
|
+
api.registerCommand({
|
|
2212
|
+
name: "github project fields",
|
|
2213
|
+
description: "Introspect a GitHub Project v2: its fields and, crucially, the Status " +
|
|
2214
|
+
"single-select options that push/pull map pm statuses to. Read-only. Use " +
|
|
2215
|
+
"this to design a --status-map. Use --json for machine output.",
|
|
2216
|
+
intent: "introspect Project v2 fields and Status options",
|
|
2217
|
+
arguments: [{ name: "owner/number", required: false, description: "Project reference, e.g. unbraind/5" }],
|
|
2218
|
+
examples: ["pm github project fields unbraind/5", "pm github project fields unbraind/5 --json"],
|
|
2219
|
+
flags: PROJECT_FIELDS_FLAGS,
|
|
2220
|
+
failure_hints: [
|
|
2221
|
+
"Pass <owner/number>, e.g. `pm github project fields unbraind/5`.",
|
|
2222
|
+
"A project without a Status field cannot receive pushed statuses (items are still added).",
|
|
2223
|
+
],
|
|
2224
|
+
async run(ctx) {
|
|
2225
|
+
return runProjectFields(ctx);
|
|
2226
|
+
},
|
|
2227
|
+
});
|
|
2228
|
+
// -----------------------------------------------------------------------
|
|
2229
|
+
// command — `pm github project import <owner/number>` (board → pm items)
|
|
2230
|
+
// Idempotent via the `gh-project:owner/number#itemId` provenance tag; draft
|
|
2231
|
+
// issues import too. Maps the board Status option → pm status.
|
|
2232
|
+
// -----------------------------------------------------------------------
|
|
2233
|
+
api.registerCommand({
|
|
2234
|
+
name: "github project import",
|
|
2235
|
+
description: "Import every item on a GitHub Project v2 board as pm items (draft issues " +
|
|
2236
|
+
"included). Idempotent on re-import via the `gh-project:owner/number#itemId` " +
|
|
2237
|
+
"provenance tag; items that wrap a real issue also carry the `gh:repo#N` tag " +
|
|
2238
|
+
"so issue- and project-import stay linked. The board's Status option maps to " +
|
|
2239
|
+
"the pm status. Safe: --dry-run previews without writing.",
|
|
2240
|
+
intent: "import GitHub Project v2 board items as pm items",
|
|
2241
|
+
arguments: [{ name: "owner/number", required: false, description: "Project reference, e.g. unbraind/5" }],
|
|
2242
|
+
examples: [
|
|
2243
|
+
"pm github project import unbraind/5",
|
|
2244
|
+
"pm github project import unbraind/5 --dry-run",
|
|
2245
|
+
"pm github project import unbraind/5 --status-map in_progress=Doing,closed=Shipped",
|
|
2246
|
+
],
|
|
2247
|
+
flags: PROJECT_IMPORT_FLAGS,
|
|
2248
|
+
failure_hints: [
|
|
2249
|
+
"Pass <owner/number>, e.g. `pm github project import unbraind/5`.",
|
|
2250
|
+
"Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` (needs `project`/`read:project`).",
|
|
2251
|
+
"Re-running is safe: linked items are updated, not duplicated.",
|
|
2252
|
+
],
|
|
2253
|
+
async run(ctx) {
|
|
2254
|
+
return runProjectImport(ctx);
|
|
2255
|
+
},
|
|
2256
|
+
});
|
|
2257
|
+
// -----------------------------------------------------------------------
|
|
2258
|
+
// command — `pm github project sync <owner/number>` (bidirectional)
|
|
2259
|
+
// SAFE BY DEFAULT: previews both directions and writes NOTHING. --apply
|
|
2260
|
+
// writes; direction chosen by --push/--pull (default --apply = push).
|
|
2261
|
+
// NEVER deletes/archives board items or pm items (no data loss).
|
|
2262
|
+
// -----------------------------------------------------------------------
|
|
2263
|
+
api.registerCommand({
|
|
2264
|
+
name: "github project sync",
|
|
2265
|
+
description: "Bidirectionally sync pm items and a GitHub Project v2 board. --push adds " +
|
|
2266
|
+
"missing pm items to the board (linking existing issues where possible, else " +
|
|
2267
|
+
"as draft issues) and sets each item's Status from its pm status. --pull " +
|
|
2268
|
+
"updates pm item status from the board. SAFE BY DEFAULT: with no --apply it " +
|
|
2269
|
+
"previews both directions and writes nothing. Unmapped statuses are SKIPPED, " +
|
|
2270
|
+
"never guessed. Never deletes or archives anything on either side.",
|
|
2271
|
+
intent: "bidirectionally sync pm items with a GitHub Project v2 board",
|
|
2272
|
+
arguments: [{ name: "owner/number", required: false, description: "Project reference, e.g. unbraind/5" }],
|
|
2273
|
+
examples: [
|
|
2274
|
+
"pm github project sync unbraind/5",
|
|
2275
|
+
"pm github project sync unbraind/5 --push --apply",
|
|
2276
|
+
"pm github project sync unbraind/5 --pull --apply",
|
|
2277
|
+
"pm github project sync unbraind/5 --push --pull --apply --prefer pm",
|
|
2278
|
+
"pm github project sync unbraind/5 --push --apply --ids pm-1,pm-2",
|
|
2279
|
+
],
|
|
2280
|
+
flags: PROJECT_SYNC_FLAGS,
|
|
2281
|
+
failure_hints: [
|
|
2282
|
+
"Preview is default; pass --apply with --push and/or --pull to write.",
|
|
2283
|
+
"--apply requires a GitHub token (GITHUB_TOKEN/GH_TOKEN or `gh auth login`).",
|
|
2284
|
+
"Design a --status-map with `pm github project fields <owner/number>` first.",
|
|
2285
|
+
"Use --ids <pm-1,pm-2> to scope; unknown IDs fail fast.",
|
|
2286
|
+
],
|
|
2287
|
+
async run(ctx) {
|
|
2288
|
+
return runProjectSync(ctx);
|
|
2289
|
+
},
|
|
2290
|
+
});
|
|
1478
2291
|
},
|
|
1479
2292
|
});
|
|
1480
2293
|
//# sourceMappingURL=index.js.map
|