Public: anyone can read this; the crew can change it.

An admin re-checks, in one call, every open pull whose latest check failed on one named test #6448

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6445-an-admin-re-checks-in-one-call-every-ope → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6445-an-admin-re-checks-in-one-call-every-ope was merged into main as aa7fa61.

Review

Sign in to review this change.
8 files changed +218 −1
README.md +2
@@ -91,6 +91,8 @@ Set `FORGE_REPO=owner/name` to the forge's own code and a pull the crew lands th
9191
9292 **Keeping a domain drill**: `POST /api/v1/drills` (a site admin person; an agent is refused with 403, and a session needs a same-origin request) takes `{domain, seen:{health, board}, backSeconds}`: a domain key from `docs/DOMAINS.md`, what the drill read from health and the board (text, at most 8000 characters each), and whole seconds until the forge was well again (0 to 31536000). The body is capped at 32000 bytes (413). It answers 201 `{id, domain, backSeconds, green, why}`: the verdict is judged against the domain's own words and kept whether green or red. An unknown domain, bad `seen` or bad `backSeconds` is 422. Kept drills are read back on **Settings → Backups**, in its **Failure domains** card (`docs/SNAPSHOT.md`). To run a drill by hand, `FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>` (plain `http` is refused unless the host is loopback or you pass `--insecure`) prompts for the fault, watches health and the board until the forge is back, and posts this for you (`docs/DOMAINS.md`).
9393
94 **Re-checking a shared red**: `POST /api/v1/repos/:owner/:name/checks/recheck` (a repository admin, `admin` token scope; anyone else gets 404, and a session needs a same-origin request) takes `{test}`: the name of one failing test exactly as the check log's failing names show it, 1 to 300 characters (otherwise 400). It asks again, through the ordinary check queue, for every open pull whose latest check on its current head failed on that test, and answers `{queued: [pull numbers]}`. At most 50 pulls are queued per call; when more were red, the answer adds `more` (how many were left) and a `message`, and a second call takes the rest. A pull waiting for a writer's approval, or whose head declares no check, is not in `queued`.
95
9496 If your shell already has `FORGE_URL` set — a crew workspace sets it to the
9597 forge you work for — this forge takes it as its own canonical address and
9698 refuses every form a browser posts to `localhost`: run it with
src/checks/recheck.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * Every open pull whose latest check on its current head failed on one
3 * named test, asked for again (#6445): once a shared red is fixed on main,
4 * the pulls it held are handed back to the check queue in one call.
5 */
6
7 import type { App } from "../app.ts";
8 import { listOpenPulls, type Pull } from "../core/pulls.ts";
9 import type { Repo } from "../core/repos.ts";
10 import { getRun, type Run } from "../core/runs.ts";
11 import type { User } from "../core/users.ts";
12 import { askCheck } from "./again.ts";
13
14 /** The most pulls one call queues. */
15 export const RECHECK_MAX = 50;

To make the cap testable without opening 51 pulls, let recheckNamed take the max as a parameter that defaults to RECHECK_MAX. Then the test can call it with 1 against the two red pulls already set up.

16
17 /** The longest test name taken. */
18 export const TEST_NAME_MAX = 300;
19
20 export interface Rechecked {
21 readonly queued: number[];
22 /** Red pulls beyond `RECHECK_MAX`, left for the next call. */
23 readonly left: number;
24 }
25
26 /** The newest check run of this head of the pull. */
27 function latestCheck(app: App, pull: Pull): Run | undefined {
28 const row = app.db
29 .query<{ id: string }, [string, string]>(
30 `SELECT id FROM run WHERE kind = 'check' AND issue_id = ?1 AND commit_oid = ?2 ORDER BY id DESC LIMIT 1`,
31 )
32 .get(pull.id, pull.headOid);
33 return row === null ? undefined : getRun(app.db, row.id)!;
34 }
35
36 export async function recheckNamed(
37 app: App,
38 user: User,
39 repo: Repo,
40 test: string,
41 max = RECHECK_MAX,
42 ): Promise<Rechecked> {
43 const red = listOpenPulls(app.db, repo.id).filter((pull) => {
44 if (pull.headOid === "") return false;
45 const run = latestCheck(app, pull);
46 return run?.state === "failed" && (run.checkDetail?.names.includes(test) ?? false);

Not blocking: checkDetail.names keeps only the first MAX_FAILING_NAMES (3) failing names (src/checks/bun-test.ts:85). A pull red on four or more tests, where the named one isn't in the first three, is silently skipped here. That is worth one sentence in the README ("matches the failing names the check recorded, the first three"), so an admin isn't puzzled by a pull missing from queued.

47 });
48 const queued: number[] = [];
49 for (const pull of red.slice(0, max)) {
50 // The ordinary request: the check queue's own slots and order apply.
51 const asked = await askCheck(app, user, repo, pull);
52 if (asked.ok) queued.push(pull.number);
53 }
54 return { queued, left: Math.max(0, red.length - max) };
55 }
src/contract/openapi/routes/index.ts +1
@@ -174,6 +174,7 @@ export { route as postApiV1ReposOwnerNameAppDomainsHostCheck } from "./postApiV1
174174 export { route as postApiV1ReposOwnerNameAppEnvRedeploy } from "./postApiV1ReposOwnerNameAppEnvRedeploy.ts";
175175 export { route as postApiV1ReposOwnerNameAppEnvStop } from "./postApiV1ReposOwnerNameAppEnvStop.ts";
176176 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
177 export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts";
177178 export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts";
178179 export { route as postApiV1ReposOwnerNameContribute } from "./postApiV1ReposOwnerNameContribute.ts";
179180 export { route as postApiV1ReposOwnerNameEdits } from "./postApiV1ReposOwnerNameEdits.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameChecksRecheck.ts added +13
@@ -0,0 +1,13 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/checks/recheck`,
6 summary: "Ask again for every open pull whose latest check on its head failed on the test named in the body { test } (admin scope, a repository admin's; at most 50 per call); answers { queued: [numbers] }, with more when some were left",
7 auth: true,
8 status: 200,
9 reply: { queued: "array" },
10 refusals: {
11 invalid_test: "test is a name of 1 to 300 characters (400)",
12 },
13 };
src/http/checks_recheck.ts added +28
@@ -0,0 +1,28 @@
1 /**
2 * The admin's one call to re-check every open pull red on one named test.
3 */
4
5 import { RECHECK_MAX, recheckNamed, TEST_NAME_MAX } from "../checks/recheck.ts";
6 import { canAdmin, getRepo } from "../core/repos.ts";
7 import type { Handler } from "./context.ts";
8 import { apiError, body, json, notFound, requireScope, str } from "./respond.ts";
9 import type { Router } from "./router.ts";
10
11 export function checksRecheckRoutes(router: Router<Handler>): void {
12 router.add("POST", "/api/v1/repos/:owner/:name/checks/recheck", async (ctx) => {
13 const denied = requireScope(ctx, "admin");
14 if (denied) return denied;
15 const repo = getRepo(ctx.app.db, ctx.params.owner!, ctx.params.name!);
16 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound();
17 const test = str((await body(ctx.request)).test);
18 if (test.length < 1 || test.length > TEST_NAME_MAX)
19 return apiError("invalid_test", 400, `test is a name of 1 to ${TEST_NAME_MAX} characters`);
20 const done = await recheckNamed(ctx.app, ctx.auth.user!, repo, test);
21 return json(
22 done.left > 0

No test reaches this branch. Add a test with more red pulls than the cap that asserts queued.length === cap and more === red - cap. Without it, a regression that drops the cap or the more field passes green.

23 ? { queued: done.queued, more: done.left, message: `at most ${RECHECK_MAX} per call; ${done.left} left` }
24 : { queued: done.queued },
25 200,
26 );
27 });
28 }
src/http/server.ts +2
@@ -469,6 +469,7 @@ import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-to
469469 import { adminGate } from "./admin_gate.ts";
470470 import { proposalGate } from "./proposal_gate.ts";
471471 import { runGate } from "./run_gate.ts";
472 import { checksRecheckRoutes } from "./checks_recheck.ts";
472473 import { feedRoutes } from "./feed.ts";
473474 import type { Database } from "bun:sqlite";
474475 import { mcpRoutes } from "./mcp.ts";
@@ -1463,6 +1464,7 @@ export function buildApiRouter(): Router<Handler> {
14631464 const router = new Router<Handler>();
14641465 apiRoutes(router);
14651466 feedRoutes(router);
1467 checksRecheckRoutes(router);
14661468 return router;
14671469 }
14681470
test/checks-recheck.test.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * An admin re-checks, in one call, every open pull whose latest check failed
3 * on one named test (#6445).
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { openPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { recheckNamed, RECHECK_MAX } from "../src/checks/recheck.ts";
13 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
14 import type { Pull } from "../src/core/pulls.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { adminCookie } from "./helpers/admin.ts";
21
22 let dataDir = "";
23 let app: App;
24 let servers: Servers;
25 let repo: Repo;
26 let cookie = "";
27 let bobCookie = "";
28 const pulls: Pull[] = [];
29
30 const asked = () => listRuns(app.db, repo.id, { kind: "check" }).filter((r) => r.reason === "asked");
31
32 function redOn(pull: Pull, name: string): void {
33 const run = createRun(app.db, { repoId: repo.id, kind: "check", env: `pull-${pull.number}`, commitOid: pull.headOid, issueId: pull.id });
34 claimRun(app.db, run.id);
35 finishRun(app.db, run.id, { state: "failed", summary: "check failed", checkDetail: { passed: 1, failed: 1, names: [name] } });
36 }
37
38 const call = (body: unknown, headers: Record<string, string> = {}, who = cookie) =>
39 fetch(`${servers.url}/api/v1/repos/alice/engine/checks/recheck`, {
40 method: "POST",
41 headers: { "content-type": "application/json", cookie: who, ...headers },
42 body: JSON.stringify(body),
43 });
44
45 beforeAll(async () => {
46 dataDir = await mkdtemp(join(tmpdir(), "forge-recheck-"));
47 app = createApp({ dataDir, port: 0 });
48 servers = await serve(app);
49 cookie = await adminCookie(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
50 const alice = getUserByName(app.db, "alice")!;
51 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
52 if (!bob.ok) throw new Error(bob.error);
53 bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`;
54 const made = await createRepo(app, alice, { name: "engine", visibility: "public" });
55 if (!made.ok) throw new Error(String(made.error));
56 repo = made.value;
57 await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "a.ts", content: "1\n" }] });
58 for (const n of [1, 2, 3]) {
59 await commitToBranch(app, alice, repo, { branch: `f/${n}`, base: "main", message: `F${n}`, files: [{ path: `f${n}.ts`, content: `${n}\n` }] });
60 const opened = await openPull(app, alice, repo, { title: `F${n}`, body: "", baseBranch: "main", headBranch: `f/${n}` });
61 if (!opened.ok) throw new Error(String(opened.error));
62 pulls.push(opened.value);
63 }
64 const request: ChecksHandle["request"] = async (r, env, oid, reason, issueId) =>
65 createRun(app.db, { repoId: r.id, kind: "check", env, commitOid: oid, reason, issueId });
66 provideChecksForTest(app, { request } as unknown as ChecksHandle);
67 redOn(pulls[0]!, "tools/list stays in budget");
68 redOn(pulls[1]!, "tools/list stays in budget");
69 redOn(pulls[2]!, "something else");
70 });
71
72 afterAll(async () => {
73 servers.stop();
74 app.close();
75 await rm(dataDir, { recursive: true, force: true });
76 });
77
78 test("a non-admin gets 404 and queues nothing", async () => {
79 expect((await call({ test: "tools/list stays in budget" }, {}, bobCookie)).status).toBe(404);
80 expect(asked()).toHaveLength(0);
81 });
82
83 test("a foreign Origin is refused", async () => {
84 expect((await call({ test: "tools/list stays in budget" }, { origin: "https://evil.example" })).status).toBe(403);
85 expect(asked()).toHaveLength(0);
86 });
87
88 test("an empty or overlong test name is a 400", async () => {
89 expect((await call({ test: "" })).status).toBe(400);
90 expect((await call({})).status).toBe(400);
91 expect((await call({ test: "x".repeat(301) })).status).toBe(400);
92 expect(asked()).toHaveLength(0);
93 });
94
95 test("the pulls red on the named test are queued, the one red on another is not", async () => {
96 const res = await call({ test: "tools/list stays in budget" });
97 expect(res.status).toBe(200);
98 expect(await res.json()).toEqual({ queued: [pulls[0]!.number, pulls[1]!.number] });
99 expect(asked().map((r) => r.issueId).sort()).toEqual([pulls[0]!.id, pulls[1]!.id].sort());
100 });
101
102 test("a pull whose latest check is no longer red is not queued again", async () => {
103 const res = await call({ test: "tools/list stays in budget" });
104 expect(((await res.json()) as { queued: number[] }).queued).toEqual([]);
105 });
106
107 test("past the cap, the rest are left and the answer says how many", async () => {
108 const alice = getUserByName(app.db, "alice")!;
109 expect(RECHECK_MAX).toBe(50);
110 redOn(pulls[0]!, "capped");
111 redOn(pulls[1]!, "capped");
112 const done = await recheckNamed(app, alice, repo, "capped", 1);
113 expect(done).toEqual({ queued: [pulls[0]!.number], left: 1 });
114 const next = await recheckNamed(app, alice, repo, "capped", 1);
115 expect(next).toEqual({ queued: [pulls[1]!.number], left: 0 });
116 });
test/openapi.test.ts +1 −1
@@ -30,7 +30,7 @@ test("every http route the manifest names is described", () => {
3030 });
3131
3232 test("every described route is registered in the router", () => {
33 const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts", "src/http/passport_edge.ts", "src/http/feed.ts"]
33 const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts", "src/http/passport_edge.ts", "src/http/feed.ts", "src/http/checks_recheck.ts"]
3434 .map((f) => readFileSync(f, "utf8"))
3535 .join("\n")
3636 // A route registered in a loop over issues and pulls is two routes.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs