1import { describe, expect, it } from "vite-plus/test";
2import type { MergebotDeploymentEnvironment, MergebotPullRequestDetails } from "@t3tools/contracts";
3import { deployments, deployState, pullRequestLiveStatus, type RepoPayload } from "./overview.ts";
4
5const repository = "work/app";
6const url = `https://mergebot.example/${repository}`;
7
8const commit = (number: number, isProductionHead = false) => ({
9 sha: `sha-${number}`,
10 pullRequestNumber: number,
11 authorId: `user-${number}`,
12 authorName: `Commit Author ${number}`,
13 message: `Change ${number}`,
14 mergedAt: `2026-10-14T10:0${number}:00Z`,
15 url: `https://github.com/${repository}/pull/${number}`,
16 labels: [],
17 status: "default" as const,
18 isProductionHead,
19});
20// Newest first, as Mergebot orders `mergedCommitIds`.
21const commits = [5, 4, 3, 2, 1, 0].map((number) => commit(number, number === 0));
22
23const deployment = (
24 sha: string,
25 deployedSha: string | null,
26 status: MergebotDeploymentEnvironment["status"],
27 etaAt: string | null = null,
28): MergebotDeploymentEnvironment => ({
29 sha,
30 deployedSha,
31 status,
32 currentStep: null,
33 etaAt,
34 checksUrl: "https://github.com/checks",
35});
36
37const repo = {
38 processing: false,
39 defaultBranch: "main",
40 productionBranch: "production",
41 stagingUrl: "https://staging.example",
42 productionUrl: "https://app.example",
43 triggerLabel: "merge",
44 stagingDeployment: deployment("sha-4", "sha-2", "pending", "2026-10-14T11:00:00Z"),
45 productionDeployment: deployment("sha-1", "sha-0", "failed"),
46};
47
48const pr = {
49 id: `${repository}#5`,
50 repoId: repository,
51 number: 5,
52 authorId: null,
53 createdAt: "2026-10-14T10:00:00Z",
54 updatedAt: "2026-10-14T10:02:00Z",
55 additions: 3,
56 deletions: 1,
57 changedFilesCount: 2,
58 labels: [{ name: "merge", color: null }],
59 title: "Ship change",
60 body: "Body",
61 draft: false,
62 headBranch: "feature",
63 baseBranch: "main",
64 headSha: "head",
65 lifecycle: "open" as const,
66 queueStatus: "queued" as const,
67 mergeEtaAt: "2026-10-14T10:30:00Z",
68 ciStatus: "passed" as const,
69 hasConflicts: false,
70 url: `https://github.com/${repository}/pull/5`,
71};
72
73const payload = (overrides: Partial<RepoPayload["entities"]> = {}): RepoPayload => ({
74 entities: {
75 repos: { [repository]: repo },
76 users: {
77 "user-4": { login: "octo", name: "Octo Cat", avatarUrl: "https://avatars.example/octo" },
78 },
79 pullRequests: { [pr.id]: pr },
80 mergedCommits: Object.fromEntries(commits.map((entry) => [entry.sha, entry])),
81 ...overrides,
82 },
83 view: {
84 repoId: repository,
85 queuePrIds: [`${repository}#9`, pr.id],
86 mergedCommitIds: commits.map((entry) => entry.sha),
87 },
88});
89
90const detail = (
91 overrides: Partial<MergebotPullRequestDetails> = {},
92): MergebotPullRequestDetails => ({
93 ...pr,
94 author: null,
95 comments: [],
96 observedAt: 0,
97 position: null,
98 checks: [
99 { status: "completed", conclusion: "success" },
100 { status: "completed", conclusion: "failure" },
101 { status: "in_progress", conclusion: null },
102 ].map((check, index) => ({
103 ...check,
104 id: `check:${index}`,
105 name: `Check ${index}`,
106 workflowName: null,
107 url: null,
108 startedAt: null,
109 completedAt: null,
110 })),
111 ...overrides,
112});
113
114describe("deployState", () => {
115 it.each([
116 [5, { state: "waiting", environment: "staging", etaAt: null }],
117 [4, { state: "deploying", environment: "staging", etaAt: "2026-10-14T11:00:00Z" }],
118 [3, { state: "deploying", environment: "staging", etaAt: "2026-10-14T11:00:00Z" }],
119 [2, { state: "staging", environment: "staging", etaAt: null }],
120 [1, { state: "failed", environment: "production", etaAt: null }],
121 [0, { state: "production", environment: "production", etaAt: null }],
122 ])("places #%s on the deploy timeline", (number, expected) => {
123 expect(deployState(repo, commits, { number })).toEqual(expected);
124 });
125
126 it("treats a passed deploy as covering everything at or below its head", () => {
127 const passed = { ...repo, stagingDeployment: deployment("sha-4", "sha-4", "passed") };
128 expect(deployState(passed, commits, { number: 3 })?.state).toBe("staging");
129 expect(deployState(passed, commits, { number: 5 })?.state).toBe("waiting");
130 });
131
132 it("reports a production deploy in flight with its own ETA", () => {
133 const shipping = {
134 ...repo,
135 productionDeployment: deployment("sha-2", "sha-0", "pending", "2026-10-14T12:00:00Z"),
136 };
137 expect(deployState(shipping, commits, { number: 1 })).toEqual({
138 state: "deploying",
139 environment: "production",
140 etaAt: "2026-10-14T12:00:00Z",
141 });
142 });
143
144 it("falls back to the production branch head without deployment checks", () => {
145 const branchOnly = { ...repo, stagingDeployment: null, productionDeployment: null };
146 expect(deployState(branchOnly, commits, { number: 0 })?.state).toBe("production");
147 expect(deployState(branchOnly, commits, { number: 1 })).toEqual({
148 state: "waiting",
149 environment: "production",
150 etaAt: null,
151 });
152 });
153
154 it("has no deploy state for a repository without deploys", () => {
155 const none = {
156 ...repo,
157 productionBranch: null,
158 stagingDeployment: null,
159 productionDeployment: null,
160 };
161 expect(deployState(none, commits, { number: 3 })).toBeNull();
162 });
163
164 it("places merges outside Mergebot's window by merge time", () => {
165 expect(
166 deployState(repo, commits, { number: 99, mergedAt: "2026-10-13T00:00:00Z" })?.state,
167 ).toBe("production");
168 expect(deployState(repo, commits, { number: 99, mergedAt: "2026-10-15T00:00:00Z" })).toBeNull();
169 expect(deployState(repo, commits, { number: 99 })).toBeNull();
170 });
171});
172
173describe("pullRequestLiveStatus", () => {
174 it("composes overview fields, queue position, and checks for the current head", () => {
175 expect(pullRequestLiveStatus(url, repository, payload(), detail(), 5)).toEqual({
176 number: 5,
177 url: `${url}/pull/5`,
178 title: "Ship change",
179 body: "Body",
180 headSha: "head",
181 headBranch: "feature",
182 lifecycle: "open",
183 draft: false,
184 autoMerge: true,
185 ciStatus: "passed",
186 checks: { completed: 2, total: 3, failed: 1 },
187 hasConflicts: false,
188 queueStatus: "queued",
189 position: 2,
190 mergeEtaAt: "2026-10-14T10:30:00Z",
191 deploy: null,
192 });
193 });
194
195 it("drops checks read at an older head and reports auto-merge from the trigger label", () => {
196 const status = pullRequestLiveStatus(
197 url,
198 repository,
199 payload({ pullRequests: { [pr.id]: { ...pr, labels: [] } } }),
200 detail({ headSha: "old-head" }),
201 5,
202 );
203 expect(status).toMatchObject({ checks: null, autoMerge: false });
204 });
205
206 it("falls back to the detail snapshot for PRs outside the overview", () => {
207 const closed = detail({ number: 77, lifecycle: "closed", queueStatus: "not_queued" });
208 expect(pullRequestLiveStatus(url, repository, payload(), closed, 77)).toMatchObject({
209 number: 77,
210 lifecycle: "closed",
211 position: null,
212 deploy: null,
213 });
214 expect(pullRequestLiveStatus(url, repository, payload(), null, 77)).toBeNull();
215 });
216
217 it("maps a merged PR through the merged commits", () => {
218 const merged = { ...pr, id: `${repository}#2`, number: 2, lifecycle: "merged" as const };
219 const status = pullRequestLiveStatus(
220 url,
221 repository,
222 payload({ pullRequests: { [merged.id]: merged } }),
223 undefined,
224 2,
225 );
226 expect(status?.deploy).toEqual({ state: "staging", environment: "staging", etaAt: null });
227 });
228});
229
230describe("deployments", () => {
231 it("keeps Mergebot's newest-first order and prefers overview author details", () => {
232 const result = deployments(url, repository, payload());
233 expect(result).toMatchObject({
234 repository,
235 url,
236 defaultBranch: "main",
237 productionBranch: "production",
238 staging: repo.stagingDeployment,
239 production: repo.productionDeployment,
240 });
241 expect(result?.mergedCommits.map((entry) => entry.sha)).toEqual(
242 commits.map((entry) => entry.sha),
243 );
244 expect(result?.mergedCommits[1]).toEqual({
245 sha: "sha-4",
246 pullRequestNumber: 4,
247 authorLogin: "octo",
248 authorName: "Octo Cat",
249 authorAvatarUrl: "https://avatars.example/octo",
250 message: "Change 4",
251 mergedAt: "2026-10-14T10:04:00Z",
252 url: `https://github.com/${repository}/pull/4`,
253 labels: [],
254 status: "default",
255 isProductionHead: false,
256 });
257 expect(result?.mergedCommits[0]).toMatchObject({
258 authorLogin: null,
259 authorName: "Commit Author 5",
260 authorAvatarUrl: null,
261 });
262 });
263});