pm-github 2026.7.10 → 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/dist/index.js CHANGED
@@ -1,15 +1,20 @@
1
- // pm-github — GitHub Issues sync (importer / exporter / sync) for pm-cli
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
- // One low-level request, no retry/backoff (that lives in `request`).
36
- function requestOnce(method, url, token, payload) {
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
- requestOnce(method, res.headers.location, token, payload).then(resolve, reject);
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
- const labels = (item.tags ?? []).filter((t) => !parseProvenanceTag(t));
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.10",
1931
+ version: "2026.7.13",
1222
1932
  activate(api) {
1223
1933
  // -----------------------------------------------------------------------
1224
1934
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -1253,13 +1963,7 @@ export default defineExtension({
1253
1963
  // -----------------------------------------------------------------------
1254
1964
  api.registerImporter("github", async (ctx) => {
1255
1965
  return runImport(ctx.args?.[0], ctx.pm_root, parseImportOptions(ctx.options || {}));
1256
- });
1257
- // The native importer capability exposes `pm github import`, but current pm
1258
- // runtimes synthesize that command without the importer's positional/flag
1259
- // contract. Register the command explicitly as well so a real installation
1260
- // can accept <owner/repo> and all importer options.
1261
- api.registerCommand({
1262
- name: "github import",
1966
+ }, {
1263
1967
  description: "Fetch GitHub issues from a repo and create/update pm items (idempotent " +
1264
1968
  "on re-import via the `gh:owner/repo#N` provenance tag). Skips pull " +
1265
1969
  "requests by default.",
@@ -1279,9 +1983,6 @@ export default defineExtension({
1279
1983
  "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for private repos / 5000 req/hr.",
1280
1984
  "Re-running is safe: existing items are updated, not duplicated.",
1281
1985
  ],
1282
- async run(ctx) {
1283
- return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options));
1284
- },
1285
1986
  });
1286
1987
  // -----------------------------------------------------------------------
1287
1988
  // exporter — `pm github export` (pm items → GitHub issues)
@@ -1292,7 +1993,26 @@ export default defineExtension({
1292
1993
  // (upsert) rather than duplicated. --json returns the plan object; we never
1293
1994
  // write our own stdout in JSON mode (pm renders the return value).
1294
1995
  // -----------------------------------------------------------------------
1295
- api.registerExporter("github", async (ctx) => runExport(ctx));
1996
+ api.registerExporter("github", async (ctx) => runExport(ctx), {
1997
+ description: "Export pm items as GitHub issues. SAFE BY DEFAULT: prints a create/update " +
1998
+ "plan and writes NOTHING. Use --apply --repo <owner/repo> to write to " +
1999
+ "GitHub; linked items are updated instead of duplicated.",
2000
+ intent: "export pm items as GitHub issues",
2001
+ examples: [
2002
+ "pm github export --repo unbraind/pm-cli",
2003
+ "pm github export --repo unbraind/pm-cli --dry-run",
2004
+ "pm github export --repo unbraind/pm-cli --apply",
2005
+ "pm github export --label-map bug=kind/bug,enhancement=kind/enhancement",
2006
+ "pm github export --ids pm-1,pm-2 --repo unbraind/pm-cli --dry-run",
2007
+ ],
2008
+ flags: EXPORT_FLAGS,
2009
+ failure_hints: [
2010
+ "Export is dry-run by default; pass --apply --repo <owner/repo> to write.",
2011
+ "--apply requires a GitHub token (GITHUB_TOKEN/GH_TOKEN or `gh auth login`).",
2012
+ "--label-map takes from=to pairs, e.g. --label-map bug=kind/bug,enhancement=kind/enhancement.",
2013
+ "Use --ids <pm-1,pm-2> to scope export; unknown IDs fail fast.",
2014
+ ],
2015
+ });
1296
2016
  // -----------------------------------------------------------------------
1297
2017
  // search — reach GitHub from `pm search` for imported items.
1298
2018
  // Guarded by a capability check so it is a no-op on runtimes that predate
@@ -1372,38 +2092,6 @@ export default defineExtension({
1372
2092
  "to push this status change upstream (or --dry-run to preview).");
1373
2093
  });
1374
2094
  // -----------------------------------------------------------------------
1375
- // command — `pm github export` (pm items → GitHub issues)
1376
- // Surfaces the exporter as a discoverable command with explicit flags so
1377
- // `--label-map`, `--dry-run`, `--apply` and `--ids` are self-documenting.
1378
- // Delegates to the same `runExport` core the exporter entry point uses.
1379
- // -----------------------------------------------------------------------
1380
- api.registerCommand({
1381
- name: "github export",
1382
- description: "Export pm items as GitHub issues. SAFE BY DEFAULT: prints a create/update " +
1383
- "plan and writes NOTHING. Use --apply --repo <owner/repo> to write to " +
1384
- "GitHub; items already linked to an issue in that repo (via the " +
1385
- "`gh:owner/repo#N` provenance tag) are updated (upsert) instead of " +
1386
- "duplicated. --label-map translates pm tags to GitHub labels.",
1387
- intent: "export pm items as GitHub issues",
1388
- examples: [
1389
- "pm github export --repo unbraind/pm-cli",
1390
- "pm github export --repo unbraind/pm-cli --dry-run",
1391
- "pm github export --repo unbraind/pm-cli --apply",
1392
- "pm github export --label-map bug=kind/bug,enhancement=kind/enhancement",
1393
- "pm github export --ids pm-1,pm-2 --repo unbraind/pm-cli --dry-run",
1394
- ],
1395
- flags: EXPORT_FLAGS,
1396
- failure_hints: [
1397
- "Export is dry-run by default; pass --apply --repo <owner/repo> to write.",
1398
- "--apply requires a GitHub token (GITHUB_TOKEN/GH_TOKEN or `gh auth login`).",
1399
- "--label-map takes from=to pairs, e.g. --label-map bug=kind/bug,enhancement=kind/enhancement.",
1400
- "Use --ids <pm-1,pm-2> to scope export; unknown IDs fail fast.",
1401
- ],
1402
- async run(ctx) {
1403
- return runExport(ctx);
1404
- },
1405
- });
1406
- // -----------------------------------------------------------------------
1407
2095
  // command — `pm github sync` (push pm status → GitHub close/reopen)
1408
2096
  // -----------------------------------------------------------------------
1409
2097
  api.registerCommand({
@@ -1497,6 +2185,109 @@ export default defineExtension({
1497
2185
  return report;
1498
2186
  },
1499
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
+ });
1500
2291
  },
1501
2292
  });
1502
2293
  //# sourceMappingURL=index.js.map