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
4 changes: 2 additions & 2 deletions src/lib/github-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function fetchPackages(repoFullName: string): Promise<GithubPackage
}

export async function rerunWorkflow(repoFullName: string, runId: number): Promise<void> {
const response = await fetch(
const response = await fetchWithRetry(
`${GITHUB_API}/repos/${repoFullName}/actions/runs/${runId}/rerun`,
{ method: "POST", headers: await getHeadersAsync() }
);
Expand All @@ -127,7 +127,7 @@ export async function triggerWorkflowDispatch(
workflowId: number,
ref: string
): Promise<void> {
const response = await fetch(
const response = await fetchWithRetry(
`${GITHUB_API}/repos/${repoFullName}/actions/workflows/${workflowId}/dispatches`,
{
method: "POST",
Expand Down
10 changes: 5 additions & 5 deletions src/lib/github-code-search.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GITHUB_API, getHeadersAsync, fetchPaginated } from "./github-auth";
import { GITHUB_API, getHeadersAsync, fetchPaginated, fetchWithRetry } from "./github-auth";

export interface GithubRepo {
full_name: string;
Expand All @@ -9,7 +9,7 @@ export interface GithubRepo {
}

async function fetchRepoJson(repoFullName: string, errorPrefix: string): Promise<Record<string, unknown>> {
const response = await fetch(`${GITHUB_API}/repos/${repoFullName}`, {
const response = await fetchWithRetry(`${GITHUB_API}/repos/${repoFullName}`, {
headers: await getHeadersAsync(),
});
if (!response.ok) {
Expand Down Expand Up @@ -51,7 +51,7 @@ export async function searchRepositoryCode(
const perPage = Math.min(Math.max(1, limit), 100);
const searchQuery = `${query} repo:${repoFullName}`;
const url = `${GITHUB_API}/search/code?q=${encodeURIComponent(searchQuery)}&per_page=${perPage}`;
const response = await fetch(url, { headers: await getHeadersAsync() });
const response = await fetchWithRetry(url, { headers: await getHeadersAsync() });
if (!response.ok) {
const text = await response.text();
throw new Error(`Code search failed for ${repoFullName}: ${response.status} ${text}`);
Expand Down Expand Up @@ -84,7 +84,7 @@ export async function fetchRepositoryFileText(
): Promise<string> {
const encodedPath = encodePathForContentsApi(path);
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const response = await fetch(
const response = await fetchWithRetry(
`${GITHUB_API}/repos/${repoFullName}/contents/${encodedPath}${query}`,
{ headers: await getHeadersAsync() },
);
Expand Down Expand Up @@ -118,7 +118,7 @@ export async function listRepositoryDirectory(
): Promise<GitHubDirectoryEntry[]> {
const encodedPath = path ? encodePathForContentsApi(path) : "";
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const response = await fetch(
const response = await fetchWithRetry(
`${GITHUB_API}/repos/${repoFullName}/contents/${encodedPath}${query}`,
{ headers: await getHeadersAsync() },
);
Expand Down
14 changes: 7 additions & 7 deletions src/lib/github-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function updateIssueLabels(
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}/labels`;

const response = await fetch(url, {
const response = await fetchWithRetry(url, {
method: "PUT",
headers: await getHeadersAsync(),
body: JSON.stringify({ labels }),
Expand Down Expand Up @@ -94,7 +94,7 @@ export async function addIssueComment(
const [owner, repo] = repoFullName.split("/");
const apiPath = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}/comments`;

const response = await fetch(apiPath, {
const response = await fetchWithRetry(apiPath, {
method: "POST",
headers: await getHeadersAsync(),
body: JSON.stringify({ body }),
Expand All @@ -121,7 +121,7 @@ export async function updateIssueComment(
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/comments/${commentId}`;

const response = await fetch(url, {
const response = await fetchWithRetry(url, {
method: "PATCH",
headers: await getHeadersAsync(),
body: JSON.stringify({ body }),
Expand All @@ -141,7 +141,7 @@ export async function addIssueLabel(
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}/labels`;

const response = await fetch(url, {
const response = await fetchWithRetry(url, {
method: "POST",
headers: await getHeadersAsync(),
body: JSON.stringify({ labels: [label] }),
Expand All @@ -166,7 +166,7 @@ export async function updateIssueTitleAndBody(
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}`;

const response = await fetch(url, {
const response = await fetchWithRetry(url, {
method: "PATCH",
headers: await getHeadersAsync(),
body: JSON.stringify(fields),
Expand All @@ -186,7 +186,7 @@ export async function removeIssueLabel(
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`;

const response = await fetch(url, {
const response = await fetchWithRetry(url, {
method: "DELETE",
headers: await getHeadersAsync(),
});
Expand Down Expand Up @@ -218,7 +218,7 @@ export async function closeIssue(
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}`;

const response = await fetch(url, {
const response = await fetchWithRetry(url, {
method: "PATCH",
headers: await getHeadersAsync(),
body: JSON.stringify({ state: "closed" }),
Expand Down
113 changes: 113 additions & 0 deletions src/lib/github-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithRetry } from "./github-auth";
import { syncStatusLabels } from "./github-issues";

process.env.GITHUB_TOKEN = "test-token-for-retry-tests";

function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)),
headers: new Headers(headers),
} as Response;
}

describe("fetchWithRetry (shared GitHub fetch retry wrapper)", () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

it("retries a 429 with Retry-After and succeeds on the second try", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse(429, { message: "rate limited" }, { "Retry-After": "1" }))
.mockResolvedValueOnce(jsonResponse(200, { number: 7 }));

const promise = fetchWithRetry("https://api.github.com/repos/o/r/issues/7", { headers: { Authorization: "Bearer test-token" } });
await vi.advanceTimersByTimeAsync(2000);
const response = await promise;

expect(response.ok).toBe(true);
expect(await response.json()).toEqual({ number: 7 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("retries a 503 and succeeds on the second try", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse(503, { message: "server error" }))
.mockResolvedValueOnce(jsonResponse(200, { number: 8 }));

const promise = fetchWithRetry("https://api.github.com/repos/o/r/issues/8", { headers: { Authorization: "Bearer test-token" } });
await vi.advanceTimersByTimeAsync(5000);
const response = await promise;

expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not retry a 404 and throws immediately", async () => {
fetchMock.mockResolvedValue(jsonResponse(404, { message: "Not Found" }));

const promise = fetchWithRetry("https://api.github.com/repos/o/r/issues/999", { headers: { Authorization: "Bearer test-token" } });
await vi.advanceTimersByTimeAsync(60_000);

const response = await promise;
expect(response.ok).toBe(false);
expect(response.status).toBe(404);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

describe("syncStatusLabels with a transient 429 mid-loop", () => {
let fetchMock: ReturnType<typeof vi.fn>;
const labels = ["status/queued", "status/in-progress", "status/blocked", "status/review", "status/done"];

beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

it("still attempts all 5 labels when one call hits a transient 429", async () => {
// Every call succeeds except the 3rd, which 429s once then succeeds on retry.
fetchMock.mockImplementation(async (url: string) => {
const callIndex = fetchMock.mock.calls.length; // 1-based
if (callIndex === 3) {
return jsonResponse(429, { message: "rate limited" }, { "Retry-After": "1" });
}
return jsonResponse(200, {});
});

const promise = syncStatusLabels("o/r", 42, labels, []);
await vi.advanceTimersByTimeAsync(10_000);
await promise;

// 5 label calls + 1 retry of the 429'd call.
expect(fetchMock).toHaveBeenCalledTimes(6);
const calledPaths = fetchMock.mock.calls.map((call) => String(call[0]));
for (const path of calledPaths) {
expect(path).toContain("/repos/o/r/issues/42/labels");
}
// Each of the 5 labels was sent in a request body.
const sentLabels = fetchMock.mock.calls.map((call) => JSON.parse(String(call[1].body)).labels[0]);
for (const label of labels) {
expect(sentLabels).toContain(label);
}
});
});
4 changes: 3 additions & 1 deletion src/lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,9 +627,11 @@ describe("issue mutations", () => {
});

it("removeIssueLabel still throws on non-404 errors", async () => {
fetchMock.mockResolvedValueOnce(httpError(500));
// 500 is transient: the shared retry wrapper retries twice before surfacing the error.
fetchMock.mockResolvedValue(httpError(500));

await expect(removeIssueLabel("org/repo", 5, "x")).rejects.toThrow("GitHub API error: 500");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

Expand Down
Loading