pm-github 2026.8.4 → 2026.8.10
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 +17 -0
- package/dist/index.d.ts +415 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +533 -110
- package/dist/index.js.map +1 -1
- package/dist/projects.d.ts +105 -0
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +67 -19
- package/dist/projects.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -43,10 +43,16 @@ export function resolveGitHubToken() {
|
|
|
43
43
|
return undefined;
|
|
44
44
|
}
|
|
45
45
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Decide whether the Authorization token may be forwarded across a redirect.
|
|
48
|
+
*
|
|
49
|
+
* Only same-origin (scheme + host + port) redirects keep the credential; any
|
|
50
|
+
* origin change drops it so the bearer token can never leak to a third party.
|
|
51
|
+
*
|
|
52
|
+
* @param fromUrl - The URL the redirect originated from.
|
|
53
|
+
* @param toUrl - The redirect target URL.
|
|
54
|
+
* @returns True when both URLs share an origin.
|
|
55
|
+
*/
|
|
50
56
|
export function sameOrigin(fromUrl, toUrl) {
|
|
51
57
|
try {
|
|
52
58
|
return new URL(fromUrl).origin.toLowerCase() === new URL(toUrl).origin.toLowerCase();
|
|
@@ -102,9 +108,14 @@ export function githubApiBase() {
|
|
|
102
108
|
// Strip a trailing slash so `${base}/repos/...` cannot produce `//repos/...`.
|
|
103
109
|
return raw.replace(/\/+$/, "");
|
|
104
110
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
/**
|
|
112
|
+
* One low-level HTTP request, with no retry or backoff.
|
|
113
|
+
*
|
|
114
|
+
* That orchestration lives in the surrounding {@link request} wrapper. This
|
|
115
|
+
* function follows up to `redirectsLeft` redirects, rejecting on a cycle or an
|
|
116
|
+
* over-long chain rather than overflowing the stack, and forwards the bearer
|
|
117
|
+
* token only to same-origin targets via {@link sameOrigin}.
|
|
118
|
+
*/
|
|
108
119
|
function requestOnce(method, url, token, payload, redirectsLeft = 5) {
|
|
109
120
|
return new Promise((resolve, reject) => {
|
|
110
121
|
const headers = {
|
|
@@ -170,10 +181,20 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
|
|
|
170
181
|
req.end();
|
|
171
182
|
});
|
|
172
183
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Compute how long (ms) to wait before retrying a rate-limited or transient
|
|
186
|
+
* response.
|
|
187
|
+
*
|
|
188
|
+
* Honors `Retry-After` (seconds) and the primary rate-limit reset window
|
|
189
|
+
* (`X-RateLimit-Remaining: 0` + `X-RateLimit-Reset` epoch), then falls back to
|
|
190
|
+
* exponential backoff. The result is capped so a CLI run is never hung
|
|
191
|
+
* indefinitely.
|
|
192
|
+
*
|
|
193
|
+
* @param headers - Response headers to read the retry hints from.
|
|
194
|
+
* @param attempt - Zero-based retry index feeding the exponential backoff floor.
|
|
195
|
+
* @param nowMs - Current epoch ms, used to measure the reset window's remaining time.
|
|
196
|
+
* @returns The capped wait in milliseconds.
|
|
197
|
+
*/
|
|
177
198
|
export function computeBackoffMs(headers, attempt, nowMs = Date.now()) {
|
|
178
199
|
const get = (k) => {
|
|
179
200
|
const v = headers[k] ?? headers[k.toLowerCase()];
|
|
@@ -449,7 +470,17 @@ export const EXIT_CODE = {
|
|
|
449
470
|
USAGE: 2,
|
|
450
471
|
NOT_FOUND: 3,
|
|
451
472
|
};
|
|
473
|
+
/**
|
|
474
|
+
* Error that carries a semantic process exit code.
|
|
475
|
+
*
|
|
476
|
+
* pm's command runtime treats a thrown error as a cleanly handled non-zero exit
|
|
477
|
+
* only when it exposes a numeric `exitCode`; a plain `Error` instead falls
|
|
478
|
+
* through to the "unhandled" path, which re-invokes the handler (doubling side
|
|
479
|
+
* effects such as a second GitHub fetch) and exits with a generic code. Throwing
|
|
480
|
+
* this routes a failure to a clean, single exit at the chosen code.
|
|
481
|
+
*/
|
|
452
482
|
export class CommandError extends Error {
|
|
483
|
+
/** Numeric exit code the runtime propagates to the shell (one of {@link EXIT_CODE}). */
|
|
453
484
|
exitCode;
|
|
454
485
|
constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
|
|
455
486
|
super(message);
|
|
@@ -460,24 +491,46 @@ export class CommandError extends Error {
|
|
|
460
491
|
// ---------------------------------------------------------------------------
|
|
461
492
|
// Provenance — link a pm item back to a specific GitHub issue
|
|
462
493
|
// ---------------------------------------------------------------------------
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
494
|
+
/**
|
|
495
|
+
* Build the `gh:owner/repo#N` provenance tag linking a pm item to a GitHub issue.
|
|
496
|
+
*
|
|
497
|
+
* The tag is the idempotency key: it round-trips losslessly through
|
|
498
|
+
* `pm create --tags` / `pm list --json`, so a re-import can find the existing
|
|
499
|
+
* item and UPDATE it instead of duplicating. Provenance also rides on declared
|
|
500
|
+
* schema fields and the description, but the tag is what matching keys on.
|
|
501
|
+
*
|
|
502
|
+
* @param repo - `owner/repo`, lowercased into the tag.
|
|
503
|
+
* @param issueNumber - The issue/PR number.
|
|
504
|
+
* @returns The provenance tag string.
|
|
505
|
+
*/
|
|
467
506
|
export function provenanceTag(repo, issueNumber) {
|
|
468
507
|
return `gh:${repo.toLowerCase()}#${issueNumber}`;
|
|
469
508
|
}
|
|
470
|
-
|
|
471
|
-
|
|
509
|
+
/**
|
|
510
|
+
* Parse a `gh:owner/repo#N` provenance tag back into its repo and issue number.
|
|
511
|
+
*
|
|
512
|
+
* Returns `undefined` for anything that is not a provenance tag, so a caller
|
|
513
|
+
* scanning a tag list can skip foreign tags without a try/catch.
|
|
514
|
+
*
|
|
515
|
+
* @param tag - The candidate tag string.
|
|
516
|
+
* @returns The parsed repo (lowercased) and number, or `undefined`.
|
|
517
|
+
*/
|
|
472
518
|
export function parseProvenanceTag(tag) {
|
|
473
519
|
const m = /^gh:([^#\s]+)#(\d+)$/.exec(tag.trim());
|
|
474
520
|
if (!m)
|
|
475
521
|
return undefined;
|
|
476
522
|
return { repo: m[1].toLowerCase(), number: Number(m[2]) };
|
|
477
523
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Build the `github_author:login` tag recording who opened a GitHub issue.
|
|
526
|
+
*
|
|
527
|
+
* Mirrors how provenance rides on tags so the author survives a round-trip.
|
|
528
|
+
* Returns `undefined` when the API supplied no usable login, so an empty tag is
|
|
529
|
+
* never emitted.
|
|
530
|
+
*
|
|
531
|
+
* @param issue - The issue whose author to tag.
|
|
532
|
+
* @returns The author tag, or `undefined`.
|
|
533
|
+
*/
|
|
481
534
|
export function authorTag(issue) {
|
|
482
535
|
const login = issue.user?.login?.trim();
|
|
483
536
|
if (!login)
|
|
@@ -937,11 +990,19 @@ export function composeBody(issue, comments) {
|
|
|
937
990
|
// reports "degraded" and proceeds unlocked with a warning, matching the
|
|
938
991
|
// pre-lock best-effort behavior.
|
|
939
992
|
export const IMPORT_LOCK_TTL_MS_DEFAULT = 5 * 60_000;
|
|
993
|
+
/** Default total budget a caller waits to acquire a contended comment-sync lock before giving up and skipping that item's sync. */
|
|
940
994
|
export const IMPORT_LOCK_WAIT_MS_DEFAULT = 30_000;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
995
|
+
/**
|
|
996
|
+
* Resolve the pm data dir from the `pmRoot` a command handler receives.
|
|
997
|
+
*
|
|
998
|
+
* The host may hand either the workspace root (the dir containing `.agents/pm`)
|
|
999
|
+
* or the data dir itself — the pm CLI accepts both for `--path`. This returns
|
|
1000
|
+
* the dir that actually holds `settings.json` and `locks/`, defaulting to
|
|
1001
|
+
* `pmRoot` unchanged when no nested `.agents/pm` exists.
|
|
1002
|
+
*
|
|
1003
|
+
* @param pmRoot - The path supplied by the extension host.
|
|
1004
|
+
* @returns The resolved pm data directory.
|
|
1005
|
+
*/
|
|
945
1006
|
export function resolvePmDataDir(pmRoot) {
|
|
946
1007
|
const nested = path.join(pmRoot, ".agents", "pm");
|
|
947
1008
|
try {
|
|
@@ -953,13 +1014,31 @@ export function resolvePmDataDir(pmRoot) {
|
|
|
953
1014
|
}
|
|
954
1015
|
return pmRoot;
|
|
955
1016
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1017
|
+
/**
|
|
1018
|
+
* Absolute lock file path for one item's comment-sync critical section.
|
|
1019
|
+
*
|
|
1020
|
+
* The item id is sanitized defensively: pm ids are already filename-safe, but
|
|
1021
|
+
* the lock path must never become a directory-traversal vector if that ever
|
|
1022
|
+
* changes.
|
|
1023
|
+
*
|
|
1024
|
+
* @param pmRoot - Workspace root or pm data dir.
|
|
1025
|
+
* @param itemId - The pm item id to serialize.
|
|
1026
|
+
* @returns Absolute path under the data dir's `locks/` directory.
|
|
1027
|
+
*/
|
|
959
1028
|
export function importCommentSyncLockPath(pmRoot, itemId) {
|
|
960
1029
|
const safe = itemId.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
961
1030
|
return path.join(resolvePmDataDir(pmRoot), "locks", `pm-github.comment-sync.${safe}.lock`);
|
|
962
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Read and parse a lock file's payload.
|
|
1034
|
+
*
|
|
1035
|
+
* Returns `undefined` when the file is absent or holds non-JSON / non-object
|
|
1036
|
+
* content (e.g. caught mid-write then abandoned), so the caller treats an
|
|
1037
|
+
* unreadable lock as owner-less rather than crashing.
|
|
1038
|
+
*
|
|
1039
|
+
* @param lockPath - Absolute lock file path.
|
|
1040
|
+
* @returns The parsed payload, or `undefined`.
|
|
1041
|
+
*/
|
|
963
1042
|
function readImportLockPayload(lockPath) {
|
|
964
1043
|
let raw;
|
|
965
1044
|
try {
|
|
@@ -978,6 +1057,16 @@ function readImportLockPayload(lockPath) {
|
|
|
978
1057
|
return undefined;
|
|
979
1058
|
}
|
|
980
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Whether the process holding a lock is still running.
|
|
1062
|
+
*
|
|
1063
|
+
* Signals the PID with signal 0 (no effect); `EPERM` means the process exists
|
|
1064
|
+
* but belongs to another user (still alive), while `ESRCH` means it is gone.
|
|
1065
|
+
* Non-numeric or non-positive PIDs are treated as not-alive.
|
|
1066
|
+
*
|
|
1067
|
+
* @param pid - The recorded owner PID (untyped at the call site).
|
|
1068
|
+
* @returns True when the PID names a live process.
|
|
1069
|
+
*/
|
|
981
1070
|
function isLockOwnerAlive(pid) {
|
|
982
1071
|
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
983
1072
|
return false;
|
|
@@ -1005,6 +1094,22 @@ function isLockOwnerAlive(pid) {
|
|
|
1005
1094
|
// same-pid payload that is legitimately ours (in-process contention → wait)
|
|
1006
1095
|
// from a leftover written by a dead process whose PID we inherited (→ break).
|
|
1007
1096
|
const heldImportLocks = new Set();
|
|
1097
|
+
/**
|
|
1098
|
+
* Decide whether a held lock is stale and why.
|
|
1099
|
+
*
|
|
1100
|
+
* Returns a human-readable reason when the lock should be broken, or
|
|
1101
|
+
* `undefined` when it is still legitimately held. A lock is stale only when its
|
|
1102
|
+
* owner is provably gone: the recorded PID is dead, or it equals our own PID on
|
|
1103
|
+
* a path this process does not hold (a recycled PID). A LIVE owner is never
|
|
1104
|
+
* age-broken; only a missing/unparseable payload falls back to the TTL, aged
|
|
1105
|
+
* from `created_at` or the file mtime.
|
|
1106
|
+
*
|
|
1107
|
+
* @param lockPath - Absolute lock file path (also tracked in {@link heldImportLocks}).
|
|
1108
|
+
* @param payload - Parsed lock payload, or `undefined` when unreadable.
|
|
1109
|
+
* @param mtimeMs - File mtime, the age fallback base.
|
|
1110
|
+
* @param ttlMs - Maximum age before an owner-less lock is considered stale.
|
|
1111
|
+
* @returns The staleness reason, or `undefined` when the lock is live.
|
|
1112
|
+
*/
|
|
1008
1113
|
function importLockStaleReason(lockPath, payload, mtimeMs, ttlMs) {
|
|
1009
1114
|
const pid = payload?.pid;
|
|
1010
1115
|
if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) {
|
|
@@ -1028,20 +1133,29 @@ function importLockStaleReason(lockPath, payload, mtimeMs, ttlMs) {
|
|
|
1028
1133
|
const IMPORT_LOCK_INITIAL_BACKOFF_MS = 25;
|
|
1029
1134
|
const IMPORT_LOCK_MAX_BACKOFF_MS = 200;
|
|
1030
1135
|
const IMPORT_LOCK_MAX_STALE_BREAKS = 3;
|
|
1031
|
-
|
|
1032
|
-
|
|
1136
|
+
/**
|
|
1137
|
+
* Staleness TTL for a breaker election sidecar, in ms.
|
|
1138
|
+
*
|
|
1139
|
+
* A breaker's critical section is a handful of syscalls (re-stat, re-read,
|
|
1140
|
+
* unlink), so a crashed breaker's sidecar goes stale in seconds, not minutes;
|
|
1141
|
+
* this short window lets a contender clear and re-run a dead election.
|
|
1142
|
+
*/
|
|
1033
1143
|
export const IMPORT_LOCK_BREAKER_TTL_MS = 10_000;
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1144
|
+
/**
|
|
1145
|
+
* Break a stale lock under a breaker election.
|
|
1146
|
+
*
|
|
1147
|
+
* Two contenders may both judge the same file stale; without mutual exclusion
|
|
1148
|
+
* the slower unlink would remove a fresh, live lock the winner had just
|
|
1149
|
+
* re-created — the exact double-acquire this module exists to prevent. The
|
|
1150
|
+
* election is an O_EXCL sidecar (`<lock>.break`): only its single winner may
|
|
1151
|
+
* unlink, and it re-verifies staleness under that mutex first, so a lock that
|
|
1152
|
+
* became live since the caller's check survives.
|
|
1153
|
+
*
|
|
1154
|
+
* @param lockPath - Absolute path of the lock judged stale.
|
|
1155
|
+
* @param ttlMs - Staleness TTL handed to {@link importLockStaleReason} on re-check.
|
|
1156
|
+
* @param reason - Human-readable staleness reason, surfaced in the break warning.
|
|
1157
|
+
* @returns True when the stale lock was removed (retry acquire now); false when the election was lost or the lock is live again.
|
|
1158
|
+
*/
|
|
1045
1159
|
function breakStaleImportLock(lockPath, ttlMs, reason) {
|
|
1046
1160
|
const breakerPath = `${lockPath}.break`;
|
|
1047
1161
|
let bfd;
|
|
@@ -1096,9 +1210,19 @@ function breakStaleImportLock(lockPath, ttlMs, reason) {
|
|
|
1096
1210
|
}
|
|
1097
1211
|
}
|
|
1098
1212
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1213
|
+
/**
|
|
1214
|
+
* Acquire the cross-process comment-sync lock for one item.
|
|
1215
|
+
*
|
|
1216
|
+
* Never throws: every failure mode maps to one of the three acquisition
|
|
1217
|
+
* statuses (`acquired`, `contended`, `degraded`) so the caller decides how to
|
|
1218
|
+
* degrade. `ttlMs`/`waitMs` are injectable so the wait and staleness windows
|
|
1219
|
+
* can be exercised without real timing in tests.
|
|
1220
|
+
*
|
|
1221
|
+
* @param pmRoot - Workspace root or pm data dir passed to {@link resolvePmDataDir}.
|
|
1222
|
+
* @param itemId - The pm item whose comment critical section is serialized.
|
|
1223
|
+
* @param opts - Optional overrides for the staleness TTL and wait budget.
|
|
1224
|
+
* @returns The acquisition outcome.
|
|
1225
|
+
*/
|
|
1102
1226
|
export async function acquireImportLock(pmRoot, itemId, opts = {}) {
|
|
1103
1227
|
const ttlMs = opts.ttlMs ?? IMPORT_LOCK_TTL_MS_DEFAULT;
|
|
1104
1228
|
const waitMs = opts.waitMs ?? IMPORT_LOCK_WAIT_MS_DEFAULT;
|
|
@@ -1221,18 +1345,35 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
|
|
|
1221
1345
|
// the GitHub comment id (`<!-- pm-github:comment:N -->`). On re-import the
|
|
1222
1346
|
// already-synced ids are read back and skipped, so re-running import never
|
|
1223
1347
|
// duplicates a comment.
|
|
1224
|
-
|
|
1225
|
-
|
|
1348
|
+
/**
|
|
1349
|
+
* Matches the hidden HTML-comment marker carrying a synced GitHub comment id.
|
|
1350
|
+
*
|
|
1351
|
+
* HTML comments are invisible in rendered markdown but survive `pm comments`
|
|
1352
|
+
* storage verbatim, so the id embedded in the marker is the stable dedupe key.
|
|
1353
|
+
*/
|
|
1226
1354
|
export const COMMENT_MARKER_REGEX = /<!--\s*pm-github:comment:(\d+)\s*-->/;
|
|
1227
|
-
|
|
1228
|
-
|
|
1355
|
+
/**
|
|
1356
|
+
* Build the text for one native pm comment from a GitHub comment.
|
|
1357
|
+
*
|
|
1358
|
+
* Appends the marker so a later re-sync can de-duplicate on the GitHub comment
|
|
1359
|
+
* id (see {@link COMMENT_MARKER_REGEX}).
|
|
1360
|
+
*
|
|
1361
|
+
* @param comment - The GitHub comment to render.
|
|
1362
|
+
* @returns The comment text with the trailing id marker.
|
|
1363
|
+
*/
|
|
1229
1364
|
export function buildCommentText(comment) {
|
|
1230
1365
|
const body = (comment.body || "").trim() || "(empty comment)";
|
|
1231
1366
|
return `${body}\n\n<!-- pm-github:comment:${comment.id} -->`;
|
|
1232
1367
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1368
|
+
/**
|
|
1369
|
+
* Collect the GitHub comment ids already synced into an item's native comments.
|
|
1370
|
+
*
|
|
1371
|
+
* Scans each stored comment's text for the stable marker. Returns the set of
|
|
1372
|
+
* synced ids (empty when none match, e.g. for hand-written pm comments).
|
|
1373
|
+
*
|
|
1374
|
+
* @param stored - The item's existing native comments (each may carry marker text).
|
|
1375
|
+
* @returns The set of already-synced GitHub comment ids.
|
|
1376
|
+
*/
|
|
1236
1377
|
export function extractSyncedCommentIds(stored) {
|
|
1237
1378
|
const ids = new Set();
|
|
1238
1379
|
for (const entry of stored) {
|
|
@@ -1263,6 +1404,17 @@ export function extractSyncedCommentIds(stored) {
|
|
|
1263
1404
|
//
|
|
1264
1405
|
// Returns undefined when the output genuinely cannot be parsed, so callers fall
|
|
1265
1406
|
// back to a safe skip-with-warning instead of crashing the whole import.
|
|
1407
|
+
/**
|
|
1408
|
+
* Extract the newly-created item id from `pm create --json` stdout.
|
|
1409
|
+
*
|
|
1410
|
+
* The CLI emits a FLAT envelope (`{ "id": "pm-xxxx", … }`) with no `item`
|
|
1411
|
+
* wrapper, so this reads `parsed.id` directly. Returns `undefined` when the
|
|
1412
|
+
* output genuinely cannot be parsed, so callers skip-with-warning instead of
|
|
1413
|
+
* crashing the whole import.
|
|
1414
|
+
*
|
|
1415
|
+
* @param stdout - Raw `pm create --json` output.
|
|
1416
|
+
* @returns The created item id, or `undefined`.
|
|
1417
|
+
*/
|
|
1266
1418
|
export function parseCreatedItemId(stdout) {
|
|
1267
1419
|
try {
|
|
1268
1420
|
const parsed = JSON.parse(stdout);
|
|
@@ -1292,6 +1444,21 @@ export function parseCreatedItemId(stdout) {
|
|
|
1292
1444
|
// (never run unlocked, so a wedged peer can cost a sync — recoverable on the
|
|
1293
1445
|
// next import — but never cause a duplicate); if the lock mechanism itself is
|
|
1294
1446
|
// unavailable the sync proceeds unlocked as before, also with a warning.
|
|
1447
|
+
/**
|
|
1448
|
+
* Sync GitHub issue comments into a pm item's native comments collection.
|
|
1449
|
+
*
|
|
1450
|
+
* Idempotent: comments already present (matched by their marker id) are skipped,
|
|
1451
|
+
* so re-running import never duplicates. Each GitHub comment becomes one pm
|
|
1452
|
+
* comment authored by the GitHub login. Failures are logged and never abort the
|
|
1453
|
+
* import; a contended or unavailable lock degrades gracefully (see
|
|
1454
|
+
* {@link acquireImportLock}).
|
|
1455
|
+
*
|
|
1456
|
+
* @param itemId - The pm item to append comments to.
|
|
1457
|
+
* @param comments - The GitHub comments to sync.
|
|
1458
|
+
* @param pmRoot - Workspace root or pm data dir.
|
|
1459
|
+
* @param issueNumber - The source issue number (for log prefixes).
|
|
1460
|
+
* @returns How many comments were added and how many skipped as duplicates.
|
|
1461
|
+
*/
|
|
1295
1462
|
export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot, issueNumber) {
|
|
1296
1463
|
if (comments.length === 0)
|
|
1297
1464
|
return { added: 0, skipped: 0 };
|
|
@@ -1337,12 +1504,29 @@ export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot,
|
|
|
1337
1504
|
release();
|
|
1338
1505
|
}
|
|
1339
1506
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1507
|
+
/**
|
|
1508
|
+
* Whether a GitHub issue node is a draft pull request.
|
|
1509
|
+
*
|
|
1510
|
+
* A draft PR is an issue that is BOTH a pull request and flagged `draft: true`;
|
|
1511
|
+
* plain issues are never drafts.
|
|
1512
|
+
*
|
|
1513
|
+
* @param issue - The issue node to test.
|
|
1514
|
+
* @returns True only for draft pull requests.
|
|
1515
|
+
*/
|
|
1342
1516
|
export function isDraftPr(issue) {
|
|
1343
1517
|
return Boolean(issue.pull_request) && issue.draft === true;
|
|
1344
1518
|
}
|
|
1345
|
-
|
|
1519
|
+
/**
|
|
1520
|
+
* Apply the client-side import filters the REST API cannot express.
|
|
1521
|
+
*
|
|
1522
|
+
* Drops pull requests (unless `includePrs`), draft PRs (when `skipDrafts`), and
|
|
1523
|
+
* items whose milestone title does not match (the API keys milestones by number,
|
|
1524
|
+
* not title). Returns the surviving issues in input order.
|
|
1525
|
+
*
|
|
1526
|
+
* @param issues - Fetched issues to narrow.
|
|
1527
|
+
* @param opts - Import options carrying the filter flags.
|
|
1528
|
+
* @returns The filtered issue list.
|
|
1529
|
+
*/
|
|
1346
1530
|
export function applyClientFilters(issues, opts) {
|
|
1347
1531
|
let result = issues;
|
|
1348
1532
|
if (!opts.includePrs)
|
|
@@ -1356,8 +1540,17 @@ export function applyClientFilters(issues, opts) {
|
|
|
1356
1540
|
}
|
|
1357
1541
|
return result;
|
|
1358
1542
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
1543
|
+
/**
|
|
1544
|
+
* Normalize the raw CLI flag bag into a typed {@link ImportOptions}.
|
|
1545
|
+
*
|
|
1546
|
+
* Resolves the state filter, labels, assignee, milestone, the `--since` window,
|
|
1547
|
+
* PR inclusion, draft skipping, and the comment-fetching mode, throwing a
|
|
1548
|
+
* {@link CommandError} with a usage code on a malformed `--since` or
|
|
1549
|
+
* `--comments-mode`.
|
|
1550
|
+
*
|
|
1551
|
+
* @param options - The raw option object from the command handler.
|
|
1552
|
+
* @returns The normalized import options.
|
|
1553
|
+
*/
|
|
1361
1554
|
export function parseImportOptions(options) {
|
|
1362
1555
|
// --state takes precedence; --all is the legacy shorthand for "all".
|
|
1363
1556
|
const stateOpt = optionString(options, "state");
|
|
@@ -1430,6 +1623,16 @@ const DEP_REF_STICKY = /(?:([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+))?#(\d+)/y;
|
|
|
1430
1623
|
// ("#1, #2 and #3"). Sticky and can match empty, so the ref loop always
|
|
1431
1624
|
// terminates: the next sticky ref match is what decides whether to continue.
|
|
1432
1625
|
const DEP_GLUE_STICKY = /[\s,]*(?:and\b[\s,]*)?/y;
|
|
1626
|
+
/**
|
|
1627
|
+
* Map a dependency phrase captured from an issue body to its pm edge kind.
|
|
1628
|
+
*
|
|
1629
|
+
* Collapses spacing/hyphen variants and returns both the canonical
|
|
1630
|
+
* {@link DepRefKind} and a normalized human phrase ("blocks", "depends on", or
|
|
1631
|
+
* "blocked by") for audit messages.
|
|
1632
|
+
*
|
|
1633
|
+
* @param raw - The raw phrase matched by the dependency-phrase regex.
|
|
1634
|
+
* @returns The resolved kind and normalized phrase.
|
|
1635
|
+
*/
|
|
1433
1636
|
function phraseToKind(raw) {
|
|
1434
1637
|
const compact = raw.toLowerCase().replace(/[\s-]/g, "");
|
|
1435
1638
|
if (compact === "blocks")
|
|
@@ -1548,12 +1751,17 @@ export function countDependencyRefCandidates(repo, issues) {
|
|
|
1548
1751
|
n += parseDependencyReferences(issue.body ?? "", repoLc).length;
|
|
1549
1752
|
return n;
|
|
1550
1753
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1754
|
+
/**
|
|
1755
|
+
* Build the default SDK-backed dependency-link collaborators.
|
|
1756
|
+
*
|
|
1757
|
+
* Wires the `--link-deps` second pass to the top-level SDK imports so it never
|
|
1758
|
+
* needs a dynamic import. `listAllItemMetadata` returns full `ItemMetadata`
|
|
1759
|
+
* objects, projected here to the {@link DepLinkSnapshotItem} structural subset
|
|
1760
|
+
* the link pass reads; the SDK cycle detector reads only id/tags/dependencies,
|
|
1761
|
+
* so the cast on `collectNewOrderingCycleWarnings` is sound.
|
|
1762
|
+
*
|
|
1763
|
+
* @returns The default SDK collaborators.
|
|
1764
|
+
*/
|
|
1557
1765
|
function defaultDepLinkSdk() {
|
|
1558
1766
|
return {
|
|
1559
1767
|
listAllItemMetadata: async (pmRoot) => {
|
|
@@ -1567,6 +1775,17 @@ function defaultDepLinkSdk() {
|
|
|
1567
1775
|
collectNewOrderingCycleWarnings: sdkCollectNewOrderingCycleWarnings,
|
|
1568
1776
|
};
|
|
1569
1777
|
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Default edge applier: spawn `pm update --dep` for one resolved edge.
|
|
1780
|
+
*
|
|
1781
|
+
* Records the source issue and the matching phrase in the mutation message so
|
|
1782
|
+
* the resulting dependency is auditable. Returns the ok/stderr shape every edge
|
|
1783
|
+
* applier shares, never throwing.
|
|
1784
|
+
*
|
|
1785
|
+
* @param edge - The resolved workspace edge to write.
|
|
1786
|
+
* @param pmRoot - Workspace root for the `pm` invocation.
|
|
1787
|
+
* @returns Whether the write succeeded and any stderr.
|
|
1788
|
+
*/
|
|
1570
1789
|
function defaultApplyDependencyLink(edge, pmRoot) {
|
|
1571
1790
|
const res = pmRun([
|
|
1572
1791
|
"--path", pmRoot, "update", edge.sourceId,
|
|
@@ -1644,6 +1863,21 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
|
|
|
1644
1863
|
}
|
|
1645
1864
|
return { linked, unresolved, orderingCycleWarnings: [...warnings], failures };
|
|
1646
1865
|
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Render one GitHub issue into the import-ready desired state.
|
|
1868
|
+
*
|
|
1869
|
+
* Computes the title, status, provenance/author tags, description, body (with
|
|
1870
|
+
* optional embedded comments), and the comment list for native sync. Returns
|
|
1871
|
+
* `undefined` for an issue with no usable title so the caller counts it skipped.
|
|
1872
|
+
*
|
|
1873
|
+
* @param issue - The fetched issue to render.
|
|
1874
|
+
* @param repo - `owner/repo`, baked into the provenance tag.
|
|
1875
|
+
* @param opts - Import options governing body/comments.
|
|
1876
|
+
* @param token - GitHub token for the comment fetch.
|
|
1877
|
+
* @param match - The existing pm item linked to this issue, if any (drives update vs create).
|
|
1878
|
+
* @param fetchIssueComments - Injectable comment fetcher (defaults to {@link fetchComments}).
|
|
1879
|
+
* @returns The prepared import entry, or `undefined` when the issue has no title.
|
|
1880
|
+
*/
|
|
1647
1881
|
async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
|
|
1648
1882
|
const title = issue.title.trim();
|
|
1649
1883
|
if (!title)
|
|
@@ -1686,9 +1920,20 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
|
|
|
1686
1920
|
match,
|
|
1687
1921
|
};
|
|
1688
1922
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1923
|
+
/**
|
|
1924
|
+
* Run the full GitHub issue import flow.
|
|
1925
|
+
*
|
|
1926
|
+
* Idempotent: items already linked (by provenance tag) to a fetched issue are
|
|
1927
|
+
* UPDATEd; new issues are created. Honors `--atomic` (one crash-resumable
|
|
1928
|
+
* transaction) versus the per-item `pm` mutation path, optional `--link-deps`,
|
|
1929
|
+
* and `--dry-run`. Returns a structured result and throws {@link CommandError}
|
|
1930
|
+
* (with a semantic exit code) on failure.
|
|
1931
|
+
*
|
|
1932
|
+
* @param repoArg - The `owner/repo` to import from.
|
|
1933
|
+
* @param pmRoot - Workspace root or pm data dir.
|
|
1934
|
+
* @param opts - Normalized import options.
|
|
1935
|
+
* @param deps - Injectable collaborators (token resolver, fetchers, etc.) for tests.
|
|
1936
|
+
*/
|
|
1692
1937
|
export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
1693
1938
|
if (!repoArg || !repoArg.includes("/")) {
|
|
1694
1939
|
throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
|
|
@@ -1976,8 +2221,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
|
1976
2221
|
reportDepLink(depLink);
|
|
1977
2222
|
return { imported, updated, skipped, ...depLinkResultFields(depLink) };
|
|
1978
2223
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
2224
|
+
/**
|
|
2225
|
+
* Emit the human-readable `--link-deps` summary to stderr.
|
|
2226
|
+
*
|
|
2227
|
+
* Import progress is written to stderr so stdout stays reserved for the
|
|
2228
|
+
* structured result; this prints the linked-edge count, unresolved refs, and
|
|
2229
|
+
* any ordering-cycle warnings or per-edge failures.
|
|
2230
|
+
*/
|
|
1981
2231
|
function reportDepLink(result) {
|
|
1982
2232
|
if (!result)
|
|
1983
2233
|
return;
|
|
@@ -1990,8 +2240,12 @@ function reportDepLink(result) {
|
|
|
1990
2240
|
for (const failure of result.failures)
|
|
1991
2241
|
console.error(` ✗ link failed: ${failure}`);
|
|
1992
2242
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
2243
|
+
/**
|
|
2244
|
+
* The public, JSON-serializable slice of a `--link-deps` result.
|
|
2245
|
+
*
|
|
2246
|
+
* Kept flat and omitted entirely when the pass did not run, so the default
|
|
2247
|
+
* import result is unchanged.
|
|
2248
|
+
*/
|
|
1995
2249
|
function depLinkResultFields(result) {
|
|
1996
2250
|
if (!result)
|
|
1997
2251
|
return {};
|
|
@@ -2174,13 +2428,15 @@ export function exportWillApply(options) {
|
|
|
2174
2428
|
return true;
|
|
2175
2429
|
return optionEnabled(options, "apply", "push");
|
|
2176
2430
|
}
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2431
|
+
/**
|
|
2432
|
+
* Apply an already-built export plan to GitHub, one issue at a time.
|
|
2433
|
+
*
|
|
2434
|
+
* Each create/update is isolated: a single failed write (e.g. a 422 for a label
|
|
2435
|
+
* that does not exist on the repo) is recorded and the loop CONTINUES with the
|
|
2436
|
+
* remaining items — it never abandons the rest of the batch. Pure aside from the
|
|
2437
|
+
* injected `requestFn`, so the per-item isolation is directly unit-testable
|
|
2438
|
+
* without real network I/O.
|
|
2439
|
+
*/
|
|
2184
2440
|
export async function applyExportPlan(plan, repo, token, requestFn) {
|
|
2185
2441
|
let created = 0;
|
|
2186
2442
|
let updated = 0;
|
|
@@ -2235,6 +2491,19 @@ export async function applyExportPlan(plan, repo, token, requestFn) {
|
|
|
2235
2491
|
// - any creates/updates landed (partial or full) → success (per-item
|
|
2236
2492
|
// failures are already reported; the batch still wrote real changes)
|
|
2237
2493
|
// - non-empty plan, nothing written, >=1 failure → GENERIC_FAILURE
|
|
2494
|
+
/**
|
|
2495
|
+
* Decide the exit status of a completed `export --apply` batch.
|
|
2496
|
+
*
|
|
2497
|
+
* Per-item continuation is intentional (one bad item never aborts the batch),
|
|
2498
|
+
* but the batch as a whole must still report honestly: a non-empty plan that
|
|
2499
|
+
* wrote NOTHING while recording at least one failure is a total failure, so it
|
|
2500
|
+
* returns a {@link CommandError} to throw; otherwise `undefined` (exit 0).
|
|
2501
|
+
*
|
|
2502
|
+
* @param plan - The plan that was applied.
|
|
2503
|
+
* @param result - What the apply loop actually wrote.
|
|
2504
|
+
* @param repo - Target repo, included in the failure message.
|
|
2505
|
+
* @returns A `CommandError` to throw on total failure, else `undefined`.
|
|
2506
|
+
*/
|
|
2238
2507
|
export function applyOutcomeError(plan, result, repo) {
|
|
2239
2508
|
if (plan.length > 0 && result.created === 0 && result.updated === 0 && result.failed > 0) {
|
|
2240
2509
|
return new CommandError(`All ${result.failed} item(s) failed to apply to ${repo}; ` +
|
|
@@ -2255,6 +2524,14 @@ export function applyOutcomeError(plan, result, repo) {
|
|
|
2255
2524
|
// stdout in JSON mode (pm renders the return value). Used by both the
|
|
2256
2525
|
// `registerExporter("github", ...)` entry point and the `pm github export`
|
|
2257
2526
|
// command so the surface stays consistent.
|
|
2527
|
+
/**
|
|
2528
|
+
* Shared handler for the `pm github export` exporter and command.
|
|
2529
|
+
*
|
|
2530
|
+
* Export is SAFE BY DEFAULT: it previews the create/update plan and writes
|
|
2531
|
+
* nothing unless the user opts in with `--apply` (plus a token and `--repo`).
|
|
2532
|
+
* Items already linked to an issue in `--repo` are upserted rather than
|
|
2533
|
+
* duplicated; `--label-map` translates pm tags to GitHub labels.
|
|
2534
|
+
*/
|
|
2258
2535
|
async function runExport(ctx) {
|
|
2259
2536
|
const options = ctx.options || {};
|
|
2260
2537
|
const jsonMode = ctx.global?.json === true;
|
|
@@ -2366,16 +2643,32 @@ async function runExport(ctx) {
|
|
|
2366
2643
|
// repo match the query, then returns hits for the pm items that are already
|
|
2367
2644
|
// imported from those issues (matched by the `gh:repo#N` provenance tag).
|
|
2368
2645
|
// Semantics: "find my imported pm items whose upstream GitHub issue matches Q".
|
|
2369
|
-
|
|
2370
|
-
|
|
2646
|
+
/**
|
|
2647
|
+
* Build the GitHub Search-API URL for issues in one repo matching a free-text query.
|
|
2648
|
+
*
|
|
2649
|
+
* Restricted to `type:issue repo:<repo>` so a search never leaks across repos.
|
|
2650
|
+
*
|
|
2651
|
+
* @param repo - The `owner/repo` to search within.
|
|
2652
|
+
* @param query - Free-text query string.
|
|
2653
|
+
* @returns The encoded search URL.
|
|
2654
|
+
*/
|
|
2371
2655
|
export function buildSearchUrl(repo, query) {
|
|
2372
2656
|
const q = `${query} repo:${repo} type:issue`;
|
|
2373
2657
|
return `${githubApiBase()}/search/issues?q=${encodeURIComponent(q)}&per_page=100`;
|
|
2374
2658
|
}
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2659
|
+
/**
|
|
2660
|
+
* Map GitHub search result numbers to local pm-item hits.
|
|
2661
|
+
*
|
|
2662
|
+
* For every returned issue number, looks up the pm item carrying the matching
|
|
2663
|
+
* `gh:repo#N` provenance tag; only locally-present items become hits (the
|
|
2664
|
+
* runtime would drop the rest anyway). Score preserves GitHub's ranking,
|
|
2665
|
+
* normalized into `(0, 1]` so hits clear pm's default threshold.
|
|
2666
|
+
*
|
|
2667
|
+
* @param matchedNumbers - Issue numbers GitHub returned, best-first.
|
|
2668
|
+
* @param repo - The repo the search ran against.
|
|
2669
|
+
* @param itemsByProvenance - Local items keyed `owner/repo#N`.
|
|
2670
|
+
* @returns One hit per locally-present matching item.
|
|
2671
|
+
*/
|
|
2379
2672
|
export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
|
|
2380
2673
|
const repoLc = repo.toLowerCase();
|
|
2381
2674
|
const hits = [];
|
|
@@ -2399,8 +2692,15 @@ export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
|
|
|
2399
2692
|
}
|
|
2400
2693
|
return hits;
|
|
2401
2694
|
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2695
|
+
/**
|
|
2696
|
+
* Resolve the search target repo for the GitHub search provider.
|
|
2697
|
+
*
|
|
2698
|
+
* An explicit option wins, then the `PM_GITHUB_REPO` env var so a workspace can
|
|
2699
|
+
* pin its upstream. Returns `undefined` when neither yields an `owner/repo`.
|
|
2700
|
+
*
|
|
2701
|
+
* @param options - The raw option object from the search context.
|
|
2702
|
+
* @returns The resolved `owner/repo`, or `undefined`.
|
|
2703
|
+
*/
|
|
2404
2704
|
export function resolveSearchRepo(options) {
|
|
2405
2705
|
const opt = optionString(options, "repo", "github-repo", "githubRepo");
|
|
2406
2706
|
if (opt && opt.includes("/"))
|
|
@@ -2543,9 +2843,18 @@ const STATUS_FIELD_GQL = `
|
|
|
2543
2843
|
statusField: field(name: "Status") {
|
|
2544
2844
|
... on ProjectV2SingleSelectField { id name options { id name } }
|
|
2545
2845
|
}`;
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2846
|
+
/**
|
|
2847
|
+
* Resolve a project reference into its GraphQL id and Status field.
|
|
2848
|
+
*
|
|
2849
|
+
* The owner may be a user or an org, and GraphQL requires us to pick which
|
|
2850
|
+
* owner-type field to query, so this asks both in one request and keeps
|
|
2851
|
+
* whichever connection resolves. Throws {@link CommandError} (NOT_FOUND) when
|
|
2852
|
+
* neither resolves (wrong owner, wrong number, or missing project scope).
|
|
2853
|
+
*
|
|
2854
|
+
* @param ref - The `owner/number` board reference.
|
|
2855
|
+
* @param token - GitHub token (the GraphQL call requires one).
|
|
2856
|
+
* @returns The board metadata including its Status field, if any.
|
|
2857
|
+
*/
|
|
2549
2858
|
async function resolveProject(ref, token) {
|
|
2550
2859
|
const query = `
|
|
2551
2860
|
query($owner:String!,$number:Int!){
|
|
@@ -2565,9 +2874,16 @@ async function resolveProject(ref, token) {
|
|
|
2565
2874
|
const statusField = sf && sf.id ? { id: sf.id, name: sf.name, options: sf.options ?? [] } : undefined;
|
|
2566
2875
|
return { id: node.id, title: node.title ?? "", url: node.url ?? "", ownerType, statusField };
|
|
2567
2876
|
}
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2877
|
+
/**
|
|
2878
|
+
* Normalize a raw GraphQL project-item node into the {@link ProjectItem} model.
|
|
2879
|
+
*
|
|
2880
|
+
* Tolerates null/undefined nodes and missing content, returning a placeholder
|
|
2881
|
+
* `Unknown` content for redacted or unmodeled item types so they are counted but
|
|
2882
|
+
* never mutated.
|
|
2883
|
+
*
|
|
2884
|
+
* @param n - The raw GraphQL node (may be null).
|
|
2885
|
+
* @returns The normalized project item.
|
|
2886
|
+
*/
|
|
2571
2887
|
function normalizeProjectItemNode(n) {
|
|
2572
2888
|
const c = n?.content ?? { __typename: "Unknown" };
|
|
2573
2889
|
const tn = c.__typename;
|
|
@@ -2609,7 +2925,17 @@ function normalizeProjectItemNode(n) {
|
|
|
2609
2925
|
content,
|
|
2610
2926
|
};
|
|
2611
2927
|
}
|
|
2612
|
-
|
|
2928
|
+
/**
|
|
2929
|
+
* Fetch every item on a board, paging through the connection at 100/page.
|
|
2930
|
+
*
|
|
2931
|
+
* Walks the GraphQL cursor until `hasNextPage` is false, normalizing each node
|
|
2932
|
+
* via {@link normalizeProjectItemNode}. Stops cleanly when a page reports no
|
|
2933
|
+
* connection so the loop never truncates silently nor spins on a missing cursor.
|
|
2934
|
+
*
|
|
2935
|
+
* @param projectId - GraphQL node id of the board.
|
|
2936
|
+
* @param token - GitHub token.
|
|
2937
|
+
* @returns Every item on the board, in page order.
|
|
2938
|
+
*/
|
|
2613
2939
|
async function fetchProjectItems(projectId, token) {
|
|
2614
2940
|
const query = `
|
|
2615
2941
|
query($id:ID!,$cursor:String){
|
|
@@ -2649,7 +2975,15 @@ async function fetchProjectItems(projectId, token) {
|
|
|
2649
2975
|
}
|
|
2650
2976
|
return items;
|
|
2651
2977
|
}
|
|
2652
|
-
|
|
2978
|
+
/**
|
|
2979
|
+
* Add a draft issue to a Projects v2 board and return the new project-item id.
|
|
2980
|
+
*
|
|
2981
|
+
* @param projectId - GraphQL node id of the board.
|
|
2982
|
+
* @param title - Draft issue title.
|
|
2983
|
+
* @param body - Draft issue body (empty string when absent).
|
|
2984
|
+
* @param token - GitHub token.
|
|
2985
|
+
* @returns The newly created project-item id.
|
|
2986
|
+
*/
|
|
2653
2987
|
async function gqlAddDraft(projectId, title, body, token) {
|
|
2654
2988
|
const q = `mutation($p:ID!,$t:String!,$b:String){ addProjectV2DraftIssue(input:{projectId:$p,title:$t,body:$b}){ projectItem{ id } } }`;
|
|
2655
2989
|
const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body ?? "" });
|
|
@@ -2658,8 +2992,14 @@ async function gqlAddDraft(projectId, title, body, token) {
|
|
|
2658
2992
|
throw new CommandError("addProjectV2DraftIssue returned no item id.");
|
|
2659
2993
|
return id;
|
|
2660
2994
|
}
|
|
2661
|
-
|
|
2662
|
-
|
|
2995
|
+
/**
|
|
2996
|
+
* Link an existing repo issue/PR to a board and return the new project-item id.
|
|
2997
|
+
*
|
|
2998
|
+
* @param projectId - GraphQL node id of the board.
|
|
2999
|
+
* @param contentId - GraphQL node id of the issue/PR to attach.
|
|
3000
|
+
* @param token - GitHub token.
|
|
3001
|
+
* @returns The newly created project-item id.
|
|
3002
|
+
*/
|
|
2663
3003
|
async function gqlAddIssue(projectId, contentId, token) {
|
|
2664
3004
|
const q = `mutation($p:ID!,$c:ID!){ addProjectV2ItemById(input:{projectId:$p,contentId:$c}){ item{ id } } }`;
|
|
2665
3005
|
const d = await githubGraphQL(token, q, { p: projectId, c: contentId });
|
|
@@ -2673,8 +3013,14 @@ async function gqlSetStatus(projectId, itemId, fieldId, optionId, token) {
|
|
|
2673
3013
|
const q = `mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){ updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f,value:{singleSelectOptionId:$o}}){ projectV2Item{ id } } }`;
|
|
2674
3014
|
await githubGraphQL(token, q, { p: projectId, i: itemId, f: fieldId, o: optionId });
|
|
2675
3015
|
}
|
|
2676
|
-
|
|
2677
|
-
|
|
3016
|
+
/**
|
|
3017
|
+
* Resolve the GraphQL node id of a repo's issue or PR by number.
|
|
3018
|
+
*
|
|
3019
|
+
* @param repo - `owner/repo` to look up in.
|
|
3020
|
+
* @param number - The issue/PR number.
|
|
3021
|
+
* @param token - GitHub token.
|
|
3022
|
+
* @returns The node id, or `undefined` when not found or the repo ref is malformed.
|
|
3023
|
+
*/
|
|
2678
3024
|
async function gqlResolveIssueNodeId(repo, number, token) {
|
|
2679
3025
|
const [owner, name] = repo.split("/");
|
|
2680
3026
|
if (!owner || !name)
|
|
@@ -2683,8 +3029,6 @@ async function gqlResolveIssueNodeId(repo, number, token) {
|
|
|
2683
3029
|
const d = await githubGraphQL(token, q, { o: owner, n: name, num: number });
|
|
2684
3030
|
return d.repository?.issueOrPullRequest?.id ?? undefined;
|
|
2685
3031
|
}
|
|
2686
|
-
// Compose a human-readable + provenance-bearing description for an imported
|
|
2687
|
-
// project item (parallels the issue-import description).
|
|
2688
3032
|
/** Build a factual close reason for a project-import item entering `closed`. */
|
|
2689
3033
|
function projectImportCloseReason(ref, c) {
|
|
2690
3034
|
if (c.repo && typeof c.number === "number") {
|
|
@@ -2692,6 +3036,17 @@ function projectImportCloseReason(ref, c) {
|
|
|
2692
3036
|
}
|
|
2693
3037
|
return `GitHub project ${ref.owner}/${ref.number} item closed`;
|
|
2694
3038
|
}
|
|
3039
|
+
/**
|
|
3040
|
+
* Compose a human-readable, provenance-bearing description for an imported
|
|
3041
|
+
* project item.
|
|
3042
|
+
*
|
|
3043
|
+
* Parallels the issue-import description: joins the board ref, item kind, issue
|
|
3044
|
+
* link, URL, and state into one summary string written to the pm item.
|
|
3045
|
+
*
|
|
3046
|
+
* @param ref - The board the item came from.
|
|
3047
|
+
* @param c - The item's resolved content.
|
|
3048
|
+
* @returns The composed description line.
|
|
3049
|
+
*/
|
|
2695
3050
|
function projectItemDescription(ref, c) {
|
|
2696
3051
|
const parts = [`GH project item ${ref.owner}/${ref.number}`];
|
|
2697
3052
|
if (c.typename === "DraftIssue")
|
|
@@ -2731,12 +3086,18 @@ export async function collectProjectsV2Pages(fetchPage) {
|
|
|
2731
3086
|
}
|
|
2732
3087
|
return out;
|
|
2733
3088
|
}
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
3089
|
+
/**
|
|
3090
|
+
* Resolve and paginate the `projectsV2` connection for a user-or-org owner.
|
|
3091
|
+
*
|
|
3092
|
+
* A login is either a user or an organization, never both; querying both in one
|
|
3093
|
+
* request resolves the owner type without an extra round-trip, then this keeps
|
|
3094
|
+
* paginating the connection that actually exists so owners with more than 100
|
|
3095
|
+
* projects are fully listed instead of silently truncated.
|
|
3096
|
+
*
|
|
3097
|
+
* @param owner - The user or org login.
|
|
3098
|
+
* @param graphQL - Injectable transport (query + variables → data), for tests.
|
|
3099
|
+
* @returns Every project-summary node for the owner.
|
|
3100
|
+
*/
|
|
2740
3101
|
export async function listOwnerProjectsV2Nodes(owner, graphQL) {
|
|
2741
3102
|
let ownerType = null;
|
|
2742
3103
|
return collectProjectsV2Pages(async (cursor) => {
|
|
@@ -2755,6 +3116,11 @@ export async function listOwnerProjectsV2Nodes(owner, graphQL) {
|
|
|
2755
3116
|
return ownerType === "organization" ? d.organization?.projectsV2 : d.user?.projectsV2;
|
|
2756
3117
|
});
|
|
2757
3118
|
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Command handler for `pm github project list`: list a user/org's Projects v2.
|
|
3121
|
+
*
|
|
3122
|
+
* @param ctx - The command-handler context.
|
|
3123
|
+
*/
|
|
2758
3124
|
async function runProjectList(ctx) {
|
|
2759
3125
|
const options = ctx.options || {};
|
|
2760
3126
|
const owner = optionString(options, "owner") || ctx.args?.[0];
|
|
@@ -2784,6 +3150,14 @@ async function runProjectList(ctx) {
|
|
|
2784
3150
|
return { owner, projects };
|
|
2785
3151
|
}
|
|
2786
3152
|
// --- project fields --------------------------------------------------------
|
|
3153
|
+
/**
|
|
3154
|
+
* Command handler for `pm github project fields`: show a board's fields.
|
|
3155
|
+
*
|
|
3156
|
+
* Resolves the board, then lists its fields and (for the Status single-select)
|
|
3157
|
+
* its option names so the caller can build a `--status-map`.
|
|
3158
|
+
*
|
|
3159
|
+
* @param ctx - The command-handler context.
|
|
3160
|
+
*/
|
|
2787
3161
|
async function runProjectFields(ctx) {
|
|
2788
3162
|
const options = ctx.options || {};
|
|
2789
3163
|
const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
|
|
@@ -2818,6 +3192,15 @@ async function runProjectFields(ctx) {
|
|
|
2818
3192
|
return { project: meta, fields };
|
|
2819
3193
|
}
|
|
2820
3194
|
// --- project import --------------------------------------------------------
|
|
3195
|
+
/**
|
|
3196
|
+
* Command handler for `pm github project import`: import board items as pm items.
|
|
3197
|
+
*
|
|
3198
|
+
* Idempotent: items already linked (by project tag or wrapped issue) are updated,
|
|
3199
|
+
* the rest are created. Status is refreshed only from an explicit, resolvable
|
|
3200
|
+
* board mapping (never a guess), honoring the no-data-loss invariant.
|
|
3201
|
+
*
|
|
3202
|
+
* @param ctx - The command-handler context.
|
|
3203
|
+
*/
|
|
2821
3204
|
async function runProjectImport(ctx) {
|
|
2822
3205
|
const options = ctx.options || {};
|
|
2823
3206
|
const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
|
|
@@ -2938,8 +3321,21 @@ async function runProjectImport(ctx) {
|
|
|
2938
3321
|
return { imported, updated, skipped, project: `${ref.owner}/${ref.number}`, planned: plan.length };
|
|
2939
3322
|
}
|
|
2940
3323
|
// --- project sync (bidirectional) ------------------------------------------
|
|
2941
|
-
|
|
2942
|
-
|
|
3324
|
+
/**
|
|
3325
|
+
* Push one pm item's status onto its board item.
|
|
3326
|
+
*
|
|
3327
|
+
* Tolerates a missing target option (writes the item but skips the Status set).
|
|
3328
|
+
* Returns whether a real change was made and an optional error string so the
|
|
3329
|
+
* caller can continue the batch per-item.
|
|
3330
|
+
*
|
|
3331
|
+
* @param entry - The push-plan entry to execute.
|
|
3332
|
+
* @param meta - The board metadata (id, Status field).
|
|
3333
|
+
* @param ref - The board reference (for the provenance tag).
|
|
3334
|
+
* @param pmById - Local pm items keyed by id (for body lookup and tagging).
|
|
3335
|
+
* @param pmRoot - Workspace root for the `pm` tag write.
|
|
3336
|
+
* @param token - GitHub token for the GraphQL calls.
|
|
3337
|
+
* @returns Whether the item changed, plus an optional error message.
|
|
3338
|
+
*/
|
|
2943
3339
|
async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
2944
3340
|
try {
|
|
2945
3341
|
let itemId = entry.itemId;
|
|
@@ -3001,6 +3397,18 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
|
|
|
3001
3397
|
// and fail for terminal→canceled transitions. Recording `--close-reason` on the
|
|
3002
3398
|
// `pm update` keeps the distinction AND the lifecycle metadata, and works for
|
|
3003
3399
|
// active→canceled and terminal→canceled alike.
|
|
3400
|
+
/**
|
|
3401
|
+
* Build the `pm` CLI argv that applies one pull (project → pm) status transition.
|
|
3402
|
+
*
|
|
3403
|
+
* Pure so the pm lifecycle contract is unit-testable without spawning `pm`:
|
|
3404
|
+
* `closed` goes through `pm close`; `canceled` keeps its distinct terminal state
|
|
3405
|
+
* via `pm update --status canceled` with `--close-reason`; everything else is a
|
|
3406
|
+
* plain `pm update --status`. See the long note above for the pm CLI contracts.
|
|
3407
|
+
*
|
|
3408
|
+
* @param entry - The pull-plan entry to render argv for.
|
|
3409
|
+
* @param pmRoot - Workspace root for the `pm` invocation.
|
|
3410
|
+
* @returns The argv array.
|
|
3411
|
+
*/
|
|
3004
3412
|
export function buildPullEntryArgs(entry, pmRoot) {
|
|
3005
3413
|
const reason = `GitHub project status → ${entry.toStatus}`;
|
|
3006
3414
|
if (entry.toStatus === "closed") {
|
|
@@ -3028,8 +3436,15 @@ function applyPullEntry(entry, pmRoot) {
|
|
|
3028
3436
|
const res = pmRun(buildPullEntryArgs(entry, pmRoot));
|
|
3029
3437
|
return res.ok ? { changed: true } : { changed: false, error: res.stderr };
|
|
3030
3438
|
}
|
|
3031
|
-
|
|
3032
|
-
|
|
3439
|
+
/**
|
|
3440
|
+
* Command handler for `pm github project sync`: preview or apply the
|
|
3441
|
+
* bidirectional Projects v2 sync plan.
|
|
3442
|
+
*
|
|
3443
|
+
* Honors `--push`/`--pull` (default previews both), `--apply`, `--ids`, and the
|
|
3444
|
+
* `--prefer` conflict winner when both directions touch the same linked item.
|
|
3445
|
+
*
|
|
3446
|
+
* @param ctx - The command-handler context.
|
|
3447
|
+
*/
|
|
3033
3448
|
async function runProjectSync(ctx) {
|
|
3034
3449
|
const options = ctx.options || {};
|
|
3035
3450
|
const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
|
|
@@ -3158,8 +3573,16 @@ async function runProjectSync(ctx) {
|
|
|
3158
3573
|
}
|
|
3159
3574
|
return result;
|
|
3160
3575
|
}
|
|
3161
|
-
|
|
3162
|
-
|
|
3576
|
+
/**
|
|
3577
|
+
* Report whether a github subcommand will mutate GitHub.
|
|
3578
|
+
*
|
|
3579
|
+
* Feeds the host CLI's "mutating command" guard, accounting for the `--dry-run`
|
|
3580
|
+
* and `--apply` overrides that turn an otherwise-mutating command into a preview.
|
|
3581
|
+
*
|
|
3582
|
+
* @param command - The subcommand string (e.g. `github sync`).
|
|
3583
|
+
* @param options - The raw option object from the command handler.
|
|
3584
|
+
* @returns True when the command will perform real GitHub writes.
|
|
3585
|
+
*/
|
|
3163
3586
|
export function isMutatingGithubCommand(command, options) {
|
|
3164
3587
|
const cmd = (command || "").toLowerCase();
|
|
3165
3588
|
const dryRun = optionEnabled(options, "dry-run", "dryRun");
|
|
@@ -3251,7 +3674,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
3251
3674
|
const defineExtension = (module) => module;
|
|
3252
3675
|
export default defineExtension({
|
|
3253
3676
|
name: "pm-github",
|
|
3254
|
-
version: "2026.8.
|
|
3677
|
+
version: "2026.8.10",
|
|
3255
3678
|
activate(api) {
|
|
3256
3679
|
// -----------------------------------------------------------------------
|
|
3257
3680
|
// schema — declare the GitHub provenance fields so the workspace knows them
|