Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/git/src/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Maps `items` through `mapper` with at most `concurrency` in flight, preserving
* input order. Stops early if `options.signal` aborts. */
export async function mapWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
mapper: (item: T) => Promise<R>,
options?: { signal?: AbortSignal },
): Promise<R[]> {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let index = 0;
const worker = async () => {
while (index < items.length) {
if (options?.signal?.aborted) return;
const i = index++;
results[i] = await mapper(items[i]);
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
);
return results;
}
79 changes: 79 additions & 0 deletions packages/git/src/gh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { execGhWithRetry, type GhExecResult, isTransientGhFailure } from "./gh";

function result(partial: Partial<GhExecResult>): GhExecResult {
return { stdout: "", stderr: "", exitCode: 1, ...partial };
}

describe("isTransientGhFailure", () => {
it.each([
{
name: "HTTP 499",
res: result({ stderr: "gh: HTTP 499" }),
expected: true,
},
{
name: "HTTP 502",
res: result({ stderr: "gh: HTTP 502" }),
expected: true,
},
{
name: "timeout",
res: result({ error: "gh timed out after 30000ms" }),
expected: true,
},
{
name: "ECONNRESET",
res: result({ error: "read ECONNRESET" }),
expected: true,
},
{
name: "success",
res: result({ exitCode: 0, stderr: "gh: HTTP 499" }),
expected: false,
},
{
name: "HTTP 404",
res: result({ stderr: "gh: HTTP 404" }),
expected: false,
},
{
name: "HTTP 422 validation",
res: result({ stderr: "gh: HTTP 422" }),
expected: false,
},
])("$name -> $expected", ({ res, expected }) => {
expect(isTransientGhFailure(res)).toBe(expected);
});
});

describe("execGhWithRetry", () => {
it("retries transient failures then succeeds", async () => {
const exec = vi
.fn()
.mockResolvedValueOnce(result({ stderr: "gh: HTTP 499" }))
.mockResolvedValueOnce(result({ stdout: "ok", exitCode: 0 }));
const res = await execGhWithRetry(["api"], {}, { backoffMs: 0 }, exec);
expect(res.exitCode).toBe(0);
expect(exec).toHaveBeenCalledTimes(2);
});

it("stops after maxAttempts on persistent transient failure", async () => {
const exec = vi.fn().mockResolvedValue(result({ stderr: "gh: HTTP 503" }));
const res = await execGhWithRetry(
["api"],
{},
{ maxAttempts: 3, backoffMs: 0 },
exec,
);
expect(res.exitCode).toBe(1);
expect(exec).toHaveBeenCalledTimes(3);
});

it("does not retry deterministic failures", async () => {
const exec = vi.fn().mockResolvedValue(result({ stderr: "gh: HTTP 404" }));
const res = await execGhWithRetry(["api"], {}, { backoffMs: 0 }, exec);
expect(res.exitCode).toBe(1);
expect(exec).toHaveBeenCalledTimes(1);
});
});
151 changes: 123 additions & 28 deletions packages/git/src/gh.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
// Namespace import (not `{ execFile }`) so the renderer's browser bundle can
// resolve this node-only module against vite's `__vite-browser-external` stub,
// which has no named exports. execGh never runs in the browser.
import * as childProcess from "node:child_process";

export interface GhExecResult {
stdout: string;
Expand All @@ -10,31 +10,126 @@ export interface GhExecResult {
error?: string;
}

export async function execGh(
export interface GhExecOptions {
cwd?: string;
env?: Record<string, string>;
/**
* Written to the child's stdin and then closed. Use with `gh api graphql
* --input -` (or `gh api --input -`) to pass a JSON request body so complex
* GraphQL variables are sent as real objects rather than `-F` string scalars.
*/
input?: string;
/**
* Kill the `gh` subprocess after this many ms. Without it a stalled network
* call (the symptom behind GitHub's `HTTP 499`) hangs the caller — and any
* MCP tool awaiting it — indefinitely. Omit for no timeout.
*/
timeoutMs?: number;
}

export function execGh(
args: string[],
options: { cwd?: string; env?: Record<string, string> } = {},
options: GhExecOptions = {},
): Promise<GhExecResult> {
try {
const { stdout, stderr } = await execFileAsync("gh", args, {
cwd: options.cwd,
env: options.env ? { ...process.env, ...options.env } : process.env,
});
return { stdout, stderr, exitCode: 0 };
} catch (error) {
const err = error as Error & {
code?: number | string;
stdout?: string;
stderr?: string;
};

const exitCode =
typeof err.code === "number" ? err.code : err.code === "ENOENT" ? 127 : 1;

return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode,
error: err.message,
};
const env = options.env ? { ...process.env, ...options.env } : process.env;

return new Promise<GhExecResult>((resolve) => {
const child = childProcess.execFile(
"gh",
args,
{ cwd: options.cwd, env, timeout: options.timeoutMs ?? 0 },
(error, stdout, stderr) => {
if (!error) {
resolve({ stdout, stderr, exitCode: 0 });
return;
}

const err = error as Error & {
code?: number | string;
killed?: boolean;
stdout?: string;
stderr?: string;
};
// execFile kills the child on timeout (`killed` set, `code` null);
// surface a recognizable message so retries treat it as transient.
const timedOut = err.killed === true && !!options.timeoutMs;
const exitCode =
typeof err.code === "number"
? err.code
: err.code === "ENOENT"
? 127
: 1;

resolve({
stdout: stdout ?? err.stdout ?? "",
stderr: stderr ?? err.stderr ?? "",
exitCode,
error: timedOut
? `gh timed out after ${options.timeoutMs}ms`
: err.message,
});
},
);

if (options.input !== undefined) {
child.stdin?.end(options.input);
}
});
}

// Failures worth retrying: server-side blips (5xx), the proxy "client closed"
// 499 we kept hitting from sandboxes, our own timeout, and transport-level
// network errors. Deterministic failures (auth, 404, 422, GraphQL validation)
// are intentionally excluded — retrying them only wastes time.
const TRANSIENT_GH_PATTERNS: readonly RegExp[] = [
/HTTP 5\d\d/,
/HTTP 499/,
/\btimed out\b/i,
/\bETIMEDOUT\b/,
/\bECONNRESET\b/,
/\bECONNREFUSED\b/,
/\bEAI_AGAIN\b/,
/connection reset/i,
];

export function isTransientGhFailure(res: GhExecResult): boolean {
if (res.exitCode === 0) {
return false;
}
const text = `${res.stderr} ${res.error ?? ""} ${res.stdout}`;
return TRANSIENT_GH_PATTERNS.some((re) => re.test(text));
}

export interface GhRetryOptions {
maxAttempts?: number;
/** Base backoff; attempt N waits `backoffMs * 2^(N-2)` before retrying. */
backoffMs?: number;
}

const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));

/**
* Runs `execGh`, retrying only on transient failures with exponential backoff.
* `exec` is injectable for tests; production callers use the default.
*/
export async function execGhWithRetry(
args: string[],
options: GhExecOptions = {},
retry: GhRetryOptions = {},
exec: typeof execGh = execGh,
): Promise<GhExecResult> {
const maxAttempts = retry.maxAttempts ?? 3;
const backoffMs = retry.backoffMs ?? 500;

let res = await exec(args, options);
for (
let attempt = 2;
attempt <= maxAttempts && isTransientGhFailure(res);
attempt++
) {
await sleep(backoffMs * 2 ** (attempt - 2));
res = await exec(args, options);
}
return res;
}
23 changes: 1 addition & 22 deletions packages/git/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createReadStream } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { CreateGitClientOptions } from "./client";
import { mapWithConcurrency } from "./concurrency";
import { getGitOperationManager } from "./operation-manager";
import { streamGitStatus } from "./status-stream";

Expand Down Expand Up @@ -547,28 +548,6 @@ async function countFileLines(
}
}

async function mapWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
mapper: (item: T) => Promise<R>,
options?: { signal?: AbortSignal },
): Promise<R[]> {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let index = 0;
const worker = async () => {
while (index < items.length) {
if (options?.signal?.aborted) return;
const i = index++;
results[i] = await mapper(items[i]);
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
);
return results;
}

export async function getChangedFilesDetailed(
baseDir: string,
options?: GetChangedFilesDetailedOptions,
Expand Down
7 changes: 1 addition & 6 deletions packages/git/src/sagas/commit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { GitSaga, type GitSagaInput } from "../git-saga";

function buildPostHogTrailers(taskId?: string): string[] {
const trailers = ["Generated-By: PostHog Code"];
if (taskId) trailers.push(`Task-Id: ${taskId}`);
return trailers;
}
import { buildPostHogTrailers } from "../trailers";

export interface CommitInput extends GitSagaInput {
message: string;
Expand Down
58 changes: 58 additions & 0 deletions packages/git/src/signed-commit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { chunkFileChanges, OversizedFileError } from "./signed-commit";

function addition(path: string, sizeBytes: number) {
// base64 string of roughly `sizeBytes` length stands in for file contents.
return { path, contents: "a".repeat(sizeBytes) };
}

describe("chunkFileChanges", () => {
it.each([
{
name: "carries deletions in a single chunk when there are no additions",
changes: { additions: [], deletions: [{ path: "gone.txt" }] },
limit: 1000,
expected: [{ additions: [], deletions: ["gone.txt"] }],
},
{
name: "packs additions under the threshold into one chunk",
changes: {
additions: [addition("a", 100), addition("b", 100), addition("c", 100)],
deletions: [],
},
limit: 10_000,
expected: [{ additions: ["a", "b", "c"], deletions: [] }],
},
{
name: "splits additions across chunks, with deletions in the first only",
changes: {
additions: [addition("a", 400), addition("b", 400), addition("c", 400)],
deletions: [{ path: "d" }],
},
limit: 500,
// Each ~400-byte addition needs its own chunk at a 500-byte budget.
expected: [
{ additions: ["a"], deletions: ["d"] },
{ additions: ["b"], deletions: [] },
{ additions: ["c"], deletions: [] },
],
},
])("$name", ({ changes, limit, expected }) => {
const chunks = chunkFileChanges(changes, limit);
expect(
chunks.map((c) => ({
additions: c.additions.map((a) => a.path),
deletions: c.deletions.map((d) => d.path),
})),
).toEqual(expected);
});

it("throws OversizedFileError for a single file larger than the limit", () => {
expect(() =>
chunkFileChanges(
{ additions: [addition("huge", 5000)], deletions: [] },
1000,
),
).toThrow(OversizedFileError);
});
});
Comment thread
tatoalo marked this conversation as resolved.
Loading
Loading