Open History to see the last commit
1import { SourceControlProviderKind } from "@t3tools/contracts";
2import {
3 buildCommitUrl,
4 changeRequestUrlBelow,
5 type ChangeRequestHostIdentity,
6 type ChangeRequestProjectIdentity,
7 defineSourceControl,
8 isChangeRequestAuthorityOfProject,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
11 parseChangeRequestPath,
12 parseCommitPath,
13 repositoryUrlFromPagePath,
14 projectChangeRequestHost,
15 remoteWebOrigin,
16} from "@t3tools/source-control-core/shared/definition";
17import { icon } from "./icon.ts";
18
19const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls?)\s+(?:checkout|co)\s+(.+)$/i;
20const CHANGE_REQUEST_REFERENCE =
21 /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i;
22
23// Forgejo and Gitea run on any hostname, possibly mounted below a path.
24const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u;
25const COMMIT_PATH = /^\/([^/]+(?:\/[^/]+)+)\/commit\/([0-9a-f]+)\/?$/iu;
26const REPOSITORY_PAGE_PATH = /^\/([^/]+(?:\/[^/]+)+?)\/src\/(?:branch|tag|commit)\/[^/]/u;
27
28const KIND = SourceControlProviderKind.make("forgejo");
29
30const changeRequestHeadRef = (number: number) => `refs/pull/${number}/head`;
31
32/**
33 * The repository's browser URL the server resolved from the signed-in account, which is the
34 * only place a Forgejo mounted below a path says so.
35 */
36function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null {
37 if (!identity.webUrl) return null;
38 try {
39 const url = new URL(identity.webUrl);
40 return url.protocol === "http:" || url.protocol === "https:" ? url : null;
41 } catch {
42 return null;
43 }
44}
45
46/** A server's identity includes its port, which only an HTTP remote names. */
47function changeRequestHost(identity: ChangeRequestHostIdentity): string {
48 try {
49 const remote = new URL(identity?.locator?.remoteUrl ?? "");
50 if (remote.protocol === "http:" || remote.protocol === "https:") {
51 return remote.host.toLowerCase();
52 }
53 } catch {
54 // SSH remotes do not name the web server.
55 }
56 return projectChangeRequestHost(identity, KIND);
57}
58
59export const definition = defineSourceControl({
60 kind: KIND,
61 label: "Forgejo",
62 pickerLabel: "Forgejo / Gitea",
63 icon,
64 searchKeywords: ["gitea", "tea", "codeberg"],
65 changeRequest: {
66 shortLabel: "PR",
67 shortLabelWithArticle: "a PR",
68 singular: "pull request",
69 plural: "pull requests",
70 },
71 repositoryPathHint: "owner/repo",
72 // Codeberg is the public Forgejo; any other server is self-hosted.
73 publicHost: "codeberg.org",
74 publishDescription: "Your signed-in server",
75 publishHost: (signedInHost) => signedInHost ?? "your server",
76 newRepositoryOwner: (account) => (account ? { owner: account } : null),
77 defaultCloneTransport: "https",
78 // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the
79 // origin, which may carry a port. SSH remotes say nothing about the web origin.
80 changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) =>
81 webUrl
82 ? changeRequestUrlBelow(webUrl, `pulls/${number}`)
83 : `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`,
84 // A server's identity includes its port, so the link carries it.
85 parseChangeRequestUrl: (url) => {
86 const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH);
87 return link === null ? null : { ...link, authority: url.host.toLowerCase() };
88 },
89 changeRequestUrlFromReferenceAutolink: () => null,
90 commitUrl: (input) => buildCommitUrl(input, "commit"),
91 parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH),
92 repositoryUrlFromPage: (url, known) => {
93 // A mount path may itself contain `src/branch/`, so a `.git` path is always the clone URL.
94 if (url.pathname.replace(/\/$/u, "").endsWith(".git")) return null;
95 const repositoryUrl = repositoryUrlFromPagePath(url, REPOSITORY_PAGE_PATH);
96 // Elsewhere the same path is a GitLab project nested under groups named `src` and `branch`.
97 return repositoryUrl === null || known ? repositoryUrl : url.href;
98 },
99 changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const),
100 // Neither `fj` nor `tea` checks out by number.
101 checkoutCommand: ({ number, repositoryUrl }) =>
102 repositoryUrl
103 ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD`
104 : null,
105 // tea also takes `pull` for `pulls` and `m` for `merge`.
106 changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls?)|fj\s+pr)\s+(?:merge|m|close)\b/u,
107 authorProfileUrl: () => null,
108 hardLineBreaks: true,
109 // Forgejo refuses a change request review without a summary, even with inline comments.
110 reviewSummaryRequired: (verdict) => verdict === "request-changes",
111 checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null,
112 isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url),
113 checkoutChangeRequestHost: () => null,
114 changeRequestHeadRef,
115 recognizeRemote: ({ remoteUrl, host, hostname }) =>
116 hostname === "codeberg.org" ||
117 hostname.split(".").includes("forgejo") ||
118 hostname.split(".").includes("gitea")
119 ? {
120 kind: KIND,
121 name: "Forgejo",
122 baseUrl: /^https?:/iu.test(remoteUrl) ? new URL(remoteUrl).origin : `https://${host}`,
123 }
124 : null,
125 changeRequestHost,
126 isChangeRequestInRepository: (identity, link) => {
127 if (!isChangeRequestAuthorityOfProject(identity, link)) return false;
128 const web = resolvedWebUrl(identity);
129 if (!web)
130 return isChangeRequestInProjectRepository(changeRequestHost(identity), identity, link);
131 return (
132 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
133 web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase()
134 );
135 },
136 // A server mounted below a path serves only repositories under that mount.
137 canReadChangeRequestOnHost: (identity, link) => {
138 const web = resolvedWebUrl(identity);
139 if (!web) return isChangeRequestOnProjectHost(changeRequestHost(identity), identity, link);
140 const mount = web.pathname
141 .replace(/^\/+|\/+$/g, "")
142 .split("/")
143 .slice(0, -2)
144 .join("/");
145 return (
146 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
147 (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`))
148 );
149 },
150});