| 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 | */ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { |
| 8 | type ChangeRequestProjectIdentity, |
| 9 | defineSourceControlClient, |
| 10 | isChangeRequestAuthorityOfProject, |
| 11 | isChangeRequestInProjectRepository, |
| 12 | isChangeRequestOnProjectHost, |
| 13 | isChangeRequestPath, |
| 14 | } from "@t3tools/source-control-core/client/definition"; |
| 15 | |
| 16 | const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; |
| 17 | const CHANGE_REQUEST_REFERENCE = |
| 18 | /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; |
| 19 | |
| 20 | const 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 | */ |
| 26 | function 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 | |
| 36 | export 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 | }); |