1import { SourceControlProviderKind } from "./sourceControl.ts";
2import * as Schema from "effect/Schema";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 PullRequestActionInput,
7 PullRequestCapabilities,
8 PullRequestFilesViewedResult,
9 PullRequestListInput,
10 PullRequestListResult,
11 PullRequestReviewerRequestInput,
12 PullRequestSetFilesViewedInput,
13 pullRequestHostOf,
14 resolvePullRequestAuthorFilter,
15} from "./pullRequest.ts";
16
17const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
18const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
19const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
20const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
21const decodeSetFilesViewed = Schema.decodeUnknownSync(PullRequestSetFilesViewedInput);
22const decodeFilesViewed = Schema.decodeUnknownSync(PullRequestFilesViewedResult);
23
24const LIST_RESULT: PullRequestListResult = {
25 viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
26 providers: [
27 {
28 host: "github.com",
29 kind: SourceControlProviderKind.make("github"),
30 searchesOnHost: true,
31 projectCount: 1,
32 configured: true,
33 detail: null,
34 },
35 {
36 host: "gitlab.com",
37 kind: SourceControlProviderKind.make("gitlab"),
38 searchesOnHost: true,
39 projectCount: 1,
40 configured: false,
41 detail: "glab is not installed.",
42 },
43 ],
44 entries: [
45 {
46 provider: SourceControlProviderKind.make("github"),
47 host: "github.com",
48 projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"],
49 projectTitle: "t3code",
50 repository: "pingdotgg/t3code",
51 number: 1,
52 title: "Add a pull requests page",
53 url: "https://github.com/pingdotgg/t3code/pull/1",
54 author: { login: "octocat", name: null, avatarUrl: null },
55 headBranch: "feat/page",
56 baseBranch: "main",
57 state: "open",
58 isDraft: false,
59 mergeability: "mergeable",
60 additions: 1,
61 deletions: 0,
62 createdAt: "2026-07-01T00:00:00Z",
63 updatedAt: "2026-07-02T00:00:00Z",
64 viewerReviewRequested: false,
65 labels: [{ name: "backend", color: null }],
66 },
67 ],
68 errors: [],
69 truncated: false,
70 nextCursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|1" },
71};
72
73describe("PullRequestListResult", () => {
74 it("separates Forgejo HTTP ports while preserving other provider host identities", () => {
75 const identity = {
76 canonicalKey: "forge.example/team/repo",
77 locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },
78 };
79 expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe(
80 "forge.example:3000",
81 );
82 expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe(
83 "forge.example",
84 );
85 expect(
86 pullRequestHostOf(
87 { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } },
88 SourceControlProviderKind.make("forgejo"),
89 ),
90 ).toBe("forge.example");
91 });
92 /**
93 * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record
94 * with an optional value, for one — fails as an interrupted request rather than as a schema
95 * error. Building it here turns that into a test failure instead.
96 */
97 it("round-trips through the JSON codec the RPC serializes with", () => {
98 const codec = Schema.toCodecJson(PullRequestListResult);
99
100 const decoded = Schema.decodeSync(codec)(Schema.encodeUnknownSync(codec)(LIST_RESULT));
101
102 expect(decoded).toStrictEqual(LIST_RESULT);
103 });
104
105 it("keys a viewer by host, so two hosts of one kind stay separate accounts", () => {
106 const decoded = decodeListResult({
107 ...LIST_RESULT,
108 viewers: { "github.com": "bilal", "github.acme.dev": "b.hassan" },
109 });
110
111 expect(decoded.viewers["github.com"]).toBe("bilal");
112 expect(decoded.viewers["github.acme.dev"]).toBe("b.hassan");
113 });
114});
115
116describe("PullRequestListInput", () => {
117 it("trims a search, so what is sent is what was typed", () => {
118 expect(decodeListInput({ state: "open", query: " page " }).query).toBe("page");
119 });
120
121 it("has no search when none was asked for", () => {
122 expect(decodeListInput({ state: "open" }).query).toBeUndefined();
123 });
124
125 it("bounds a search, because it travels into a command and a query string", () => {
126 expect(decodeListInput({ state: "open", query: "p".repeat(200) }).query).toHaveLength(200);
127 expect(() => decodeListInput({ state: "open", query: "p".repeat(201) })).toThrow();
128 });
129
130 it("takes back the continuation a result handed out, keyed the way it arrived", () => {
131 const cursors = { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|99|1,2" };
132
133 expect(decodeListInput({ state: "open", cursors }).cursors).toStrictEqual(cursors);
134 });
135
136 it("has no continuation when the listing is being read from the top", () => {
137 expect(decodeListInput({ state: "open" }).cursors).toBeUndefined();
138 });
139
140 it("bounds a continuation, because it comes back from the page and goes into a filter", () => {
141 const long = (length: number) => ({ "github.com acme/web": "c".repeat(length) });
142 expect(decodeListInput({ state: "open", cursors: long(4096) })).toBeDefined();
143 expect(() => decodeListInput({ state: "open", cursors: long(4097) })).toThrow();
144 });
145});
146
147describe("PullRequestReviewerRequestInput", () => {
148 const ref = { projectId: "p1", repository: "acme/web", number: 1 };
149 const reviewer = { id: "octocat", kind: "user" };
150
151 it("carries the same shape whichever direction the request goes", () => {
152 // One operation, turned around: `requested` is the whole difference between asking somebody
153 // for a review and taking the request back.
154 for (const requested of [true, false]) {
155 expect(decodeReviewerRequest({ ...ref, reviewers: [reviewer], requested }).requested).toBe(
156 requested,
157 );
158 }
159 });
160
161 it("refuses a request that names nobody, which no host would do anything with", () => {
162 expect(() => decodeReviewerRequest({ ...ref, reviewers: [], requested: true })).toThrow();
163 });
164
165 it("bounds the reviewers, because they travel into a body the page composed", () => {
166 const many = (count: number) =>
167 Array.from({ length: count }, (_, index) => ({ id: `user${index}`, kind: "user" }));
168 expect(
169 decodeReviewerRequest({ ...ref, reviewers: many(25), requested: true }).reviewers,
170 ).toHaveLength(25);
171 expect(() => decodeReviewerRequest({ ...ref, reviewers: many(26), requested: true })).toThrow();
172 });
173
174 it("keeps a team apart from a person, which is a different thing to ask", () => {
175 expect(
176 decodeReviewerRequest({
177 ...ref,
178 reviewers: [reviewer, { id: "web-platform", kind: "team" }],
179 requested: true,
180 }).reviewers.map((entry) => entry.kind),
181 ).toEqual(["user", "team"]);
182 });
183});
184
185describe("updating a branch that has fallen behind its base", () => {
186 const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
187
188 it("carries the way the branch should be brought up to date", () => {
189 expect(decodeAction({ ...ref, action: "update-branch", updateMethod: "rebase" })).toMatchObject(
190 {
191 action: "update-branch",
192 updateMethod: "rebase",
193 },
194 );
195 });
196
197 it("takes the action without a method, which is the host's own default", () => {
198 expect(decodeAction({ ...ref, action: "update-branch" }).updateMethod).toBeUndefined();
199 });
200
201 it("refuses a way no host offers", () => {
202 expect(() =>
203 decodeAction({ ...ref, action: "update-branch", updateMethod: "squash" }),
204 ).toThrow();
205 });
206});
207
208describe("leaving a merge for the host to make once it is ready", () => {
209 const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
210
211 it("carries the strategy the deferred merge should use, as merging now does", () => {
212 expect(
213 decodeAction({ ...ref, action: "enable-auto-merge", mergeMethod: "squash" }),
214 ).toMatchObject({ action: "enable-auto-merge", mergeMethod: "squash" });
215 });
216
217 it("takes the arming back without a strategy, because there is nothing to choose", () => {
218 expect(decodeAction({ ...ref, action: "disable-auto-merge" }).mergeMethod).toBeUndefined();
219 });
220});
221
222describe("reverting a merged pull request", () => {
223 it("carries a standalone branch deletion action", () => {
224 expect(
225 decodeAction({
226 projectId: "project-1",
227 repository: "acme/web",
228 number: 7,
229 action: "delete-branch",
230 }).action,
231 ).toBe("delete-branch");
232 });
233 it("carries the revert action without merge options", () => {
234 const action = decodeAction({
235 projectId: "project-1",
236 repository: "acme/web",
237 number: 7,
238 action: "revert",
239 });
240
241 expect(action.action).toBe("revert");
242 expect(action.mergeMethod).toBeUndefined();
243 });
244});
245
246describe("approving fork workflows", () => {
247 it("carries workflow approval as its own action", () => {
248 const action = decodeAction({
249 projectId: "project-1",
250 repository: "acme/web",
251 number: 7,
252 action: "approve-workflows",
253 });
254
255 expect(action.action).toBe("approve-workflows");
256 });
257});
258
259describe("PullRequestCapabilities", () => {
260 const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities);
261 const base = {
262 diff: true,
263 comment: true,
264 actions: [],
265 mergeMethods: [],
266 search: true,
267 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
268 reviewers: { request: true, listCandidates: true },
269 };
270
271 it("decodes a server that says nothing about reactions as a server with none", () => {
272 expect(decodeCapabilities(base).reactions).toBeUndefined();
273 });
274
275 it("only offers branch deletion when the server advertises it", () => {
276 expect(decodeCapabilities(base).deleteBranch).toBeUndefined();
277 expect(decodeCapabilities({ ...base, deleteBranch: true }).deleteBranch).toBe(true);
278 });
279});
280
281describe("naming the reader as the author to narrow by", () => {
282 it("reads me as whoever is signed in, however it is written", () => {
283 expect(resolvePullRequestAuthorFilter("me", "octocat")).toBe("octocat");
284 expect(resolvePullRequestAuthorFilter("@me", "octocat")).toBe("octocat");
285 expect(resolvePullRequestAuthorFilter(" ME ", "octocat")).toBe("octocat");
286 });
287
288 it("leaves any other name as it was typed", () => {
289 expect(resolvePullRequestAuthorFilter(" mercedes ", "octocat")).toBe("mercedes");
290 expect(resolvePullRequestAuthorFilter("me-too", "octocat")).toBe("me-too");
291 });
292
293 it("stands as typed where the host has not said who the reader is", () => {
294 expect(resolvePullRequestAuthorFilter("me", null)).toBe("me");
295 expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me");
296 });
297});
298
299describe("naming the file a tick belongs to", () => {
300 // A space on either end of a name is part of the name as far as git is concerned. The patch on
301 // screen and the environment's record of what was cleared are both keyed by it, so a path
302 // tidied in transit ticks a file that does not exist and leaves the one on screen unticked.
303 it("keeps the spaces around a path being ticked", () => {
304 expect(
305 decodeSetFilesViewed({
306 projectId: "p1",
307 repository: "group/project",
308 number: 7,
309 files: [{ path: "docs/readme.md ", viewed: true }],
310 }).files,
311 ).toEqual([{ path: "docs/readme.md ", viewed: true }]);
312 });
313
314 it("keeps the spaces around a path being reported back", () => {
315 expect(
316 decodeFilesViewed({
317 files: [{ path: " leading.md", state: "viewed" }],
318 truncated: false,
319 }).files,
320 ).toEqual([{ path: " leading.md", state: "viewed" }]);
321 });
322
323 it("still refuses a path that is nothing at all", () => {
324 expect(() =>
325 decodeSetFilesViewed({
326 projectId: "p1",
327 repository: "group/project",
328 number: 7,
329 files: [{ path: "", viewed: true }],
330 }),
331 ).toThrow();
332 });
333
334 it("refuses a batch larger than a reader can press", () => {
335 // Every element of a batch is a statement of its own inside one transaction on an
336 // environment-kept host, or a field of its own in one GraphQL document on GitHub, so what a
337 // client may send has to be bounded rather than trusted to be a burst of presses.
338 const press = (path: string) => ({ path, viewed: true });
339 const batch = (count: number) => ({
340 projectId: "p1",
341 repository: "group/project",
342 number: 7,
343 files: Array.from({ length: count }, (_, at) => press(`src/f${at}.ts`)),
344 });
345
346 expect(() => decodeSetFilesViewed(batch(500))).not.toThrow();
347 expect(() => decodeSetFilesViewed(batch(501))).toThrow();
348 });
349
350 it("refuses a path far longer than any real one", () => {
351 expect(() =>
352 decodeSetFilesViewed({
353 projectId: "p1",
354 repository: "group/project",
355 number: 7,
356 files: [{ path: `src/${"a".repeat(4096)}.ts`, viewed: true }],
357 }),
358 ).toThrow();
359 });
360});