Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/gate/changed-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as github from "@actions/github";
import type { ChangedFile } from "./test-presence.ts";

/**
* Fetch the PR's changed files from the GitHub API. Returns `null` when the run
* isn't a pull request (e.g. a push to the comparison branch) — the gate is a
* PR-diff heuristic, so with no PR there is nothing to evaluate.
*
* We use the API rather than `git diff` because a default `actions/checkout` is
* shallow: the base ref often isn't fetched, so a local diff would be unreliable.
* The `GITHUB_TOKEN` the action already receives has the `pull_requests: read`
* scope this needs.
*/
export async function fetchPrChangedFiles(
token: string,
): Promise<ChangedFile[] | null> {
const prNumber = github.context.payload.pull_request?.number;
if (typeof prNumber === "undefined") {
return null;
}
const octokit = github.getOctokit(token);
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
return files.map((f) => ({
path: f.filename,
status: f.status,
patch: f.patch,
}));
}
156 changes: 156 additions & 0 deletions src/gate/test-presence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, test } from "vitest";
import {
classifyChangedFiles,
evaluateTestPresence,
patchAddsQueryCode,
type ChangedFile,
} from "./test-presence.ts";

/** A minimal patch whose single added line is `line`. */
function addPatch(line: string): string {
return `@@ -1,0 +1,1 @@\n+${line}`;
}

const changed = (
path: string,
line: string,
status = "modified",
): ChangedFile => ({ path, status, patch: addPatch(line) });

describe("patchAddsQueryCode", () => {
test("detects an ORM query call in added lines", () => {
expect(patchAddsQueryCode(addPatch("const u = await db.select().from(users);"))).toBe(
true,
);
});

test("detects a raw SQL string", () => {
expect(patchAddsQueryCode(addPatch('await client.query("INSERT INTO users VALUES (1)");'))).toBe(
true,
);
});

test("ignores a comment that merely mentions SQL keywords", () => {
expect(patchAddsQueryCode(addPatch("// TODO: select the user from the list"))).toBe(
false,
);
});

test("ignores non-query code", () => {
expect(patchAddsQueryCode(addPatch("const total = items.length + 1;"))).toBe(false);
});

test("only looks at added lines, not removed ones", () => {
const patch = "@@ -1,1 +1,1 @@\n-await db.select().from(users);\n+const x = 1;";
expect(patchAddsQueryCode(patch)).toBe(false);
});

test("is false for a missing patch", () => {
expect(patchAddsQueryCode(undefined)).toBe(false);
});
});

describe("classifyChangedFiles", () => {
test("classes a query-bearing non-test file as data-access, wherever it lives", () => {
// Not named like a repository — content is what counts now.
const surface = classifyChangedFiles([
changed("apps/api/src/users/user.service.ts", "return db.select().from(users);"),
]);
expect(surface.dataAccessChanged).toEqual(["apps/api/src/users/user.service.ts"]);
});

test("does NOT flag a comment-only edit to a repository file", () => {
const surface = classifyChangedFiles([
changed("apps/api/src/users/user.repository.ts", "// clarify the join order"),
]);
expect(surface.dataAccessChanged).toEqual([]);
});

test("classes a query-bearing test as a data-layer test", () => {
const surface = classifyChangedFiles([
changed(
"apps/api/src/users/user.repository.spec.ts",
"expect(await db.select().from(users)).toHaveLength(1);",
),
]);
expect(surface.dataLayerTestChanged).toEqual([
"apps/api/src/users/user.repository.spec.ts",
]);
expect(surface.dataAccessChanged).toEqual([]);
});

test("falls back to the filename prior when a patch is unavailable", () => {
const surface = classifyChangedFiles([
{ path: "apps/api/src/users/user.repository.ts", status: "modified" },
]);
expect(surface.dataAccessChanged).toEqual(["apps/api/src/users/user.repository.ts"]);
});

test("does not count a removed file as a change", () => {
const surface = classifyChangedFiles([
changed("apps/api/src/users/user.repository.ts", "db.select().from(users)", "removed"),
]);
expect(surface.dataAccessChanged).toEqual([]);
});
});

describe("evaluateTestPresence", () => {
test("flags a query change with no related test", () => {
const verdict = evaluateTestPresence([
changed("apps/api/src/users/user.repository.ts", "db.insert(users).values(u);"),
]);
expect(verdict).toMatchObject({
condition: "untested-data-access",
verdictClass: "uncertain-conservative-flag",
dataAccessFiles: ["apps/api/src/users/user.repository.ts"],
});
});

test("passes when a related data-layer test changes alongside the query", () => {
const verdict = evaluateTestPresence([
changed("apps/api/src/users/user.repository.ts", "db.insert(users).values(u);"),
changed(
"apps/api/src/users/user.repository.spec.ts",
"expect(await db.select().from(users)).toEqual([u]);",
),
]);
expect(verdict).toBeNull();
});

test("still flags when only an UNRELATED data-layer test changed", () => {
const verdict = evaluateTestPresence([
changed("apps/api/src/orders/order.repository.ts", "db.insert(orders).values(o);"),
changed(
"apps/api/src/users/user.repository.spec.ts",
"expect(await db.select().from(users)).toEqual([u]);",
),
]);
expect(verdict?.dataAccessFiles).toEqual([
"apps/api/src/orders/order.repository.ts",
]);
});

test("passes when the PR changes no query code", () => {
const verdict = evaluateTestPresence([
changed("apps/app/src/components/Button.tsx", "const label = props.label;"),
changed("docs/guide.md", "Some prose about SELECT and FROM."),
]);
expect(verdict).toBeNull();
});

test("does not fire on a comment-only edit to a repository file", () => {
const verdict = evaluateTestPresence([
changed("apps/api/src/users/user.repository.ts", "// rename for clarity"),
]);
expect(verdict).toBeNull();
});

test("frames the finding as unverified, not a bad query", () => {
const verdict = evaluateTestPresence([
changed("src/dal/orders.ts", "db.update(orders).set({ shipped: true });"),
]);
expect(verdict?.reason).toContain("could not verify");
expect(verdict?.reason).toContain("flagged conservatively");
expect(verdict?.nextStep).toContain("test");
});
});
Loading
Loading