@senad-d/branchme 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  <a href="https://pi.dev"><img alt="pi package" src="https://img.shields.io/badge/pi-package-6f42c1?style=flat-square" /></a>
7
7
  <a href="https://www.npmjs.com/package/@senad-d/branchme"><img alt="npm" src="https://img.shields.io/npm/v/%40senad-d%2Fbranchme?style=flat-square" /></a>
8
8
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" /></a>
9
+ <a align="center" href="https://sonarcloud.io/summary/new_code?id=senad-d_BranchMe"><img alt="Quality Gate Status" src="https://sonarcloud.io/api/project_badges/measure?project=senad-d_BranchMe&metric=alert_status" /></a>
9
10
  </p>
10
11
 
11
12
  <p align="center">
@@ -272,7 +273,7 @@ Ensure the token and Git credentials have permission for the branch and pull req
272
273
  | PR branch does not exist locally | Create or fetch/check out the local `headBranch` and `baseBranch` branches first; BranchMe does not use remote-only or cross-repository PR refs. |
273
274
  | PR branch is not visible or is stale on GitHub | Run `push_branch`, wait for it to complete, then retry `pull_request`; do not batch `push_branch` and `pull_request` in the same assistant tool call. |
274
275
  | Repository mismatch | Make `origin` and `GITHUB_REPOSITORY` refer to the same `owner/repo`. |
275
- | Need a commit | Use CommitMe or normal git commands; BranchMe never commits. |
276
+ | Need a commit | Use BranchMe or normal git commands; BranchMe never commits. |
276
277
  | Other extensions interfere | Test with `pi --no-extensions -e .`. |
277
278
 
278
279
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senad-d/branchme",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Minimal Pi extension for current-repository git branch, push, and pull request workflows.",
6
6
  "license": "MIT",
@@ -49,6 +49,7 @@
49
49
  "lint": "npm run typecheck && npm run format:check && npm run check",
50
50
  "format:check": "node scripts/check-format.mjs",
51
51
  "test": "node --test test/*.test.mjs",
52
+ "test:coverage": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=coverage/lcov.info test/*.test.mjs",
52
53
  "smoke:pi": "node scripts/smoke-pi-runtime.mjs",
53
54
  "smoke:pi:packed": "node scripts/smoke-pi-installed-package.mjs",
54
55
  "check": "node --check scripts/check-format.mjs && node --check scripts/check-package-contents.mjs && node --check scripts/publish-npm.mjs && node --check scripts/smoke-pi-installed-package.mjs && node --check scripts/smoke-pi-runtime.mjs",
@@ -82,7 +82,9 @@ function summaryRepository(data: BranchMePanelData): string {
82
82
  }
83
83
 
84
84
  function summaryToken(data: BranchMePanelData): string {
85
- return data.tokenWarning ? `warning: ${data.tokenWarning}` : data.tokenSource ? `present (${data.tokenSource})` : "not set";
85
+ if (data.tokenWarning) return `warning: ${data.tokenWarning}`;
86
+ if (data.tokenSource) return `present (${data.tokenSource})`;
87
+ return "not set";
86
88
  }
87
89
 
88
90
  function summaryStatusWarning(data: BranchMePanelData): string {
package/src/git.ts CHANGED
@@ -52,7 +52,7 @@ export async function withRepositoryMutationQueue<T>(repoRoot: string, operation
52
52
  }
53
53
 
54
54
  function trimOutput(value: string): string {
55
- return value.replace(/\s+$/u, "");
55
+ return value.trimEnd();
56
56
  }
57
57
 
58
58
  function compactOutput(value: string): string {
@@ -191,7 +191,7 @@ function validateRemoteName(remote: string): void {
191
191
  if (remote.includes(":") || remote.includes("@")) {
192
192
  throw new Error("Unable to push current branch: upstream remote name cannot be a URL or user-prefixed target.");
193
193
  }
194
- if (/[\u0000-\u001f\u007f\s]/u.test(remote)) {
194
+ if (/[\u0000-\u001f\u007f]/u.test(remote) || /\s/u.test(remote)) {
195
195
  throw new Error("Unable to push current branch: upstream remote contains whitespace or control characters.");
196
196
  }
197
197
  }
@@ -290,13 +290,13 @@ export async function getAheadBehindCount(
290
290
  const ahead = Number(aheadText);
291
291
  const behind = Number(behindText);
292
292
  if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
293
- throw new Error(`Unable to parse ahead/behind counts from git output: ${safeOutput(result.stdout)}`);
293
+ throw new TypeError(`Unable to parse ahead/behind counts from git output: ${safeOutput(result.stdout)}`);
294
294
  }
295
295
  return { ahead, behind };
296
296
  }
297
297
 
298
298
  export function validateBranchNameInput(branchName: unknown, label = "Branch name"): asserts branchName is string {
299
- if (typeof branchName !== "string") throw new Error(`${label} must be a string.`);
299
+ if (typeof branchName !== "string") throw new TypeError(`${label} must be a string.`);
300
300
  if (branchName.length === 0) throw new Error(`${label} is required.`);
301
301
  if (branchName.trim().length === 0) throw new Error(`${label} cannot be blank.`);
302
302
  if (branchName !== branchName.trim()) throw new Error(`${label} cannot start or end with whitespace.`);
package/src/github.ts CHANGED
@@ -80,16 +80,26 @@ export function repositoryLabel(repository: GitHubRepository): string {
80
80
  return `${repository.owner}/${repository.repo}`;
81
81
  }
82
82
 
83
+ function trimPathSlashes(pathname: string): string {
84
+ let start = 0;
85
+ let end = pathname.length;
86
+
87
+ while (start < end && pathname.charAt(start) === "/") start += 1;
88
+ while (end > start && pathname.charAt(end - 1) === "/") end -= 1;
89
+
90
+ return pathname.slice(start, end);
91
+ }
92
+
83
93
  export function parseGitHubRepository(value: string): GitHubRepository | null {
84
94
  const input = value.trim();
85
95
  if (!input) return null;
86
96
 
87
- const scpLike = input.match(/^git@github\.com:([^\s/]+)\/([^\s/]+)$/iu);
97
+ const scpLike = /^git@github\.com:([^\s/]+)\/([^\s/]+)$/iu.exec(input);
88
98
  if (scpLike) {
89
99
  return normalizeRepository(scpLike[1] ?? "", scpLike[2] ?? "");
90
100
  }
91
101
 
92
- const shorthand = input.match(/^([^\s/:]+)\/([^\s/]+)$/u);
102
+ const shorthand = /^([^\s/:]+)\/([^\s/]+)$/u.exec(input);
93
103
  if (shorthand) {
94
104
  return normalizeRepository(shorthand[1] ?? "", shorthand[2] ?? "");
95
105
  }
@@ -105,7 +115,7 @@ export function parseGitHubRepository(value: string): GitHubRepository | null {
105
115
  if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:") return null;
106
116
  if (parsed.protocol === "ssh:" && parsed.username && parsed.username !== "git") return null;
107
117
 
108
- const parts = parsed.pathname.replace(/^\/+|\/+$/gu, "").split("/");
118
+ const parts = trimPathSlashes(parsed.pathname).split("/");
109
119
  if (parts.length !== 2) return null;
110
120
  return normalizeRepository(parts[0] ?? "", parts[1] ?? "");
111
121
  }
@@ -139,6 +149,20 @@ function decodeDoubleQuotedDotEnvValue(value: string): string {
139
149
  });
140
150
  }
141
151
 
152
+ function isWhitespaceCharacter(character: string): boolean {
153
+ return character.trim() === "";
154
+ }
155
+
156
+ function stripDotEnvInlineComment(value: string): string {
157
+ for (let index = 1; index < value.length; index += 1) {
158
+ if (value.charAt(index) === "#" && isWhitespaceCharacter(value.charAt(index - 1))) {
159
+ return value.slice(0, index);
160
+ }
161
+ }
162
+
163
+ return value;
164
+ }
165
+
142
166
  function parseDotEnvValue(rawValue: string): string {
143
167
  const value = rawValue.trim();
144
168
  if (!value) return "";
@@ -151,7 +175,7 @@ function parseDotEnvValue(rawValue: string): string {
151
175
  return value.slice(1, -1).trim();
152
176
  }
153
177
 
154
- return value.replace(/\s+#.*$/u, "").trim();
178
+ return stripDotEnvInlineComment(value).trim();
155
179
  }
156
180
 
157
181
  function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey, string>> {
@@ -162,13 +186,13 @@ function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey
162
186
  if (!trimmed || trimmed.startsWith("#")) continue;
163
187
 
164
188
  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;
189
+ const separatorIndex = assignment.indexOf("=");
190
+ if (separatorIndex === -1) continue;
167
191
 
168
- const key = match[1] ?? "";
192
+ const key = assignment.slice(0, separatorIndex).trim();
169
193
  if (!isTokenEnvironmentKey(key)) continue;
170
194
 
171
- tokens[key] = parseDotEnvValue(match[2] ?? "");
195
+ tokens[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
172
196
  }
173
197
 
174
198
  return tokens;
@@ -294,29 +318,40 @@ function requireStringRef(value: unknown, field: "headBranch" | "baseBranch"): s
294
318
  return value;
295
319
  }
296
320
 
297
- export function validatePullRequestBranchRef(value: unknown, field: "headBranch" | "baseBranch"): void {
298
- const ref = requireStringRef(value, field);
321
+ const PULL_REQUEST_REF_INVALID_CHARACTER_PATTERN = /[~^?*[\]]/u;
299
322
 
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.`);
323
+ function validatePullRequestRefCharacters(ref: string, field: "headBranch" | "baseBranch"): void {
302
324
  if (ref.includes(":")) throw new Error(`${field} cannot contain ':' or an owner-prefixed cross-repository ref.`);
303
325
  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.`);
326
+ if (PULL_REQUEST_REF_INVALID_CHARACTER_PATTERN.test(ref)) {
327
+ throw new Error(`${field} contains characters that are not valid in a branch ref.`);
328
+ }
329
+ }
330
+
331
+ function validatePullRequestRefPathShape(ref: string, field: "headBranch" | "baseBranch"): void {
305
332
  if (ref.includes("..")) throw new Error(`${field} cannot contain path traversal-like '..' segments.`);
306
333
  if (ref.includes("@{")) throw new Error(`${field} cannot contain '@{'.`);
307
334
  if (ref.includes("//")) throw new Error(`${field} cannot contain empty path segments.`);
308
335
  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
336
  if (ref.endsWith(".")) throw new Error(`${field} cannot end with '.'.`);
311
337
  if (ref === "@") throw new Error(`${field} cannot be '@'.`);
312
338
  if (ref.startsWith("refs/")) throw new Error(`${field} must be a branch name, not a full ref path.`);
339
+ }
313
340
 
341
+ function validatePullRequestRefSegments(ref: string, field: "headBranch" | "baseBranch"): void {
314
342
  for (const segment of ref.split("/")) {
315
343
  if (segment === "." || segment === "..") throw new Error(`${field} cannot contain path traversal segments.`);
316
344
  if (segment.endsWith(".lock")) throw new Error(`${field} cannot contain '.lock' path segments.`);
317
345
  }
318
346
  }
319
347
 
348
+ export function validatePullRequestBranchRef(value: unknown, field: "headBranch" | "baseBranch"): void {
349
+ const ref = requireStringRef(value, field);
350
+ validatePullRequestRefCharacters(ref, field);
351
+ validatePullRequestRefPathShape(ref, field);
352
+ validatePullRequestRefSegments(ref, field);
353
+ }
354
+
320
355
  function validatePullRequestInput(input: unknown): asserts input is PullRequestInput {
321
356
  if (!isRecord(input)) throw new Error("Pull request input must be an object.");
322
357
  validatePullRequestBranchRef(input.headBranch, "headBranch");
@@ -399,72 +434,110 @@ function gitHubJsonHeaders(token: string): Record<string, string> {
399
434
  };
400
435
  }
401
436
 
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;
437
+ function requireFetchImplementation(fetchImpl: typeof fetch | undefined): typeof fetch {
413
438
  if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node.js runtime.");
439
+ return fetchImpl;
440
+ }
414
441
 
415
- const branchLabel = redactSecrets(branchName);
416
- const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/branches/${encodePathSegment(branchName)}`;
442
+ function redactedErrorMessage(error: unknown, tokens: readonly string[]): string {
443
+ const message = error instanceof Error ? error.message : String(error);
444
+ return redactSecrets(message, tokens);
445
+ }
417
446
 
418
- let response: Response;
447
+ async function fetchGitHubResponse(
448
+ fetchImpl: typeof fetch,
449
+ url: string,
450
+ init: RequestInit,
451
+ failurePrefix: string,
452
+ token: string,
453
+ ): Promise<Response> {
419
454
  try {
420
- response = await fetchImpl(url, {
421
- method: "GET",
422
- headers: gitHubJsonHeaders(token),
423
- signal: options.signal,
424
- });
455
+ return await fetchImpl(url, init);
425
456
  } 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])}`);
457
+ throw new Error(`${failurePrefix}: ${redactedErrorMessage(error, [token])}`);
428
458
  }
459
+ }
429
460
 
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
- };
461
+ async function readGitHubResponseBody(
462
+ response: Response,
463
+ signal: AbortSignal | undefined,
464
+ failurePrefix: string,
465
+ token: string,
466
+ ): Promise<BoundedResponseBody> {
467
+ try {
468
+ return await readBoundedResponseText(response, signal);
469
+ } catch (error) {
470
+ throw new Error(`${failurePrefix}: ${redactedErrorMessage(error, [token])}`);
457
471
  }
472
+ }
458
473
 
459
- let body: BoundedResponseBody;
474
+ function parseGitHubJson(text: string, responseContext: string, token: string): unknown {
460
475
  try {
461
- body = await readBoundedResponseText(response, options.signal);
476
+ return JSON.parse(text);
462
477
  } 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
- );
478
+ throw new Error(`${responseContext} was not valid JSON: ${redactedErrorMessage(error, [token])}`);
467
479
  }
480
+ }
481
+
482
+ function requireGitHubResponseObject(payload: unknown, responseContext: string): Record<string, unknown> {
483
+ if (!isRecord(payload)) throw new Error(`${responseContext} was not an object.`);
484
+ return payload;
485
+ }
486
+
487
+ async function readGitHubJsonObject(
488
+ response: Response,
489
+ signal: AbortSignal | undefined,
490
+ token: string,
491
+ responseContext: string,
492
+ readFailurePrefix: string,
493
+ ): Promise<Record<string, unknown>> {
494
+ const body = await readGitHubResponseBody(response, signal, readFailurePrefix, token);
495
+ if (body.truncated) throw new Error(`${responseContext} exceeded the ${GITHUB_RESPONSE_BODY_LIMIT_BYTES} byte limit.`);
496
+ return requireGitHubResponseObject(parseGitHubJson(body.text, responseContext, token), responseContext);
497
+ }
498
+
499
+ function parseGitHubBranchDetails(payload: Record<string, unknown>, branchName: string): GitHubBranchDetails {
500
+ const commit = isRecord(payload.commit) ? stringField(payload.commit.sha, "commit.sha") : "";
501
+ if (!/^[0-9a-f]{40,64}$/iu.test(commit)) throw new Error("GitHub branch preflight response is missing commit.sha.");
502
+
503
+ return {
504
+ name: typeof payload.name === "string" && payload.name ? payload.name : branchName,
505
+ commitSha: commit,
506
+ };
507
+ }
508
+
509
+ async function readGitHubBranchDetails(
510
+ response: Response,
511
+ signal: AbortSignal | undefined,
512
+ token: string,
513
+ branchName: string,
514
+ field: "headBranch" | "baseBranch",
515
+ branchLabel: string,
516
+ ): Promise<GitHubBranchDetails> {
517
+ const payload = await readGitHubJsonObject(
518
+ response,
519
+ signal,
520
+ token,
521
+ "GitHub branch preflight response",
522
+ `GitHub branch preflight response for ${field} '${branchLabel}' could not be read`,
523
+ );
524
+ return parseGitHubBranchDetails(payload, branchName);
525
+ }
526
+
527
+ async function throwGitHubBranchPreflightHttpError(
528
+ response: Response,
529
+ signal: AbortSignal | undefined,
530
+ token: string,
531
+ repository: GitHubRepository,
532
+ field: "headBranch" | "baseBranch",
533
+ branchLabel: string,
534
+ ): Promise<never> {
535
+ const body = await readGitHubResponseBody(
536
+ response,
537
+ signal,
538
+ `GitHub branch preflight failed for ${field} '${branchLabel}' with HTTP ${response.status}: unable to read error response`,
539
+ token,
540
+ );
468
541
 
469
542
  if (response.status === 404) {
470
543
  throw new Error(
@@ -477,79 +550,45 @@ export async function ensureGitHubBranchExists(
477
550
  );
478
551
  }
479
552
 
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 = {
553
+ function pullRequestRequestBody(input: PullRequestInput): Record<string, unknown> {
554
+ return {
494
555
  title: input.title,
495
556
  head: input.headBranch,
496
557
  base: input.baseBranch,
497
558
  body: input.body,
498
559
  draft: input.draft,
499
560
  };
561
+ }
500
562
 
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
- }
563
+ async function throwGitHubPullRequestHttpError(
564
+ response: Response,
565
+ signal: AbortSignal | undefined,
566
+ token: string,
567
+ ): Promise<never> {
568
+ const body = await readGitHubResponseBody(
569
+ response,
570
+ signal,
571
+ `GitHub pull request request failed with HTTP ${response.status}: unable to read error response`,
572
+ token,
573
+ );
537
574
 
538
- if (responseBody.truncated) {
539
- throw new Error(`GitHub pull request response exceeded the ${GITHUB_RESPONSE_BODY_LIMIT_BYTES} byte limit.`);
540
- }
575
+ throw new Error(`GitHub pull request request failed with HTTP ${response.status}: ${redactSecrets(truncate(body.text), [token])}`);
576
+ }
541
577
 
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
- }
578
+ async function readPullRequestPayload(
579
+ response: Response,
580
+ signal: AbortSignal | undefined,
581
+ token: string,
582
+ ): Promise<Record<string, unknown>> {
583
+ return readGitHubJsonObject(response, signal, token, "GitHub pull request response", "GitHub pull request response could not be read");
584
+ }
549
585
 
550
- if (!isRecord(payload)) throw new Error("GitHub pull request response was not an object.");
586
+ function pullRequestDetailsFromPayload(
587
+ repository: GitHubRepository,
588
+ input: PullRequestInput,
589
+ payload: Record<string, unknown>,
590
+ ): PullRequestDetails {
551
591
  const number = pullRequestNumberField(payload.number);
552
-
553
592
  const head = isRecord(payload.head) ? stringField(payload.head.ref, "head.ref") : input.headBranch;
554
593
  const base = isRecord(payload.base) ? stringField(payload.base.ref, "base.ref") : input.baseBranch;
555
594
  const draft = typeof payload.draft === "boolean" ? payload.draft : input.draft;
@@ -564,3 +603,62 @@ export async function createGitHubPullRequest(
564
603
  draft,
565
604
  };
566
605
  }
606
+
607
+ export async function ensureGitHubBranchExists(
608
+ repository: GitHubRepository,
609
+ branchName: string,
610
+ field: "headBranch" | "baseBranch",
611
+ token: string,
612
+ options: PullRequestFetchOptions = {},
613
+ ): Promise<GitHubBranchDetails> {
614
+ validateGitHubRepository(repository);
615
+ validatePullRequestBranchRef(branchName, field);
616
+
617
+ const fetchImpl = requireFetchImplementation(options.fetchImpl ?? globalThis.fetch);
618
+ const branchLabel = redactSecrets(branchName);
619
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/branches/${encodePathSegment(branchName)}`;
620
+ const response = await fetchGitHubResponse(
621
+ fetchImpl,
622
+ url,
623
+ { method: "GET", headers: gitHubJsonHeaders(token), signal: options.signal },
624
+ `GitHub branch preflight request failed for ${field} '${branchLabel}'`,
625
+ token,
626
+ );
627
+
628
+ if (!response.ok) {
629
+ return throwGitHubBranchPreflightHttpError(response, options.signal, token, repository, field, branchLabel);
630
+ }
631
+
632
+ return readGitHubBranchDetails(response, options.signal, token, branchName, field, branchLabel);
633
+ }
634
+
635
+ export async function createGitHubPullRequest(
636
+ repository: GitHubRepository,
637
+ input: PullRequestInput,
638
+ token: string,
639
+ options: PullRequestFetchOptions = {},
640
+ ): Promise<PullRequestDetails> {
641
+ validateGitHubRepository(repository);
642
+ validatePullRequestInput(input);
643
+
644
+ const fetchImpl = requireFetchImplementation(options.fetchImpl ?? globalThis.fetch);
645
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls`;
646
+ const requestBody = pullRequestRequestBody(input);
647
+ const response = await fetchGitHubResponse(
648
+ fetchImpl,
649
+ url,
650
+ {
651
+ method: "POST",
652
+ headers: gitHubJsonHeaders(token),
653
+ body: JSON.stringify(requestBody),
654
+ signal: options.signal,
655
+ },
656
+ "GitHub pull request request failed",
657
+ token,
658
+ );
659
+
660
+ if (!response.ok) return throwGitHubPullRequestHttpError(response, options.signal, token);
661
+
662
+ const payload = await readPullRequestPayload(response, options.signal, token);
663
+ return pullRequestDetailsFromPayload(repository, input, payload);
664
+ }
package/src/redaction.ts CHANGED
@@ -1,7 +1,7 @@
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;
1
+ const URL_USERINFO_PATTERN = /\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/@]+@/gu;
2
+ const BEARER_PATTERN = /\bBearer\s+[\w.~+/=-]+/giu;
3
+ const GITHUB_CLASSIC_PAT_PATTERN = /\b(?:ghp|gho|ghu|ghs|ghr)_\w+\b/gu;
4
+ const GITHUB_FINE_GRAINED_PAT_PATTERN = /\bgithub_pat_\w+\b/giu;
5
5
  const TOKEN_KEY_PATTERN = /\b(token|access_token|authorization|github_token|gh_token)(["']?\s*[:=]\s*["']?)([^\s"',}]+)/giu;
6
6
 
7
7
  export function redactSecrets(value: string, tokens: readonly string[] = []): string {
@@ -32,11 +32,11 @@ const MAX_PANEL_WIDTH = 96;
32
32
  const MAX_PANEL_HEIGHT = 14;
33
33
  const BODY_HEIGHT = 7;
34
34
  const PANEL_SECTIONS: BranchMePanelSection[] = ["status", "workflow"];
35
- const ANSI_RESET = /\u001b\[0m/gu;
35
+ const ANSI_RESET = "\u001b[0m";
36
36
 
37
37
  function truncateVisible(value: string, width: number, ellipsis = "…", pad = false): string {
38
38
  const truncated = truncateToWidth(value, width, ellipsis, pad);
39
- return value.includes("\u001b") ? truncated : truncated.replace(ANSI_RESET, "");
39
+ return value.includes("\u001b") ? truncated : truncated.replaceAll(ANSI_RESET, "");
40
40
  }
41
41
 
42
42
  function sanitize(value: string): string {
@@ -334,7 +334,9 @@ export class BranchMePanel {
334
334
  }
335
335
  }
336
336
 
337
- invalidate(): void {}
337
+ invalidate(): void {
338
+ // Intentional no-op: BranchMePanel has no external cache to refresh; input handlers request renders through onChange.
339
+ }
338
340
 
339
341
  private moveSelection(delta: number): void {
340
342
  const currentIndex = sectionIndex(this.selectedSection);