1/**
2 * Forgejo's client definition, which covers Gitea as well. Browser- and React Native-safe.
3 *
4 * @module source-control-forgejo/client/definition
5 */
6import { SourceControlProviderKind } from "@t3tools/contracts";
7import {
8 type ChangeRequestProjectIdentity,
9 defineSourceControlClient,
10 isChangeRequestAuthorityOfProject,
11 isChangeRequestInProjectRepository,
12 isChangeRequestOnProjectHost,
13 isChangeRequestPath,
14} from "@t3tools/source-control-core/client/definition";
15
16const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i;
17const CHANGE_REQUEST_REFERENCE =
18 /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i;
19
20const KIND = SourceControlProviderKind.make("forgejo");
21
22/**
23 * The repository's browser URL the server resolved from the signed-in account, which is the
24 * only place a Forgejo mounted below a path says so.
25 */
26function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null {
27 if (!identity.webUrl) return null;
28 try {
29 const url = new URL(identity.webUrl);
30 return url.protocol === "http:" || url.protocol === "https:" ? url : null;
31 } catch {
32 return null;
33 }
34}
35
36export const definition = defineSourceControlClient({
37 kind: KIND,
38 label: "Forgejo",
39 pickerLabel: "Forgejo / Gitea",
40 icon: "forgejo",
41 changeRequest: { shortLabel: "PR", singular: "pull request" },
42 repositoryPathHint: "owner/repo",
43 // Forgejo and Gitea are self-hosted, so no hostname names them.
44 publicHost: null,
45 publishDescription: "Your signed-in server",
46 publishHost: (signedInHost) => signedInHost ?? "your server",
47 newRepositoryOwner: (account) => (account ? { owner: account } : null),
48 defaultCloneTransport: "https",
49 // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the
50 // origin, which may carry a port. SSH remotes say nothing about the web origin.
51 changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => {
52 if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`;
53 try {
54 const remote = new URL(remoteUrl ?? "");
55 if (
56 (remote.protocol === "http:" || remote.protocol === "https:") &&
57 (remote.hostname.toLowerCase() === host.toLowerCase() ||
58 remote.host.toLowerCase() === host.toLowerCase())
59 ) {
60 return `${remote.origin}/${repository}/pulls/${number}`;
61 }
62 } catch {
63 // Not an HTTP remote.
64 }
65 return `https://${host}/${repository}/pulls/${number}`;
66 },
67 changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const),
68 // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself.
69 checkoutCommand: ({ number, repositoryUrl }) =>
70 repositoryUrl
71 ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD`
72 : null,
73 authorProfileUrl: () => null,
74 // Forgejo refuses a change request review without a summary, even with inline comments.
75 referenceAutolinkRepositoryUrl: () => null,
76 reviewSummaryRequired: (verdict) => verdict === "request-changes",
77 checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null,
78 isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url),
79 // Forgejo servers are addressed with their port, so two on one hostname stay apart.
80 changeRequestUrlHost: (url) => url.host,
81 checkoutChangeRequestHost: () => null,
82 isChangeRequestInRepository: (identity, link) => {
83 if (!isChangeRequestAuthorityOfProject(identity, link)) return false;
84 const web = resolvedWebUrl(identity);
85 if (!web) return isChangeRequestInProjectRepository(KIND, identity, link);
86 return (
87 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
88 web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase()
89 );
90 },
91 // A server mounted below a path serves only repositories under that mount.
92 canReadChangeRequestOnHost: (identity, link) => {
93 const web = resolvedWebUrl(identity);
94 if (!web) return isChangeRequestOnProjectHost(KIND, identity, link);
95 const mount = web.pathname
96 .replace(/^\/+|\/+$/g, "")
97 .split("/")
98 .slice(0, -2)
99 .join("/");
100 return (
101 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
102 (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`))
103 );
104 },
105 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"),
106});