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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

140 changes: 140 additions & 0 deletions lib/daemon/__tests__/ignored-mrs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test";
import type { PullRequest } from "@mattstack/glance";
import {
createExcludedTargetsCache,
expandTargetBranches,
gitlabBranchSearch,
isIgnoredMr,
readIgnoredMrs,
type IgnoredMrRules,
} from "../ignored-mrs.ts";

const WIRE = "remote:gitlab.com%2Fg%2Fp";
const RULES: IgnoredMrRules = { targetBranches: ["deployments/*", "release/**"], authors: ["deploy-bot"] };

function mr(targetBranch: string, author = "ada"): PullRequest {
return { iid: 1, targetBranch, author: { username: author } } as unknown as PullRequest;
}

describe("isIgnoredMr", () => {
test("a target branch matching a glob is ignored", () => {
expect(isIgnoredMr(mr("deployments/qa"), RULES)).toBe(true);
expect(isIgnoredMr(mr("release/base/2026.34"), RULES)).toBe(true);
});

test("a single-segment glob does not reach into nested branches or the bare prefix", () => {
expect(isIgnoredMr(mr("deployments/qa/extra"), RULES)).toBe(false);
expect(isIgnoredMr(mr("deployments"), RULES)).toBe(false);
});

test("a listed author is ignored whatever the target", () => {
expect(isIgnoredMr(mr("main", "deploy-bot"), RULES)).toBe(true);
});

test("anything else, and any MR under empty rules, is kept", () => {
expect(isIgnoredMr(mr("main"), RULES)).toBe(false);
expect(isIgnoredMr(mr("deployments/qa"), { targetBranches: [], authors: [] })).toBe(false);
});
});

describe("readIgnoredMrs", () => {
test("reads the repo's rules with its raw host/path identity", () => {
const seen: Array<string | null | undefined> = [];
const rules = readIgnoredMrs(WIRE, (_key, opts) => {
seen.push(opts?.repoIdentity);
return { value: { targetBranches: ["deployments/*"], authors: ["deploy-bot"] } };
});
expect(seen).toEqual(["gitlab.com/g/p"]);
expect(rules).toEqual({ targetBranches: ["deployments/*"], authors: ["deploy-bot"] });
});

test("drops non-string entries and fills a missing field with nothing", () => {
const rules = readIgnoredMrs(WIRE, () => ({ value: { targetBranches: ["deployments/*", 7, ""] } }));
expect(rules).toEqual({ targetBranches: ["deployments/*"], authors: [] });
});

test("unset, malformed, unreadable, or a path-kind repo all ignore nothing", () => {
const none = { targetBranches: [], authors: [] };
expect(readIgnoredMrs(WIRE, () => ({ value: undefined }))).toEqual(none);
expect(readIgnoredMrs(WIRE, () => ({ value: ["deployments/*"] }))).toEqual(none);
expect(readIgnoredMrs(WIRE, () => { throw new Error("bad store"); })).toEqual(none);
let read = false;
expect(readIgnoredMrs("path:%2Ftmp%2Frepo", () => { read = true; return { value: {} }; })).toEqual(none);
expect(read).toBe(false);
});
});

describe("expandTargetBranches", () => {
test("searches each glob's literal prefix and keeps only the names the glob matches", async () => {
const searched: string[] = [];
const branches: Record<string, string[]> = {
"deployments/": ["deployments/qa", "deployments/prod", "deployments/qa/old"],
"release/": ["release/base/2026.34", "release/base/2026.35"],
};
const names = await expandTargetBranches(["deployments/*", "release/**"], async (prefix) => {
searched.push(prefix);
return branches[prefix] ?? [];
});
expect(searched).toEqual(["deployments/", "release/"]);
expect(names).toEqual(["deployments/prod", "deployments/qa", "release/base/2026.34", "release/base/2026.35"]);
});

test("a name with no wildcard is used as-is without a search", async () => {
let searches = 0;
expect(await expandTargetBranches(["main", "main"], async () => { searches++; return []; })).toEqual(["main"]);
expect(searches).toBe(0);
});
});

describe("gitlabBranchSearch", () => {
test("pages a starts-with branch search until a short page", async () => {
const paths: string[] = [];
const page = (n: number, count: number) => Array.from({ length: count }, (_, i) => ({ name: `deployments/e${n}-${i}` }));
const search = gitlabBranchSearch({
restRequest: async (_method: string, path: string) => {
paths.push(path);
const n = Number(new URL(path, "https://x").searchParams.get("page"));
return new Response(JSON.stringify(n === 1 ? page(1, 100) : page(2, 3)));
},
} as never, "g/sub/p");
const names = await search("deployments/");
expect(names).toHaveLength(103);
expect(paths).toEqual([
"/projects/g%2Fsub%2Fp/repository/branches?search=%5Edeployments%2F&per_page=100&page=1",
"/projects/g%2Fsub%2Fp/repository/branches?search=%5Edeployments%2F&per_page=100&page=2",
]);
});

test("a failed page throws instead of reading as no branches", async () => {
const search = gitlabBranchSearch({ restRequest: async () => new Response("nope", { status: 503 }) } as never, "g/p");
await expect(search("deployments/")).rejects.toThrow("503");
});
});

describe("createExcludedTargetsCache", () => {
test("reuses an expansion until it ages out or the globs change", async () => {
let now = 0;
let calls = 0;
const cache = createExcludedTargetsCache({ ttlMs: 1000, now: () => now });
const search = async () => { calls++; return ["deployments/qa"]; };
await cache.get("repo", ["deployments/*"], search);
await cache.get("repo", ["deployments/*"], search);
expect(calls).toBe(1);
now = 1001;
await cache.get("repo", ["deployments/*"], search);
expect(calls).toBe(2);
expect(await cache.get("repo", ["deployments/q*"], search)).toEqual(["deployments/qa"]);
expect(calls).toBe(3);
});

test("a failed search excludes nothing this cycle and is retried next time", async () => {
const cache = createExcludedTargetsCache({ ttlMs: 1000, now: () => 0 });
expect(await cache.get("repo", ["deployments/*"], async () => { throw new Error("503"); })).toEqual([]);
expect(await cache.get("repo", ["deployments/*"], async () => ["deployments/qa"])).toEqual(["deployments/qa"]);
});

test("no globs means no search", async () => {
const cache = createExcludedTargetsCache({ ttlMs: 1000, now: () => 0 });
expect(await cache.get("repo", [], async () => { throw new Error("must not search"); })).toEqual([]);
});
});
49 changes: 49 additions & 0 deletions lib/daemon/__tests__/project-mrs-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,3 +656,52 @@ describe("rekeyProjectMrsTable / rekeyProjectMrsMetaTable / rekeyProjectMrDemand
expect(warnSpy).toHaveBeenCalled();
});
});

describe("ignored MRs (ignoreFor)", () => {
const deploy = (iid: number) => pr(iid, { targetBranch: "deployments/qa" });
let rulesOn = true;
const ignoringDeploys = () => {
const db = tmpDb();
const asked: string[] = [];
const store = createProjectMRs(db, {
ignoreFor: (repo) => {
asked.push(repo);
return (p) => rulesOn && p.targetBranch.startsWith("deployments/");
},
});
return { store, db, asked };
};
beforeEach(() => { rulesOn = true; });

test("fullSync never stores an ignored MR", () => {
const { store } = ignoringDeploys();
const changed = store.fullSync("repo", "g/p", [pr(1), deploy(2)], Date.now());
expect(Object.keys(store.read("repo")!.mrs)).toEqual(["1"]);
expect(changed).toEqual([1]);
});

test("applyDelta skips ignored MRs and removes any already stored, reporting them as changed", () => {
const { store, db } = ignoringDeploys();
rulesOn = false;
store.fullSync("repo", "g/p", [pr(1), deploy(2)], Date.now() - 1000);
rulesOn = true;
const changed = store.applyDelta("repo", "g/p", [deploy(3), pr(4)], Date.now() - 500);
expect(Object.keys(store.read("repo")!.mrs).sort()).toEqual(["1", "4"]);
expect(changed.sort()).toEqual([2, 4]);
const rows = db.query("SELECT iid FROM project_mrs WHERE repo = 'repo' ORDER BY iid").all() as Array<{ iid: number }>;
expect(rows.map((r) => r.iid)).toEqual([1, 4]);
});

test("upsert writes nothing for an ignored MR", () => {
const { store } = ignoringDeploys();
store.fullSync("repo", "g/p", [pr(1)], Date.now());
expect(store.upsert("repo", "g/p", deploy(5), "events")).toEqual([]);
expect(store.read("repo")!.mrs[5]).toBeUndefined();
});

test("rules are read once per write, per repo", () => {
const { store, asked } = ignoringDeploys();
store.applyDelta("repo", "g/p", [pr(1), pr(2), deploy(3)], Date.now());
expect(asked).toEqual(["repo"]);
});
});
64 changes: 59 additions & 5 deletions lib/daemon/__tests__/project-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { syncProjectMRs, fetchDeltaFrom, backfillAuthors, backfillSections, effe
import { createProjectMRs } from "../project-mrs-store.ts";
import { openStateDb } from "../../state/index.ts";
import { readSyncHealth, recordSyncFailure } from "../project-sync-health.ts";
import type { FetchMergeRequestIndexOptions, FetchPullRequestsOptions, MergeRequestIndexRow, PullRequest } from "@mattstack/glance";
import type { FetchApprovalRulesOptions, FetchMergeRequestIndexOptions, FetchPullRequestsOptions, MergeRequestIndexRow, PullRequest } from "@mattstack/glance";

function pr(iid: number, over: Partial<PullRequest> = {}): PullRequest {
return {
Expand Down Expand Up @@ -1581,14 +1581,19 @@ describe("fetchDeltaFrom (terminal MRs via the index)", () => {
const pulls: FetchPullRequestsOptions[] = [];
const indexCalls: FetchMergeRequestIndexOptions[] = [];
const singleCalls: number[] = [];
const ruleCalls: FetchApprovalRulesOptions[] = [];
return {
pulls,
indexCalls,
singleCalls,
ruleCalls,
provider: {
fetchPullRequests: async (o?: FetchPullRequestsOptions) => { pulls.push(o!); return open; },
fetchMergeRequestIndex: async (o: FetchMergeRequestIndexOptions) => { indexCalls.push(o); return index; },
fetchSingleMR: async (_pp: string, iid: number) => { singleCalls.push(iid); return singles[iid] ?? null; },
fetchApprovalRules: async (o: FetchApprovalRulesOptions) => { ruleCalls.push(o); return []; },
fetchCodeownerSections: async () => null,
restRequest: async () => new Response("[]"),
},
};
}
Expand All @@ -1602,8 +1607,15 @@ describe("fetchDeltaFrom (terminal MRs via the index)", () => {
test("asks for list-weight fields on opened MRs only, and the index for merged/closed", async () => {
const fake = fakeProvider([], []);
await fetchDeltaFrom(fake.provider, "g/p", UA, undefined);
expect(fake.pulls).toEqual([{ projectPath: "g/p", state: "opened", updatedAfter: UA, listWeight: true }]);
expect(fake.indexCalls).toEqual([{ projectPaths: ["g/p"], updatedAfter: UA, states: ["merged", "closed"] }]);
expect(fake.pulls).toEqual([{ projectPath: "g/p", state: "opened", updatedAfter: UA, listWeight: true, excludeTargetBranches: [] }]);
expect(fake.indexCalls).toEqual([{ projectPaths: ["g/p"], updatedAfter: UA, states: ["merged", "closed"], excludeTargetBranches: [] }]);
});

test("passes the excluded target branches to both reads", async () => {
const fake = fakeProvider([], []);
await fetchDeltaFrom(fake.provider, "g/p", UA, undefined, ["deployments/qa"]);
expect(fake.pulls[0]!.excludeTargetBranches).toEqual(["deployments/qa"]);
expect(fake.indexCalls[0]!.excludeTargetBranches).toEqual(["deployments/qa"]);
});

test("moves a stored open MR to its terminal state and keeps its stored fields", async () => {
Expand Down Expand Up @@ -1688,7 +1700,7 @@ describe("fetchDeltaFrom (terminal MRs via the index)", () => {
const fake = fakeProvider([], [row(3)]);
await syncProjectMRs({ repoIndex: () => ({ repo: "/tmp/repo" }), broadcast: () => {} }, "repo", {
store,
deltaContext: async () => ({ provider: fake.provider, projectPath: "g/p" }),
repoContext: async () => ({ provider: fake.provider, projectPath: "g/p" }),
});
expect(fake.pulls.map((o) => o.state)).toEqual(["opened"]);
expect(store.read("repo")!.mrs[3]!.pr.state).toBe("merged");
Expand All @@ -1701,10 +1713,52 @@ describe("fetchDeltaFrom (terminal MRs via the index)", () => {
const singles: number[] = [];
await syncProjectMRs({ repoIndex: () => ({ repo: "/tmp/repo" }), broadcast: () => {} }, "repo", {
store,
deltaContext: async () => ({ provider: fake.provider, projectPath: "g/p" }),
repoContext: async () => ({ provider: fake.provider, projectPath: "g/p" }),
fetchSingle: async (_r, _pp, iid) => { singles.push(iid); return pr(iid, { state: "merged", pipeline: { status: "success" } } as Partial<PullRequest>); },
});
expect(singles).toEqual([4]);
expect(store.read("repo")!.mrs[4]!.pr).toMatchObject({ state: "merged", pipeline: { status: "success" } });
});

describe("every default read carries the repo's excluded target branches", () => {
const EXCLUDED = ["deployments/qa"];
const deps = { repoIndex: () => ({ repo: "/tmp/repo" }), broadcast: () => {} };
const seams = (fake: ReturnType<typeof fakeProvider>) => ({
repoContext: async () => ({ provider: fake.provider, projectPath: "g/p" }),
excludedTargets: async () => EXCLUDED,
});

test("delta", async () => {
const store = tmpStore();
store.fullSync("repo", "g/p", [pr(1)], Date.now() - 1000);
const fake = fakeProvider([], []);
await syncProjectMRs(deps, "repo", { store, ...seams(fake) });
expect(fake.pulls.map((o) => o.excludeTargetBranches)).toEqual([EXCLUDED]);
expect(fake.indexCalls.map((o) => o.excludeTargetBranches)).toEqual([EXCLUDED]);
});

test("unscoped deep", async () => {
const fake = fakeProvider([], []);
await syncProjectMRs(deps, "repo", { store: tmpStore(), ...seams(fake) });
expect(fake.pulls).toEqual([{ projectPath: "g/p", state: "opened", listWeight: true, excludeTargetBranches: EXCLUDED }]);
});

test("scoped deep", async () => {
const fake = fakeProvider([], []);
await syncProjectMRs(deps, "repo", { store: tmpStore(), selfUsername: "ada", windowDays: 30, ...seams(fake) });
expect(fake.pulls).toEqual([{ projectPath: "g/p", authorUsernames: ["ada"], state: "opened", excludeTargetBranches: EXCLUDED }]);
});

test("backfillAuthors", async () => {
const fake = fakeProvider([], []);
await backfillAuthors(deps, "repo", ["bo"], { store: tmpStore(), selfUsername: null, windowDays: 30, ...seams(fake) });
expect(fake.pulls).toEqual([{ projectPath: "g/p", authorUsernames: ["bo"], state: "opened", excludeTargetBranches: EXCLUDED }]);
});

test("backfillSections", async () => {
const fake = fakeProvider([], []);
await backfillSections(deps, "repo", ["Pod"], { store: tmpStore(), windowDays: 30, ...seams(fake) });
expect(fake.ruleCalls.map((o) => o.excludeTargetBranches)).toEqual([EXCLUDED]);
});
});
});
Loading
Loading