1import * as Schema from "effect/Schema";
2import * as Struct from "effect/Struct";
3import { describe, expect, it } from "vite-plus/test";
4import {
5 MergebotPullRequestDetails,
6 isMergebotRepository,
7 mergebotPullRequestStatus,
8 mergebotPullRequestUrl,
9 mergebotPullRequestBrowserThreadId,
10 parseMergebotPullRequestUrl,
11 type MergebotRepoQueue,
12} from "./mergebot.ts";
13
14const decodeDetails = Schema.decodeSync(MergebotPullRequestDetails);
15
16const entry = {
17 number: 123,
18 headSha: "current-head",
19 lifecycle: "open",
20 queueStatus: "waiting_ci",
21 position: 1,
22 mergeEtaAt: null,
23 url: "https://mergebot.example/work/app/pull/123",
24} as const;
25const queue: MergebotRepoQueue = {
26 repo: "work/app",
27 status: "ready",
28 url: "https://mergebot.example/work/app",
29 processing: true,
30 pullRequests: [entry],
31};
32
33describe("mergebotPullRequestStatus", () => {
34 it("uses Mergebot's verified head unless a known T3 head disagrees", () => {
35 expect(mergebotPullRequestStatus(queue, 123)).toEqual(entry);
36 expect(mergebotPullRequestStatus(queue, 123, "current-head")).toEqual(entry);
37 expect(mergebotPullRequestStatus(queue, 123, "new-head")).toBeNull();
38 });
39
40 it("keeps absent PRs, unknown queue heads and loading snapshots unknown", () => {
41 expect(mergebotPullRequestStatus(queue, 456)).toBeNull();
42 expect(mergebotPullRequestStatus(null, 123)).toBeNull();
43 expect(
44 mergebotPullRequestStatus({ ...queue, status: "loading", pullRequests: null }, 123),
45 ).toBeNull();
46 expect(
47 mergebotPullRequestStatus({ ...queue, pullRequests: [{ ...entry, queueStatus: null }] }, 123),
48 ).toBeNull();
49 });
50});
51
52const configuration = {
53 origin: "https://mergebot.example",
54 repositories: ["work/app", "work/research.sh", "work/research"],
55};
56
57it("gives each standalone PR its own browser owner with case-insensitive repository identity", () => {
58 const owner = mergebotPullRequestBrowserThreadId({ repository: "Work/App", number: 12 });
59 expect(owner).toBe(mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 12 }));
60 expect(owner).not.toBe(
61 mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 13 }),
62 );
63 expect(owner).not.toBe(
64 mergebotPullRequestBrowserThreadId({ repository: "work/other", number: 12 }),
65 );
66});
67
68describe("isMergebotRepository", () => {
69 it("scopes native badges to configured work repositories", () => {
70 expect(isMergebotRepository(undefined, "work/app")).toBe(false);
71 expect(isMergebotRepository(configuration, "WORK/APP")).toBe(true);
72 expect(isMergebotRepository(configuration, "work/app")).toBe(true);
73 expect(isMergebotRepository(configuration, "work/research.sh")).toBe(true);
74 expect(isMergebotRepository(configuration, "work/research")).toBe(true);
75 expect(isMergebotRepository(configuration, "work/mergebot-test")).toBe(false);
76 expect(isMergebotRepository(configuration, "another/app")).toBe(false);
77 expect(isMergebotRepository(configuration, "work/app/extra")).toBe(false);
78 });
79});
80
81describe("MergebotPullRequestDetails", () => {
82 it("preserves check rerun identity and lifecycle timestamps without inventing absent dates", () => {
83 const details = {
84 ...entry,
85 title: "Ship change",
86 createdAt: "2026-10-14T10:00:00Z",
87 author: null,
88 additions: 3,
89 deletions: 1,
90 changedFilesCount: 2,
91 labels: [],
92 comments: [],
93 observedAt: 1791972120000,
94 body: null,
95 updatedAt: "2026-10-14T10:02:00Z",
96 mergedAt: "2026-10-14T10:01:00Z",
97 closedAt: null,
98 draft: false,
99 baseBranch: "main",
100 headBranch: "feature",
101 ciStatus: "failed",
102 hasConflicts: false,
103 checks: [
104 {
105 id: "check:42",
106 name: "Tests",
107 workflowName: "CI",
108 status: "completed",
109 conclusion: "failure",
110 url: null,
111 startedAt: null,
112 completedAt: "2026-10-14T10:00:00Z",
113 },
114 ],
115 } as const;
116 expect(decodeDetails(details)).toEqual(details);
117 const withoutDates = Struct.omit(details, ["mergedAt", "closedAt"]);
118 expect(decodeDetails(withoutDates)).not.toHaveProperty("mergedAt");
119 });
120});
121
122describe("mergebotPullRequestUrl", () => {
123 const config = { origin: "https://mergebot.example", repositories: ["Work/App"] };
124 it("uses configured casing and matches GitHub and repositories case-insensitively", () => {
125 expect(
126 mergebotPullRequestUrl(config, { host: "GitHub.Com", repository: "work/APP", number: 12 }),
127 ).toBe("https://mergebot.example/Work/App/pull/12");
128 });
129 it("rejects lookalike and enterprise hosts, unconfigured repositories and invalid numbers", () => {
130 for (const host of ["github.com.example", "example.com", "github.com:443"]) {
131 expect(
132 mergebotPullRequestUrl(config, { host, repository: "work/app", number: 12 }),
133 ).toBeNull();
134 }
135 expect(mergebotPullRequestUrl(undefined, { repository: "work/app", number: 12 })).toBeNull();
136 expect(mergebotPullRequestUrl(config, { repository: "work/other", number: 12 })).toBeNull();
137 expect(mergebotPullRequestUrl(config, { repository: "work/app", number: 0 })).toBeNull();
138 });
139});
140
141describe("parseMergebotPullRequestUrl", () => {
142 const config = { origin: "https://mergebot.example", repositories: ["Work/App"] };
143 it("recognizes configured Mergebot PR URLs as their GitHub repository", () => {
144 expect(
145 parseMergebotPullRequestUrl(config, "https://mergebot.example/work/APP/pull/12#checks"),
146 ).toEqual({ host: "github.com", repository: "Work/App", number: 12 });
147 });
148 it("leaves lookalike origins, credentials and unconfigured repository paths alone", () => {
149 for (const url of [
150 "https://mergebot.example.evil/work/app/pull/12",
151 "http://mergebot.example/work/app/pull/12",
152 "https://mergebot.example:444/work/app/pull/12",
153 "https://user@mergebot.example/work/app/pull/12",
154 "https://mergebot.example/work/other/pull/12",
155 ])
156 expect(parseMergebotPullRequestUrl(config, url)).toBeNull();
157 });
158});