Rate-limit reset headers override retry timing. Inaccessible PRs back off instead of
@@ -102,6 +155,10 @@ export function GitHubReconciliationPolicy({ isAdmin }: { isAdmin: boolean }) {
githubSyncBatchSize: policy.batchSize,
githubSyncBackoffMinutes: policy.backoffMinutes,
githubSyncMaxBackoffMinutes: policy.maxBackoffMinutes,
+ githubRequestTimeoutSeconds: policy.requestTimeoutSeconds,
+ githubSweepBudgetSeconds: policy.sweepBudgetSeconds,
+ githubClosedReprobeMinutes: policy.closedReprobeMinutes,
+ githubManualCooldownSeconds: policy.manualCooldownSeconds,
})
}
>
diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts
index a537fdcc..c85efc57 100644
--- a/src/server/routers/workspace.ts
+++ b/src/server/routers/workspace.ts
@@ -143,6 +143,10 @@ export const workspaceRouter = router({
githubSyncBatchSize: true,
githubSyncBackoffMinutes: true,
githubSyncMaxBackoffMinutes: true,
+ githubRequestTimeoutSeconds: true,
+ githubSweepBudgetSeconds: true,
+ githubClosedReprobeMinutes: true,
+ githubManualCooldownSeconds: true,
stalledThresholdDays: true,
agentHeartbeatWarnMinutes: true,
agentHeartbeatCriticalMinutes: true,
@@ -317,6 +321,10 @@ export const workspaceRouter = router({
githubSyncBatchSize: z.number().int().min(0).max(250).optional(),
githubSyncBackoffMinutes: z.number().int().min(1).max(1440).optional(),
githubSyncMaxBackoffMinutes: z.number().int().min(1).max(10080).optional(),
+ githubRequestTimeoutSeconds: z.number().int().min(1).max(60).optional(),
+ githubSweepBudgetSeconds: z.number().int().min(5).max(300).optional(),
+ githubClosedReprobeMinutes: z.number().int().min(60).max(43200).optional(),
+ githubManualCooldownSeconds: z.number().int().min(1).max(3600).optional(),
assignmentEngagementMode: z.nativeEnum(EngagementMode).optional(),
mentionEngagementPolicy: z.nativeEnum(MentionEngagementPolicy).optional(),
mentionDefaultMode: z.nativeEnum(EngagementMode).optional(),
diff --git a/src/server/services/__tests__/completion-candidate.test.ts b/src/server/services/__tests__/completion-candidate.test.ts
index 89e02d59..e75d3d91 100644
--- a/src/server/services/__tests__/completion-candidate.test.ts
+++ b/src/server/services/__tests__/completion-candidate.test.ts
@@ -61,7 +61,9 @@ async function linkedPullRequest(
url: "https://github.com/acme/forge/pull/42",
title: "Complete issue",
state,
- metadata: conclusion ? { checks: { status: "completed", conclusion } } : {},
+ metadata: conclusion
+ ? { checks: { status: "completed", conclusion, source: "api-aggregate" } }
+ : {},
},
});
await prisma.externalResourceLink.create({
diff --git a/src/server/services/__tests__/github-reconciliation.test.ts b/src/server/services/__tests__/github-reconciliation.test.ts
index 5c8a1b1f..a5074aa9 100644
--- a/src/server/services/__tests__/github-reconciliation.test.ts
+++ b/src/server/services/__tests__/github-reconciliation.test.ts
@@ -1,7 +1,13 @@
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { GitHubRequestError } from "@/server/services/github/client";
-import { upsertExternalResource } from "@/server/services/github/resource-sync";
-import { sweepGitHubStatusReconciliation } from "@/server/services/github/reconciliation";
+import {
+ claimGitHubManualSync,
+ upsertExternalResource,
+} from "@/server/services/github/resource-sync";
+import {
+ claimGitHubReconciliationCandidate,
+ sweepGitHubStatusReconciliation,
+} from "@/server/services/github/reconciliation";
import {
createIssue,
createWorkspaceFixture,
@@ -33,6 +39,27 @@ async function setupResource() {
githubSyncBatchSize: 10,
githubSyncBackoffMinutes: 5,
githubSyncMaxBackoffMinutes: 1440,
+ githubRequestTimeoutSeconds: 10,
+ githubSweepBudgetSeconds: 45,
+ githubClosedReprobeMinutes: 1440,
+ githubManualCooldownSeconds: 30,
+ },
+ });
+ const connection = await prisma.connection.create({
+ data: {
+ ownerId: fixture.user.id,
+ provider: "GITHUB",
+ label: "GitHub test",
+ status: "CONNECTED",
+ config: { installationId: "1" },
+ },
+ });
+ const mapping = await prisma.connectionMapping.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ connectionId: connection.id,
+ kind: "repo",
+ target: "acme/forge",
},
});
const resource = await prisma.externalResource.create({
@@ -45,6 +72,7 @@ async function setupResource() {
url: "https://github.com/acme/forge/pull/42",
title: "Ship it",
state: "open",
+ connectionMappingId: mapping.id,
lastSyncedAt: new Date("2026-07-14T10:00:00.000Z"),
},
});
@@ -57,7 +85,7 @@ async function setupResource() {
createdById: fixture.user.id,
},
});
- return { fixture, prisma, resource };
+ return { fixture, prisma, resource, mapping };
}
describe("GitHub status reconciliation", () => {
@@ -259,4 +287,213 @@ describe("GitHub status reconciliation", () => {
).toMatchObject({ inspected: 0 });
expect(calls).toBe(0);
});
+
+ it("slowly re-probes closed PRs so a missed reopen webhook self-heals", async () => {
+ const { fixture, prisma, resource } = await setupResource();
+ const now = new Date("2026-07-14T12:00:00.000Z");
+ await prisma.externalResource.update({
+ where: { id: resource.id },
+ data: {
+ state: "closed",
+ syncTerminalAt: new Date("2026-07-12T10:00:00.000Z"),
+ },
+ });
+ const syncResource = async () =>
+ prisma.externalResource.update({
+ where: { id: resource.id },
+ data: {
+ state: "open",
+ syncTerminalAt: null,
+ lastSyncedAt: now,
+ syncRetryAt: null,
+ metadata: {
+ checks: {
+ source: "api-aggregate",
+ status: "pending",
+ conclusion: null,
+ partial: false,
+ },
+ },
+ },
+ });
+
+ await expect(
+ sweepGitHubStatusReconciliation(prisma, {
+ workspaceId: fixture.workspace.id,
+ now,
+ syncResource,
+ }),
+ ).resolves.toMatchObject({ inspected: 1, reconciled: 1 });
+ expect(
+ (await prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } })).state,
+ ).toBe("open");
+ });
+
+ it("backs off partial open PR snapshots and opens a rate-limit circuit", async () => {
+ const { fixture, prisma, resource, mapping } = await setupResource();
+ const now = new Date("2026-07-14T12:00:00.000Z");
+ const retryAt = new Date("2026-07-14T13:00:00.000Z");
+ const sibling = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 99,
+ url: "https://github.com/acme/forge/pull/99",
+ title: "Same installation",
+ state: "open",
+ connectionMappingId: mapping.id,
+ },
+ });
+ const syncResource = async () =>
+ prisma.externalResource.update({
+ where: { id: resource.id },
+ data: {
+ lastSyncedAt: now,
+ syncRetryAt: null,
+ syncFailureCount: 0,
+ metadata: {
+ checks: {
+ source: "api-aggregate",
+ status: "unknown",
+ conclusion: null,
+ partial: true,
+ rateLimited: true,
+ retryAt: retryAt.toISOString(),
+ diagnostic: "API rate limit exceeded",
+ },
+ },
+ },
+ });
+
+ await expect(
+ sweepGitHubStatusReconciliation(prisma, {
+ workspaceId: fixture.workspace.id,
+ now,
+ syncResource,
+ }),
+ ).resolves.toMatchObject({
+ reconciled: 1,
+ partial: 1,
+ rateLimited: 1,
+ circuitBroken: true,
+ });
+ const row = await prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } });
+ expect(row.syncRetryAt).toEqual(retryAt);
+ expect(row.syncFailureCount).toBe(1);
+ expect(
+ (await prisma.externalResource.findUniqueOrThrow({ where: { id: sibling.id } })).syncRetryAt,
+ ).toEqual(retryAt);
+ });
+
+ it("rechecks snapshot freshness in the lease claim to close manual-sync races", async () => {
+ const { fixture, prisma, resource } = await setupResource();
+ const now = new Date("2026-07-14T12:00:00.000Z");
+ const candidate = await prisma.externalResource.findUniqueOrThrow({
+ where: { id: resource.id },
+ });
+ await prisma.externalResource.update({
+ where: { id: resource.id },
+ data: { lastSyncedAt: now },
+ });
+
+ await expect(
+ claimGitHubReconciliationCandidate({
+ db: prisma,
+ workspaceId: fixture.workspace.id,
+ candidate,
+ now,
+ staleBefore: new Date("2026-07-14T11:45:00.000Z"),
+ dormantBefore: new Date("2026-07-13T12:00:00.000Z"),
+ leaseUntil: new Date("2026-07-14T12:01:00.000Z"),
+ }),
+ ).resolves.toBe(false);
+ });
+
+ it("limits manual refreshes with one atomic cooldown/lease claim", async () => {
+ const { fixture, prisma, resource } = await setupResource();
+ const now = new Date("2026-07-14T12:00:00.000Z");
+ const input = {
+ db: prisma,
+ workspaceId: fixture.workspace.id,
+ externalResourceId: resource.id,
+ now,
+ cooldownSeconds: 30,
+ leaseUntil: new Date("2026-07-14T12:00:10.000Z"),
+ };
+ await expect(claimGitHubManualSync(input)).resolves.toBe(true);
+ await expect(claimGitHubManualSync(input)).resolves.toBe(false);
+ await expect(
+ claimGitHubManualSync({
+ ...input,
+ now: new Date("2026-07-14T12:00:31.000Z"),
+ leaseUntil: new Date("2026-07-14T12:00:41.000Z"),
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("stops starting resources after the workspace sweep budget is exhausted", async () => {
+ const { fixture, prisma, resource } = await setupResource();
+ const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" });
+ const second = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 43,
+ url: "https://github.com/acme/forge/pull/43",
+ title: "Second PR",
+ state: "open",
+ lastSyncedAt: new Date("2026-07-14T10:00:00.000Z"),
+ },
+ });
+ await prisma.externalResourceLink.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: second.id,
+ kind: "IMPLEMENTS",
+ },
+ });
+ let clockMs = 0;
+ let calls = 0;
+ const syncResource = async ({ externalResourceId }: { externalResourceId: string }) => {
+ calls += 1;
+ clockMs += 50_000;
+ return prisma.externalResource.update({
+ where: { id: externalResourceId },
+ data: {
+ lastSyncedAt: new Date("2026-07-14T12:00:00.000Z"),
+ syncRetryAt: null,
+ metadata: {
+ checks: {
+ source: "api-aggregate",
+ status: "pending",
+ conclusion: null,
+ partial: false,
+ },
+ },
+ },
+ });
+ };
+
+ await expect(
+ sweepGitHubStatusReconciliation(prisma, {
+ workspaceId: fixture.workspace.id,
+ now: new Date("2026-07-14T12:00:00.000Z"),
+ clock: () => clockMs,
+ syncResource: syncResource as never,
+ }),
+ ).resolves.toMatchObject({ budgetExhausted: true, reconciled: 1 });
+ expect(calls).toBe(1);
+ const rows = await prisma.externalResource.findMany({
+ where: { id: { in: [resource.id, second.id] } },
+ select: { lastSyncedAt: true },
+ });
+ expect(
+ rows.filter((row) => row.lastSyncedAt?.getTime() === Date.parse("2026-07-14T12:00:00.000Z")),
+ ).toHaveLength(1);
+ });
});
diff --git a/src/server/services/completion-candidate.ts b/src/server/services/completion-candidate.ts
index 81a374e5..40abd7bc 100644
--- a/src/server/services/completion-candidate.ts
+++ b/src/server/services/completion-candidate.ts
@@ -43,7 +43,9 @@ function metadataRecord(value: unknown): Record {
function checkConclusion(metadata: unknown): string | null {
const checks = metadataRecord(metadataRecord(metadata).checks);
- return typeof checks.conclusion === "string" ? checks.conclusion : null;
+ return checks.source === "api-aggregate" && typeof checks.conclusion === "string"
+ ? checks.conclusion
+ : null;
}
function checksArePassing(conclusion: string | null): boolean {
diff --git a/src/server/services/github/client.ts b/src/server/services/github/client.ts
index 735e484b..a182d00e 100644
--- a/src/server/services/github/client.ts
+++ b/src/server/services/github/client.ts
@@ -4,6 +4,8 @@ import { resolveInstallationToken } from "@/server/services/github/installation-
import type { GitHubResourceSnapshot } from "@/server/services/github/types";
const GITHUB_API_BASE = "https://api.github.com";
+const DEFAULT_GITHUB_REQUEST_TIMEOUT_MS = 10_000;
+const MAX_CHECK_SUITE_PAGES = 10;
type GitHubUser = { login?: string | null };
type GitHubLabel = { name?: string | null; color?: string | null };
@@ -83,8 +85,10 @@ export type GitHubChecksSnapshot = {
suiteCount: number;
statusCount: number;
updatedAt: string;
- source: "reconciliation";
+ source: "api-aggregate";
partial: boolean;
+ rateLimited: boolean;
+ timedOut: boolean;
diagnostic: string | null;
retryAt: string | null;
headSha: string;
@@ -96,6 +100,7 @@ export class GitHubRequestError extends Error {
readonly status: number,
readonly retryAt: Date | null,
readonly rateLimited: boolean = status === 429,
+ readonly timedOut: boolean = status === 408,
) {
super(message);
this.name = "GitHubRequestError";
@@ -110,16 +115,48 @@ export type GitHubInstallationResponse = {
events?: string[];
};
-async function githubAppRequest(path: string): Promise {
+function requestSignal(signal: AbortSignal | null | undefined, timeoutMs: number): AbortSignal {
+ const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
+}
+
+function timeoutError(timeoutMs: number): GitHubRequestError {
+ return new GitHubRequestError(
+ `GitHub API request timed out after ${timeoutMs}ms.`,
+ 408,
+ null,
+ false,
+ true,
+ );
+}
+
+async function githubAppRequest(
+ path: string,
+ timeoutMs = DEFAULT_GITHUB_REQUEST_TIMEOUT_MS,
+): Promise {
const jwt = await createGitHubAppJwt();
- const res = await fetch(`${GITHUB_API_BASE}${path}`, {
- headers: {
- accept: "application/vnd.github+json",
- authorization: `Bearer ${jwt}`,
- "user-agent": "forge-github-app",
- "x-github-api-version": "2022-11-28",
- },
- });
+ let res: Response;
+ try {
+ res = await fetch(`${GITHUB_API_BASE}${path}`, {
+ signal: requestSignal(null, timeoutMs),
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${jwt}`,
+ "user-agent": "forge-github-app",
+ "x-github-api-version": "2022-11-28",
+ },
+ });
+ } catch (error) {
+ if (
+ error &&
+ typeof error === "object" &&
+ "name" in error &&
+ (error.name === "TimeoutError" || error.name === "AbortError")
+ ) {
+ throw timeoutError(timeoutMs);
+ }
+ throw error;
+ }
const json = (await res.json().catch(() => ({}))) as { message?: string };
if (!res.ok) {
throw new Error(json.message || `GitHub API returned HTTP ${res.status}.`);
@@ -131,21 +168,36 @@ async function githubRequest(
installationId: string | number,
path: string,
init: RequestInit = {},
+ timeoutMs = DEFAULT_GITHUB_REQUEST_TIMEOUT_MS,
): Promise {
// Prefer a configured GithubApp's credentials for this installation, falling
// back to the global env app — so linking works off the same app a workspace
// set up in Settings → GitHub Apps.
const token = await resolveInstallationToken(installationId);
- const res = await fetch(`${GITHUB_API_BASE}${path}`, {
- ...init,
- headers: {
- accept: "application/vnd.github+json",
- authorization: `Bearer ${token}`,
- "user-agent": "forge-github-app",
- "x-github-api-version": "2022-11-28",
- ...(init.headers ?? {}),
- },
- });
+ let res: Response;
+ try {
+ res = await fetch(`${GITHUB_API_BASE}${path}`, {
+ ...init,
+ signal: requestSignal(init.signal, timeoutMs),
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${token}`,
+ "user-agent": "forge-github-app",
+ "x-github-api-version": "2022-11-28",
+ ...(init.headers ?? {}),
+ },
+ });
+ } catch (error) {
+ if (
+ error &&
+ typeof error === "object" &&
+ "name" in error &&
+ (error.name === "TimeoutError" || error.name === "AbortError")
+ ) {
+ throw timeoutError(timeoutMs);
+ }
+ throw error;
+ }
const text = await res.text();
let json: unknown = {};
if (text) {
@@ -172,7 +224,7 @@ async function githubRequest(
res.status === 429 ||
(res.status === 403 &&
(res.headers.has("retry-after") || res.headers.get("x-ratelimit-remaining") === "0"));
- throw new GitHubRequestError(message, res.status, retryAt, rateLimited);
+ throw new GitHubRequestError(message, res.status, retryAt, rateLimited, false);
}
return json as T;
}
@@ -198,10 +250,14 @@ export async function getGitHubIssue(args: {
owner: string;
repo: string;
number: number;
+ requestTimeoutMs?: number;
+ signal?: AbortSignal;
}): Promise {
return githubRequest(
args.installationId,
`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/issues/${args.number}`,
+ { signal: args.signal },
+ args.requestTimeoutMs,
);
}
@@ -210,10 +266,14 @@ export async function getGitHubPullRequest(args: {
owner: string;
repo: string;
number: number;
+ requestTimeoutMs?: number;
+ signal?: AbortSignal;
}): Promise {
return githubRequest(
args.installationId,
`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/pulls/${args.number}`,
+ { signal: args.signal },
+ args.requestTimeoutMs,
);
}
@@ -227,26 +287,30 @@ export async function getGitHubPullRequestChecks(args: {
owner: string;
repo: string;
headSha: string;
+ requestTimeoutMs?: number;
+ signal?: AbortSignal;
}): Promise {
const ref = encodeURIComponent(args.headSha);
- const [suiteResult, statusResult] = await Promise.allSettled([
- githubRequest(
- args.installationId,
- `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/commits/${ref}/check-suites?filter=latest&per_page=100`,
- ),
+ const suiteResult = await settle(() => listAllCheckSuites({ ...args, ref }));
+ // Deliberately serial: GitHub recommends avoiding concurrent REST requests
+ // to reduce secondary-rate-limit pressure.
+ const statusResult = await settle(() =>
githubRequest(
args.installationId,
`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/commits/${ref}/status`,
+ { signal: args.signal },
+ args.requestTimeoutMs,
),
- ]);
- const suites = suiteResult.status === "fulfilled" ? suiteResult.value : {};
- const statuses = statusResult.status === "fulfilled" ? statusResult.value : {};
+ );
+ const suites: GitHubCheckSuitesResponse & { truncated?: boolean } =
+ suiteResult.status === "fulfilled" ? suiteResult.value : {};
+ const statuses: GitHubCombinedStatusResponse =
+ statusResult.status === "fulfilled" ? statusResult.value : {};
const rows = suites.check_suites ?? [];
const suiteCount = suites.total_count ?? rows.length;
const statusCount = statuses.total_count ?? 0;
const discovered = suiteCount + statusCount;
- const pending =
- rows.some((suite) => suite.status !== "completed") || statuses.state === "pending";
+ const pending = rows.some((suite) => suite.status !== "completed");
const failedSuite = rows.find(
(suite) =>
suite.status === "completed" &&
@@ -257,10 +321,16 @@ export async function getGitHubPullRequestChecks(args: {
const conclusion =
failedSuite?.conclusion ?? (failedStatus ? (statuses.state ?? "failure") : null);
const failures = [suiteResult, statusResult].filter(
- (result): result is PromiseRejectedResult => result.status === "rejected",
+ (result): result is { status: "rejected"; reason: unknown } => result.status === "rejected",
);
- const incompleteSuites = suiteCount > rows.length;
+ const incompleteSuites = suiteResult.status === "fulfilled" && suiteResult.value.truncated;
const partial = failures.length > 0 || incompleteSuites;
+ const rateLimited = failures.some(
+ (failure) => failure.reason instanceof GitHubRequestError && failure.reason.rateLimited,
+ );
+ const timedOut = failures.some(
+ (failure) => failure.reason instanceof GitHubRequestError && failure.reason.timedOut,
+ );
const retryAt =
failures
.map((failure) =>
@@ -269,36 +339,91 @@ export async function getGitHubPullRequestChecks(args: {
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
+ const nullConclusion = rows.some(
+ (suite) => suite.status === "completed" && suite.conclusion == null,
+ );
+ const statusPending = statusCount > 0 && statuses.state === "pending";
+ const statusUnknown = statusCount > 0 && statuses.state == null;
+ const unresolved = pending || nullConclusion || statusPending || statusUnknown;
+
return {
status:
conclusion !== null
? "completed"
- : partial || discovered === 0
+ : partial || discovered === 0 || nullConclusion || statusUnknown
? "unknown"
- : pending
+ : unresolved
? "pending"
: "completed",
- conclusion: conclusion ?? (!partial && discovered > 0 && !pending ? "success" : null),
+ conclusion: conclusion ?? (!partial && discovered > 0 && !unresolved ? "success" : null),
suiteCount,
statusCount,
updatedAt: new Date().toISOString(),
- source: "reconciliation",
+ source: "api-aggregate",
partial,
- diagnostic: partial
- ? [
- ...failures.map((failure) =>
- failure.reason instanceof Error ? failure.reason.message : "GitHub checks unavailable",
- ),
- ...(incompleteSuites ? ["More than 100 check suites require another page."] : []),
- ]
- .join("; ")
- .slice(0, 2_000)
- : null,
+ rateLimited,
+ timedOut,
+ diagnostic:
+ partial || nullConclusion || statusUnknown
+ ? [
+ ...failures.map((failure) =>
+ failure.reason instanceof Error
+ ? failure.reason.message
+ : "GitHub checks unavailable",
+ ),
+ ...(incompleteSuites
+ ? [`More than ${MAX_CHECK_SUITE_PAGES * 100} check suites require another page.`]
+ : []),
+ ...(nullConclusion ? ["A completed check suite has no conclusion."] : []),
+ ...(statusUnknown
+ ? ["GitHub returned commit statuses without an aggregate state."]
+ : []),
+ ]
+ .join("; ")
+ .slice(0, 2_000)
+ : null,
retryAt: retryAt?.toISOString() ?? null,
headSha: args.headSha,
};
}
+type Settled = { status: "fulfilled"; value: T } | { status: "rejected"; reason: unknown };
+
+async function settle(fn: () => Promise): Promise> {
+ try {
+ return { status: "fulfilled", value: await fn() };
+ } catch (reason) {
+ return { status: "rejected", reason };
+ }
+}
+
+async function listAllCheckSuites(args: {
+ installationId: string | number;
+ owner: string;
+ repo: string;
+ ref: string;
+ requestTimeoutMs?: number;
+ signal?: AbortSignal;
+}): Promise {
+ const check_suites: GitHubCheckSuite[] = [];
+ let total = 0;
+ for (let page = 1; page <= MAX_CHECK_SUITE_PAGES; page += 1) {
+ const response = await githubRequest(
+ args.installationId,
+ `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/commits/${args.ref}/check-suites?per_page=100&page=${page}`,
+ { signal: args.signal },
+ args.requestTimeoutMs,
+ );
+ const rows = response.check_suites ?? [];
+ check_suites.push(...rows);
+ total = response.total_count ?? check_suites.length;
+ if (check_suites.length >= total || rows.length < 100) {
+ return { total_count: total, check_suites, truncated: false };
+ }
+ }
+ return { total_count: total, check_suites, truncated: check_suites.length < total };
+}
+
export async function listGitHubInstallationRepos(args: {
installationId: string | number;
}): Promise {
diff --git a/src/server/services/github/reconciliation.ts b/src/server/services/github/reconciliation.ts
index 8936a2c5..86df0e2a 100644
--- a/src/server/services/github/reconciliation.ts
+++ b/src/server/services/github/reconciliation.ts
@@ -15,6 +15,9 @@ export type GitHubReconciliationResult = {
failed: number;
rateLimited: number;
skipped: number;
+ partial: number;
+ budgetExhausted: boolean;
+ circuitBroken: boolean;
};
type SyncResource = typeof syncGitHubExternalResource;
@@ -49,6 +52,53 @@ function checksMetadata(value: unknown): Record {
: {};
}
+type ReconciliationCandidate = {
+ id: string;
+ state: string;
+ lastSyncedAt: Date | null;
+ syncTerminalAt: Date | null;
+ syncFailureCount: number;
+ connectionMappingId: string | null;
+};
+
+export async function claimGitHubReconciliationCandidate(args: {
+ db: PrismaClient;
+ workspaceId: string;
+ candidate: ReconciliationCandidate;
+ now: Date;
+ staleBefore: Date;
+ dormantBefore: Date;
+ leaseUntil: Date;
+}): Promise {
+ const candidateEligibility = args.candidate.syncTerminalAt
+ ? {
+ state: "closed",
+ syncTerminalAt: args.candidate.syncTerminalAt,
+ AND: [{ syncTerminalAt: { lte: args.dormantBefore } }],
+ }
+ : {
+ syncTerminalAt: null,
+ lastSyncedAt: args.candidate.lastSyncedAt,
+ AND: [
+ {
+ OR: [{ lastSyncedAt: null }, { lastSyncedAt: { lte: args.staleBefore } }],
+ },
+ ],
+ };
+ const claimed = await args.db.externalResource.updateMany({
+ where: {
+ id: args.candidate.id,
+ workspaceId: args.workspaceId,
+ provider: PROVIDER,
+ resourceType: RESOURCE_TYPE,
+ ...candidateEligibility,
+ OR: [{ syncRetryAt: null }, { syncRetryAt: { lte: args.now } }],
+ },
+ data: { syncAttemptedAt: args.now, syncRetryAt: args.leaseUntil },
+ });
+ return claimed.count === 1;
+}
+
/**
* Reconcile stale native IMPLEMENTS links. GitHub webhooks stay primary; this
* sweep is a bounded repair loop for missed delivery, permission recovery, and
@@ -61,10 +111,13 @@ export async function sweepGitHubStatusReconciliation(
workspaceId?: string;
now?: Date;
syncResource?: SyncResource;
+ clock?: () => number;
} = {},
): Promise {
const now = options.now ?? new Date();
const syncResource = options.syncResource ?? syncGitHubExternalResource;
+ const clock = options.clock ?? Date.now;
+ const sweepStartedMs = clock();
const result: GitHubReconciliationResult = {
workspaces: 0,
inspected: 0,
@@ -72,6 +125,9 @@ export async function sweepGitHubStatusReconciliation(
failed: 0,
rateLimited: 0,
skipped: 0,
+ partial: 0,
+ budgetExhausted: false,
+ circuitBroken: false,
};
const workspaces = await db.workspace.findMany({
where: {
@@ -85,22 +141,47 @@ export async function sweepGitHubStatusReconciliation(
githubSyncBatchSize: true,
githubSyncBackoffMinutes: true,
githubSyncMaxBackoffMinutes: true,
+ githubRequestTimeoutSeconds: true,
+ githubSweepBudgetSeconds: true,
+ githubClosedReprobeMinutes: true,
},
});
+ // The scheduled job spans workspaces, so it needs one deterministic hard
+ // ceiling as well as each workspace's local budget. The largest configured
+ // budget preserves an administrator's explicit allowance without letting
+ // the job consume the sum of every tenant's allowance.
+ const globalDeadlineMs =
+ sweepStartedMs +
+ Math.max(0, ...workspaces.map((workspace) => workspace.githubSweepBudgetSeconds)) * 1000;
+ let globalBudgetExhausted = false;
for (const workspace of workspaces) {
+ let workspaceCircuitBroken = false;
result.workspaces += 1;
if (workspace.githubSyncBatchSize <= 0 || workspace.githubSyncStaleMinutes <= 0) {
continue;
}
const staleBefore = new Date(now.getTime() - workspace.githubSyncStaleMinutes * 60_000);
+ const dormantBefore = new Date(now.getTime() - workspace.githubClosedReprobeMinutes * 60_000);
+ const workspaceDeadlineMs = Math.min(
+ globalDeadlineMs,
+ clock() + workspace.githubSweepBudgetSeconds * 1000,
+ );
const candidates = await db.externalResource.findMany({
where: {
workspaceId: workspace.id,
provider: PROVIDER,
resourceType: RESOURCE_TYPE,
- syncTerminalAt: null,
- OR: [{ lastSyncedAt: null }, { lastSyncedAt: { lte: staleBefore } }],
+ OR: [
+ {
+ syncTerminalAt: null,
+ OR: [{ lastSyncedAt: null }, { lastSyncedAt: { lte: staleBefore } }],
+ },
+ {
+ state: "closed",
+ syncTerminalAt: { lte: dormantBefore },
+ },
+ ],
AND: [{ OR: [{ syncRetryAt: null }, { syncRetryAt: { lte: now } }] }],
links: {
some: {
@@ -110,24 +191,39 @@ export async function sweepGitHubStatusReconciliation(
},
},
},
- select: { id: true, syncFailureCount: true },
- orderBy: [{ lastSyncedAt: "asc" }, { id: "asc" }],
+ select: {
+ id: true,
+ state: true,
+ lastSyncedAt: true,
+ syncTerminalAt: true,
+ syncFailureCount: true,
+ connectionMappingId: true,
+ },
+ orderBy: [{ syncTerminalAt: "asc" }, { lastSyncedAt: "asc" }, { id: "asc" }],
take: workspace.githubSyncBatchSize,
});
for (const candidate of candidates) {
+ const currentMs = clock();
+ const remainingMs = workspaceDeadlineMs - currentMs;
+ if (remainingMs <= 0) {
+ result.budgetExhausted = true;
+ globalBudgetExhausted = currentMs >= globalDeadlineMs;
+ break;
+ }
result.inspected += 1;
- const leaseUntil = new Date(now.getTime() + LEASE_MS);
- const claimed = await db.externalResource.updateMany({
- where: {
- id: candidate.id,
- workspaceId: workspace.id,
- syncTerminalAt: null,
- OR: [{ syncRetryAt: null }, { syncRetryAt: { lte: now } }],
- },
- data: { syncAttemptedAt: now, syncRetryAt: leaseUntil },
+ const leaseMs = Math.max(LEASE_MS, workspace.githubSweepBudgetSeconds * 1000 + 5_000);
+ const leaseUntil = new Date(now.getTime() + leaseMs);
+ const claimed = await claimGitHubReconciliationCandidate({
+ db,
+ workspaceId: workspace.id,
+ candidate,
+ now,
+ staleBefore,
+ dormantBefore,
+ leaseUntil,
});
- if (claimed.count === 0) {
+ if (!claimed) {
result.skipped += 1;
continue;
}
@@ -138,18 +234,23 @@ export async function sweepGitHubStatusReconciliation(
workspaceId: workspace.id,
externalResourceId: candidate.id,
actor: { actorId: null },
+ skipCollisionGuard: true,
+ signal: AbortSignal.timeout(Math.max(1, remainingMs)),
});
const checks = checksMetadata(resource.metadata);
- // A merged PR with zero/partially-readable checks must remain held, but
- // exponential retry keeps repositories with no CI from being polled at
- // the normal stale cadence forever. Provider retry headers win.
- if (resource.state === "merged" && checks.status === "unknown") {
+ const untrustedChecks =
+ checks.source !== "api-aggregate" ||
+ checks.status === "unknown" ||
+ checks.partial === true;
+ // Unknown/partial checks on any PR state use exponential/provider
+ // backoff. This includes open/draft resources with missing permissions.
+ if (untrustedChecks && resource.state !== "closed") {
const failureCount = candidate.syncFailureCount + 1;
const exponential = retryAtForFailure({
error: new Error(
typeof checks.diagnostic === "string"
? checks.diagnostic
- : "No aggregate GitHub checks are available for this merged PR.",
+ : "No trusted aggregate GitHub checks are available for this PR.",
),
failureCount,
baseMinutes: Math.max(1, workspace.githubSyncBackoffMinutes),
@@ -158,21 +259,47 @@ export async function sweepGitHubStatusReconciliation(
});
const providerRetryAt =
typeof checks.retryAt === "string" ? new Date(checks.retryAt) : null;
+ const nextRetryAt =
+ providerRetryAt &&
+ Number.isFinite(providerRetryAt.getTime()) &&
+ providerRetryAt > exponential.retryAt
+ ? providerRetryAt
+ : exponential.retryAt;
await db.externalResource.updateMany({
where: { id: candidate.id, workspaceId: workspace.id },
data: {
syncFailureCount: failureCount,
syncLastError: exponential.message,
- syncRetryAt:
- providerRetryAt &&
- Number.isFinite(providerRetryAt.getTime()) &&
- providerRetryAt > exponential.retryAt
- ? providerRetryAt
- : exponential.retryAt,
+ syncRetryAt: nextRetryAt,
},
});
+ result.partial += 1;
+ if (checks.rateLimited === true) {
+ result.rateLimited += 1;
+ }
+ if (checks.partial === true) {
+ // Permission, timeout, or rate-limit failures generally affect the
+ // installation/mapping, not one PR. Open a persisted circuit for
+ // every resource on that mapping before ending this sweep.
+ if (resource.connectionMappingId) {
+ await db.externalResource.updateMany({
+ where: {
+ workspaceId: workspace.id,
+ provider: PROVIDER,
+ connectionMappingId: resource.connectionMappingId,
+ },
+ data: {
+ syncRetryAt: nextRetryAt,
+ syncLastError: exponential.message,
+ },
+ });
+ }
+ result.circuitBroken = true;
+ workspaceCircuitBroken = true;
+ }
}
result.reconciled += 1;
+ if (workspaceCircuitBroken) break;
} catch (error) {
const failureCount = candidate.syncFailureCount + 1;
const retry = retryAtForFailure({
@@ -193,6 +320,20 @@ export async function sweepGitHubStatusReconciliation(
});
result.failed += 1;
if (retry.rateLimited) result.rateLimited += 1;
+ const mappingWideFailure =
+ retry.rateLimited ||
+ (error instanceof GitHubRequestError &&
+ (error.timedOut || [401, 403].includes(error.status)));
+ if (mappingWideFailure && candidate.connectionMappingId) {
+ await db.externalResource.updateMany({
+ where: {
+ workspaceId: workspace.id,
+ provider: PROVIDER,
+ connectionMappingId: candidate.connectionMappingId,
+ },
+ data: { syncRetryAt: retry.retryAt, syncLastError: retry.message },
+ });
+ }
logger.warn(
{
err: error,
@@ -203,8 +344,14 @@ export async function sweepGitHubStatusReconciliation(
},
"github-reconciliation: resource sync failed",
);
+ if (mappingWideFailure) {
+ result.circuitBroken = true;
+ workspaceCircuitBroken = true;
+ break;
+ }
}
}
+ if (globalBudgetExhausted) break;
}
if (result.inspected > 0 || result.failed > 0) {
diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts
index 34e91c96..4da69105 100644
--- a/src/server/services/github/resource-sync.ts
+++ b/src/server/services/github/resource-sync.ts
@@ -119,6 +119,7 @@ export async function resolveGitHubRepoMapping(args: {
export async function fetchGitHubSnapshotForParsed(
parsed: ParsedGitHubUrl,
mapping: GitHubMappingWithConnection,
+ options: { requestTimeoutMs?: number; signal?: AbortSignal } = {},
): Promise {
const installationId = githubInstallationId(mapping.connection);
if (parsed.type === "PULL_REQUEST") {
@@ -127,6 +128,8 @@ export async function fetchGitHubSnapshotForParsed(
owner: parsed.owner,
repo: parsed.repo,
number: parsed.number,
+ requestTimeoutMs: options.requestTimeoutMs,
+ signal: options.signal,
});
const snapshot = pullRequestSnapshot(parsed.repoFullName, pr);
if (pr.head?.sha) {
@@ -137,6 +140,8 @@ export async function fetchGitHubSnapshotForParsed(
owner: parsed.owner,
repo: parsed.repo,
headSha: pr.head.sha,
+ requestTimeoutMs: options.requestTimeoutMs,
+ signal: options.signal,
}),
};
}
@@ -147,6 +152,8 @@ export async function fetchGitHubSnapshotForParsed(
owner: parsed.owner,
repo: parsed.repo,
number: parsed.number,
+ requestTimeoutMs: options.requestTimeoutMs,
+ signal: options.signal,
});
if (issue.pull_request) {
const pr = await getGitHubPullRequest({
@@ -154,6 +161,8 @@ export async function fetchGitHubSnapshotForParsed(
owner: parsed.owner,
repo: parsed.repo,
number: parsed.number,
+ requestTimeoutMs: options.requestTimeoutMs,
+ signal: options.signal,
});
const snapshot = pullRequestSnapshot(parsed.repoFullName, pr);
if (pr.head?.sha) {
@@ -164,6 +173,8 @@ export async function fetchGitHubSnapshotForParsed(
owner: parsed.owner,
repo: parsed.repo,
headSha: pr.head.sha,
+ requestTimeoutMs: options.requestTimeoutMs,
+ signal: options.signal,
}),
};
}
@@ -177,6 +188,8 @@ export async function fetchGitHubSnapshot(args: {
repoFullName: string;
resourceType: GitHubResourceType;
number: number;
+ requestTimeoutMs?: number;
+ signal?: AbortSignal;
}): Promise {
const repo = splitRepoFullName(args.repoFullName);
return fetchGitHubSnapshotForParsed(
@@ -190,6 +203,7 @@ export async function fetchGitHubSnapshot(args: {
: `https://github.com/${repo.repoFullName}/issues/${args.number}`,
},
args.mapping,
+ { requestTimeoutMs: args.requestTimeoutMs, signal: args.signal },
);
}
@@ -253,6 +267,7 @@ export async function upsertExternalResource(
? (metadata.checks as Record)
: {};
const passingChecks =
+ checks.source === "api-aggregate" &&
checks.status === "completed" &&
typeof checks.conclusion === "string" &&
["success", "neutral", "skipped"].includes(checks.conclusion);
@@ -427,7 +442,13 @@ export async function linkGitHubUrlToIssue(args: {
mappingId: args.mappingId,
repoFullName: parsed.repoFullName,
});
- const snapshot = await fetchGitHubSnapshotForParsed(parsed, mapping);
+ const workspace = await args.db.workspace.findUniqueOrThrow({
+ where: { id: args.workspaceId },
+ select: { githubRequestTimeoutSeconds: true },
+ });
+ const snapshot = await fetchGitHubSnapshotForParsed(parsed, mapping, {
+ requestTimeoutMs: workspace.githubRequestTimeoutSeconds * 1000,
+ });
const result = await args.db.$transaction(async (tx) => {
const resource = await upsertExternalResource(tx, {
workspaceId: args.workspaceId,
@@ -503,13 +524,19 @@ export async function importGitHubIssue(args: {
mappingId: args.mappingId,
repoFullName,
});
+ const workspace = await args.db.workspace.findUniqueOrThrow({
+ where: { id: args.workspaceId },
+ select: { githubRequestTimeoutSeconds: true },
+ });
+ const requestTimeoutMs = workspace.githubRequestTimeoutSeconds * 1000;
const snapshot = parsed
- ? await fetchGitHubSnapshotForParsed(parsed, mapping)
+ ? await fetchGitHubSnapshotForParsed(parsed, mapping, { requestTimeoutMs })
: await fetchGitHubSnapshot({
mapping,
repoFullName,
resourceType: args.resourceType ?? "ISSUE",
number,
+ requestTimeoutMs,
});
const resource = await upsertExternalResource(args.db, {
workspaceId: args.workspaceId,
@@ -662,7 +689,35 @@ export async function syncGitHubExternalResource(args: {
workspaceId: string;
externalResourceId: string;
actor: ActorMeta;
+ skipCollisionGuard?: boolean;
+ signal?: AbortSignal;
}): Promise {
+ const workspace = await args.db.workspace.findFirst({
+ where: { id: args.workspaceId, deletedAt: null },
+ select: {
+ githubRequestTimeoutSeconds: true,
+ githubManualCooldownSeconds: true,
+ },
+ });
+ if (!workspace) throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found." });
+ const now = new Date();
+ if (!args.skipCollisionGuard) {
+ const leaseMs = workspace.githubRequestTimeoutSeconds * 12_000 + 5_000;
+ const claimed = await claimGitHubManualSync({
+ db: args.db,
+ workspaceId: args.workspaceId,
+ externalResourceId: args.externalResourceId,
+ now,
+ cooldownSeconds: workspace.githubManualCooldownSeconds,
+ leaseUntil: new Date(now.getTime() + leaseMs),
+ });
+ if (!claimed) {
+ throw new TRPCError({
+ code: "TOO_MANY_REQUESTS",
+ message: "This GitHub link was refreshed recently or is already being refreshed.",
+ });
+ }
+ }
const existing = await args.db.externalResource.findFirst({
where: {
id: args.externalResourceId,
@@ -694,6 +749,8 @@ export async function syncGitHubExternalResource(args: {
repoFullName: existing.repoFullName,
resourceType: existing.resourceType as GitHubResourceType,
number: existing.number,
+ requestTimeoutMs: workspace.githubRequestTimeoutSeconds * 1000,
+ signal: args.signal,
});
const resource = await args.db.$transaction(async (tx) => {
const resource = await upsertExternalResource(tx, {
@@ -721,3 +778,25 @@ export async function syncGitHubExternalResource(args: {
}
return resource;
}
+
+export async function claimGitHubManualSync(args: {
+ db: PrismaClient;
+ workspaceId: string;
+ externalResourceId: string;
+ now: Date;
+ cooldownSeconds: number;
+ leaseUntil: Date;
+}): Promise {
+ const cooldownBefore = new Date(args.now.getTime() - args.cooldownSeconds * 1000);
+ const claimed = await args.db.externalResource.updateMany({
+ where: {
+ id: args.externalResourceId,
+ workspaceId: args.workspaceId,
+ provider: GITHUB_PROVIDER,
+ OR: [{ syncAttemptedAt: null }, { syncAttemptedAt: { lte: cooldownBefore } }],
+ AND: [{ OR: [{ syncRetryAt: null }, { syncRetryAt: { lte: args.now } }] }],
+ },
+ data: { syncAttemptedAt: args.now, syncRetryAt: args.leaseUntil },
+ });
+ return claimed.count === 1;
+}
diff --git a/src/server/services/github/webhook.ts b/src/server/services/github/webhook.ts
index cac236cf..35318619 100644
--- a/src/server/services/github/webhook.ts
+++ b/src/server/services/github/webhook.ts
@@ -340,15 +340,20 @@ function completedCheckPullRequestNumbers(payload: GitHubWebhookPayload): number
return [...out];
}
-export function aggregateGitHubCheckConclusion(args: {
+export function githubCheckWebhookHint(args: {
event: "check_suite" | "check_run";
conclusion: string | null;
- existingConclusion: unknown;
-}): string | null {
- const successful =
- args.conclusion !== null && ["success", "neutral", "skipped"].includes(args.conclusion);
- if (args.event === "check_suite" || !successful) return args.conclusion;
- return typeof args.existingConclusion === "string" ? args.existingConclusion : null;
+ headSha?: string | null;
+}) {
+ return {
+ status: "dirty",
+ conclusion: null,
+ source: "webhook-hint",
+ updatedAt: new Date().toISOString(),
+ event: args.event,
+ observedConclusion: args.conclusion,
+ ...(args.headSha ? { headSha: args.headSha } : {}),
+ } as const;
}
async function processCheckEvent(args: {
@@ -411,13 +416,13 @@ async function processCheckEvent(args: {
if (eventHeadSha && typeof head.sha === "string" && head.sha !== eventHeadSha) {
continue;
}
- // A check_suite conclusion is aggregate. A successful check_run only
- // proves one job passed, so it must not certify the whole PR; failures
- // remain safe to persist immediately.
- const aggregateConclusion = aggregateGitHubCheckConclusion({
+ // A webhook check_suite is only one suite, not repository-wide CI.
+ // Persist it as a dirty hint; the worker must fetch every suite plus the
+ // combined commit status before completion can trust a conclusion.
+ const webhookHint = githubCheckWebhookHint({
event: args.payload.check_suite ? "check_suite" : "check_run",
conclusion,
- existingConclusion: existingChecks.conclusion,
+ headSha: eventHeadSha,
});
await tx.externalResource.update({
where: { id: resource.id },
@@ -426,24 +431,12 @@ async function processCheckEvent(args: {
...metadata,
checks: {
...existingChecks,
- status: "completed",
- conclusion: aggregateConclusion,
- updatedAt: new Date().toISOString(),
- event: args.payload.check_suite ? "check_suite" : "check_run",
+ ...webhookHint,
...(args.payload.check_run ? { lastRunConclusion: conclusion } : {}),
- ...(eventHeadSha ? { headSha: eventHeadSha } : {}),
},
} as Prisma.InputJsonValue,
- syncAttemptedAt: new Date(),
- syncRetryAt: null,
- syncFailureCount: 0,
- syncLastError: null,
- syncTerminalAt:
- resource.state === "merged" &&
- aggregateConclusion !== null &&
- ["success", "neutral", "skipped"].includes(aggregateConclusion)
- ? new Date()
- : null,
+ lastSyncedAt: null,
+ syncTerminalAt: null,
},
});
processed += 1;
diff --git a/tests/e2e/dashboard-layout.spec.ts b/tests/e2e/dashboard-layout.spec.ts
index 5f8757ca..a9603b45 100644
--- a/tests/e2e/dashboard-layout.spec.ts
+++ b/tests/e2e/dashboard-layout.spec.ts
@@ -42,8 +42,12 @@ test("dashboard keeps priority work bounded and reflows secondary modules", asyn
await expect(flow).toBeVisible();
await expect(flow.getByText("Pipeline", { exact: true }).first()).toBeVisible();
- const priorityGrid = cockpit.locator(`[data-dashboard-columns="2"]`);
+ const priorityGrid = cockpit.locator(`[data-dashboard-columns]`).first();
await expect(priorityGrid).toBeVisible();
+ const configuredPriorityColumns = Number(
+ await priorityGrid.getAttribute("data-dashboard-columns"),
+ );
+ expect([1, 2]).toContain(configuredPriorityColumns);
await expect
.poll(() =>
priorityGrid
@@ -55,7 +59,9 @@ test("dashboard keeps priority work bounded and reflows secondary modules", asyn
const tracks = getComputedStyle(node).gridTemplateColumns;
return tracks === "none" ? 1 : tracks.split(" ").length;
});
- expect(priorityColumns).toBe(state.priorityColumns);
+ expect(priorityColumns).toBe(
+ state.name === "desktop" ? configuredPriorityColumns : state.priorityColumns,
+ );
await expect(cockpit.getByTestId("dashboard-pulse")).toBeVisible();
await expect(cockpit.getByTestId("dashboard-schedule")).toBeVisible();
diff --git a/tests/unit/github-check-conclusion.test.ts b/tests/unit/github-check-conclusion.test.ts
index 03486e72..aa1f6b89 100644
--- a/tests/unit/github-check-conclusion.test.ts
+++ b/tests/unit/github-check-conclusion.test.ts
@@ -1,41 +1,28 @@
import { describe, expect, it } from "vitest";
-import { aggregateGitHubCheckConclusion } from "@/server/services/github/webhook";
+import { githubCheckWebhookHint } from "@/server/services/github/webhook";
-describe("aggregateGitHubCheckConclusion", () => {
- it("accepts aggregate suite success as completion evidence", () => {
+describe("githubCheckWebhookHint", () => {
+ it("treats a successful suite as a dirty hint, never aggregate completion evidence", () => {
expect(
- aggregateGitHubCheckConclusion({
+ githubCheckWebhookHint({
event: "check_suite",
conclusion: "success",
- existingConclusion: null,
}),
- ).toBe("success");
+ ).toMatchObject({
+ status: "dirty",
+ conclusion: null,
+ source: "webhook-hint",
+ observedConclusion: "success",
+ });
});
- it("does not treat one successful check run as aggregate success", () => {
+ it("also treats an individual failure as a non-terminal refresh hint", () => {
expect(
- aggregateGitHubCheckConclusion({
- event: "check_run",
- conclusion: "success",
- existingConclusion: null,
- }),
- ).toBeNull();
- });
-
- it("keeps a known suite result across later successful jobs and records failures", () => {
- expect(
- aggregateGitHubCheckConclusion({
- event: "check_run",
- conclusion: "success",
- existingConclusion: "success",
- }),
- ).toBe("success");
- expect(
- aggregateGitHubCheckConclusion({
+ githubCheckWebhookHint({
event: "check_run",
conclusion: "failure",
- existingConclusion: "success",
+ headSha: "abc123",
}),
- ).toBe("failure");
+ ).toMatchObject({ conclusion: null, observedConclusion: "failure", headSha: "abc123" });
});
});
diff --git a/tests/unit/github-client.test.ts b/tests/unit/github-client.test.ts
index 1cf4b972..b1c4e320 100644
--- a/tests/unit/github-client.test.ts
+++ b/tests/unit/github-client.test.ts
@@ -40,6 +40,138 @@ describe("GitHub REST client resilience", () => {
expect(checks.diagnostic).toContain("Checks permission required");
});
+ it("aggregates every check-suite page and ignores empty legacy status pending", async () => {
+ const firstPage = Array.from({ length: 100 }, () => ({
+ status: "completed",
+ conclusion: "success",
+ }));
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ total_count: 101, check_suites: firstPage }), {
+ status: 200,
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ total_count: 101,
+ check_suites: [{ status: "completed", conclusion: "neutral" }],
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ state: "pending", total_count: 0 }), { status: 200 }),
+ );
+
+ const checks = await getGitHubPullRequestChecks({
+ installationId: 1,
+ owner: "acme",
+ repo: "forge",
+ headSha: "abc123",
+ });
+
+ expect(checks).toMatchObject({
+ status: "completed",
+ conclusion: "success",
+ suiteCount: 101,
+ statusCount: 0,
+ partial: false,
+ source: "api-aggregate",
+ });
+ const urls = vi.mocked(globalThis.fetch).mock.calls.map(([url]) => String(url));
+ expect(urls[0]).not.toContain("filter=latest");
+ expect(urls[1]).toContain("page=2");
+ });
+
+ it("keeps a completed suite with a null conclusion untrusted", async () => {
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ total_count: 2,
+ check_suites: [
+ { status: "completed", conclusion: "success" },
+ { status: "completed", conclusion: null },
+ ],
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ state: "success", total_count: 1 }), { status: 200 }),
+ );
+
+ await expect(
+ getGitHubPullRequestChecks({
+ installationId: 1,
+ owner: "acme",
+ repo: "forge",
+ headSha: "abc123",
+ }),
+ ).resolves.toMatchObject({ status: "unknown", conclusion: null });
+ });
+
+ it("keeps malformed legacy status aggregates untrusted", async () => {
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ total_count: 1,
+ check_suites: [{ status: "completed", conclusion: "success" }],
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ total_count: 1 }), { status: 200 }),
+ );
+
+ await expect(
+ getGitHubPullRequestChecks({
+ installationId: 1,
+ owner: "acme",
+ repo: "forge",
+ headSha: "abc123",
+ }),
+ ).resolves.toMatchObject({
+ status: "unknown",
+ conclusion: null,
+ diagnostic: "GitHub returned commit statuses without an aggregate state.",
+ });
+ });
+
+ it("reports partial rate limits accurately even when another checks endpoint succeeds", async () => {
+ const reset = Math.floor(Date.now() / 1000) + 600;
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ message: "API rate limit exceeded" }), {
+ status: 403,
+ headers: {
+ "x-ratelimit-remaining": "0",
+ "x-ratelimit-reset": String(reset),
+ },
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ state: "success", total_count: 1 }), { status: 200 }),
+ );
+
+ await expect(
+ getGitHubPullRequestChecks({
+ installationId: 1,
+ owner: "acme",
+ repo: "forge",
+ headSha: "abc123",
+ }),
+ ).resolves.toMatchObject({
+ partial: true,
+ rateLimited: true,
+ conclusion: null,
+ retryAt: new Date(reset * 1000).toISOString(),
+ });
+ });
+
it("preserves GitHub rate-limit reset timing on request errors", async () => {
const reset = Math.floor(Date.now() / 1000) + 600;
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
@@ -57,4 +189,25 @@ describe("GitHub REST client resilience", () => {
retryAt: new Date(reset * 1000),
} satisfies Partial);
});
+
+ it("aborts GitHub requests at the configured timeout", async () => {
+ vi.spyOn(globalThis, "fetch").mockImplementationOnce(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () =>
+ reject(new DOMException("Timed out", "TimeoutError")),
+ );
+ }),
+ );
+
+ await expect(
+ getGitHubPullRequest({
+ installationId: 1,
+ owner: "acme",
+ repo: "forge",
+ number: 42,
+ requestTimeoutMs: 5,
+ }),
+ ).rejects.toMatchObject({ status: 408, timedOut: true, rateLimited: false });
+ });
});