Open History to see the last commit
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
5
6import * as ForgejoCli from "./ForgejoCli.ts";
7import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
8
9it.effect("reads file revisions from the pull request diff and refuses a truncated one", () =>
10 Effect.gen(function* () {
11 let truncated = false;
12 const diff = [
13 "diff --git a/alpha.ts b/alpha.ts",
14 "index eb2d7c5..b485fe5 100644",
15 "diff --git a/beta.ts b/beta.ts",
16 "index b39e8b3..5851425 100644",
17 'diff --git "a/caf\\303\\251 notes.txt" "b/caf\\303\\251 notes.txt"',
18 "index ffd99ce..6dd9855 100644",
19 "diff --git a/deleted.txt b/deleted.txt",
20 "deleted file mode 100644",
21 "index 233f5c6..0000000",
22 "diff --git a/old.txt b/renamed.txt",
23 "similarity index 100%",
24 "rename from old.txt",
25 "rename to renamed.txt",
26 "",
27 ].join("\n");
28 const provider = yield* ForgejoPullRequestProvider.make.pipe(
29 Effect.provide(
30 Layer.mock(ForgejoCli.ForgejoCli)({
31 api: (input) => {
32 assert.strictEqual(input.host, "forge.example:3000");
33 assert.strictEqual(input.repository, "reviewer/project");
34 assert.strictEqual(input.path, "repos/reviewer/project/pulls/1.diff");
35 return Effect.succeed({
36 ...TestSourceControlHost.processOutput(diff),
37 stdoutTruncated: truncated,
38 });
39 },
40 }),
41 ),
42 );
43 const reference = {
44 cwd: "/forgejo",
45 repository: "reviewer/project",
46 host: "forge.example:3000",
47 number: 1,
48 };
49 const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"];
50 assert.isDefined(provider.getFileRevisions);
51 const answer = yield* provider.getFileRevisions!({ ...reference, paths });
52 assert.isTrue(answer.complete);
53 assert.deepStrictEqual(
54 paths.map((path) => [path, answer.revisions.get(path)]),
55 [
56 ["alpha.ts", "b485fe5"],
57 ["beta.ts", "5851425"],
58 ["café notes.txt", "6dd9855"],
59 ["deleted.txt", "0000000"],
60 ["renamed.txt", ""],
61 ],
62 );
63
64 truncated = true;
65 const refused = yield* Effect.flip(provider.getFileRevisions!({ ...reference, paths }));
66 assert.strictEqual(refused._tag, "PullRequestProviderError");
67 }),
68);
69
70it.effect("filters listed pull requests by the requested state", () =>
71 Effect.gen(function* () {
72 // Forgejo answers a `state=closed` query with merged and closed-unmerged rows alike.
73 const rows = [true, false].map((merged) => ({
74 number: merged ? 1 : 2,
75 title: `Pull request ${merged ? 1 : 2}`,
76 body: "",
77 html_url: `https://forge.example:3000/maria/project/pulls/${merged ? 1 : 2}`,
78 user: { login: "maria" },
79 state: "closed",
80 merged,
81 head: { ref: "feature", sha: "head-sha", repo: null },
82 base: { ref: "main", sha: "base-sha", repo: null },
83 created_at: "2026-10-12T00:00:00Z",
84 updated_at: "2026-10-12T00:00:00Z",
85 closed_at: "2026-10-13T00:00:00Z",
86 merged_at: merged ? "2026-10-13T00:00:00Z" : null,
87 labels: [],
88 }));
89 const provider = yield* ForgejoPullRequestProvider.make.pipe(
90 Effect.provide(
91 Layer.mock(ForgejoCli.ForgejoCli)({
92 api: (input) => {
93 const [path, query] = input.path.split("?");
94 assert.strictEqual(path, "repos/maria/project/pulls");
95 const params = new URLSearchParams(query);
96 // A merged list asks Forgejo for closed rows and separates them itself.
97 const state = params.get("state");
98 assert.ok(state === "closed" || state === "open" || state === "all");
99 const page = params.get("page") === "1" && state !== "open" ? rows : [];
100 return Effect.succeed(TestSourceControlHost.processOutput(JSON.stringify(page)));
101 },
102 }),
103 ),
104 );
105 const input = {
106 cwd: "/forgejo",
107 repository: "maria/project",
108 host: "forge.example:3000",
109 involvement: "all" as const,
110 viewer: "maria",
111 limit: 10,
112 };
113 const numbers = (state: "open" | "closed" | "merged" | "all") =>
114 provider
115 .listChangeRequests({ ...input, state })
116 .pipe(Effect.map((page) => page.items.map((item) => item.number)));
117
118 assert.deepStrictEqual(yield* numbers("merged"), [1]);
119 assert.deepStrictEqual(yield* numbers("closed"), [2]);
120 assert.deepStrictEqual(yield* numbers("all"), [1, 2]);
121 assert.deepStrictEqual(yield* numbers("open"), []);
122 }),
123);
124
125const fastForwardOnly = {
126 cwd: "/forgejo",
127 repository: "maria/project",
128 host: "forge.example:3000",
129 number: 3,
130};
131const fastForwardOnlySettings = {
132 allow_merge_commits: false,
133 allow_squash_merge: false,
134 allow_rebase: false,
135 allow_rebase_explicit: false,
136 allow_fast_forward_only_merge: true,
137 default_merge_style: "fast-forward-only",
138};
139const fastForwardOnlyProvider = (
140 writes: Array<{ path: string; body: unknown }>,
141 mergeSettings: object = fastForwardOnlySettings,
142) =>
143 ForgejoPullRequestProvider.make.pipe(
144 Effect.provide(
145 Layer.mock(ForgejoCli.ForgejoCli)({
146 api: (input) => {
147 const [path] = input.path.split("?");
148 const reply = (value: unknown) =>
149 Effect.succeed(TestSourceControlHost.processOutput(JSON.stringify(value)));
150 if (input.method === "POST") {
151 writes.push({ path: input.path, body: input.body });
152 return reply("");
153 }
154 switch (path) {
155 case "user":
156 return reply({ login: "maria" });
157 case "repos/maria/project":
158 return reply({
159 full_name: "maria/project",
160 permissions: { push: true, admin: true },
161 ...mergeSettings,
162 });
163 case "repos/maria/project/pulls/3":
164 return reply({
165 number: 3,
166 title: "Fast-forward me",
167 body: "",
168 html_url: "https://forge.example:3000/maria/project/pulls/3",
169 user: { login: "maria" },
170 state: "open",
171 merged: false,
172 mergeable: true,
173 head: { ref: "feature", sha: "head-sha", repo: null },
174 base: { ref: "main", sha: "base-sha", repo: null },
175 merge_base: "base-sha",
176 created_at: "2026-10-12T00:00:00Z",
177 updated_at: "2026-10-12T00:00:00Z",
178 closed_at: null,
179 merged_at: null,
180 labels: [],
181 });
182 case "repos/maria/project/branches/main":
183 return reply({});
184 default:
185 return reply([]);
186 }
187 },
188 }),
189 ),
190 );
191
192it.effect("offers a fast-forward merge on a repository that only allows fast-forward", () =>
193 Effect.gen(function* () {
194 const provider = yield* fastForwardOnlyProvider([]);
195 const detail = yield* provider.getChangeRequest(fastForwardOnly);
196 const allowed = provider.capabilities.mergeMethods.filter(
197 (method) => detail.mergeCapabilities[method],
198 );
199 assert.deepStrictEqual(allowed, ["fast-forward"]);
200 }),
201);
202
203it.effect("does not offer fast-forward when Forgejo omits the setting", () =>
204 Effect.gen(function* () {
205 const provider = yield* fastForwardOnlyProvider([], {});
206 const detail = yield* provider.getChangeRequest(fastForwardOnly);
207 const allowed = provider.capabilities.mergeMethods.filter(
208 (method) => detail.mergeCapabilities[method],
209 );
210 assert.deepStrictEqual(allowed, ["merge", "squash", "rebase"]);
211 }),
212);
213
214it.effect("merges with Forgejo's fast-forward-only style", () =>
215 Effect.gen(function* () {
216 const writes: Array<{ path: string; body: unknown }> = [];
217 const provider = yield* fastForwardOnlyProvider(writes);
218 yield* provider.runAction({ ...fastForwardOnly, action: "merge", mergeMethod: "fast-forward" });
219 assert.deepStrictEqual(writes, [
220 {
221 path: "repos/maria/project/pulls/3/merge",
222 body: { Do: "fast-forward-only", delete_branch_after_merge: false },
223 },
224 ]);
225 }),
226);