Improve Foundry auth and task flows (#240)

This commit is contained in:
Nathan Flurry 2026-03-11 18:13:31 -07:00 committed by GitHub
parent d75e8c31d1
commit dbc2ff0682
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 621 additions and 137 deletions

View file

@ -10,8 +10,12 @@ const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000;
const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000;
const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
function resolveGithubToken(): string | null {
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null;
interface GitAuthOptions {
githubToken?: string | null;
}
function resolveGithubToken(options?: GitAuthOptions): string | null {
const token = options?.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null;
if (!token) return null;
const trimmed = token.trim();
return trimmed.length > 0 ? trimmed : null;
@ -47,30 +51,45 @@ function ensureAskpassScript(): string {
return path;
}
function gitEnv(): Record<string, string> {
function gitEnv(options?: GitAuthOptions): Record<string, string> {
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
env.GIT_TERMINAL_PROMPT = "0";
const token = resolveGithubToken();
const token = resolveGithubToken(options);
if (token) {
env.GIT_ASKPASS = ensureAskpassScript();
// Some tooling expects these vars; keep them aligned.
env.GITHUB_TOKEN = env.GITHUB_TOKEN || token;
env.GH_TOKEN = env.GH_TOKEN || token;
env.GITHUB_TOKEN = token;
env.GH_TOKEN = token;
}
return env;
}
async function configureGithubAuth(repoPath: string, options?: GitAuthOptions): Promise<void> {
const token = resolveGithubToken(options);
if (!token) {
return;
}
const authHeader = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64");
await execFileAsync("git", ["-C", repoPath, "config", "--local", "credential.helper", ""], {
env: gitEnv(options),
});
await execFileAsync("git", ["-C", repoPath, "config", "--local", "http.https://github.com/.extraheader", `AUTHORIZATION: basic ${authHeader}`], {
env: gitEnv(options),
});
}
export interface BranchSnapshot {
branchName: string;
commitSha: string;
}
export async function fetch(repoPath: string): Promise<void> {
export async function fetch(repoPath: string, options?: GitAuthOptions): Promise<void> {
await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], {
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
env: gitEnv(),
env: gitEnv(options),
});
}
@ -79,7 +98,7 @@ export async function revParse(repoPath: string, ref: string): Promise<string> {
return stdout.trim();
}
export async function validateRemote(remoteUrl: string): Promise<void> {
export async function validateRemote(remoteUrl: string, options?: GitAuthOptions): Promise<void> {
const remote = remoteUrl.trim();
if (!remote) {
throw new Error("remoteUrl is required");
@ -91,7 +110,7 @@ export async function validateRemote(remoteUrl: string): Promise<void> {
cwd: tmpdir(),
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS,
env: gitEnv(),
env: gitEnv(options),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@ -103,7 +122,7 @@ function isGitRepo(path: string): boolean {
return existsSync(resolve(path, ".git"));
}
export async function ensureCloned(remoteUrl: string, targetPath: string): Promise<void> {
export async function ensureCloned(remoteUrl: string, targetPath: string, options?: GitAuthOptions): Promise<void> {
const remote = remoteUrl.trim();
if (!remote) {
throw new Error("remoteUrl is required");
@ -118,9 +137,10 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
env: gitEnv(),
env: gitEnv(options),
});
await fetch(targetPath);
await configureGithubAuth(targetPath, options);
await fetch(targetPath, options);
return;
}
@ -128,9 +148,40 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
await execFileAsync("git", ["clone", remote, targetPath], {
maxBuffer: 1024 * 1024 * 8,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(options),
});
await configureGithubAuth(targetPath, options);
await fetch(targetPath, options);
await ensureLocalBaseBranch(targetPath);
}
async function hasLocalBranches(repoPath: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short)", "refs/heads"], {
env: gitEnv(),
});
return stdout
.split("\n")
.map((line) => line.trim())
.some(Boolean);
} catch {
return false;
}
}
async function ensureLocalBaseBranch(repoPath: string): Promise<void> {
if (await hasLocalBranches(repoPath)) {
return;
}
const baseRef = await remoteDefaultBaseRef(repoPath);
const localBranch = baseRef.replace(/^origin\//, "");
await execFileAsync("git", ["-C", repoPath, "checkout", "-B", localBranch, baseRef], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
env: gitEnv(),
});
await fetch(targetPath);
}
export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
@ -157,10 +208,11 @@ export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
return "origin/main";
}
export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> {
export async function listRemoteBranches(repoPath: string, options?: GitAuthOptions): Promise<BranchSnapshot[]> {
await fetch(repoPath, options);
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], {
maxBuffer: 1024 * 1024,
env: gitEnv(),
env: gitEnv(options),
});
return stdout
@ -185,8 +237,9 @@ async function remoteBranchExists(repoPath: string, branchName: string): Promise
}
}
export async function ensureRemoteBranch(repoPath: string, branchName: string): Promise<void> {
await fetch(repoPath);
export async function ensureRemoteBranch(repoPath: string, branchName: string, options?: GitAuthOptions): Promise<void> {
await fetch(repoPath, options);
await ensureLocalBaseBranch(repoPath);
if (await remoteBranchExists(repoPath, branchName)) {
return;
}
@ -194,9 +247,9 @@ export async function ensureRemoteBranch(repoPath: string, branchName: string):
const baseRef = await remoteDefaultBaseRef(repoPath);
await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], {
maxBuffer: 1024 * 1024 * 2,
env: gitEnv(),
env: gitEnv(options),
});
await fetch(repoPath);
await fetch(repoPath, options);
}
export async function diffStatForBranch(repoPath: string, branchName: string): Promise<string> {

View file

@ -3,6 +3,20 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
interface GithubAuthOptions {
githubToken?: string | null;
}
function ghEnv(options?: GithubAuthOptions): Record<string, string> {
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
const token = options?.githubToken?.trim();
if (token) {
env.GH_TOKEN = token;
env.GITHUB_TOKEN = token;
}
return env;
}
export interface PullRequestSnapshot {
number: number;
headRefName: string;
@ -117,9 +131,13 @@ function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> {
export async function listPullRequests(repoPath: string, options?: GithubAuthOptions): Promise<PullRequestSnapshot[]> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], {
maxBuffer: 1024 * 1024 * 4,
cwd: repoPath,
env: ghEnv(options),
});
const parsed = JSON.parse(stdout) as GhPrListItem[];
@ -134,9 +152,13 @@ export async function listPullRequests(repoPath: string): Promise<PullRequestSna
}
}
export async function getPrInfo(repoPath: string, branchName: string): Promise<PullRequestSnapshot | null> {
export async function getPrInfo(repoPath: string, branchName: string, options?: GithubAuthOptions): Promise<PullRequestSnapshot | null> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], {
maxBuffer: 1024 * 1024 * 4,
cwd: repoPath,
env: ghEnv(options),
});
const item = JSON.parse(stdout) as GhPrListItem;
return snapshotFromGhItem(item);
@ -145,7 +167,13 @@ export async function getPrInfo(repoPath: string, branchName: string): Promise<P
}
}
export async function createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }> {
export async function createPr(
repoPath: string,
headBranch: string,
title: string,
body?: string,
options?: GithubAuthOptions,
): Promise<{ number: number; url: string }> {
const args = ["pr", "create", "--title", title, "--head", headBranch];
if (body) {
args.push("--body", body);
@ -156,6 +184,7 @@ export async function createPr(repoPath: string, headBranch: string, title: stri
const { stdout } = await execFileAsync("gh", args, {
maxBuffer: 1024 * 1024,
cwd: repoPath,
env: ghEnv(options),
});
// gh pr create outputs the PR URL on success
@ -167,10 +196,11 @@ export async function createPr(repoPath: string, headBranch: string, title: stri
return { number, url };
}
export async function starRepository(repoFullName: string): Promise<void> {
export async function starRepository(repoFullName: string, options?: GithubAuthOptions): Promise<void> {
try {
await execFileAsync("gh", ["api", "--method", "PUT", `user/starred/${repoFullName}`], {
maxBuffer: 1024 * 1024,
env: ghEnv(options),
});
} catch (error) {
const message =
@ -179,16 +209,17 @@ export async function starRepository(repoFullName: string): Promise<void> {
}
}
export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> {
export async function getAllowedMergeMethod(repoPath: string, options?: GithubAuthOptions): Promise<"squash" | "rebase" | "merge"> {
try {
// Get the repo owner/name from gh
const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath });
const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath, env: ghEnv(options) });
const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string };
const repoFullName = `${repo.owner.login}/${repo.name}`;
const { stdout } = await execFileAsync("gh", ["api", `repos/${repoFullName}`, "--jq", ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"], {
maxBuffer: 1024 * 1024,
cwd: repoPath,
env: ghEnv(options),
});
const lines = stdout.trim().split("\n");
@ -205,14 +236,14 @@ export async function getAllowedMergeMethod(repoPath: string): Promise<"squash"
}
}
export async function mergePr(repoPath: string, prNumber: number): Promise<void> {
const method = await getAllowedMergeMethod(repoPath);
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath });
export async function mergePr(repoPath: string, prNumber: number, options?: GithubAuthOptions): Promise<void> {
const method = await getAllowedMergeMethod(repoPath, options);
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath, env: ghEnv(options) });
}
export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
export async function isPrMerged(repoPath: string, branchName: string, options?: GithubAuthOptions): Promise<boolean> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath });
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath, env: ghEnv(options) });
const parsed = JSON.parse(stdout) as { state: string };
return parsed.state.toUpperCase() === "MERGED";
} catch {