@senad-d/branchme 0.1.1

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/src/github.ts ADDED
@@ -0,0 +1,566 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { redactSecrets } from "./redaction.ts";
5
+ import {
6
+ GITHUB_API_BASE_URL,
7
+ GITHUB_API_VERSION,
8
+ GITHUB_RESPONSE_BODY_LIMIT_BYTES,
9
+ GITHUB_USER_AGENT,
10
+ MAX_SUMMARY_OUTPUT_CHARS,
11
+ } from "./constants.ts";
12
+ import { getGitRoot, getOriginUrl, validateBranchNameInput, type GitCommandContext } from "./git.ts";
13
+ import type { GitHubRepository, PullRequestDetails, PullRequestInput } from "./types.ts";
14
+
15
+ type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
16
+
17
+ export type TokenResolutionSource = TokenEnvironmentKey | `${TokenEnvironmentKey} (.env)`;
18
+
19
+ export interface TokenResolution {
20
+ token: string;
21
+ source: TokenResolutionSource;
22
+ }
23
+
24
+ // Exported for focused tests and future embedders that call GitHub helpers directly.
25
+ export interface TokenResolutionOptions {
26
+ cwd?: string;
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ // Exported for focused tests and future embedders that inject fetch/cancellation.
31
+ export interface PullRequestFetchOptions {
32
+ fetchImpl?: typeof fetch;
33
+ signal?: AbortSignal;
34
+ }
35
+
36
+ export interface GitHubBranchDetails {
37
+ name: string;
38
+ commitSha: string;
39
+ }
40
+
41
+ function stripGitSuffix(repo: string): string {
42
+ return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
43
+ }
44
+
45
+ function requireRepositorySegment(value: unknown, field: "owner" | "repo"): string {
46
+ if (typeof value !== "string") throw new Error(`GitHub repository ${field} must be a string.`);
47
+ if (!value) throw new Error(`GitHub repository ${field} is required.`);
48
+ if (value !== value.trim()) throw new Error(`GitHub repository ${field} cannot start or end with whitespace.`);
49
+ if (/[/\\]/u.test(value)) throw new Error(`GitHub repository ${field} must be a single path segment.`);
50
+ if (value === "." || value === "..") throw new Error(`GitHub repository ${field} cannot be a dot segment.`);
51
+ if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error(`GitHub repository ${field} cannot contain control characters.`);
52
+ if (/\s/u.test(value)) throw new Error(`GitHub repository ${field} cannot contain whitespace.`);
53
+ if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`GitHub repository ${field} contains unsupported characters.`);
54
+ return value;
55
+ }
56
+
57
+ export function validateGitHubRepository(repository: GitHubRepository): void {
58
+ if (!isRecord(repository)) throw new Error("GitHub repository must be an object.");
59
+ requireRepositorySegment(repository.owner, "owner");
60
+ requireRepositorySegment(repository.repo, "repo");
61
+ }
62
+
63
+ function normalizeRepository(owner: string, repo: string): GitHubRepository | null {
64
+ const normalizedOwner = owner.trim();
65
+ const normalizedRepo = stripGitSuffix(repo.trim());
66
+ try {
67
+ requireRepositorySegment(normalizedOwner, "owner");
68
+ requireRepositorySegment(normalizedRepo, "repo");
69
+ } catch {
70
+ return null;
71
+ }
72
+ return { owner: normalizedOwner, repo: normalizedRepo };
73
+ }
74
+
75
+ export function repositoriesEqual(left: GitHubRepository, right: GitHubRepository): boolean {
76
+ return left.owner.toLowerCase() === right.owner.toLowerCase() && left.repo.toLowerCase() === right.repo.toLowerCase();
77
+ }
78
+
79
+ export function repositoryLabel(repository: GitHubRepository): string {
80
+ return `${repository.owner}/${repository.repo}`;
81
+ }
82
+
83
+ export function parseGitHubRepository(value: string): GitHubRepository | null {
84
+ const input = value.trim();
85
+ if (!input) return null;
86
+
87
+ const scpLike = input.match(/^git@github\.com:([^\s/]+)\/([^\s/]+)$/iu);
88
+ if (scpLike) {
89
+ return normalizeRepository(scpLike[1] ?? "", scpLike[2] ?? "");
90
+ }
91
+
92
+ const shorthand = input.match(/^([^\s/:]+)\/([^\s/]+)$/u);
93
+ if (shorthand) {
94
+ return normalizeRepository(shorthand[1] ?? "", shorthand[2] ?? "");
95
+ }
96
+
97
+ let parsed: URL;
98
+ try {
99
+ parsed = new URL(input);
100
+ } catch {
101
+ return null;
102
+ }
103
+
104
+ if (parsed.hostname.toLowerCase() !== "github.com") return null;
105
+ if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:") return null;
106
+ if (parsed.protocol === "ssh:" && parsed.username && parsed.username !== "git") return null;
107
+
108
+ const parts = parsed.pathname.replace(/^\/+|\/+$/gu, "").split("/");
109
+ if (parts.length !== 2) return null;
110
+ return normalizeRepository(parts[0] ?? "", parts[1] ?? "");
111
+ }
112
+
113
+ function resolveProcessToken(env: NodeJS.ProcessEnv): TokenResolution | null {
114
+ const githubToken = env.GITHUB_TOKEN?.trim();
115
+ if (githubToken) return { token: githubToken, source: "GITHUB_TOKEN" };
116
+
117
+ const ghToken = env.GH_TOKEN?.trim();
118
+ if (ghToken) return { token: ghToken, source: "GH_TOKEN" };
119
+
120
+ return null;
121
+ }
122
+
123
+ function isTokenEnvironmentKey(value: string): value is TokenEnvironmentKey {
124
+ return value === "GITHUB_TOKEN" || value === "GH_TOKEN";
125
+ }
126
+
127
+ function decodeDoubleQuotedDotEnvValue(value: string): string {
128
+ return value.replace(/\\([nrt"\\])/gu, (_match, escaped: string) => {
129
+ switch (escaped) {
130
+ case "n":
131
+ return "\n";
132
+ case "r":
133
+ return "\r";
134
+ case "t":
135
+ return "\t";
136
+ default:
137
+ return escaped;
138
+ }
139
+ });
140
+ }
141
+
142
+ function parseDotEnvValue(rawValue: string): string {
143
+ const value = rawValue.trim();
144
+ if (!value) return "";
145
+
146
+ if (value.startsWith("\"") && value.endsWith("\"")) {
147
+ return decodeDoubleQuotedDotEnvValue(value.slice(1, -1)).trim();
148
+ }
149
+
150
+ if (value.startsWith("'") && value.endsWith("'")) {
151
+ return value.slice(1, -1).trim();
152
+ }
153
+
154
+ return value.replace(/\s+#.*$/u, "").trim();
155
+ }
156
+
157
+ function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey, string>> {
158
+ const tokens: Partial<Record<TokenEnvironmentKey, string>> = {};
159
+
160
+ for (const line of contents.split(/\r?\n/u)) {
161
+ const trimmed = line.trim();
162
+ if (!trimmed || trimmed.startsWith("#")) continue;
163
+
164
+ const assignment = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed;
165
+ const match = assignment.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u);
166
+ if (!match) continue;
167
+
168
+ const key = match[1] ?? "";
169
+ if (!isTokenEnvironmentKey(key)) continue;
170
+
171
+ tokens[key] = parseDotEnvValue(match[2] ?? "");
172
+ }
173
+
174
+ return tokens;
175
+ }
176
+
177
+ const MAX_DOTENV_BYTES = 64 * 1024;
178
+
179
+ function isMissingFileError(error: unknown): boolean {
180
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
181
+ }
182
+
183
+ function throwIfAborted(signal: AbortSignal | undefined): void {
184
+ if (signal?.aborted) throw new Error("GitHub token .env fallback was aborted.");
185
+ }
186
+
187
+ function errorMessage(error: unknown): string {
188
+ return redactSecrets(error instanceof Error ? error.message : String(error));
189
+ }
190
+
191
+ async function readDotEnvTokens(
192
+ cwd: string | undefined,
193
+ signal?: AbortSignal,
194
+ ): Promise<Partial<Record<TokenEnvironmentKey, string>>> {
195
+ if (!cwd) return {};
196
+
197
+ const envPath = join(cwd, ".env");
198
+ throwIfAborted(signal);
199
+
200
+ let stats: Awaited<ReturnType<typeof lstat>>;
201
+ try {
202
+ stats = await lstat(envPath);
203
+ } catch (error) {
204
+ if (isMissingFileError(error)) return {};
205
+ throw new Error(`Unable to inspect .env file for GitHub token fallback: ${errorMessage(error)}`);
206
+ }
207
+
208
+ if (!stats.isFile()) {
209
+ throw new Error("Unable to read .env file for GitHub token fallback: .env must be a small regular file.");
210
+ }
211
+ if (stats.size > MAX_DOTENV_BYTES) {
212
+ throw new Error(
213
+ `Unable to read .env file for GitHub token fallback: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
214
+ );
215
+ }
216
+
217
+ throwIfAborted(signal);
218
+
219
+ let contents: string;
220
+ try {
221
+ contents = await readFile(envPath, { encoding: "utf8", signal });
222
+ } catch (error) {
223
+ if (isMissingFileError(error)) return {};
224
+ throw new Error(`Unable to read .env file for GitHub token fallback: ${errorMessage(error)}`);
225
+ }
226
+
227
+ if (Buffer.byteLength(contents, "utf8") > MAX_DOTENV_BYTES) {
228
+ throw new Error(`Unable to read .env file for GitHub token fallback: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
229
+ }
230
+
231
+ return parseDotEnvTokens(contents);
232
+ }
233
+
234
+ export async function resolveGitHubToken(
235
+ env: NodeJS.ProcessEnv = process.env,
236
+ options: TokenResolutionOptions = {},
237
+ ): Promise<TokenResolution> {
238
+ const processToken = resolveProcessToken(env);
239
+ if (processToken) return processToken;
240
+
241
+ const dotEnvTokens = await readDotEnvTokens(options.cwd, options.signal);
242
+ const githubToken = dotEnvTokens.GITHUB_TOKEN?.trim();
243
+ if (githubToken) return { token: githubToken, source: "GITHUB_TOKEN (.env)" };
244
+
245
+ const ghToken = dotEnvTokens.GH_TOKEN?.trim();
246
+ if (ghToken) return { token: ghToken, source: "GH_TOKEN (.env)" };
247
+
248
+ throw new Error("GitHub token is required. Set GITHUB_TOKEN or GH_TOKEN in the process environment or repository .env file.");
249
+ }
250
+
251
+ export async function resolveGitHubRepository(
252
+ pi: Pick<ExtensionAPI, "exec">,
253
+ ctx: GitCommandContext,
254
+ signal?: AbortSignal,
255
+ env: NodeJS.ProcessEnv = process.env,
256
+ ): Promise<GitHubRepository> {
257
+ await getGitRoot(pi, ctx, signal);
258
+ const originUrl = await getOriginUrl(pi, ctx, signal);
259
+ const localRepository = originUrl ? parseGitHubRepository(originUrl) : null;
260
+ const envValue = env.GITHUB_REPOSITORY?.trim();
261
+ const envRepository = envValue ? parseGitHubRepository(envValue) : null;
262
+
263
+ if (envValue && !envRepository) {
264
+ throw new Error("Invalid GITHUB_REPOSITORY. Expected owner/repo for the current GitHub repository.");
265
+ }
266
+
267
+ if (localRepository && envRepository && !repositoriesEqual(localRepository, envRepository)) {
268
+ throw new Error(
269
+ `Repository boundary mismatch: local origin resolves to ${repositoryLabel(localRepository)} but GITHUB_REPOSITORY resolves to ${repositoryLabel(envRepository)}.`,
270
+ );
271
+ }
272
+
273
+ const repository = localRepository ?? envRepository;
274
+ if (!repository) {
275
+ throw new Error("Unable to resolve a GitHub repository from origin or GITHUB_REPOSITORY.");
276
+ }
277
+
278
+ return repository;
279
+ }
280
+
281
+ function truncate(value: string): string {
282
+ if (value.length <= MAX_SUMMARY_OUTPUT_CHARS) return value;
283
+ return `${value.slice(0, MAX_SUMMARY_OUTPUT_CHARS)}… [truncated]`;
284
+ }
285
+
286
+ export { redactSecrets } from "./redaction.ts";
287
+
288
+ function encodePathSegment(value: string): string {
289
+ return encodeURIComponent(value);
290
+ }
291
+
292
+ function requireStringRef(value: unknown, field: "headBranch" | "baseBranch"): string {
293
+ validateBranchNameInput(value, field);
294
+ return value;
295
+ }
296
+
297
+ export function validatePullRequestBranchRef(value: unknown, field: "headBranch" | "baseBranch"): void {
298
+ const ref = requireStringRef(value, field);
299
+
300
+ if (/[\u0000-\u001f\u007f]/u.test(ref)) throw new Error(`${field} cannot contain control characters.`);
301
+ if (/\s/u.test(ref)) throw new Error(`${field} cannot contain whitespace.`);
302
+ if (ref.includes(":")) throw new Error(`${field} cannot contain ':' or an owner-prefixed cross-repository ref.`);
303
+ if (ref.includes("\\")) throw new Error(`${field} cannot contain backslashes.`);
304
+ if (/[~^?*[\]]/u.test(ref)) throw new Error(`${field} contains characters that are not valid in a branch ref.`);
305
+ if (ref.includes("..")) throw new Error(`${field} cannot contain path traversal-like '..' segments.`);
306
+ if (ref.includes("@{")) throw new Error(`${field} cannot contain '@{'.`);
307
+ if (ref.includes("//")) throw new Error(`${field} cannot contain empty path segments.`);
308
+ if (ref.startsWith("/") || ref.endsWith("/")) throw new Error(`${field} cannot start or end with '/'.`);
309
+ if (ref.startsWith("-")) throw new Error(`${field} cannot start with '-'.`);
310
+ if (ref.endsWith(".")) throw new Error(`${field} cannot end with '.'.`);
311
+ if (ref === "@") throw new Error(`${field} cannot be '@'.`);
312
+ if (ref.startsWith("refs/")) throw new Error(`${field} must be a branch name, not a full ref path.`);
313
+
314
+ for (const segment of ref.split("/")) {
315
+ if (segment === "." || segment === "..") throw new Error(`${field} cannot contain path traversal segments.`);
316
+ if (segment.endsWith(".lock")) throw new Error(`${field} cannot contain '.lock' path segments.`);
317
+ }
318
+ }
319
+
320
+ function validatePullRequestInput(input: unknown): asserts input is PullRequestInput {
321
+ if (!isRecord(input)) throw new Error("Pull request input must be an object.");
322
+ validatePullRequestBranchRef(input.headBranch, "headBranch");
323
+ validatePullRequestBranchRef(input.baseBranch, "baseBranch");
324
+ if (typeof input.title !== "string") throw new Error("title must be a string.");
325
+ if (!input.title.trim()) throw new Error("title is required.");
326
+ if (typeof input.body !== "string") throw new Error("body must be a string.");
327
+ if (typeof input.draft !== "boolean") throw new Error("draft must be a boolean.");
328
+ }
329
+
330
+ function isRecord(value: unknown): value is Record<string, unknown> {
331
+ return typeof value === "object" && value !== null && !Array.isArray(value);
332
+ }
333
+
334
+ function stringField(value: unknown, field: string): string {
335
+ if (typeof value !== "string" || !value) throw new Error(`GitHub response is missing ${field}.`);
336
+ return value;
337
+ }
338
+
339
+ function pullRequestNumberField(value: unknown): number {
340
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) {
341
+ throw new Error("GitHub response pull request number must be a finite positive safe integer.");
342
+ }
343
+ return value;
344
+ }
345
+
346
+ interface BoundedResponseBody {
347
+ text: string;
348
+ truncated: boolean;
349
+ }
350
+
351
+ function throwIfResponseReadAborted(signal: AbortSignal | undefined): void {
352
+ if (signal?.aborted) throw new Error("GitHub pull request response read was aborted.");
353
+ }
354
+
355
+ async function readBoundedResponseText(response: Response, signal: AbortSignal | undefined): Promise<BoundedResponseBody> {
356
+ throwIfResponseReadAborted(signal);
357
+ if (!response.body) return { text: "", truncated: false };
358
+
359
+ const reader = response.body.getReader();
360
+ const chunks: Buffer[] = [];
361
+ let capturedBytes = 0;
362
+ let truncated = false;
363
+
364
+ try {
365
+ while (true) {
366
+ throwIfResponseReadAborted(signal);
367
+ const { done, value } = await reader.read();
368
+ if (done) break;
369
+ if (!value || value.byteLength === 0) continue;
370
+
371
+ const remainingBytes = GITHUB_RESPONSE_BODY_LIMIT_BYTES - capturedBytes;
372
+ if (remainingBytes > 0) {
373
+ const capturedChunk = value.byteLength <= remainingBytes ? value : value.subarray(0, remainingBytes);
374
+ chunks.push(Buffer.from(capturedChunk));
375
+ capturedBytes += capturedChunk.byteLength;
376
+ }
377
+
378
+ if (value.byteLength > remainingBytes) {
379
+ truncated = true;
380
+ await reader.cancel().catch(() => undefined);
381
+ break;
382
+ }
383
+ }
384
+ } finally {
385
+ reader.releaseLock();
386
+ }
387
+
388
+ const text = Buffer.concat(chunks).toString("utf8");
389
+ return { text: truncated ? `${text}… [truncated]` : text, truncated };
390
+ }
391
+
392
+ function gitHubJsonHeaders(token: string): Record<string, string> {
393
+ return {
394
+ Accept: "application/vnd.github+json",
395
+ Authorization: `Bearer ${token}`,
396
+ "Content-Type": "application/json",
397
+ "User-Agent": GITHUB_USER_AGENT,
398
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
399
+ };
400
+ }
401
+
402
+ export async function ensureGitHubBranchExists(
403
+ repository: GitHubRepository,
404
+ branchName: string,
405
+ field: "headBranch" | "baseBranch",
406
+ token: string,
407
+ options: PullRequestFetchOptions = {},
408
+ ): Promise<GitHubBranchDetails> {
409
+ validateGitHubRepository(repository);
410
+ validatePullRequestBranchRef(branchName, field);
411
+
412
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
413
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node.js runtime.");
414
+
415
+ const branchLabel = redactSecrets(branchName);
416
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/branches/${encodePathSegment(branchName)}`;
417
+
418
+ let response: Response;
419
+ try {
420
+ response = await fetchImpl(url, {
421
+ method: "GET",
422
+ headers: gitHubJsonHeaders(token),
423
+ signal: options.signal,
424
+ });
425
+ } catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ throw new Error(`GitHub branch preflight request failed for ${field} '${branchLabel}': ${redactSecrets(message, [token])}`);
428
+ }
429
+
430
+ if (response.ok) {
431
+ let body: BoundedResponseBody;
432
+ try {
433
+ body = await readBoundedResponseText(response, options.signal);
434
+ } catch (error) {
435
+ const message = error instanceof Error ? error.message : String(error);
436
+ throw new Error(`GitHub branch preflight response for ${field} '${branchLabel}' could not be read: ${redactSecrets(message, [token])}`);
437
+ }
438
+
439
+ if (body.truncated) throw new Error(`GitHub branch preflight response exceeded the ${GITHUB_RESPONSE_BODY_LIMIT_BYTES} byte limit.`);
440
+
441
+ let payload: unknown;
442
+ try {
443
+ payload = JSON.parse(body.text);
444
+ } catch (error) {
445
+ const message = error instanceof Error ? error.message : String(error);
446
+ throw new Error(`GitHub branch preflight response was not valid JSON: ${redactSecrets(message, [token])}`);
447
+ }
448
+
449
+ if (!isRecord(payload)) throw new Error("GitHub branch preflight response was not an object.");
450
+ const commit = isRecord(payload.commit) ? stringField(payload.commit.sha, "commit.sha") : "";
451
+ if (!/^[0-9a-f]{40,64}$/iu.test(commit)) throw new Error("GitHub branch preflight response is missing commit.sha.");
452
+
453
+ return {
454
+ name: typeof payload.name === "string" && payload.name ? payload.name : branchName,
455
+ commitSha: commit,
456
+ };
457
+ }
458
+
459
+ let body: BoundedResponseBody;
460
+ try {
461
+ body = await readBoundedResponseText(response, options.signal);
462
+ } catch (error) {
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ throw new Error(
465
+ `GitHub branch preflight failed for ${field} '${branchLabel}' with HTTP ${response.status}: unable to read error response: ${redactSecrets(message, [token])}`,
466
+ );
467
+ }
468
+
469
+ if (response.status === 404) {
470
+ throw new Error(
471
+ `${field} branch '${branchLabel}' is not visible in ${repositoryLabel(repository)} on GitHub. Run push_branch and wait for it to complete before calling pull_request, then retry.`,
472
+ );
473
+ }
474
+
475
+ throw new Error(
476
+ `GitHub branch preflight failed for ${field} '${branchLabel}' with HTTP ${response.status}: ${redactSecrets(truncate(body.text), [token])}`,
477
+ );
478
+ }
479
+
480
+ export async function createGitHubPullRequest(
481
+ repository: GitHubRepository,
482
+ input: PullRequestInput,
483
+ token: string,
484
+ options: PullRequestFetchOptions = {},
485
+ ): Promise<PullRequestDetails> {
486
+ validateGitHubRepository(repository);
487
+ validatePullRequestInput(input);
488
+
489
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
490
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node.js runtime.");
491
+
492
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls`;
493
+ const requestBody = {
494
+ title: input.title,
495
+ head: input.headBranch,
496
+ base: input.baseBranch,
497
+ body: input.body,
498
+ draft: input.draft,
499
+ };
500
+
501
+ let response: Response;
502
+ try {
503
+ response = await fetchImpl(url, {
504
+ method: "POST",
505
+ headers: gitHubJsonHeaders(token),
506
+ body: JSON.stringify(requestBody),
507
+ signal: options.signal,
508
+ });
509
+ } catch (error) {
510
+ const message = error instanceof Error ? error.message : String(error);
511
+ throw new Error(`GitHub pull request request failed: ${redactSecrets(message, [token])}`);
512
+ }
513
+
514
+ if (!response.ok) {
515
+ let body: BoundedResponseBody;
516
+ try {
517
+ body = await readBoundedResponseText(response, options.signal);
518
+ } catch (error) {
519
+ const message = error instanceof Error ? error.message : String(error);
520
+ throw new Error(
521
+ `GitHub pull request request failed with HTTP ${response.status}: unable to read error response: ${redactSecrets(message, [token])}`,
522
+ );
523
+ }
524
+
525
+ throw new Error(
526
+ `GitHub pull request request failed with HTTP ${response.status}: ${redactSecrets(truncate(body.text), [token])}`,
527
+ );
528
+ }
529
+
530
+ let responseBody: BoundedResponseBody;
531
+ try {
532
+ responseBody = await readBoundedResponseText(response, options.signal);
533
+ } catch (error) {
534
+ const message = error instanceof Error ? error.message : String(error);
535
+ throw new Error(`GitHub pull request response could not be read: ${redactSecrets(message, [token])}`);
536
+ }
537
+
538
+ if (responseBody.truncated) {
539
+ throw new Error(`GitHub pull request response exceeded the ${GITHUB_RESPONSE_BODY_LIMIT_BYTES} byte limit.`);
540
+ }
541
+
542
+ let payload: unknown;
543
+ try {
544
+ payload = JSON.parse(responseBody.text);
545
+ } catch (error) {
546
+ const message = error instanceof Error ? error.message : String(error);
547
+ throw new Error(`GitHub pull request response was not valid JSON: ${redactSecrets(message, [token])}`);
548
+ }
549
+
550
+ if (!isRecord(payload)) throw new Error("GitHub pull request response was not an object.");
551
+ const number = pullRequestNumberField(payload.number);
552
+
553
+ const head = isRecord(payload.head) ? stringField(payload.head.ref, "head.ref") : input.headBranch;
554
+ const base = isRecord(payload.base) ? stringField(payload.base.ref, "base.ref") : input.baseBranch;
555
+ const draft = typeof payload.draft === "boolean" ? payload.draft : input.draft;
556
+
557
+ return {
558
+ repository,
559
+ number,
560
+ url: stringField(payload.html_url, "html_url"),
561
+ state: stringField(payload.state, "state"),
562
+ head,
563
+ base,
564
+ draft,
565
+ };
566
+ }
@@ -0,0 +1,22 @@
1
+ const URL_USERINFO_PATTERN = /\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^\s/@]+(?:[^\s/@]*))@/gu;
2
+ const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu;
3
+ const GITHUB_CLASSIC_PAT_PATTERN = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]+\b/gu;
4
+ const GITHUB_FINE_GRAINED_PAT_PATTERN = /\bgithub_pat_[A-Za-z0-9_]+\b/giu;
5
+ const TOKEN_KEY_PATTERN = /\b(token|access_token|authorization|github_token|gh_token)(["']?\s*[:=]\s*["']?)([^\s"',}]+)/giu;
6
+
7
+ export function redactSecrets(value: string, tokens: readonly string[] = []): string {
8
+ let redacted = value;
9
+
10
+ for (const token of tokens) {
11
+ if (!token) continue;
12
+ redacted = redacted.split(token).join("[REDACTED]");
13
+ }
14
+
15
+ redacted = redacted.replace(URL_USERINFO_PATTERN, "$1[REDACTED]@");
16
+ redacted = redacted.replace(BEARER_PATTERN, "Bearer [REDACTED]");
17
+ redacted = redacted.replace(GITHUB_CLASSIC_PAT_PATTERN, "[REDACTED]");
18
+ redacted = redacted.replace(GITHUB_FINE_GRAINED_PAT_PATTERN, "[REDACTED]");
19
+ redacted = redacted.replace(TOKEN_KEY_PATTERN, (_match, key: string, separator: string) => `${key}${separator}[REDACTED]`);
20
+
21
+ return redacted;
22
+ }