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

When main goes red after a single pull landed, the forge opens a revert pull for it that names the failing test #6388

Merged opened this

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

Merged

scout/forge:scout/6377-when-main-goes-red-after-a-single-pull-l → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6377-when-main-goes-red-after-a-single-pull-l was merged into main as 8341488.

Review

Sign in to review this change.
4 files changed +234 −1
src/actions/revert.ts +2 −1
@@ -34,6 +34,7 @@ export async function revertPull(
3434 actor: User | undefined,
3535 repo: Repo,
3636 pull: Pull,
37 body: string = `Reverts #${pull.number}.`,
3738 ): Promise<Result<Pull, RevertPullError>> {
3839 const writer = requireWriter(app, actor, repo);
3940 if (!writer.ok) return writer;
@@ -77,7 +78,7 @@ export async function revertPull(
7778
7879 const opened = await openPull(app, writer.value, repo, {
7980 title: `Revert "${pull.title}"`,
80 body: `Reverts #${pull.number}.`,
81 body,
8182 baseBranch: repo.defaultBranch,
8283 headBranch: branchName,
8384 revertsIssueId: pull.id,
src/checks/revert_red.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The way back from a red main (#6377). When main's check goes red and the
3 * commits since its last green are exactly one landed pull, the forge
4 * proposes the revert of that pull: one pull, `revertsPullId` set, titled
5 * for the pull it takes back and naming the failing test. It is only ever
6 * proposed — a person lands it. A red that spans more than one landing (or a
7 * push no pull carried) is the alarm's, not this module's, and a landing
8 * already reverted, open or shipped or closed, is never proposed twice.
9 */
10
11 import type { App } from "../app.ts";
12 import { revertPull } from "../actions/revert.ts";
13 import { getIssueById } from "../core/issues.ts";
14 import { getPull, type Pull } from "../core/pulls.ts";
15 import { getRepoById } from "../core/repos.ts";
16 import { getUserById } from "../core/users.ts";
17 import { runGit } from "../git/env.ts";
18 import { repoDir } from "../git/repos.ts";
19 import { isSafeOid } from "../git/guard.ts";
20 import { manifestError, redMain, type RedMain } from "./red_main.ts";
21
22 /** The reds being answered now: a check finishing twice at once proposes once. */
23 const working = new Set<string>();
24
25 /** The id of the last passed default-branch check before run `below`, with its commit. */
26 function greenBefore(app: App, repoId: string, branch: string, below: string): string | undefined {
27 return app.db
28 .query<{ commit_oid: string }, [string, string, string]>(
29 `SELECT commit_oid FROM run
30 WHERE repo_id = ?1 AND kind = 'check' AND issue_id IS NULL AND env = ?2
31 AND state = 'ok' AND id < ?3
32 ORDER BY id DESC LIMIT 1`,
33 )
34 .get(repoId, branch, below)?.commit_oid;
35 }
36
37 /** Whether any pull, in any state, already takes `pullId` back. */
38 function everReverted(app: App, pullId: string): boolean {
39 return (
40 app.db
41 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM pull WHERE reverts_issue_id = ?1")
42 .get(pullId)!.n > 0
43 );
44 }
45
46 /**
47 * The one pull landed between the last green and `red`, else undefined:
48 * every first-parent commit in that range must be a pull's landing, and
49 * there must be exactly one.
50 */
51 async function soleLanding(app: App, red: RedMain, green: string): Promise<Pull | undefined> {
52 const repo = getRepoById(app.db, red.repoId);
53 if (repo === undefined || !isSafeOid(green) || !isSafeOid(red.commitOid)) return undefined;
54 const log = await runGit(["rev-list", "--first-parent", `${green}..${red.commitOid}`], {
55 cwd: repoDir(app.reposRoot, repo.ownerName, repo.name),
56 });
57 if (!log.ok) return undefined;
58 const commits = log.value.split("\n").filter(Boolean);
59 if (commits.length === 0) return undefined;
60 const landed = commits.map((oid) =>
61 app.db
62 .query<{ number: number }, [string, string, string]>(
63 `SELECT i.number FROM pull p JOIN issue i ON i.id = p.issue_id
64 WHERE i.repo_id = ?1 AND p.merged_oid = ?2 AND p.base_branch = ?3`,
65 )
66 .get(repo.id, oid, repo.defaultBranch),
67 );
68 if (landed.length !== 1 || landed[0] === null || landed[0] === undefined) return undefined;
69 return getPull(app.db, repo.id, landed[0].number);
70 }
71
72 /** What the revert pull says besides what it reverts: the red it answers. */
73 export function revertBody(pull: Pick<Pull, "number">, red: RedMain): string {
74 const what =
75 red.failing[0] !== undefined
76 ? `the failing test is "${red.failing[0]}"${red.failing.length > 1 ? ` (and ${red.failing.length - 1} more)` : ""}`

Skip when landed.revertsPullId !== null, so a landed revert is not itself reverted.

77 : red.summary.replace(/^check failed: /, "");
78 return `Reverts #${pull.number}, the one pull landed since main was last green.\n\nMain is red at ${red.commitOid.slice(0, 7)}: ${what}.\n\nProposed by the forge; a person lands it.`;
79 }
80
81 /**
82 * Called when a default-branch check of `repoId` has finished: opens the
83 * revert pull if the rules above hold. Returns it, or undefined when none
84 * was proposed.
85 */
86 export async function proposeRevert(app: App, repoId: string): Promise<Pull | undefined> {
87 if (working.has(repoId)) return undefined;
88 working.add(repoId);
89 try {
90 const red = redMain(app.db, repoId);
91 if (red === undefined || red.limit || manifestError(red) !== undefined) return undefined;
92 const green = greenBefore(app, repoId, red.branch, red.runId);
93 if (green === undefined) return undefined;
94 const landed = await soleLanding(app, red, green);
95 if (landed === undefined || everReverted(app, landed.id)) return undefined;
96 const repo = getRepoById(app.db, repoId);
97 const owner = repo === undefined ? undefined : getUserById(app.db, repo.ownerId);
98 if (repo === undefined || owner === undefined || getIssueById(app.db, landed.id) === undefined)

This guard re-reads an issue we just resolved through getPull. It is dead weight; remove it.

99 return undefined;
100 const made = await revertPull(app, owner, repo, landed, revertBody(landed, red));
101 return made.ok ? made.value : undefined;
102 } finally {
103 working.delete(repoId);
104 }
105 }
src/checks/service.ts +5
@@ -55,6 +55,7 @@ import { keepCache, restoreCache } from "./cache.ts";
5555 import { MAX_FILED, clearedFiles, fileOrderDependent } from "./order_dependent.ts";
5656 import { withDisk } from "./disk_red.ts";
5757 import { ASKED, redMain, unheld } from "./red_main.ts";
58 import { proposeRevert } from "./revert_red.ts";
5859 import { rebasesFirst } from "./finishing_first.ts";
5960 import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts";
6061 import { getIssueById } from "../core/issues.ts";
@@ -633,6 +634,10 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
633634 });
634635 write(outcome.ok ? `passed in ${seconds}s` : `failed: ${outcome.error}`);
635636 if (done) announceRun(app, done);
637 if (!outcome.ok && run.issueId === null && run.env === repo.defaultBranch)
638 proposeRevert(app, repo.id).catch((error) =>
639 log.error("revert proposal failed", { error: String(error) }),
640 );
636641 } finally {
637642 release();
638643 }
test/revert-red.test.ts added +122
@@ -0,0 +1,122 @@
1 /**
2 * A red main after one landed pull gets one revert pull naming the failing
3 * test (#6377); after two landings, or a second tick, none.
4 */
5
6 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { mergePull, openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { proposeRevert } from "../src/checks/revert_red.ts";
14 import { getPull, listPulls, type Pull } from "../src/core/pulls.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { createRun } from "../src/core/runs.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { resolveOid } from "../src/git/merge.ts";
19 import { repoDir } from "../src/git/repos.ts";
20
21 describe("a red main after one landing proposes its revert", () => {
22 let dataDir: string;
23 let app: App;
24 let alice: User;
25 let repo: Repo;
26
27 const land = async (branch: string, content: string): Promise<Pull> => {
28 await commitToBranch(app, alice, repo, {
29 branch,
30 base: "main",
31 message: branch,
32 files: [{ path: "code.txt", content }],
33 });
34 const opened = await openPull(app, alice, repo, {
35 title: `Change ${branch}`,
36 baseBranch: "main",
37 headBranch: branch,
38 });
39 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
40 const merged = await mergePull(app, alice, repo, opened.value, "squash");
41 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
42 return getPull(app.db, repo.id, opened.value.number)!;
43 };
44
45 const mainCheck = async (state: "ok" | "failed", names: string[] = []): Promise<void> => {
46 const oid = await resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), "refs/heads/main");
47 const run = createRun(app.db, { repoId: repo.id, kind: "check", env: "main", commitOid: oid ?? "" });
48 app.db
49 .query("UPDATE run SET state = ?2, summary = ?3, check_detail = ?4, finished_at = ?5 WHERE id = ?1")
50 .run(
51 run.id,
52 state,
53 state === "ok" ? "check passed in 1s" : "check failed: 1 test failed",
54 state === "ok" ? "" : JSON.stringify({ names }),
55 new Date().toISOString(),
56 );
57 };
58
59 const reverts = (): Pull[] =>
60 listPulls(app.db, repo.id, { state: "all" }).filter((p) => p.revertsPullId !== null);
61
62 beforeEach(async () => {
63 dataDir = await mkdtemp(join(tmpdir(), "forge-revert-red-"));
64 app = createApp({ dataDir, port: 0 });
65 const created = await createUser(app.db, {
66 origin: "seed",
67 trusted: true,
68 username: "alice",
69 email: "[email protected]",
70 password: "a-good-passphrase",
71 adminIfFirst: true,
72 });
73 if (!created.ok) throw new Error(created.error);
74 alice = created.value;
75 const made = await createRepo(app, alice, { name: "app", visibility: "public" });
76 if (!made.ok) throw new Error(String(made.error));
77 repo = made.value;
78 await commitToBranch(app, alice, repo, {
79 branch: "main",
80 message: "base",
81 files: [{ path: "code.txt", content: "alpha\n" }],
82 });
83 });
84
85 afterEach(async () => {
86 app.close();
87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("one landing then red opens one revert; a second tick opens none", async () => {
91 await mainCheck("ok");
92 const landed = await land("one", "beta\n");
93 await mainCheck("failed", ["parses the thing"]);
94
95 const first = await proposeRevert(app, repo.id);
96 expect(first?.revertsPullId).toBe(landed.id);
97 expect(first?.title).toContain(landed.title);
98 expect(first?.body).toContain("parses the thing");
99 expect(first?.mergedAt).toBeNull();
100 expect(reverts()).toHaveLength(1);
101
102 expect(await proposeRevert(app, repo.id)).toBeUndefined();
103 expect(reverts()).toHaveLength(1);
104 });
105
106 test("two landings since the green open none", async () => {
107 await mainCheck("ok");
108 await land("one", "beta\n");
109 await land("two", "gamma\n");
110 await mainCheck("failed", ["parses the thing"]);
111
112 expect(await proposeRevert(app, repo.id)).toBeUndefined();
113 expect(reverts()).toHaveLength(0);
114 });
115
116 test("a green main opens none", async () => {
117 await mainCheck("ok");
118 await land("one", "beta\n");
119 await mainCheck("ok");
120 expect(await proposeRevert(app, repo.id)).toBeUndefined();
121 });
122 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs