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
46 changes: 46 additions & 0 deletions apps/daemon/src/app-github-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,52 @@ exit 1
ROUTE_TEST_TIMEOUT_MS,
);

it(
"starts GitHub cooldown from a nearly exhausted quota response before it reaches zero",
async () => {
const fixture = createFixture();
const fakeGh = path.join(fixture.config.dataDir, "fake-gh");
fs.writeFileSync(
fakeGh,
`#!/usr/bin/env bash
if [ "$1" = "api" ] && [ "$2" = "rate_limit" ]; then
cat <<'JSON'
{"resources":{"core":{"limit":5000,"used":25,"remaining":4975,"reset":4102444800},"graphql":{"limit":5000,"used":4950,"remaining":50,"reset":4102444800},"search":{"limit":30,"used":0,"remaining":30,"reset":4102444800}}}
JSON
exit 0
fi
exit 1
`,
{ mode: 0o755 },
);
fixture.config.providers.github.enabled = true;
fixture.config.providers.github.command = fakeGh;
const { server } = await createDaemonApp(fixture);
const baseUrl = await listen(server);
try {
const body = await getJson<{
quota: {
status: string;
reason: string | null;
cooldownUntil: string | null;
resources: Array<{ name: string; remaining: number; percentUsed: number }>;
};
}>(`${baseUrl}/api/integrations/github/quota`);
expect(body.quota.status).toBe("degraded");
expect(body.quota.reason).toContain("GitHub graphql quota nearly exhausted");
expect(body.quota.cooldownUntil).toBeTruthy();
expect(body.quota.resources.find((resource) => resource.name === "graphql")).toMatchObject({
remaining: 50,
percentUsed: 99,
});
} finally {
clearGhCooldown();
await closeServer(server);
}
},
ROUTE_TEST_TIMEOUT_MS,
);

it(
"injects versionControl.cooldownUntil into provider-summary while gh is in cooldown (review #6)",
async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/src/checkout-pr-primer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe("checkout PR primer", () => {
expect.objectContaining({ id: "ws_a" }),
expect.objectContaining({ id: "co_api" }),
expect.objectContaining({ id: "repo_a" }),
"vc:ws_a:checkout:co_api:2026-06-05T00:00:00.000Z",
"vc:ws_a:checkout:co_api",
{ intent: "automatic", force: true, staleWhileRevalidate: true },
);
expect(fetchVersionControl).not.toHaveBeenCalled();
Expand Down
8 changes: 6 additions & 2 deletions apps/daemon/src/extra-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type AsyncRoute = (

const execFileAsync = promisify(execFile);
const GITHUB_QUOTA_CACHE_TTL_MS = 60_000;
const GITHUB_QUOTA_COOLDOWN_PERCENT_USED = 99;

export function registerWorkspaceExtraRoutes(input: {
app: express.Express;
Expand Down Expand Up @@ -497,12 +498,15 @@ async function readGitHubQuota(

function quotaCooldownFromResources(quota: GitHubQuotaSummary): { until: number; reason: string } | null {
const exhausted = quota.resources.find(
(resource) => (resource.name === "graphql" || resource.name === "core") && resource.remaining === 0,
(resource) =>
(resource.name === "graphql" || resource.name === "core") &&
(resource.remaining === 0 || resource.percentUsed >= GITHUB_QUOTA_COOLDOWN_PERCENT_USED),
);
if (!exhausted?.resetAt) return null;
const resetMs = Date.parse(exhausted.resetAt);
if (!Number.isFinite(resetMs) || resetMs <= Date.now()) return null;
const reason = `GitHub ${exhausted.name} quota exhausted until ${exhausted.resetAt}`;
const state = exhausted.remaining === 0 ? "exhausted" : "nearly exhausted";
const reason = `GitHub ${exhausted.name} quota ${state} until ${exhausted.resetAt}`;
const until = setGhCooldown(reason, Math.max(1, resetMs - Date.now()));
return { until, reason };
}
Expand Down
6 changes: 3 additions & 3 deletions apps/daemon/src/github-provider-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ describe("GitHub provider state service", () => {
makeWorkspace({ kind: "root" }),
checkout,
makeRepo(),
"vc:w1:checkout:co_api:2026-05-25T00:00:00Z",
"vc:w1:checkout:co_api",
{ intent: "interactive", force: true, staleWhileRevalidate: false },
);

Expand All @@ -304,7 +304,7 @@ describe("GitHub provider state service", () => {
makeWorkspace({ kind: "root" }),
checkout,
makeRepo(),
"vc:w1:checkout:co_api:2026-05-25T00:00:00Z",
"vc:w1:checkout:co_api",
{ intent: "interactive", force: true, staleWhileRevalidate: false },
);

Expand Down Expand Up @@ -338,7 +338,7 @@ describe("GitHub provider state service", () => {
makeWorkspace({ kind: "root" }),
checkout,
makeRepo(),
"vc:w1:checkout:co_api:2026-05-25T00:00:00Z",
"vc:w1:checkout:co_api",
{ intent: "automatic" },
);

Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/src/provider-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ export function ciCacheKey(id: string, updatedAt: string): string {
return `ci:${id}:${updatedAt}`;
}

export function checkoutVcCacheKey(workspaceId: string, checkoutId: string, checkoutUpdatedAt: string): string {
return `vc:${workspaceId}:checkout:${checkoutId}:${checkoutUpdatedAt}`;
export function checkoutVcCacheKey(workspaceId: string, checkoutId: string, _checkoutUpdatedAt: string): string {
return `vc:${workspaceId}:checkout:${checkoutId}`;
}

export function issueCacheKey(issueKey: string): string {
Expand Down
9 changes: 5 additions & 4 deletions apps/daemon/src/provider-refresh-job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,9 @@ describe("startProviderRefreshJob", () => {
const callPaths = (deps.providers.collectGitHubVersionControlSummary as ReturnType<typeof vi.fn>).mock.calls.map(
(call) => call[0],
);
expect(callPaths).toEqual(["/tmp/w1", "/tmp/w1/co_api"]);
expect(deps.cache.get("vc:w1:checkout:co_api:2026-05-25T00:00:00Z")?.value).toBeDefined();
expect(deps.cache.get("vc:w1:checkout:co_archived:2026-05-25T00:00:00Z")).toBeUndefined();
expect(callPaths).toEqual(["/tmp/w1/co_api"]);
expect(deps.cache.get("vc:w1:checkout:co_api")?.value).toBeDefined();
expect(deps.cache.get("vc:w1:checkout:co_archived")).toBeUndefined();
job.stop();
});

Expand Down Expand Up @@ -409,11 +409,12 @@ describe("startProviderRefreshJob", () => {
};
const job = startProviderRefreshJob({ ...deps, github, tickIntervalMs: 0, jitterMaxMs: 0 });
await job.runTickForTest();
expect(github.fetchVersionControl).not.toHaveBeenCalled();
expect(github.fetchCheckoutVersionControl).toHaveBeenCalledWith(
workspace,
checkout,
repo,
"vc:w1:checkout:co_api:2026-05-25T00:00:00Z",
"vc:w1:checkout:co_api",
{ intent: "automatic" },
);
job.stop();
Expand Down
12 changes: 7 additions & 5 deletions apps/daemon/src/provider-refresh-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,13 @@ export function startProviderRefreshJob(deps: ProviderRefreshDeps): ProviderRefr
const ciMs = deps.config.providerRefresh.intervals.ciMs ?? prCiMs;
const vcKey = vcCacheKey(workspace.id, workspace.updatedAt);
const ciKey = ciCacheKey(workspace.id, workspace.updatedAt);
if (isStale(vcKey, prCiMs)) {
items.push({ kind: "vc", workspaceId: workspace.id, cacheKey: vcKey, ttlMs: prCiMs, rootPath: workspace.path });
}
if (shouldFetchGithubCi(deps.store, workspace) && isStale(ciKey, ciMs)) {
items.push({ kind: "ci", workspaceId: workspace.id, cacheKey: ciKey, ttlMs: ciMs, rootPath: workspace.path });
if (workspace.kind !== "root") {
if (isStale(vcKey, prCiMs)) {
items.push({ kind: "vc", workspaceId: workspace.id, cacheKey: vcKey, ttlMs: prCiMs, rootPath: workspace.path });
}
if (shouldFetchGithubCi(deps.store, workspace) && isStale(ciKey, ciMs)) {
items.push({ kind: "ci", workspaceId: workspace.id, cacheKey: ciKey, ttlMs: ciMs, rootPath: workspace.path });
}
}
for (const checkout of listActiveWorkspaceCheckouts(deps.store, workspace.id)) {
const checkoutVcKey = checkoutVcCacheKey(workspace.id, checkout.id, checkout.updatedAt);
Expand Down
136 changes: 136 additions & 0 deletions apps/daemon/src/workspaces-pr-state-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,140 @@ describe("GET /api/workspaces/pr-state", () => {
await closeServer(server);
}
});

it(
"keeps checkout PR summaries visible after PR binding updates bump checkout.updatedAt",
{ timeout: 30_000 },
async () => {
const fixture = createFixture(dirs);
const timestamp = "2026-06-04T00:00:00.000Z";
fixture.store.insertRepo({
id: "repo_1",
name: "Repo",
rootPath: path.join(fixture.config.dataDir, "repo"),
defaultBranch: "main",
defaultRemote: "origin",
worktreeParent: path.join(fixture.config.dataDir, "worktrees"),
setupHookIds: [],
teardownHookIds: [],
providerIds: [],
deployHookCommand: null,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
});
fixture.store.insertWorkspace({
id: "ws_structured",
repoId: null,
name: "Structured",
path: path.join(fixture.config.dataDir, "structured"),
rootPath: path.join(fixture.config.dataDir, "structured"),
mode: "structured",
branch: "home",
baseBranch: "main",
source: "scratch",
kind: "root",
lifecyclePhase: "implementation",
parentIssue: null,
prUrl: null,
issueKey: null,
issueTitle: null,
issueUrl: null,
slackThreadUrl: null,
section: "backlog",
pinned: false,
lifecycle: "ready",
dirty: false,
namespaceId: null,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
});
fixture.store.insertWorkspaceCheckout({
id: "co_api",
workspaceId: "ws_structured",
repoId: "repo_1",
name: "api",
path: path.join(fixture.config.dataDir, "structured", "api"),
branch: "feature/api",
baseBranch: "main",
issue: null,
intendedPr: null,
stackParentCheckoutId: null,
inferredPurpose: "implementation",
gateStatus: "not_started",
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
});
const cachedAtMs = Date.now();
fs.writeFileSync(
`${fixture.config.dataDir}/provider-cache.json`,
JSON.stringify({
version: 1,
savedAt: new Date().toISOString(),
entries: [
[
checkoutVcCacheKey("ws_structured", "co_api", timestamp),
{
expiresAt: Date.now() + 60_000,
cachedAt: cachedAtMs,
value: {
providerId: "github-gh",
status: "healthy",
reason: null,
checkedAt: timestamp,
defaultBranch: "main",
currentBranch: "feature/api",
remotes: [],
pullRequest: {
number: 42,
title: "Ship API",
url: "https://github.example.test/org/repo/pull/42",
state: "OPEN",
draft: false,
reviewDecision: null,
checks: [],
additions: null,
deletions: null,
reviewers: [],
commits: [],
headRefName: "feature/api",
parentPr: null,
mergeable: "unknown",
allowedMergeStrategies: [],
mergeStateStatus: null,
headSha: "abc123",
},
},
},
],
],
}),
);
fixture.store.updateWorkspaceCheckoutPr("co_api", {
provider: "github",
number: 42,
url: "https://github.example.test/org/repo/pull/42",
state: "open",
headSha: "abc123",
baseRef: "main",
fetchedAt: "2026-06-04T00:01:00.000Z",
checksGreen: null,
mergeStateStatus: null,
hasConflicts: null,
});

const { server } = await createDaemonApp(fixture);
const baseUrl = await listen(server);
try {
const body = await getJson<{
checkoutPrState: Record<string, Record<string, { pullRequest: { number: number } | null }>>;
}>(`${baseUrl}/api/workspaces/pr-state`);
expect(body.checkoutPrState.ws_structured?.co_api?.pullRequest).toMatchObject({ number: 42 });
} finally {
await closeServer(server);
}
},
);
});
Loading