@senad-d/branchme 0.1.5 → 0.1.7

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/constants.ts CHANGED
@@ -4,22 +4,29 @@ export const BRANCHME_COMMAND_NAME = "branchme";
4
4
  export const BRANCH_STATUS_TOOL_NAME = "branch_status";
5
5
  export const CREATE_BRANCH_TOOL_NAME = "create_branch";
6
6
  export const CHANGE_BRANCH_TOOL_NAME = "change_branch";
7
+ export const FETCH_BRANCH_TOOL_NAME = "fetch_branch";
7
8
  export const PULL_BRANCH_TOOL_NAME = "pull_branch";
9
+ export const REBASE_BRANCH_TOOL_NAME = "rebase_branch";
8
10
  export const PUSH_BRANCH_TOOL_NAME = "push_branch";
9
11
  export const PULL_REQUEST_TOOL_NAME = "pull_request";
12
+ export const PULL_REQUEST_AUTOFILL_ENV_NAME = "BRANCHME_PR_AUTOFILL";
10
13
 
11
14
  export const BRANCHME_TOOL_NAMES = [
12
15
  BRANCH_STATUS_TOOL_NAME,
13
16
  CREATE_BRANCH_TOOL_NAME,
14
17
  CHANGE_BRANCH_TOOL_NAME,
18
+ FETCH_BRANCH_TOOL_NAME,
15
19
  PULL_BRANCH_TOOL_NAME,
20
+ REBASE_BRANCH_TOOL_NAME,
16
21
  PUSH_BRANCH_TOOL_NAME,
17
22
  PULL_REQUEST_TOOL_NAME,
18
23
  ] as const;
19
24
 
20
25
  export const GIT_STATUS_TIMEOUT_MS = 5_000;
21
26
  export const GIT_MUTATION_TIMEOUT_MS = 30_000;
27
+ export const GIT_FETCH_TIMEOUT_MS = 120_000;
22
28
  export const GIT_PULL_TIMEOUT_MS = 120_000;
29
+ export const GIT_REBASE_TIMEOUT_MS = 120_000;
23
30
  export const GIT_PUSH_TIMEOUT_MS = 120_000;
24
31
  export const GITHUB_RELATED_PR_TIMEOUT_MS = 4_000;
25
32
 
@@ -27,6 +34,8 @@ export const GIT_CONTEXT_RECENT_COMMIT_LIMIT = 5;
27
34
  export const GIT_CONTEXT_CHANGE_LIMIT = 20;
28
35
  export const GIT_CONTEXT_VALUE_LIMIT_CHARS = 512;
29
36
  export const GIT_CONTEXT_SUMMARY_LIMIT_CHARS = 4_000;
37
+ export const PULL_REQUEST_AUTOFILL_COMMIT_LIMIT = 20;
38
+ export const PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS = 256;
30
39
 
31
40
  export const GITHUB_API_BASE_URL = "https://api.github.com";
32
41
  export const GITHUB_API_VERSION = "2022-11-28";
@@ -19,7 +19,7 @@ import {
19
19
  getWorkingTreeStatus,
20
20
  type GitCommandContext,
21
21
  } from "./git.ts";
22
- import { lookupRelatedPullRequest } from "./github.ts";
22
+ import { lookupRelatedPullRequest, resolvePullRequestAutofill } from "./github.ts";
23
23
  import { redactSecrets } from "./redaction.ts";
24
24
  import type { GitContextDetails, GitFileChange, RecentCommit, RelatedPullRequest } from "./types.ts";
25
25
 
@@ -138,6 +138,7 @@ export async function collectGitContext(
138
138
  const recentCommits = await getRecentCommits(pi, rootCtx, options.signal);
139
139
  const upstream = current.detached ? null : await getUpstreamBranch(pi, rootCtx, options.signal);
140
140
  const warnings: string[] = [];
141
+ let pullRequestAutofill: boolean | null = null;
141
142
  let ahead: number | null = null;
142
143
  let behind: number | null = null;
143
144
 
@@ -152,6 +153,13 @@ export async function collectGitContext(
152
153
  }
153
154
  }
154
155
 
156
+ try {
157
+ pullRequestAutofill = await resolvePullRequestAutofill(options.env, { cwd: repoRoot, signal: options.signal });
158
+ } catch (error) {
159
+ throwIfCollectionAborted(options.signal);
160
+ warnings.push(`pull request autofill configuration unavailable (${safeFailureType(error)})`);
161
+ }
162
+
155
163
  const relatedPullRequest = await collectRelatedPullRequest(pi, rootCtx, options);
156
164
 
157
165
  return {
@@ -166,6 +174,7 @@ export async function collectGitContext(
166
174
  ...(relatedPullRequest.status === "found"
167
175
  ? { githubRepository: relatedPullRequest.pullRequest.repository }
168
176
  : {}),
177
+ pullRequestAutofill,
169
178
  workingTree: workingTreeStatus.workingTree,
170
179
  unstagedChanges: workingTreeStatus.unstagedChanges,
171
180
  relatedPullRequest,
@@ -223,6 +232,13 @@ function formatRelatedPullRequest(details: GitContextDetails, valueLimit: number
223
232
  return `- Related PR: #${safeNonNegativeInteger(pullRequest.number)} ${quoteMetadata(pullRequest.title, valueLimit)}; repository ${quoteMetadata(repository, valueLimit)}; ${quoteMetadata(pullRequest.head, valueLimit)} -> ${quoteMetadata(pullRequest.base, valueLimit)}; ${quoteMetadata(pullRequest.url, valueLimit)}; ${quoteMetadata(pullRequest.state, valueLimit)}${draft}`;
224
233
  }
225
234
 
235
+ function formatPullRequestAutofill(details: GitContextDetails): string {
236
+ let state = "unavailable";
237
+ if (details.pullRequestAutofill === true) state = "enabled";
238
+ if (details.pullRequestAutofill === false) state = "disabled";
239
+ return `- Pull request field autofill: ${state} (BRANCHME_PR_AUTOFILL)`;
240
+ }
241
+
226
242
  function appendRecentCommits(lines: string[], selection: FormatSelection): void {
227
243
  if (selection.commits.length === 0 && selection.omittedCommits === 0) {
228
244
  lines.push("- Recent commits: none");
@@ -257,7 +273,10 @@ function renderGitContext(
257
273
  formatWorkingTree(details),
258
274
  ];
259
275
  appendUnstagedChanges(lines, selection);
260
- lines.push(formatRelatedPullRequest(details, selection.valueLimit));
276
+ lines.push(
277
+ formatRelatedPullRequest(details, selection.valueLimit),
278
+ formatPullRequestAutofill(details),
279
+ );
261
280
  appendRecentCommits(lines, selection);
262
281
  lines.push(
263
282
  "",
@@ -295,6 +314,7 @@ function boundedFallbackContext(mode: GitContextFormatMode): string {
295
314
  "- Working tree: unavailable",
296
315
  "- Unstaged changes: [entries omitted]",
297
316
  "- Related PR: unavailable",
317
+ "- Pull request field autofill: unavailable",
298
318
  "- Recent commits: [entries omitted]",
299
319
  "",
300
320
  "Call branch_status to request a current bounded snapshot.",
package/src/git.ts CHANGED
@@ -3,11 +3,15 @@ import {
3
3
  GIT_CONTEXT_CHANGE_LIMIT,
4
4
  GIT_CONTEXT_RECENT_COMMIT_LIMIT,
5
5
  GIT_CONTEXT_VALUE_LIMIT_CHARS,
6
+ GIT_FETCH_TIMEOUT_MS,
6
7
  GIT_MUTATION_TIMEOUT_MS,
7
8
  GIT_PULL_TIMEOUT_MS,
8
9
  GIT_PUSH_TIMEOUT_MS,
10
+ GIT_REBASE_TIMEOUT_MS,
9
11
  GIT_STATUS_TIMEOUT_MS,
10
12
  MAX_SUMMARY_OUTPUT_CHARS,
13
+ PULL_REQUEST_AUTOFILL_COMMIT_LIMIT,
14
+ PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS,
11
15
  } from "./constants.ts";
12
16
  import { redactSecrets } from "./redaction.ts";
13
17
  import type {
@@ -16,11 +20,13 @@ import type {
16
20
  ChangeBranchDetails,
17
21
  CreateBranchDetails,
18
22
  CurrentBranchInfo,
23
+ FetchBranchDetails,
19
24
  GitExecResult,
20
25
  GitFileChange,
21
26
  GitFileChangeSummary,
22
27
  PullBranchDetails,
23
28
  PushBranchDetails,
29
+ RebaseBranchDetails,
24
30
  RecentCommit,
25
31
  WorkingTreeDetails,
26
32
  } from "./types.ts";
@@ -33,6 +39,7 @@ export interface GitRunOptions {
33
39
  signal?: AbortSignal;
34
40
  timeout?: number;
35
41
  allowFailure?: boolean;
42
+ tokens?: readonly string[];
36
43
  }
37
44
 
38
45
  const repositoryMutationQueues = new Map<string, Promise<void>>();
@@ -110,11 +117,11 @@ export async function runGit(
110
117
  });
111
118
 
112
119
  if (result.killed) {
113
- throw new Error(formatGitFailure(args, result));
120
+ throw new Error(formatGitFailure(args, result, options.tokens));
114
121
  }
115
122
 
116
123
  if (!options.allowFailure && result.code !== 0) {
117
- throw new Error(formatGitFailure(args, result));
124
+ throw new Error(formatGitFailure(args, result, options.tokens));
118
125
  }
119
126
 
120
127
  return result;
@@ -199,7 +206,7 @@ interface PushTarget {
199
206
  args: string[];
200
207
  }
201
208
 
202
- type UpstreamOperation = "pull" | "push";
209
+ type UpstreamOperation = "fetch" | "pull" | "push" | "rebase";
203
210
 
204
211
  function validateRemoteName(remote: string, operation: UpstreamOperation): void {
205
212
  if (!remote) throw new Error(`Unable to ${operation} current branch: upstream remote is missing.`);
@@ -241,6 +248,17 @@ async function getBranchConfigValue(
241
248
  return value || null;
242
249
  }
243
250
 
251
+ function remoteTrackingRef(upstream: string, remote: string): string {
252
+ const prefix = `${remote}/`;
253
+ if (!upstream.startsWith(prefix)) {
254
+ throw new Error("Unable to fetch current branch: configured upstream does not match its remote.");
255
+ }
256
+
257
+ const trackingBranch = upstream.slice(prefix.length);
258
+ validateBranchNameInput(trackingBranch, "Upstream branch name");
259
+ return `refs/remotes/${upstream}`;
260
+ }
261
+
244
262
  async function resolveConfiguredUpstreamTarget(
245
263
  pi: Pick<ExtensionAPI, "exec">,
246
264
  ctx: GitCommandContext,
@@ -553,6 +571,98 @@ export async function getRecentCommits(
553
571
  throw new Error(formatGitFailure(args, result));
554
572
  }
555
573
 
574
+ function truncatePullRequestCommitSubject(value: string): string {
575
+ if (value.length <= PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS) return value;
576
+
577
+ let end = PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS - 1;
578
+ const lastCodePoint = value.codePointAt(end - 1);
579
+ if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
580
+ return `${value.slice(0, end).trimEnd()}…`;
581
+ }
582
+
583
+ function safePullRequestCommitSubject(value: string, tokens: readonly string[]): string {
584
+ const normalized = redactSecrets(value, tokens)
585
+ .replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
586
+ .replace(/\s+/gu, " ")
587
+ .trim();
588
+ return truncatePullRequestCommitSubject(normalized);
589
+ }
590
+
591
+ async function getOriginDefaultBranch(
592
+ pi: Pick<ExtensionAPI, "exec">,
593
+ ctx: GitCommandContext,
594
+ signal?: AbortSignal,
595
+ ): Promise<string | null> {
596
+ const args = ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"];
597
+ const result = await runGit(pi, ctx, args, {
598
+ signal,
599
+ timeout: GIT_STATUS_TIMEOUT_MS,
600
+ allowFailure: true,
601
+ });
602
+ if (result.code !== 0) return null;
603
+
604
+ const remoteBranch = trimOutput(result.stdout);
605
+ const prefix = "origin/";
606
+ if (!remoteBranch.startsWith(prefix)) return null;
607
+
608
+ const branch = remoteBranch.slice(prefix.length);
609
+ try {
610
+ validateBranchNameInput(branch, "Default base branch");
611
+ } catch {
612
+ return null;
613
+ }
614
+ return branch;
615
+ }
616
+
617
+ export async function inferPullRequestBaseBranch(
618
+ pi: Pick<ExtensionAPI, "exec">,
619
+ ctx: GitCommandContext,
620
+ headBranch: string,
621
+ signal?: AbortSignal,
622
+ ): Promise<string> {
623
+ validateBranchNameInput(headBranch, "headBranch");
624
+ const originDefault = await getOriginDefaultBranch(pi, ctx, signal);
625
+ if (originDefault) {
626
+ if (originDefault === headBranch) {
627
+ throw new Error("Unable to infer baseBranch because the current branch is the origin default branch.");
628
+ }
629
+ if (await localBranchExists(pi, ctx, originDefault, signal)) return originDefault;
630
+ }
631
+
632
+ for (const candidate of ["main", "master", "trunk", "develop"]) {
633
+ if (candidate !== headBranch && await localBranchExists(pi, ctx, candidate, signal)) return candidate;
634
+ }
635
+
636
+ throw new Error(
637
+ "Unable to infer baseBranch from origin/HEAD or a local main, master, trunk, or develop branch; provide baseBranch explicitly.",
638
+ );
639
+ }
640
+
641
+ export async function getPullRequestCommitSubjects(
642
+ pi: Pick<ExtensionAPI, "exec">,
643
+ ctx: GitCommandContext,
644
+ headBranch: string,
645
+ baseBranch: string,
646
+ signal?: AbortSignal,
647
+ tokens: readonly string[] = [],
648
+ ): Promise<string[]> {
649
+ validateBranchNameInput(headBranch, "headBranch");
650
+ validateBranchNameInput(baseBranch, "baseBranch");
651
+ const revisionRange = `refs/heads/${baseBranch}..refs/heads/${headBranch}`;
652
+ const args = [
653
+ "log",
654
+ `--max-count=${PULL_REQUEST_AUTOFILL_COMMIT_LIMIT}`,
655
+ "--format=%s",
656
+ revisionRange,
657
+ "--",
658
+ ];
659
+ const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, tokens });
660
+ return result.stdout
661
+ .split(/\r?\n/u)
662
+ .map((subject) => safePullRequestCommitSubject(subject, tokens))
663
+ .filter(Boolean);
664
+ }
665
+
556
666
  export async function getAheadBehindCount(
557
667
  pi: Pick<ExtensionAPI, "exec">,
558
668
  ctx: GitCommandContext,
@@ -700,6 +810,46 @@ export async function changeExistingLocalBranch(
700
810
  });
701
811
  }
702
812
 
813
+ export async function fetchCurrentBranch(
814
+ pi: Pick<ExtensionAPI, "exec">,
815
+ ctx: GitCommandContext,
816
+ signal?: AbortSignal,
817
+ ): Promise<FetchBranchDetails> {
818
+ const repoRoot = await getGitRoot(pi, ctx, signal);
819
+ const rootCtx = { cwd: repoRoot };
820
+
821
+ return withRepositoryMutationQueue(repoRoot, async () => {
822
+ const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
823
+ const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "fetch", signal);
824
+ if (!target) {
825
+ throw new Error(`Unable to fetch current branch '${safeDetail(currentBranch)}': no upstream is configured.`);
826
+ }
827
+
828
+ const trackingRef = remoteTrackingRef(target.upstream, target.remote);
829
+ const refspec = `${target.remoteRef}:${trackingRef}`;
830
+ const result = await runGit(
831
+ pi,
832
+ rootCtx,
833
+ ["fetch", "--no-tags", "--no-recurse-submodules", target.remote, refspec],
834
+ {
835
+ signal,
836
+ timeout: GIT_FETCH_TIMEOUT_MS,
837
+ },
838
+ );
839
+
840
+ return {
841
+ repoRoot,
842
+ currentBranch: safeDetail(currentBranch),
843
+ upstream: safeDetail(target.upstream),
844
+ remote: safeDetail(target.remote),
845
+ remoteRef: safeDetail(target.remoteRef),
846
+ remoteTrackingRef: safeDetail(trackingRef),
847
+ refspec: safeDetail(refspec),
848
+ output: safeOutput(result.stdout || result.stderr),
849
+ };
850
+ });
851
+ }
852
+
703
853
  export async function pullCurrentBranch(
704
854
  pi: Pick<ExtensionAPI, "exec">,
705
855
  ctx: GitCommandContext,
@@ -735,6 +885,78 @@ export async function pullCurrentBranch(
735
885
  });
736
886
  }
737
887
 
888
+ async function abortFailedRebase(
889
+ pi: Pick<ExtensionAPI, "exec">,
890
+ ctx: GitCommandContext,
891
+ failureMessage: string,
892
+ ): Promise<never> {
893
+ const abortArgs = ["rebase", "--abort"];
894
+ let abortResult: GitExecResult;
895
+ try {
896
+ abortResult = await runGit(pi, ctx, abortArgs, {
897
+ timeout: GIT_MUTATION_TIMEOUT_MS,
898
+ allowFailure: true,
899
+ });
900
+ } catch (error) {
901
+ const reason = safeOutput(error instanceof Error ? error.message : String(error)) || "git rebase --abort failed";
902
+ throw new Error(`${failureMessage}. Automatic rebase cleanup failed: ${reason}. Inspect the repository before continuing.`);
903
+ }
904
+
905
+ if (abortResult.code === 0) {
906
+ throw new Error(`${failureMessage}. Rebase was aborted and the current branch was restored.`);
907
+ }
908
+
909
+ const reason = safeOutput(abortResult.stderr || abortResult.stdout) || `exit code ${abortResult.code}`;
910
+ throw new Error(`${failureMessage}. Automatic git rebase --abort did not complete: ${reason}. Inspect the repository before continuing.`);
911
+ }
912
+
913
+ export async function rebaseCurrentBranch(
914
+ pi: Pick<ExtensionAPI, "exec">,
915
+ ctx: GitCommandContext,
916
+ signal?: AbortSignal,
917
+ ): Promise<RebaseBranchDetails> {
918
+ const repoRoot = await getGitRoot(pi, ctx, signal);
919
+ const rootCtx = { cwd: repoRoot };
920
+
921
+ return withRepositoryMutationQueue(repoRoot, async () => {
922
+ const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
923
+ if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
924
+ throw new Error("Working tree has uncommitted changes; clean it before rebasing the current branch.");
925
+ }
926
+
927
+ const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "rebase", signal);
928
+ if (!target) {
929
+ throw new Error(`Unable to rebase current branch '${safeDetail(currentBranch)}': no upstream is configured.`);
930
+ }
931
+
932
+ const args = ["rebase", "--no-autostash", "--no-update-refs", target.upstream];
933
+ let result: GitExecResult;
934
+ try {
935
+ result = await runGit(pi, rootCtx, args, {
936
+ signal,
937
+ timeout: GIT_REBASE_TIMEOUT_MS,
938
+ allowFailure: true,
939
+ });
940
+ } catch (error) {
941
+ const failureMessage = safeOutput(error instanceof Error ? error.message : String(error)) || "git rebase failed";
942
+ return abortFailedRebase(pi, rootCtx, failureMessage);
943
+ }
944
+
945
+ if (result.code !== 0) {
946
+ return abortFailedRebase(pi, rootCtx, formatGitFailure(args, result));
947
+ }
948
+
949
+ return {
950
+ repoRoot,
951
+ currentBranch: safeDetail(currentBranch),
952
+ upstream: safeDetail(target.upstream),
953
+ remote: safeDetail(target.remote),
954
+ remoteRef: safeDetail(target.remoteRef),
955
+ output: safeOutput(result.stdout || result.stderr),
956
+ };
957
+ });
958
+ }
959
+
738
960
  export async function pushCurrentBranch(
739
961
  pi: Pick<ExtensionAPI, "exec">,
740
962
  ctx: GitCommandContext,
package/src/github.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  GITHUB_USER_AGENT,
11
11
  GIT_CONTEXT_VALUE_LIMIT_CHARS,
12
12
  MAX_SUMMARY_OUTPUT_CHARS,
13
+ PULL_REQUEST_AUTOFILL_ENV_NAME,
13
14
  } from "./constants.ts";
14
15
  import {
15
16
  getCurrentBranch,
@@ -27,6 +28,9 @@ import type {
27
28
  } from "./types.ts";
28
29
 
29
30
  type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
31
+ type PullRequestAutofillEnvironmentKey = typeof PULL_REQUEST_AUTOFILL_ENV_NAME;
32
+ type SupportedDotEnvKey = TokenEnvironmentKey | PullRequestAutofillEnvironmentKey;
33
+ type DotEnvReadPurpose = "GitHub token fallback" | "pull request autofill configuration";
30
34
 
31
35
  export type TokenResolutionSource = TokenEnvironmentKey | `${TokenEnvironmentKey} (.env)`;
32
36
 
@@ -149,8 +153,8 @@ function resolveProcessToken(env: NodeJS.ProcessEnv): TokenResolution | null {
149
153
  return null;
150
154
  }
151
155
 
152
- function isTokenEnvironmentKey(value: string): value is TokenEnvironmentKey {
153
- return value === "GITHUB_TOKEN" || value === "GH_TOKEN";
156
+ function isSupportedDotEnvKey(value: string): value is SupportedDotEnvKey {
157
+ return value === "GITHUB_TOKEN" || value === "GH_TOKEN" || value === PULL_REQUEST_AUTOFILL_ENV_NAME;
154
158
  }
155
159
 
156
160
  function decodeDoubleQuotedDotEnvValue(value: string): string {
@@ -182,23 +186,46 @@ function stripDotEnvInlineComment(value: string): string {
182
186
  return value;
183
187
  }
184
188
 
189
+ function closingDotEnvQuoteIndex(value: string, quote: "\"" | "'"): number {
190
+ let escaped = false;
191
+ for (let index = 1; index < value.length; index += 1) {
192
+ const character = value.charAt(index);
193
+ if (quote === "\"" && character === "\\" && !escaped) {
194
+ escaped = true;
195
+ continue;
196
+ }
197
+ if (character === quote && !escaped) return index;
198
+ escaped = false;
199
+ }
200
+ return -1;
201
+ }
202
+
203
+ function parseQuotedDotEnvValue(value: string): string | null {
204
+ const quote = value.charAt(0);
205
+ if (quote !== "\"" && quote !== "'") return null;
206
+
207
+ const closingIndex = closingDotEnvQuoteIndex(value, quote);
208
+ if (closingIndex === -1) return null;
209
+
210
+ const remainder = value.slice(closingIndex + 1).trim();
211
+ if (remainder && !remainder.startsWith("#")) return null;
212
+
213
+ const quotedValue = value.slice(1, closingIndex);
214
+ return quote === "\"" ? decodeDoubleQuotedDotEnvValue(quotedValue).trim() : quotedValue.trim();
215
+ }
216
+
185
217
  function parseDotEnvValue(rawValue: string): string {
186
218
  const value = rawValue.trim();
187
219
  if (!value) return "";
188
220
 
189
- if (value.startsWith("\"") && value.endsWith("\"")) {
190
- return decodeDoubleQuotedDotEnvValue(value.slice(1, -1)).trim();
191
- }
192
-
193
- if (value.startsWith("'") && value.endsWith("'")) {
194
- return value.slice(1, -1).trim();
195
- }
221
+ const quotedValue = parseQuotedDotEnvValue(value);
222
+ if (quotedValue !== null) return quotedValue;
196
223
 
197
224
  return stripDotEnvInlineComment(value).trim();
198
225
  }
199
226
 
200
- function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey, string>> {
201
- const tokens: Partial<Record<TokenEnvironmentKey, string>> = {};
227
+ function parseDotEnvValues(contents: string): Partial<Record<SupportedDotEnvKey, string>> {
228
+ const values: Partial<Record<SupportedDotEnvKey, string>> = {};
202
229
 
203
230
  for (const line of contents.split(/\r?\n/u)) {
204
231
  const trimmed = line.trim();
@@ -209,12 +236,12 @@ function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey
209
236
  if (separatorIndex === -1) continue;
210
237
 
211
238
  const key = assignment.slice(0, separatorIndex).trim();
212
- if (!isTokenEnvironmentKey(key)) continue;
239
+ if (!isSupportedDotEnvKey(key)) continue;
213
240
 
214
- tokens[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
241
+ values[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
215
242
  }
216
243
 
217
- return tokens;
244
+ return values;
218
245
  }
219
246
 
220
247
  const MAX_DOTENV_BYTES = 64 * 1024;
@@ -223,55 +250,77 @@ function isMissingFileError(error: unknown): boolean {
223
250
  return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
224
251
  }
225
252
 
226
- function throwIfAborted(signal: AbortSignal | undefined): void {
227
- if (signal?.aborted) throw new Error("GitHub token .env fallback was aborted.");
253
+ function throwIfAborted(signal: AbortSignal | undefined, purpose: DotEnvReadPurpose): void {
254
+ if (signal?.aborted) throw new Error(`${purpose} .env read was aborted.`);
228
255
  }
229
256
 
230
257
  function errorMessage(error: unknown): string {
231
258
  return redactSecrets(error instanceof Error ? error.message : String(error));
232
259
  }
233
260
 
234
- async function readDotEnvTokens(
261
+ async function readDotEnvValues(
235
262
  cwd: string | undefined,
236
263
  signal?: AbortSignal,
237
- ): Promise<Partial<Record<TokenEnvironmentKey, string>>> {
264
+ purpose: DotEnvReadPurpose = "GitHub token fallback",
265
+ ): Promise<Partial<Record<SupportedDotEnvKey, string>>> {
238
266
  if (!cwd) return {};
239
267
 
240
268
  const envPath = join(cwd, ".env");
241
- throwIfAborted(signal);
269
+ throwIfAborted(signal, purpose);
242
270
 
243
271
  let stats: Awaited<ReturnType<typeof lstat>>;
244
272
  try {
245
273
  stats = await lstat(envPath);
246
274
  } catch (error) {
247
275
  if (isMissingFileError(error)) return {};
248
- throw new Error(`Unable to inspect .env file for GitHub token fallback: ${errorMessage(error)}`);
276
+ throw new Error(`Unable to inspect .env file for ${purpose}: ${errorMessage(error)}`);
249
277
  }
250
278
 
251
279
  if (!stats.isFile()) {
252
- throw new Error("Unable to read .env file for GitHub token fallback: .env must be a small regular file.");
280
+ throw new Error(`Unable to read .env file for ${purpose}: .env must be a small regular file.`);
253
281
  }
254
282
  if (stats.size > MAX_DOTENV_BYTES) {
255
283
  throw new Error(
256
- `Unable to read .env file for GitHub token fallback: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
284
+ `Unable to read .env file for ${purpose}: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
257
285
  );
258
286
  }
259
287
 
260
- throwIfAborted(signal);
288
+ throwIfAborted(signal, purpose);
261
289
 
262
290
  let contents: string;
263
291
  try {
264
292
  contents = await readFile(envPath, { encoding: "utf8", signal });
265
293
  } catch (error) {
266
294
  if (isMissingFileError(error)) return {};
267
- throw new Error(`Unable to read .env file for GitHub token fallback: ${errorMessage(error)}`);
295
+ throw new Error(`Unable to read .env file for ${purpose}: ${errorMessage(error)}`);
268
296
  }
269
297
 
270
298
  if (Buffer.byteLength(contents, "utf8") > MAX_DOTENV_BYTES) {
271
- throw new Error(`Unable to read .env file for GitHub token fallback: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
299
+ throw new Error(`Unable to read .env file for ${purpose}: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
272
300
  }
273
301
 
274
- return parseDotEnvTokens(contents);
302
+ return parseDotEnvValues(contents);
303
+ }
304
+
305
+ function parsePullRequestAutofillValue(value: string): boolean {
306
+ const normalized = value.trim().toLowerCase();
307
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
308
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
309
+ throw new Error(
310
+ `${PULL_REQUEST_AUTOFILL_ENV_NAME} must be one of true, false, 1, 0, yes, no, on, or off.`,
311
+ );
312
+ }
313
+
314
+ export async function resolvePullRequestAutofill(
315
+ env: NodeJS.ProcessEnv = process.env,
316
+ options: TokenResolutionOptions = {},
317
+ ): Promise<boolean> {
318
+ const processValue = env[PULL_REQUEST_AUTOFILL_ENV_NAME];
319
+ if (processValue !== undefined) return parsePullRequestAutofillValue(processValue);
320
+
321
+ const dotEnvValues = await readDotEnvValues(options.cwd, options.signal, "pull request autofill configuration");
322
+ const dotEnvValue = dotEnvValues[PULL_REQUEST_AUTOFILL_ENV_NAME];
323
+ return dotEnvValue === undefined ? false : parsePullRequestAutofillValue(dotEnvValue);
275
324
  }
276
325
 
277
326
  export async function resolveGitHubToken(
@@ -281,7 +330,7 @@ export async function resolveGitHubToken(
281
330
  const processToken = resolveProcessToken(env);
282
331
  if (processToken) return processToken;
283
332
 
284
- const dotEnvTokens = await readDotEnvTokens(options.cwd, options.signal);
333
+ const dotEnvTokens = await readDotEnvValues(options.cwd, options.signal);
285
334
  const githubToken = dotEnvTokens.GITHUB_TOKEN?.trim();
286
335
  if (githubToken) return { token: githubToken, source: "GITHUB_TOKEN (.env)" };
287
336