-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
5349 lines (5058 loc) · 221 KB
/
Copy pathindex.ts
File metadata and controls
5349 lines (5058 loc) · 221 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// pm-github — GitHub Issues + Projects v2 sync for pm-cli
//
// Capabilities (see manifest.json):
// commands — `pm gh-issues import` (legacy) + `pm github sync` +
// `pm github project list|fields|import|sync` (Projects v2)
// importers — `pm github import <owner/repo>` (idempotent native import)
// exporters — `pm github export` (render pm items as a GitHub-issues payload)
// schema — declares github_url / github_number / github_state /
// github_author / github_created_at / github_updated_at item fields
// hooks — afterCommand: actionable sync hint for github-linked items
// preflight — local guard for mutating github commands (token presence)
//
// Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2
// section below and the pure plan/mapping logic in ./projects.ts).
import type {
AfterCommandHookContext,
CommandHandlerContext,
ExtensionApi,
ExtensionModule,
ImportExportContext,
PreflightOverrideContext,
SearchProviderQueryContext,
} from "@unbrained/pm-cli/sdk/authoring";
import http from "node:http";
import https from "node:https";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
comments as pmCommentsFn,
commitItemMutations as sdkCommitItemMutations,
listAllItemMetadata as sdkListAllItemMetadata,
normalizeItemId as sdkNormalizeItemId,
readSettings as sdkReadSettings,
type BulkItemMutation,
type CommentsCommandOptions,
type CommentsResult,
type CommitItemMutationsOptions,
type CommitItemMutationsResult,
type ItemDocument,
type ItemMetadata,
type PmClientOptions,
} from "@unbrained/pm-cli/sdk";
import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
import {
type ProjectItem,
type ProjectItemContent,
type ProjectMeta,
type ProjectRef,
type ProjectStatusField,
buildProjectImportPlan,
buildProjectPullPlan,
buildProjectPushPlan,
parseProjectItemTag,
parseProjectRef,
parseStatusMap,
projectItemTag,
} from "./projects.ts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* One GitHub issue or pull request as returned by the REST issues endpoint.
*
* The REST issues endpoint lists PRs alongside issues (a PR is an issue that
* also carries a `pull_request` object); {@link isDraftPr} and the
* `includePrs` / `skipDrafts` filters decide which of those survive into an
* import.
*/
export interface GhIssue {
/** Issue/PR number within its repo (the `N` in `gh:owner/repo#N` provenance). */
number: number;
/** One-line issue title, imported as the pm item title. */
title: string;
/** Markdown body, imported as the pm item body (`null` when GitHub stored none). */
body: string | null;
/** GitHub lifecycle state; mapped to a pm status by {@link mapState}. */
state: "open" | "closed";
/** Why a closed issue was closed; `not_planned` maps to `canceled`, not `closed`. */
state_reason?: "completed" | "not_planned" | "reopened" | null;
/** Labels on the issue, imported verbatim as pm tags (before any `--label-map`). */
labels: Array<{ name: string }>;
/** Author login, surfaced as a `github_author:` tag when present. */
user?: { login: string } | null;
/** Assigned user, or `null` (imported as the item assignee when set). */
assignee: { login: string } | null;
/** Milestone title, or `null` (filtered client-side by `--milestone`). */
milestone: { title: string } | null;
/** ISO 8601 creation timestamp. */
created_at: string;
/** ISO 8601 last-update timestamp, used by the `--since` incremental filter. */
updated_at: string;
/** GitHub completion timestamp (`null` while the issue is open); carried as `--completed-at` on close. */
closed_at?: string | null;
/** Browser URL of the issue, embedded in the imported description. */
html_url: string;
/** Comment count GitHub reports; drives whether comments are fetched at all. */
comments?: number;
/** REST URL of the issue's comments collection (paginated when present). */
comments_url?: string;
/** Present (opaque) when the issue is actually a pull request; its presence is the PR signal. */
pull_request?: unknown;
/** `true` when the issue is a draft pull request (GitHub sets this only on draft PRs). */
draft?: boolean;
}
export interface GhComment {
id: number;
user: { login: string } | null;
created_at: string;
body: string | null;
}
// How fetched GitHub issue comments are persisted on the pm item.
//
// - "body" — (default, byte-identical to pre-2026.7.14 behavior) flatten
// comments into the item body as blockquoted markdown under a
// `### GitHub comments (N)` heading. Governed by --with-comments.
// - "annotations" — sync comments into the pm item's native comments collection
// via the SDK `comments()` primitive. Comments are fetched
// regardless of --with-comments. Re-sync is idempotent: each
// stored comment carries a stable `<!-- pm-github:comment:N -->`
// marker (the GitHub comment id), so re-running import never
// duplicates.
// - "both" — write the body section AND sync the native comments.
type CommentsMode = "body" | "annotations" | "both";
const COMMENTS_MODES: readonly CommentsMode[] = ["body", "annotations", "both"];
/**
* Normalized options governing one GitHub issue import.
*
* Produced by {@link parseImportOptions} from the raw CLI flag bag so the rest
* of the import path consumes a typed shape rather than re-parsing strings.
*/
export interface ImportOptions {
/** GitHub issue state to fetch: `open`, `closed`, or `all`. */
state: "open" | "closed" | "all";
/** Comma-separated label filter applied server-side. */
labels?: string;
/** ISO timestamp; only issues updated after it are fetched (server-side). */
since?: string;
/** Assignee login filter applied server-side. */
assignee?: string;
/** Milestone title filter applied client-side (the API keys milestones by number, not title). */
milestone?: string;
/** Whether pull requests are included (filtered out by default). */
includePrs: boolean;
/** Whether draft pull requests are excluded (only meaningful with {@link includePrs}). */
skipDrafts: boolean;
/** Whether fetched comments are also embedded in the item body (legacy behavior). */
withComments: boolean;
/** How fetched comments are persisted: body, native annotations, or both. */
commentsMode: CommentsMode;
/** pm item type assigned to every created item (default `Issue`). */
itemType: string;
/** When true, preview the plan without writing to the tracker or GitHub. */
dryRun: boolean;
/** When true, commit the batch as one crash-resumable SDK transaction. */
atomic: boolean;
/** When true, run the `--link-deps` dependency-edge second pass after import. */
linkDeps: boolean;
}
type CommitItemMutations = (
options: CommitItemMutationsOptions,
) => Promise<CommitItemMutationsResult>;
type NormalizeItemId = (input: string, prefix: string) => string;
type ReadSettings = (pmRoot: string) => Promise<{ id_prefix?: string }>;
/**
* Injectable collaborators for the atomic import path.
*
* Lets the crash-resumable transaction be exercised hermetically against fake
* SDK functions. Every member is optional because production wires the real SDK
* defaults; a test overrides only the ones it needs.
*/
export interface AtomicImportOptions {
/** Author identity stamped onto the transaction's mutations (default `pm-github`). */
atomicAuthor?: string;
/** SDK bulk-mutation primitive that applies and journals the transaction. */
commitItemMutations?: CommitItemMutations;
/** SDK id normalizer that derives each item's stable external-key id. */
normalizeItemId?: NormalizeItemId;
/** SDK settings reader used to resolve the workspace's id prefix. */
readSettings?: ReadSettings;
}
export interface ImportRunDependencies {
resolveToken?: () => string | undefined;
fetchIssues?: (
repo: string,
opts: ImportOptions,
token?: string,
) => Promise<GhIssue[]>;
fetchIssueComments?: (
issue: GhIssue,
repo: string,
token?: string,
) => Promise<GhComment[]>;
readItems?: (pmRoot: string) => PmItem[];
commitAtomic?: typeof importGithubAtomic;
// --link-deps second-pass hooks (all optional; defaults wire the real SDK /
// `pm` CLI). Injecting all three keeps the pass fully hermetic for tests.
/** Snapshot workspace item metadata for the ordering-cycle advisory. Defaults to the SDK `listAllItemMetadata`. */
listItemMetadata?: (pmRoot: string) => Promise<DepLinkSnapshotItem[]>;
/** Warnings for ordering cycles a mutation newly introduced. Defaults to the SDK `collectNewOrderingCycleWarnings`. */
collectOrderingCycleWarnings?: (
before: readonly DepLinkSnapshotItem[],
after: readonly DepLinkSnapshotItem[],
changedItemId: string,
) => string[];
/** Apply one resolved dependency edge. Defaults to spawning `pm update --dep`. */
applyDependencyLink?: (edge: ResolvedDepEdge, pmRoot: string) => { ok: boolean; stderr: string };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** A decoded GitHub HTTP response: status, body, headers, and the parsed
* `Link` header (used for REST pagination). Now public so HTTP-boundary tests
* can assert on the exact response the client observed. *
* @internal Exported only so the HTTP-boundary tests can drive the real client.
* `stripInternal` keeps it out of the published `.d.ts`, so this is NOT a public API
* commitment and must not be relied on from outside this package.
*/
export interface FetchResult {
status: number;
body: string;
headers: Record<string, string | string[] | undefined>;
linkHeader?: string;
}
// Resolve a GitHub token so the importer is not stuck on the 60 req/hr
// unauthenticated quota and can read private repos. Order: explicit env vars,
// then the locally authenticated `gh` CLI if present.
export function resolveGitHubToken(): string | undefined {
const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (envToken && envToken.trim()) return envToken.trim();
try {
const result = spawnSync("gh", ["auth", "token"], { encoding: "utf-8" });
if (result.status === 0) {
const token = result.stdout.trim();
if (token) return token;
}
} catch {
// gh not installed — fall back to unauthenticated requests.
}
return undefined;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Decide whether the Authorization token may be forwarded across a redirect.
*
* Only same-origin (scheme + host + port) redirects keep the credential; any
* origin change drops it so the bearer token can never leak to a third party.
*
* @param fromUrl - The URL the redirect originated from.
* @param toUrl - The redirect target URL.
* @returns True when both URLs share an origin.
*/
export function sameOrigin(fromUrl: string, toUrl: string): boolean {
try {
return new URL(fromUrl).origin.toLowerCase() === new URL(toUrl).origin.toLowerCase();
} catch {
return false;
}
}
// Resolve the GitHub REST/GraphQL API origin. Production always targets
// `https://api.github.com`, but the whole HTTP stack must be exercisable against
// a local server for the failure-surface tests (retry, backoff, pagination,
// redirect token handling, mid-batch errors). Reading the override at CALL time
// (not module-eval time) means a test can flip `PM_GITHUB_API_BASE` per-case
// without import-order coupling, and production is unchanged when it is unset.
//
// SECURITY: every request built from this base carries the resolved GITHUB_TOKEN,
// so an unvalidated override is a token-exfiltration and TLS-downgrade primitive:
// anything able to set an env var for this process could point authenticated
// traffic at an attacker host over plain HTTP. The same-origin redirect guard
// below does NOT mitigate that, because the base *is* the origin — the very first
// request already carries the bearer token. The override is therefore constrained
// to what the tests actually need:
// - `https:` anywhere (no credential exposure on the wire), or
// - `http:` ONLY for loopback, which cannot leave the machine.
// Anything else throws rather than being silently ignored, so a misconfiguration
// is loud instead of quietly redirecting traffic.
/**
* Resolve the GitHub API origin, honouring a constrained test-only override.
*
* @internal Exported only so the HTTP-boundary tests can drive the real client.
* `stripInternal` keeps it out of the published `.d.ts`, so this is NOT a public API
* commitment and must not be relied on from outside this package.
*/
export function githubApiBase(): string {
const raw = process.env.PM_GITHUB_API_BASE?.trim();
if (!raw) return "https://api.github.com";
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`PM_GITHUB_API_BASE is not a valid absolute URL: ${raw}`);
}
const isLoopback =
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "::1" ||
parsed.hostname === "[::1]" ||
parsed.hostname === "localhost";
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
throw new Error(
`PM_GITHUB_API_BASE must be https, or http on loopback for a local test server; got ${raw}. ` +
"Requests carry the GitHub token, so a plaintext non-loopback base would leak it.",
);
}
// Strip a trailing slash so `${base}/repos/...` cannot produce `//repos/...`.
return raw.replace(/\/+$/, "");
}
/**
* One low-level HTTP request, with no retry or backoff.
*
* That orchestration lives in the surrounding {@link request} wrapper. This
* function follows up to `redirectsLeft` redirects, rejecting on a cycle or an
* over-long chain rather than overflowing the stack, and forwards the bearer
* token only to same-origin targets via {@link sameOrigin}.
*/
function requestOnce(
method: string,
url: string,
token: string | undefined,
payload?: string,
redirectsLeft = 5,
): Promise<FetchResult> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {
"User-Agent": "pm-github",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (token) headers.Authorization = `Bearer ${token}`;
if (payload) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = String(Buffer.byteLength(payload));
}
// Dispatch on the URL scheme: production URLs are `https://` (GitHub), but
// a `PM_GITHUB_API_BASE` override pointing at a local `http://` test server
// must reach it over plain HTTP so the real request/response code runs.
const target = new URL(url);
const transport = target.protocol === "http:" ? http : https;
const req = transport.request(target, { method, headers }, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
// Drain the redirect response so the socket is returned to the pool.
res.resume();
if (redirectsLeft <= 0) {
reject(new Error(`too many redirects following ${url}`));
return;
}
// Resolve the (possibly relative) Location against the current URL, and
// only carry the token forward on a same-origin redirect. Named
// distinctly from the outer `target` URL: shadowing it here worked only
// because nothing read the outer binding first, so a later edit
// referencing the parsed URL would hit a TDZ ReferenceError at runtime
// rather than a type error at build time.
let redirectUrl: string;
try {
redirectUrl = new URL(res.headers.location, url).toString();
} catch {
reject(new Error(`invalid redirect Location from ${url}`));
return;
}
const forwardToken = sameOrigin(url, redirectUrl) ? token : undefined;
requestOnce(method, redirectUrl, forwardToken, payload, redirectsLeft - 1).then(resolve, reject);
return;
}
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => {
resolve({
status,
body: Buffer.concat(chunks).toString("utf-8"),
headers: res.headers as Record<string, string | string[] | undefined>,
linkHeader: typeof res.headers.link === "string" ? res.headers.link : undefined,
});
});
});
req.on("error", reject);
req.setTimeout(30000, () => {
req.destroy(new Error("request timed out after 30s"));
});
if (payload) req.write(payload);
req.end();
});
}
/**
* Compute how long (ms) to wait before retrying a rate-limited or transient
* response.
*
* Honors `Retry-After` (seconds) and the primary rate-limit reset window
* (`X-RateLimit-Remaining: 0` + `X-RateLimit-Reset` epoch), then falls back to
* exponential backoff. The result is capped so a CLI run is never hung
* indefinitely.
*
* @param headers - Response headers to read the retry hints from.
* @param attempt - Zero-based retry index feeding the exponential backoff floor.
* @param nowMs - Current epoch ms, used to measure the reset window's remaining time.
* @returns The capped wait in milliseconds.
*/
export function computeBackoffMs(
headers: Record<string, string | string[] | undefined>,
attempt: number,
nowMs: number = Date.now(),
): number {
const get = (k: string): string | undefined => {
const v = headers[k] ?? headers[k.toLowerCase()];
return Array.isArray(v) ? v[0] : v;
};
const cap = 60_000;
const retryAfter = get("retry-after");
if (retryAfter) {
const secs = Number(retryAfter);
if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, cap);
}
const remaining = get("x-ratelimit-remaining");
const reset = get("x-ratelimit-reset");
if (remaining === "0" && reset) {
const resetMs = Number(reset) * 1000;
if (Number.isFinite(resetMs)) {
const wait = resetMs - nowMs;
if (wait > 0) return Math.min(wait + 1000, cap);
}
}
// Exponential backoff: 1s, 2s, 4s … capped.
return Math.min(1000 * 2 ** attempt, cap);
}
/**
* Structured view of GitHub's rate-limit headers off one response.
*
* Fields are `undefined` when the corresponding header is absent or non-numeric
* so callers can degrade gracefully; `reset` is the epoch-second timestamp at
* which the current window reopens.
*/
export interface RateLimitInfo {
/** Remaining requests in the current window. */
remaining?: number;
/** Total requests permitted per window. */
limit?: number;
/** Epoch seconds at which the window resets. */
reset?: number;
/** True when the remaining quota is at/under the low-water mark. */
low: boolean;
}
// Read GitHub's x-ratelimit-* headers into a structured snapshot, reporting a
// low-remaining warning once the budget drops below `lowThreshold`.
export function parseRateLimit(
headers: Record<string, string | string[] | undefined>,
lowThreshold = 10,
): RateLimitInfo {
// Node lowercases response header names, but be defensive: scan
// case-insensitively so a mixed-case key (e.g. from a different runtime or a
// mocked response) is still found.
const lower: Record<string, string | string[] | undefined> = {};
for (const [hk, hv] of Object.entries(headers)) lower[hk.toLowerCase()] = hv;
const get = (k: string): string | undefined => {
const v = lower[k.toLowerCase()];
return Array.isArray(v) ? v[0] : v;
};
const num = (s: string | undefined): number | undefined => {
if (s === undefined) return undefined;
const n = Number(s);
return Number.isFinite(n) ? n : undefined;
};
const remaining = num(get("x-ratelimit-remaining"));
const limit = num(get("x-ratelimit-limit"));
const reset = num(get("x-ratelimit-reset"));
return {
remaining,
limit,
reset,
low: remaining !== undefined && remaining <= lowThreshold,
};
}
// Human-readable one-liner for a rate-limit snapshot, e.g.
// "GitHub API quota: 4998/5000 remaining (resets 2026-06-04T01:00:00.000Z)".
// Returns undefined when no quota headers were present.
export function formatRateLimit(info: RateLimitInfo): string | undefined {
if (info.remaining === undefined) return undefined;
const limitPart = info.limit !== undefined ? `/${info.limit}` : "";
let resetPart = "";
if (info.reset !== undefined) {
try {
resetPart = ` (resets ${new Date(info.reset * 1000).toISOString()})`;
} catch {
resetPart = "";
}
}
return `GitHub API quota: ${info.remaining}${limitPart} remaining${resetPart}`;
}
// Decide whether a failed HTTP response is worth retrying: 429, any 5xx, or a
// 403 that is actually a primary/secondary rate-limit wall (remaining=0).
function isRetryableStatus(status: number, headers: Record<string, string | string[] | undefined>): boolean {
if (status === 429) return true;
if (status >= 500) return true;
// Secondary/primary rate limit surfaces as 403 with remaining=0.
if (status === 403) {
const v = headers["x-ratelimit-remaining"] ?? headers["X-RateLimit-Remaining"];
const remaining = Array.isArray(v) ? v[0] : v;
if (remaining === "0") return true;
if (headers["retry-after"] ?? headers["Retry-After"]) return true;
}
return false;
}
// Request with rate-limit/backoff handling. Retries on 429/5xx and GitHub
// rate-limit 403s, honoring Retry-After / X-RateLimit-Reset. Throws on a
// non-retryable error status so callers can map it to a semantic exit code.
async function request(
method: string,
url: string,
token: string | undefined,
payload?: string,
maxRetries = 4,
): Promise<FetchResult> {
let attempt = 0;
for (;;) {
const res = await requestOnce(method, url, token, payload);
if (res.status >= 200 && res.status < 300) return res;
if (attempt < maxRetries && isRetryableStatus(res.status, res.headers)) {
const wait = computeBackoffMs(res.headers, attempt);
console.error(
`GitHub returned HTTP ${res.status}; retrying in ${Math.round(wait / 1000)}s ` +
`(attempt ${attempt + 1}/${maxRetries})…`,
);
await sleep(wait);
attempt++;
continue;
}
throw new Error(`GitHub API returned HTTP ${res.status}`);
}
}
/** Fetch a single GitHub REST endpoint via GET and return the decoded
* {@link FetchResult}. Runs the full retry/backoff/redirect stack (`request` →
* `requestOnce`), so it is the public entry point the failure-surface tests use
* to exercise that stack against a local server. *
* @internal Exported only so the HTTP-boundary tests can drive the real client.
* `stripInternal` keeps it out of the published `.d.ts`, so this is NOT a public API
* commitment and must not be relied on from outside this package.
*/
export function fetchJSON(url: string, token?: string): Promise<FetchResult> {
return request("GET", url, token);
}
// Follow GitHub's RFC 5988 Link header so repos with more than one page of
// issues are fully imported instead of silently truncated at per_page.
export function parseNextLink(linkHeader?: string): string | undefined {
if (!linkHeader) return undefined;
for (const part of linkHeader.split(",")) {
const match = part.match(/^\s*<([^>]{1,2048})>\s*;\s*rel="next"/);
if (match) return match[1];
}
return undefined;
}
// Map a GitHub issue/PR state (+ optional stateReason) onto a pm status,
// preserving `not_planned` closures as `canceled` rather than `closed`.
export function mapState(state: string, stateReason?: string | null): string {
if (state === "closed" && stateReason === "not_planned") return "canceled";
return state === "closed" ? "closed" : "open";
}
// Flags may arrive under their kebab-case (`dry-run`) or camelCase (`dryRun`)
// key depending on runtime normalization, so check every candidate.
export function optionEnabled(options: Record<string, unknown>, ...keys: string[]): boolean {
return keys.some((k) => {
const v = options[k];
return v === true || v === "true" || v === "1";
});
}
// Read the first non-empty trimmed string option under any of the given
// (kebab- or camel-case) keys; returns undefined when none are set.
export function optionString(options: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const k of keys) {
const v = options[k];
if (typeof v === "string" && v.trim().length > 0) return v.trim();
}
return undefined;
}
// Whether an option key was explicitly provided (even if empty/falsey).
export function optionProvided(options: Record<string, unknown>, ...keys: string[]): boolean {
return keys.some((k) => Object.prototype.hasOwnProperty.call(options, k));
}
// Parse a `--since` value into an ISO timestamp the GitHub `since` query param
// accepts. Accepts either an ISO 8601 timestamp (passed through, invalid date
// returns undefined) or a relative duration like `7d` / `12h` / `1w` / `30m`,
// resolved against `now`. This enables incremental imports without the caller
// having to compute an absolute timestamp first.
export function parseSince(value: string | undefined, nowMs: number = Date.now()): string | undefined {
if (!value || !value.trim()) return undefined;
const v = value.trim();
const rel = /^(\d+)\s*(m|h|d|w)$/i.exec(v);
if (rel) {
const n = Number(rel[1]);
if (!Number.isFinite(n) || n <= 0) return undefined;
const unit = rel[2].toLowerCase();
const ms =
unit === "m" ? n * 60_000 :
unit === "h" ? n * 3_600_000 :
unit === "d" ? n * 86_400_000 :
n * 604_800_000;
const relativeDate = new Date(nowMs - ms);
if (Number.isNaN(relativeDate.getTime())) return undefined;
return relativeDate.toISOString();
}
const d = new Date(v);
if (Number.isNaN(d.getTime())) return undefined;
return d.toISOString();
}
// Parse a `--label-map` option into a translation table from pm tag/label
// names to GitHub label names. Accepts `from=to` pairs, comma-separated in a
// single value ("bug=kind/bug,enhancement=kind/enhancement") or repeated as an
// array. Entries without a `=` or with an empty side are skipped. Returns
// undefined when no usable mapping was provided so callers can short-circuit.
export function parseLabelMap(
options: Record<string, unknown>,
...keys: string[]
): Map<string, string> | undefined {
const lookup = keys.length > 0 ? keys : ["label-map", "labelMap"];
const raw = optionCsv(options, ...lookup);
if (raw.length === 0) return undefined;
const map = new Map<string, string>();
for (const entry of raw) {
const eq = entry.indexOf("=");
if (eq <= 0) continue; // need a non-empty "from" before the '='
const from = entry.slice(0, eq).trim();
const to = entry.slice(eq + 1).trim();
if (!from || !to) continue;
map.set(from, to);
}
return map.size > 0 ? map : undefined;
}
// Apply a label translation table to a list of labels. Labels with a mapping
// are replaced; unmapped labels pass through unchanged. Two source labels that
// map to the same GitHub label are collapsed (GitHub rejects duplicate labels
// on an issue with a 422), preserving first-seen order.
export function applyLabelMap(
labels: string[],
labelMap: Map<string, string> | undefined,
): string[] {
if (!labelMap || labelMap.size === 0) return labels;
const out: string[] = [];
const seen = new Set<string>();
for (const label of labels) {
const mapped = labelMap.get(label) ?? label;
if (seen.has(mapped)) continue;
seen.add(mapped);
out.push(mapped);
}
return out;
}
// Parse one or more CSV-like option values into a deduplicated string list.
// Accepts a single string ("a,b") or repeated values (["a,b", "c"]).
export function optionCsv(options: Record<string, unknown>, ...keys: string[]): string[] {
const rawChunks: string[] = [];
for (const k of keys) {
const v = options[k];
if (typeof v === "string") {
rawChunks.push(v);
continue;
}
if (Array.isArray(v)) {
for (const entry of v) {
if (typeof entry === "string") rawChunks.push(entry);
}
}
}
const out: string[] = [];
const seen = new Set<string>();
for (const chunk of rawChunks) {
for (const piece of chunk.split(",")) {
const id = piece.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
}
return out;
}
// pm's extension command runtime only treats a thrown error as a cleanly
// handled non-zero exit when the error carries a numeric `exitCode` property
// (see @unbrained/pm-cli runCommandHandler). A plain `Error` makes the runtime
// fall through to its "unhandled" path, which RE-INVOKES the command handler a
// second time — doubling side effects (e.g. a second GitHub fetch) and exiting
// with a generic code instead of a semantic one. We mirror the SDK's EXIT_CODE
// contract here rather than importing it: standalone-installed extensions load
// only their own `dist/`, so `@unbrained/pm-cli` is not resolvable at runtime.
export const EXIT_CODE = {
GENERIC_FAILURE: 1,
USAGE: 2,
NOT_FOUND: 3,
} as const;
/**
* Error that carries a semantic process exit code.
*
* pm's command runtime treats a thrown error as a cleanly handled non-zero exit
* only when it exposes a numeric `exitCode`; a plain `Error` instead falls
* through to the "unhandled" path, which re-invokes the handler (doubling side
* effects such as a second GitHub fetch) and exits with a generic code. Throwing
* this routes a failure to a clean, single exit at the chosen code.
*/
export class CommandError extends Error {
/** Numeric exit code the runtime propagates to the shell (one of {@link EXIT_CODE}). */
exitCode: number;
constructor(message: string, exitCode: number = EXIT_CODE.GENERIC_FAILURE) {
super(message);
this.name = "CommandError";
this.exitCode = exitCode;
}
}
// ---------------------------------------------------------------------------
// Provenance — link a pm item back to a specific GitHub issue
// ---------------------------------------------------------------------------
/**
* Build the `gh:owner/repo#N` provenance tag linking a pm item to a GitHub issue.
*
* The tag is the idempotency key: it round-trips losslessly through
* `pm create --tags` / `pm list --json`, so a re-import can find the existing
* item and UPDATE it instead of duplicating. Provenance also rides on declared
* schema fields and the description, but the tag is what matching keys on.
*
* @param repo - `owner/repo`, lowercased into the tag.
* @param issueNumber - The issue/PR number.
* @returns The provenance tag string.
*/
export function provenanceTag(repo: string, issueNumber: number): string {
return `gh:${repo.toLowerCase()}#${issueNumber}`;
}
/**
* Parse a `gh:owner/repo#N` provenance tag back into its repo and issue number.
*
* Returns `undefined` for anything that is not a provenance tag, so a caller
* scanning a tag list can skip foreign tags without a try/catch.
*
* @param tag - The candidate tag string.
* @returns The parsed repo (lowercased) and number, or `undefined`.
*/
export function parseProvenanceTag(tag: string): { repo: string; number: number } | undefined {
const m = /^gh:([^#\s]+)#(\d+)$/.exec(tag.trim());
if (!m) return undefined;
return { repo: m[1].toLowerCase(), number: Number(m[2]) };
}
/**
* Build the `github_author:login` tag recording who opened a GitHub issue.
*
* Mirrors how provenance rides on tags so the author survives a round-trip.
* Returns `undefined` when the API supplied no usable login, so an empty tag is
* never emitted.
*
* @param issue - The issue whose author to tag.
* @returns The author tag, or `undefined`.
*/
export function authorTag(issue: GhIssue): string | undefined {
const login = issue.user?.login?.trim();
if (!login) return undefined;
return `github_author:${login}`;
}
// ---------------------------------------------------------------------------
// pm workspace I/O
// ---------------------------------------------------------------------------
/**
* The slice of a pm item this package reads and writes.
*
* A deliberately loose projection of the full tracker item: only the fields the
* import / export / sync paths touch, so JSON from `pm list --all` parses without
* depending on every SDK field.
*/
export interface PmItem {
/** Stable item id (absent for items not yet created). */
id?: string;
/** One-line title. */
title?: string;
/** Lifecycle status (`open`, `in_progress`, `closed`, `canceled`, …). */
status?: string;
/** Long-form markdown body. */
body?: string;
/** Short summary shown in listings. */
description?: string;
/** Tag set, carrying provenance and labels. */
tags?: string[];
}
export interface ItemScopeResult<TItem> {
selected: TItem[];
missing: string[];
}
// Narrow a set of pm items to explicit IDs. Unknown IDs are surfaced so
// command handlers can fail fast instead of silently ignoring typos.
export function scopeItemsByIds<TItem extends { id?: string }>(
items: TItem[],
ids: string[] | undefined,
): ItemScopeResult<TItem> {
if (!ids || ids.length === 0) {
return { selected: [...items], missing: [] };
}
const wanted = new Set(ids);
const selected = items.filter((item) => item.id && wanted.has(item.id));
const found = new Set(
selected
.map((item) => item.id)
.filter((id): id is string => typeof id === "string"),
);
const missing = ids.filter((id) => !found.has(id));
return { selected, missing };
}
/** True only for a JSON object, excluding arrays and `null`. */
function isJsonRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Render an untrusted JSON value compactly inside a read-contract error. */
function describeJsonValue(value: unknown): string {
const rendered = JSON.stringify(value);
return rendered === undefined ? String(value) : rendered;
}
/** Require one exact field value from the `pm list --all` truthfulness envelope. */
function requireCompletePmField(
actual: unknown,
expected: string | number | boolean | null,
field: string,
): void {
if (actual !== expected) {
throw new CommandError(
`Refusing unverifiable pm list --all output: ${field} must be ${describeJsonValue(expected)}; received ${describeJsonValue(actual)}.`,
);
}
}
/** Installed CLI receipt version and command accepted by the complete reader. */
const COMPLETE_PM_READ_CONTRACT = {
version: 1,
command: "list",
} as const;
/**
* Build the canonical installed-CLI invocation for a whole-workspace read.
*
* The output controls are deliberately explicit. `list --all` includes terminal
* items, `--output-include full` plus `--include-body` retains every field this
* integration consumes, `--strict-read` rejects unreadable records, and both
* amount and cost are unbounded. An arbitrary `--limit` must never be added:
* callers use this corpus to prevent duplicate imports and missing syncs.
*
* @param pmRoot - Workspace or tracker root accepted by the pm CLI `--pm-path` flag.
* @returns Argument vector passed to the installed `pm` executable.
* @internal Exported so the acceptance test can bind the safety contract to the
* exact production invocation; it is removed from the published declaration.
*/
export function completePmListArgs(pmRoot: string): string[] {
return [
"--pm-path",
pmRoot,
"--output-include",
"full",
"--output-limit",
"unbounded",
"--output-budget",
"unbounded",
"list",
"--all",
"--json",
"--include-body",
"--strict-read",
];
}
/**
* Decode only a complete, unbounded `pm list --all` response.
*
* The subprocess JSON is untrusted. This gate independently verifies every
* completeness signal emitted by the current CLI, reconciles envelope counts,
* rejects duplicate identities, and validates each field consumed by GitHub
* import, export, state sync, Projects v2 sync, and search fallback paths.
* Missing receipts fail closed because an unverifiable read is not a whole
* workspace read.
*
* @param parsed - JSON decoded from the installed pm CLI.
* @returns Fresh runtime-validated item objects.
* @throws {@link CommandError} When a receipt, count, identity, or consumed row
* field is absent, incomplete, or contradictory.
* @internal Exported for direct adversarial contract tests and stripped from
* the published declaration surface.
*/
export function decodeCompletePmItems(parsed: unknown): PmItem[] {
if (!isJsonRecord(parsed)) {
throw new CommandError(
"Refusing unverifiable pm list --all output: the response must be a top-level object with completeness receipts.",
);
}
if (!Array.isArray(parsed.items)) {
throw new CommandError("Refusing unverifiable pm list --all output: items must be an array.");
}
requireCompletePmField(parsed.truncated, false, "truncated");
requireCompletePmField(parsed.has_more, false, "has_more");
requireCompletePmField(parsed.next_cursor, null, "next_cursor");
const completeness = isJsonRecord(parsed.completeness) ? parsed.completeness : {};
requireCompletePmField(completeness.status, "complete", "completeness.status");
requireCompletePmField(completeness.unreadable_item_count, 0, "completeness.unreadable_item_count");
requireCompletePmField(
completeness.unreadable_directory_count,
0,
"completeness.unreadable_directory_count",
);
const omission = isJsonRecord(parsed.omission_receipt) ? parsed.omission_receipt : {};
requireCompletePmField(omission.has_omissions, false, "omission_receipt.has_omissions");
requireCompletePmField(
omission.omitted_field_group_count,
0,
"omission_receipt.omitted_field_group_count",
);
if (!Array.isArray(omission.omitted_field_groups) || omission.omitted_field_groups.length !== 0) {
throw new CommandError(
"Refusing unverifiable pm list --all output: omission_receipt.omitted_field_groups must be empty.",
);
}
const projection = isJsonRecord(parsed.projection) ? parsed.projection : {};
requireCompletePmField(projection.mode, "full", "projection.mode");
const readOutput = isJsonRecord(parsed.read_output) ? parsed.read_output : {};
requireCompletePmField(
readOutput.contract_version,
COMPLETE_PM_READ_CONTRACT.version,
"read_output.contract_version",
);
requireCompletePmField(
readOutput.command,
COMPLETE_PM_READ_CONTRACT.command,
"read_output.command",
);
requireCompletePmField(readOutput.within_budget, true, "read_output.within_budget");
requireCompletePmField(readOutput.strings_compacted, false, "read_output.strings_compacted");
requireCompletePmField(readOutput.rows_compacted, false, "read_output.rows_compacted");
requireCompletePmField(readOutput.result_omitted, false, "read_output.result_omitted");
if (
!Array.isArray(readOutput.requested_dimensions)
|| !readOutput.requested_dimensions.includes("include")
|| !readOutput.requested_dimensions.includes("amount")
|| !readOutput.requested_dimensions.includes("cost")
) {
throw new CommandError(
"Refusing unverifiable pm list --all output: read_output.requested_dimensions must include include, amount, and cost.",
);
}
if ("output_budget_truncation" in parsed || "output_budget_exceeded" in parsed) {
throw new CommandError(
"Refusing unverifiable pm list --all output: a budget truncation or omission disclosure was present.",
);
}
if (!Number.isSafeInteger(parsed.count) || (parsed.count as number) < 0) {