feat(gate): send baseSha to GateTest so enforcement scopes to the change #5622
3 changed files+138−5
Addedsrc/__tests__/gatetest-base-sha.test.ts+85−0View fileUnifiedSplit
@@ -0,0 +1,85 @@
1/**
2 * GateTest payloads carry the diff base so enforcement can be scoped.
3 *
4 * gatetest@3d917a95 (gate-verdict.js) fails a scan only when a BLOCKING
5 * finding sits in a file the push/PR actually touched — but only when the
6 * payload supplies a base SHA. Without one, GateTest enforces the whole
7 * repo, and a single pre-existing blocking finding anywhere in the tree
8 * fails every push. Before this coverage existed, Gluecron never sent a
9 * base at all, so every scan ran in the loud whole-repo mode.
10 *
11 * Contract pinned here (field name agreed with the GateTest side, which
12 * maps it into scan_queue.base_sha):
13 * - `baseSha` is included when a real ancestor SHA is known;
14 * - it is OMITTED (not null, not zeros) for branch creation
15 * (all-zero oldSha) and when no base could be resolved.
16 */
17
18import { afterEach, beforeEach, describe, expect, it } from "bun:test";
19import { notifyGateTestOfPush, runGateTestScan } from "../lib/gate";
20
21const origFetch = globalThis.fetch;
22const origUrl = process.env.GATETEST_URL;
23
24let lastBody: Record<string, unknown> | null = null;
25
26function captureFetch(): void {
27 // @ts-expect-error — override global fetch for the test
28 globalThis.fetch = async (_url: unknown, init?: RequestInit): Promise<Response> => {
29 lastBody = JSON.parse(String(init?.body ?? "{}"));
30 return new Response(JSON.stringify({ status: "passed", summary: "ok" }), {
31 status: 200,
32 headers: { "Content-Type": "application/json" },
33 });
34 };
35}
36
37beforeEach(() => {
38 process.env.GATETEST_URL = "https://gatetest.example/api/events/push";
39 lastBody = null;
40 captureFetch();
41});
42
43afterEach(() => {
44 globalThis.fetch = origFetch;
45 if (origUrl === undefined) delete process.env.GATETEST_URL;
46 else process.env.GATETEST_URL = origUrl;
47});
48
49const HEAD = "a".repeat(40);
50const BASE = "b".repeat(40);
51const ZEROS = "0".repeat(40);
52
53describe("notifyGateTestOfPush — baseSha in the push payload", () => {
54 it("sends baseSha when the pre-push tip is a real SHA", async () => {
55 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, BASE);
56 expect(lastBody?.baseSha).toBe(BASE);
57 expect(lastBody?.sha).toBe(HEAD);
58 });
59
60 it("omits baseSha entirely on branch creation (all-zero oldSha)", async () => {
61 await notifyGateTestOfPush("o", "r", "refs/heads/new-branch", HEAD, ZEROS);
62 expect(lastBody).not.toBeNull();
63 expect("baseSha" in (lastBody as object)).toBe(false);
64 });
65
66 it("omits baseSha when none was supplied", async () => {
67 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD);
68 expect(lastBody).not.toBeNull();
69 expect("baseSha" in (lastBody as object)).toBe(false);
70 });
71});
72
73describe("runGateTestScan — baseSha in the blocking payload", () => {
74 it("sends baseSha when a merge-base was resolved", async () => {
75 await runGateTestScan("o", "r", "refs/heads/feat", HEAD, BASE);
76 expect(lastBody?.baseSha).toBe(BASE);
77 expect(lastBody?.mode).toBe("blocking");
78 });
79
80 it("omits baseSha when resolution failed", async () => {
81 await runGateTestScan("o", "r", "refs/heads/feat", HEAD, undefined);
82 expect(lastBody).not.toBeNull();
83 expect("baseSha" in (lastBody as object)).toBe(false);
84 });
85});
Modifiedsrc/hooks/post-receive.ts+2−2View fileUnifiedSplit
@@ -331,8 +331,8 @@ export async function onPostReceive(
331331 // webhook at POST /api/hooks/gatetest.
332332 for (const ref of refs) {
333333 if (ref.newSha.startsWith("0000")) continue;
334 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
335 console.warn("[gatetest] notify error:", err)
334 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha, ref.oldSha).catch(
335 (err) => console.warn("[gatetest] notify error:", err)
336336 );
337337 }
338338
Modifiedsrc/lib/gate.ts+51−3View fileUnifiedSplit
@@ -170,7 +170,13 @@ export async function notifyGateTestOfPush(
170170 owner: string,
171171 repo: string,
172172 ref: string,
173 headSha: string
173 headSha: string,
174 // The pre-push tip of the ref (post-receive's oldSha). GateTest's verdict
175 // (gate-verdict.js, gatetest@3d917a95) scopes BLOCKING enforcement to files
176 // this push touched when a base is supplied; without it the whole repo is
177 // enforced and every pre-existing blocking finding fails the push. Omitted
178 // (or all-zeros = new branch) → GateTest reports "base unknown" honestly.
179 baseSha?: string
174180): Promise<void> {
175181 if (!process.env.GATETEST_URL) return;
176182 try {
@@ -196,6 +202,10 @@ export async function notifyGateTestOfPush(
196202 repository: `${owner}/${repo}`,
197203 ref,
198204 sha: headSha,
205 // Only send a real ancestor commit — an all-zero oldSha (branch
206 // creation) is not a base, and sending it would make GateTest diff
207 // against garbage instead of falling back to "base unknown".
208 ...(baseSha && !/^0+$/.test(baseSha) ? { baseSha } : {}),
199209 source: "gluecron",
200210 mode: "async",
201211 }),
@@ -217,12 +227,18 @@ export async function notifyGateTestOfPush(
217227
218228/**
219229 * Run GateTest scan on a repository at a specific ref.
230 *
231 * `baseSha` (optional) is the merge-base of the PR's base and head — it lets
232 * GateTest scope BLOCKING enforcement to the files this PR actually changes
233 * (gate-verdict.js). Without it GateTest enforces the whole repo, so a
234 * pre-existing blocking finding anywhere in the tree fails every merge.
220235 */
221236export async function runGateTestScan(
222237 owner: string,
223238 repo: string,
224239 ref: string,
225 headSha: string
240 headSha: string,
241 baseSha?: string
226242): Promise<GateCheckResult> {
227243 if (!config.gatetestUrl) {
228244 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
@@ -241,6 +257,7 @@ export async function runGateTestScan(
241257 repository: `${owner}/${repo}`,
242258 ref,
243259 sha: headSha,
260 ...(baseSha && !/^0+$/.test(baseSha) ? { baseSha } : {}),
244261 source: "gluecron",
245262 mode: "blocking",
246263 }),
@@ -862,10 +879,41 @@ export async function runAllGateChecks(
862879 }
863880 }
864881
882 // Merge-base of base and head — sent to GateTest so its BLOCKING
883 // enforcement covers only this PR's changes, not every pre-existing
884 // finding in the tree (base tip would be wrong here: a base that moved
885 // ahead would make base-side changes look like part of this PR). Any
886 // failure degrades to undefined → GateTest enforces whole-repo and says
887 // "base unknown", the same loud-but-honest state as before this existed.
888 let gateTestBaseSha: string | undefined;
889 if (runGateTest) {
890 try {
891 const { getRepoPath, isSafeRef } = await import("../git/repository");
892 if (isSafeRef(baseBranch) && /^[0-9a-f]{40}$/.test(headSha)) {
893 const proc = Bun.spawn(["git", "merge-base", baseBranch, headSha], {
894 cwd: getRepoPath(owner, repo),
895 stdout: "pipe",
896 stderr: "pipe",
897 });
898 const killer = setTimeout(() => proc.kill(), 10_000);
899 try {
900 const out = (await new Response(proc.stdout).text()).trim();
901 if ((await proc.exited) === 0 && /^[0-9a-f]{40}$/.test(out)) {
902 gateTestBaseSha = out;
903 }
904 } finally {
905 clearTimeout(killer);
906 }
907 }
908 } catch {
909 /* base stays unknown — GateTest degrades to whole-repo enforcement */
910 }
911 }
912
865913 const [gateTestResult, mergeResult, scanResults, ciResult] =
866914 await Promise.all([
867915 runGateTest
868 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
916 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha, gateTestBaseSha)
869917 : Promise.resolve<GateCheckResult>({
870918 name: "GateTest",
871919 passed: true,
872920
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts