mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 10:03:34 +00:00
Improve Foundry auth and task flows (#240)
This commit is contained in:
parent
d75e8c31d1
commit
dbc2ff0682
26 changed files with 621 additions and 137 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue