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

mergePull refuses a green checked against an older base when requireChecks is set (after #2028) #2315

Merged opened this

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

Merged

scout/forge:scout/2290-mergepull-refuses-a-green-checked-agains → main 2 commits fork

  • Merged. scout/2290-mergepull-refuses-a-green-checked-agains was merged into main as a209906.

Review

Sign in to review this change.
2 files changed +151 −5
src/core/pulls.ts +12 −5
@@ -38,6 +38,7 @@ import {
3838 type ListIssuesOptions,
3939 } from "./issues.ts";
4040 import { reviewSummary } from "./reviews.ts";
41 import { checkStale, checksHold } from "../checks/stale.ts";
4142 import { checkSummary } from "./runs.ts";
4243
4344 export interface Pull extends Issue {
@@ -896,11 +897,17 @@ export async function mergePull(
896897 return err("review_required");
897898 // The check is the latest deploy run of *this* head. Read here, not
898899 // handed in, for the same reason as the review verdict above.
899 if (
900 input.gate?.requireChecks === true &&
901 checkSummary(input.gate.db, pull.id, status.headOid).state !== "ok"
902 )
903 return err("checks_required");
900 if (input.gate !== undefined) {
901 const check = checkSummary(input.gate.db, pull.id, status.headOid);
902 if (
903 checksHold(
904 input.gate.requireChecks === true,
905 check.state,
906 checkStale(check.run, status.baseOid),
907 )
908 )
909 return err("checks_required");
910 }
904911 const landed = await merge(dir, {
905912 baseBranch: pull.baseBranch,
906913 baseOid: status.baseOid,
test/merge-stale-green.test.ts added +139
@@ -0,0 +1,139 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { gitClientEnv } from "./helpers/git-client.ts";
8 import { createRun, setRunBase } from "../src/core/runs.ts";
9
10 const gitEnv = gitClientEnv();
11
12 let dataDir: string;
13 let work: string;
14 let app: App;
15 let servers: Servers;
16 let base: string;
17 let dir: string;
18 const auth = { authorization: "" };
19
20 async function gitOk(args: string[]): Promise<string> {
21 const p = Bun.spawn(["git", ...args], {
22 cwd: dir,
23 env: gitEnv,
24 stdout: "pipe",
25 stderr: "pipe",
26 });
27 const [out, err, code] = await Promise.all([
28 new Response(p.stdout).text(),
29 new Response(p.stderr).text(),
30 p.exited,
31 ]);
32 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
33 return out.trim();
34 }
35 const call = (path: string, init: RequestInit = {}) =>
36 fetch(`${base}/api/v1/repos/dev/app${path}`, {
37 ...init,
38 headers: { ...auth, "content-type": "application/json" },
39 });
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-msg-"));
43 work = await mkdtemp(join(tmpdir(), "forge-msg-work-"));
44 app = createApp({ dataDir, port: 0 });
45 servers = await serve(app);
46 base = servers.url;
47 await fetch(`${base}/signup`, {
48 method: "POST",
49 body: new URLSearchParams({
50 username: "dev",
51 email: "[email protected]",
52 password: "merge-stale-pass",
53 }),
54 redirect: "manual",
55 });
56 const token = await fetch(`${base}/api/v1/tokens`, {
57 method: "POST",
58 headers: { "content-type": "application/json" },
59 body: JSON.stringify({
60 adminConfirm: true,
61 username: "dev",
62 password: "merge-stale-pass",
63 name: "cli",
64 scope: "rw",
65 }),
66 })
67 .then((r) => r.json())
68 .then((j) => (j as { token: string }).token);
69 auth.authorization = `Bearer ${token}`;
70 await fetch(`${base}/api/v1/repos`, {
71 method: "POST",
72 headers: { ...auth, "content-type": "application/json" },
73 body: JSON.stringify({ name: "app", visibility: "public" }),
74 });
75 dir = work;
76 await gitOk([
77 "clone",
78 `${base.replace("http://", `http://dev:${token}@`)}/dev/app.git`,
79 "app",
80 ]);
81 dir = join(work, "app");
82 await Bun.write(join(dir, "a.txt"), "a\n");
83 await gitOk(["add", "."]);
84 await gitOk(["commit", "-m", "base"]);
85 await gitOk(["push", "origin", "main"]);
86 });
87
88 afterAll(async () => {
89 servers.stop();
90 app.close();
91 await rm(dataDir, { recursive: true, force: true });
92 await rm(work, { recursive: true, force: true });
93 });
94
95 test("the API merge refuses a green checked against an older base when checks are required", async () => {
96 await gitOk(["checkout", "-b", "feat"]);
97 await Bun.write(join(dir, "b.txt"), "b\n");
98 await gitOk(["add", "."]);
99 await gitOk(["commit", "-m", "feat"]);
100 await gitOk(["push", "origin", "feat"]);
101 const made = await call("/pulls", {
102 method: "POST",
103 body: JSON.stringify({ base: "main", head: "feat", title: "Feat" }),
104 });
105 expect(made.status).toBe(201);
106 app.db.query("UPDATE repo SET require_checks = 1").run();
107
108 const oldBase = await gitOk(["rev-parse", "main"]);
109 const row = app.db
110 .query<{ issue_id: string; repo_id: string; head_oid: string }, []>(
111 "SELECT pull.issue_id, issue.repo_id, pull.head_oid FROM pull JOIN issue ON issue.id = pull.issue_id LIMIT 1",
112 )
113 .get()!;
114 const run = createRun(app.db, {
115 repoId: row.repo_id,
116 kind: "check",
117 issueId: row.issue_id,
118 env: "pull-1",
119 commitOid: row.head_oid,
120 });
121 app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(run.id);
122 setRunBase(app.db, run.id, oldBase);
123
124 // The base moves on after the green.
125 await gitOk(["checkout", "main"]);
126 await Bun.write(join(dir, "c.txt"), "c\n");
127 await gitOk(["add", "."]);
128 await gitOk(["commit", "-m", "main moves"]);
129 await gitOk(["push", "origin", "main"]);
130
131 const refused = await call("/pulls/1/merge", { method: "POST", body: "{}" });
132 expect(refused.status).toBe(409);
133 expect(((await refused.json()) as any).error).toBe("checks_required");
134
135 // A green against the current tip lands.
136 setRunBase(app.db, run.id, await gitOk(["rev-parse", "main"]));
137 const landed = await call("/pulls/1/merge", { method: "POST", body: "{}" });
138 expect(landed.status).toBe(200);
139 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs