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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2290-mergepull-refuses-a-green-checked-agains → main 2 commits fork
2 files
changed +151
−5
src/core/pulls.ts +12 −5
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | type ListIssuesOptions, |
| 39 | 39 | } from "./issues.ts"; |
| 40 | 40 | import { reviewSummary } from "./reviews.ts"; |
| 41 | import { checkStale, checksHold } from "../checks/stale.ts"; | |
| 41 | 42 | import { checkSummary } from "./runs.ts"; |
| 42 | 43 | |
| 43 | 44 | export interface Pull extends Issue { |
| @@ -896,11 +897,17 @@ export async function mergePull( | ||
| 896 | 897 | return err("review_required"); |
| 897 | 898 | // The check is the latest deploy run of *this* head. Read here, not |
| 898 | 899 | // 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 | } | |
| 904 | 911 | const landed = await merge(dir, { |
| 905 | 912 | baseBranch: pull.baseBranch, |
| 906 | 913 | 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 | }); |
new above
offline