1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Schema from "effect/Schema";
5import * as Result from "effect/Result";
6import { SourceControlProviderError } from "@t3tools/contracts";
7import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
8import * as ForgejoCli from "./ForgejoCli.ts";
9import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
10import {
11 providerAuth,
12 probeSourceControlProvider,
13 type SourceControlCliDiscoverySpec,
14 type SourceControlManagedCliDiscoverySpec,
15} from "@t3tools/source-control-core/server/discovery";
16import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";
17
18const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
19
20export const discovery = {
21 type: "cli",
22 kind: SourceControlProviderKind.make("forgejo"),
23 label: "Forgejo / Gitea",
24 executable: "tea",
25 versionArgs: ["--version"],
26 authArgs: ["login", "status", "--output", "json"],
27 remoteRefinementArgs: ["login", "list", "--output", "json"],
28 parseAuth: (input) => {
29 const logins = ForgejoCli.parseForgejoLogins(input.stdout);
30 const login = logins.find((entry) => entry.default === "true") ?? logins[0];
31 return login
32 ? providerAuth({
33 status: login.valid === "true" ? "authenticated" : "unauthenticated",
34 account: login.user,
35 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
36 })
37 : providerAuth({
38 status: "unauthenticated",
39 detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.",
40 });
41 },
42 refineUnknownRemote: (input) => {
43 const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
44 const login =
45 remote &&
46 ForgejoCli.matchForgejoLogin(
47 ForgejoCli.parseForgejoLogins(input.auth.stdout),
48 remote,
49 input.context.requestedHost,
50 );
51 return login
52 ? {
53 kind: SourceControlProviderKind.make("forgejo"),
54 name: "Forgejo / Gitea",
55 baseUrl: login.url,
56 }
57 : null;
58 },
59 installHint:
60 "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.",
61} satisfies SourceControlCliDiscoverySpec;
62
63export const makeDiscovery = Effect.gen(function* () {
64 const cli = yield* ForgejoCli.ForgejoCli;
65 const { process } = yield* SourceControlHost.SourceControlHost;
66 const listLogins = cli.listLogins;
67 if (!listLogins) return discovery;
68 return {
69 type: "managed-cli",
70 kind: SourceControlProviderKind.make("forgejo"),
71 label: discovery.label,
72 installHint: discovery.installHint,
73 probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
74 const remoteUrl = yield* process
75 .run({
76 operation: "source-control.discovery.remote",
77 command: "git",
78 args: ["remote", "get-url", "origin"],
79 cwd,
80 allowNonZeroExit: true,
81 timeoutMs: 5_000,
82 maxOutputBytes: 8_000,
83 })
84 .pipe(
85 Effect.map((result) => result.stdout.trim()),
86 Effect.orElseSucceed(() => ""),
87 );
88 const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl }));
89 const logins = Result.isSuccess(credentials) ? credentials.success : [];
90 const remote = ForgejoCli.parseForgejoRemote(remoteUrl);
91 const login =
92 (remote && ForgejoCli.matchForgejoLogin(logins, remote)) ||
93 logins.find((entry) => entry.default === "true") ||
94 logins[0];
95 const fj = yield* probeSourceControlProvider({
96 cwd,
97 process,
98 spec: {
99 ...discovery,
100 executable: "fj",
101 versionArgs: ["version"],
102 authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"],
103 parseAuth: (result) =>
104 Result.isFailure(credentials)
105 ? providerAuth({
106 status: "unknown",
107 detail: "Could not read fj authentication storage. Authenticate again with fj.",
108 })
109 : login && result.exitCode === 0
110 ? providerAuth({
111 status: "authenticated",
112 account: login.user,
113 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
114 })
115 : providerAuth({
116 status: "unauthenticated",
117 detail:
118 "Authenticate this server with `fj --host <server-url> auth add-token`.",
119 }),
120 },
121 });
122 // A configured fj account owns its requests, including authentication errors.
123 if (fj.status === "available" && (login || Result.isFailure(credentials))) {
124 if (login && fj.auth.status === "authenticated" && cli.getAccount) {
125 const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result);
126 return {
127 ...fj,
128 auth: Result.isSuccess(account)
129 ? providerAuth({
130 status: "authenticated",
131 account: account.success,
132 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
133 })
134 : providerAuth({
135 status: "unknown",
136 detail: account.failure.detail,
137 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
138 }),
139 };
140 }
141 return fj;
142 }
143 const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery });
144 return tea.status === "available" || fj.status === "missing" ? tea : fj;
145 }),
146 refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")(
147 function* (input: {
148 readonly cwd: string;
149 readonly context: SourceControlProvider.SourceControlProviderContext;
150 }) {
151 const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
152 if (!remote) return null;
153 for (const command of ["fj", "tea"] as const) {
154 const logins = yield* listLogins({
155 cwd: input.cwd,
156 command,
157 remoteUrl: input.context.remoteUrl,
158 }).pipe(Effect.orElseSucceed(() => []));
159 const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);
160 if (login)
161 return {
162 kind: SourceControlProviderKind.make("forgejo"),
163 name: discovery.label,
164 baseUrl: login.url,
165 };
166 }
167 return null;
168 },
169 ),
170 } satisfies SourceControlManagedCliDiscoverySpec;
171});
172
173/** HTTP paths can include an installation mount; Forgejo's API always names owner/repo. */
174export function repositoryNameFromRemoteUrl(url: string): string | null {
175 const path = SourceControlProvider.repositoryPathFromRemoteUrl(url);
176 return path === null || !/^https?:\/\//iu.test(url.trim())
177 ? path
178 : path.split("/").slice(-2).join("/");
179}
180
181/**
182 * A Forgejo remote's URL can't say which server it belongs to (an SSH alias, an installation
183 * mount), so the identity's browser URL comes from the login that serves it.
184 */
185const refineRepositoryIdentity: NonNullable<
186 SourceControlProvider.SourceControlProvider["Service"]["refineRepositoryIdentity"]
187> = Effect.fn("ForgejoSourceControlProvider.refineRepositoryIdentity")(function* ({
188 identity,
189 resolveContext,
190}) {
191 const remote = ForgejoCli.parseForgejoRemote(identity.locator.remoteUrl);
192 if (
193 !remote ||
194 !identity.rootPath ||
195 (identity.provider !== undefined &&
196 identity.provider !== "unknown" &&
197 identity.provider !== "forgejo")
198 )
199 return identity;
200 const context = yield* resolveContext({
201 cwd: identity.rootPath,
202 context: {
203 provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" },
204 remoteName: identity.locator.remoteName,
205 remoteUrl: identity.locator.remoteUrl,
206 },
207 });
208 if (context?.provider.kind !== "forgejo") return identity;
209 const baseUrl = context.provider.baseUrl.replace(/\/+$/, "");
210 const basePath = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, "");
211 const path =
212 !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`)
213 ? remote.path.slice(basePath.length + 1)
214 : remote.path;
215 return {
216 ...identity,
217 provider: SourceControlProviderKind.make("forgejo"),
218 webUrl: `${baseUrl}/${path}`,
219 };
220});
221
222const RepositorySchema = Schema.Struct({
223 full_name: Schema.String,
224 clone_url: Schema.String,
225 ssh_url: Schema.String,
226 default_branch: Schema.optional(Schema.NullOr(Schema.String)),
227});
228const cloneUrls = (raw: typeof RepositorySchema.Type) => ({
229 nameWithOwner: raw.full_name,
230 url: raw.clone_url,
231 sshUrl: raw.ssh_url,
232});
233const repositoryPath = (repository: string) =>
234 `repos/${repository.split("/").map(encodeURIComponent).join("/")}`;
235
236export const make = Effect.gen(function* () {
237 const cli = yield* ForgejoCli.ForgejoCli;
238 const fs = yield* FileSystem.FileSystem;
239 const { process } = yield* SourceControlHost.SourceControlHost;
240 const request = <S extends Schema.Codec<unknown, unknown, never, never>>(
241 input: ForgejoCli.ForgejoApiInput,
242 schema: S,
243 ) =>
244 cli.api(input).pipe(
245 Effect.flatMap((result) =>
246 Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe(
247 Effect.mapError(
248 (cause) =>
249 new ForgejoCli.ForgejoCliError({
250 command: "tea",
251 cwd: input.cwd,
252 detail: "Forgejo API returned an invalid response.",
253 reason: "invalid-response",
254 cause,
255 }),
256 ),
257 ),
258 ),
259 );
260 const mapError = (operation: string, cwd: string) =>
261 Effect.mapError(
262 (cause: unknown) =>
263 new SourceControlProviderError({
264 provider: SourceControlProviderKind.make("forgejo"),
265 operation,
266 cwd,
267 ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
268 detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.",
269 cause,
270 }),
271 );
272 const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* (
273 input: Parameters<
274 SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"]
275 >[0],
276 ) {
277 const repo = yield* cli.resolveRepository(input);
278 const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1];
279 if (!number)
280 return yield* new ForgejoCli.ForgejoCliError({
281 command: "tea",
282 cwd: input.cwd,
283 detail: "Specify a pull request number or Forgejo pull request URL.",
284 });
285 return yield* request(
286 { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` },
287 ForgejoPullRequestSchema,
288 );
289 });
290 return SourceControlProvider.SourceControlProvider.of({
291 kind: SourceControlProviderKind.make("forgejo"),
292 repositoryNameFromRemoteUrl,
293 refineRepositoryIdentity,
294 listChangeRequests: (input) =>
295 Effect.gen(function* () {
296 const repo = yield* cli.resolveRepository(input);
297 const source = SourceControlProvider.sourceControlRefFromInput(input);
298 const branch = SourceControlProvider.sourceBranch(input);
299 const results: ReturnType<typeof toForgejoChangeRequest>[] = [];
300 const limit = input.limit ?? 20;
301 for (let page = 1; results.length < limit; page++) {
302 const items = yield* request(
303 {
304 ...input,
305 path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&head=${encodeURIComponent(branch)}&sort=recentupdate&limit=50&page=${page}`,
306 },
307 Schema.Array(ForgejoPullRequestSchema),
308 );
309 for (const item of items) {
310 if (
311 item.head.ref !== branch ||
312 (source?.repository && item.head.repo?.full_name !== source.repository) ||
313 (source?.owner && item.head.repo?.owner.login !== source.owner)
314 )
315 continue;
316 const normalized = toForgejoChangeRequest(item);
317 if (input.state === "all" || normalized.state === input.state) results.push(normalized);
318 }
319 if (items.length === 0) break;
320 }
321 return results.slice(0, limit);
322 }).pipe(mapError("listChangeRequests", input.cwd)),
323 getChangeRequest: (input) =>
324 getPull(input).pipe(
325 Effect.map(toForgejoChangeRequest),
326 mapError("getChangeRequest", input.cwd),
327 ),
328 createChangeRequest: (input) =>
329 Effect.gen(function* () {
330 const repo = yield* cli.resolveRepository(input);
331 const source = SourceControlProvider.sourceControlRefFromInput(input);
332 const owner = source?.owner ?? source?.repository?.split("/")[0];
333 const head = SourceControlProvider.sourceBranch(input);
334 yield* cli.api({
335 ...input,
336 path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`,
337 method: "POST",
338 body: {
339 base: input.target?.refName ?? input.baseRefName,
340 head: owner ? `${owner}:${head}` : head,
341 title: input.title,
342 body: yield* fs.readFileString(input.bodyFile),
343 },
344 });
345 }).pipe(mapError("createChangeRequest", input.cwd)),
346 getRepositoryCloneUrls: (input) =>
347 Effect.gen(function* () {
348 const repo = yield* cli.resolveRepository(input);
349 return cloneUrls(
350 yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema),
351 );
352 }).pipe(mapError("getRepositoryCloneUrls", input.cwd)),
353 createRepository: (input) =>
354 Effect.gen(function* () {
355 const repo = yield* cli.resolveRepository(input);
356 const user = yield* request(
357 { ...input, path: "user" },
358 Schema.Struct({ login: Schema.String }),
359 );
360 const [owner, name] = repo.repository.split("/");
361 return cloneUrls(
362 yield* request(
363 {
364 ...input,
365 path:
366 owner === user.login
367 ? "user/repos"
368 : `orgs/${encodeURIComponent(owner ?? "")}/repos`,
369 method: "POST",
370 body: { name, private: input.visibility === "private", auto_init: false },
371 },
372 RepositorySchema,
373 ),
374 );
375 }).pipe(mapError("createRepository", input.cwd)),
376 getDefaultBranch: (input) =>
377 Effect.gen(function* () {
378 const repo = yield* cli.resolveRepository(input);
379 return (
380 (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema))
381 .default_branch ?? null
382 );
383 }).pipe(mapError("getDefaultBranch", input.cwd)),
384 checkoutChangeRequest: (input) =>
385 Effect.gen(function* () {
386 const repo = yield* cli.resolveRepository(input);
387 const pull = yield* getPull(input);
388 if (repo.command === "fj") {
389 // fj checkout cannot target a repository outside the local remotes.
390 const urls = yield* request(
391 { ...input, path: repositoryPath(repo.repository) },
392 RepositorySchema,
393 );
394 const remote = input.context?.remoteUrl;
395 const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh;
396 yield* process.run({
397 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
398 command: "git",
399 cwd: input.cwd,
400 args: [
401 "fetch",
402 "--",
403 useSsh ? urls.ssh_url : urls.clone_url,
404 `refs/pull/${pull.number}/head`,
405 ],
406 });
407 const branch = `pulls/${pull.number}`;
408 const existing = yield* process.run({
409 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
410 command: "git",
411 cwd: input.cwd,
412 args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
413 allowNonZeroExit: true,
414 });
415 yield* process.run({
416 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
417 command: "git",
418 cwd: input.cwd,
419 args:
420 existing.exitCode === 0
421 ? ["checkout", branch]
422 : ["checkout", "-b", branch, "FETCH_HEAD"],
423 });
424 } else
425 yield* cli.execute({
426 cwd: input.cwd,
427 args: [
428 "pulls",
429 "checkout",
430 "--login",
431 repo.login,
432 "--repo",
433 repo.repository,
434 "--branch",
435 String(pull.number),
436 ],
437 });
438 if (input.force) {
439 // tea leaves an existing PR branch at its old tip. Keep dirty files safe
440 // while bringing the selected branch to the PR revision we fetched.
441 yield* process.run({
442 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
443 command: "git",
444 cwd: input.cwd,
445 args: ["reset", "--keep", pull.head.sha],
446 });
447 }
448 }).pipe(mapError("checkoutChangeRequest", input.cwd)),
449 });
450});