authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:07:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:07:01-07:00
logb0249ac8711703f1a1b8cd0f3acd11155f5bf172
treeb40040b1ab7d3ea245dee98bad131e31eb235d90
parent579159d6c6e96f9c18358e492fbf0b8af740e80c
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): reference parsing and project matching are host resolvers (#17770)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

10 files changed, 286 insertions(+), 136 deletions(-)

apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+4-7
...@@ -1288,12 +1288,9 @@ export function resolvePullRequestReferenceHost(...@@ -1288,12 +1288,9 @@ export function resolvePullRequestReferenceHost(
1288 reference: PullRequestRef,1288 reference: PullRequestRef,
1289 identity: RepositoryIdentity | null | undefined,1289 identity: RepositoryIdentity | null | undefined,
1290): PullRequestRef {1290): PullRequestRef {
1291 // Other providers may resolve an SSH remote to a different web authority on the server.1291 if (reference.host !== undefined || !identity?.provider) return reference;
1292 if (reference.host !== undefined || identity?.provider !== "github") return reference;1292 const host = sourceControlClients.get(identity.provider).checkoutChangeRequestHost(identity);
1293 return {1293 return host === null ? reference : { ...reference, host };
1294 ...reference,
1295 host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")),
1296 };
1297}1294}
12981295
1299export interface PullRequestDetailSnapshotRef {1296export interface PullRequestDetailSnapshotRef {
...@@ -1377,7 +1374,7 @@ export function resolveDisplayedPullRequestDetail(input: {...@@ -1377,7 +1374,7 @@ export function resolveDisplayedPullRequestDetail(input: {
1377 if (input.reference.host === undefined) return input.cached;1374 if (input.reference.host === undefined) return input.cached;
1378 try {1375 try {
1379 const url = new URL(input.cached.url);1376 const url = new URL(input.cached.url);
1380 const host = input.cached.provider === "forgejo" ? url.host : url.hostname;1377 const host = sourceControlClients.get(input.cached.provider).changeRequestUrlHost(url);
1381 return (url.protocol === "https:" || url.protocol === "http:") &&1378 return (url.protocol === "https:" || url.protocol === "http:") &&
1382 host.toLowerCase() === input.reference.host.toLowerCase()1379 host.toLowerCase() === input.reference.host.toLowerCase()
1383 ? input.cached1380 ? input.cached
apps/web/src/lib/openPullRequestLink.ts+6-81
...@@ -3,12 +3,9 @@ import { useAtomValue } from "@effect/atom-react";...@@ -3,12 +3,9 @@ import { useAtomValue } from "@effect/atom-react";
3import { useNavigate } from "@tanstack/react-router";3import { useNavigate } from "@tanstack/react-router";
4import { type MouseEvent, useCallback, useMemo } from "react";4import { type MouseEvent, useCallback, useMemo } from "react";
55
6import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";6import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
7import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";7import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
8import {8import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
9 canonicalRepositoryKey,
10 sourceControlRepositorySelector,
11} from "@t3tools/shared/sourceControl";
129
13import { useOpenLink } from "../browser/useOpenLink";10import { useOpenLink } from "../browser/useOpenLink";
14import { stackedThreadToast, toastManager } from "../components/ui/toast";11import { stackedThreadToast, toastManager } from "../components/ui/toast";
...@@ -28,34 +25,6 @@ export {...@@ -28,34 +25,6 @@ export {
28 changeRequestRepositoryUrl,25 changeRequestRepositoryUrl,
29} from "@t3tools/shared/changeRequestUrl";26} from "@t3tools/shared/changeRequestUrl";
3027
31function resolvedForgejoRepository(project: EnvironmentProject): URL | null {
32 const identity = project.repositoryIdentity;
33 if (identity?.provider !== "forgejo" || !identity.webUrl) return null;
34 try {
35 const url = new URL(identity.webUrl);
36 return url.protocol === "http:" || url.protocol === "https:" ? url : null;
37 } catch {
38 return null;
39 }
40}
41
42/** Keep Forgejo servers on different HTTP ports separate when selecting a project. */
43function matchesChangeRequestAuthority(
44 project: EnvironmentProject,
45 link: ChangeRequestLink,
46): boolean {
47 if (link.authority === undefined) return true;
48 try {
49 const remote = new URL(project.repositoryIdentity?.locator.remoteUrl ?? "");
50 if (remote.protocol === "http:" || remote.protocol === "https:") {
51 return remote.host.toLowerCase() === link.authority;
52 }
53 } catch {
54 // SSH remotes do not specify the server's HTTP port; tea resolves the configured login.
55 }
56 return true;
57}
58
59/**28/**
60 * The project a link belongs to, or nothing. Matched the way the server matches: the repository29 * The project a link belongs to, or nothing. Matched the way the server matches: the repository
61 * identity is the full path below the host where one was recorded — which is what nested GitLab30 * identity is the full path below the host where one was recorded — which is what nested GitLab
...@@ -68,29 +37,9 @@ export function findProjectForChangeRequest(...@@ -68,29 +37,9 @@ export function findProjectForChangeRequest(
68): EnvironmentProject | undefined {37): EnvironmentProject | undefined {
69 return projects.find((project) => {38 return projects.find((project) => {
70 const identity = project.repositoryIdentity;39 const identity = project.repositoryIdentity;
71 if (!identity || !matchesChangeRequestAuthority(project, link)) return false;
72 const kind = identity.provider as SourceControlProviderKind | undefined;
73 if (kind === undefined) return false;
74 const web = resolvedForgejoRepository(project);
75 if (web)
76 return (
77 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
78 web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase()
79 );
80 if (kind === "azure-devops") {
81 return (
82 canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
83 canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase())
84 );
85 }
86 const repository =
87 identity.displayName ??
88 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
89 return (40 return (
90 repository !== null &&41 identity?.provider !== undefined &&
91 repository.toLowerCase() === link.repository.toLowerCase() &&42 sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link)
92 (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
93 pullRequestHostOf(identity, kind) === link.authority)
94 );43 );
95 });44 });
96}45}
...@@ -151,35 +100,11 @@ export function findProjectOnChangeRequestHost(...@@ -151,35 +100,11 @@ export function findProjectOnChangeRequestHost(
151): EnvironmentProject | undefined {100): EnvironmentProject | undefined {
152 const own = findProjectForChangeRequest(projects, link);101 const own = findProjectForChangeRequest(projects, link);
153 if (own !== undefined) return own;102 if (own !== undefined) return own;
154 // Azure CLI reads use the checkout's organization and project, not host-wide credentials.
155 if (
156 canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith(
157 "dev.azure.com/",
158 )
159 )
160 return undefined;
161 return projects.find((project) => {103 return projects.find((project) => {
162 const identity = project.repositoryIdentity;104 const identity = project.repositoryIdentity;
163 const kind = identity?.provider as SourceControlProviderKind | undefined;
164 const web = resolvedForgejoRepository(project);
165 if (web) {
166 const mount = web.pathname
167 .replace(/^\/+|\/+$/g, "")
168 .split("/")
169 .slice(0, -2)
170 .join("/");
171 return (
172 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
173 (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`))
174 );
175 }
176 return (105 return (
177 identity != null &&106 identity?.provider !== undefined &&
178 kind !== undefined &&107 sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link)
179 kind !== "azure-devops" &&
180 matchesChangeRequestAuthority(project, link) &&
181 (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
182 pullRequestHostOf(identity, kind) === link.authority)
183 );108 );
184 });109 });
185}110}
apps/web/src/pullRequestReference.ts+15-41
...@@ -1,57 +1,31 @@...@@ -1,57 +1,31 @@
1const FORGEJO_PULL_REQUEST_URL_PATTERN =1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2 /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i;
3const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i;
4const GITHUB_PULL_REQUEST_URL_PATTERN =
5 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
6const GITLAB_MERGE_REQUEST_URL_PATTERN =
7 /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i;
8const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN =
9 /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i;
10const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
11const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i;
12const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i;
13const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i;
142
15function parseAzureDevOpsCheckoutReference(args: string): string | null {3const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
16 const parts = args.trim().split(/\s+/).filter(Boolean);
17 for (const [index, part] of parts.entries()) {
18 if (part === "--id" || part === "-i") {
19 return parts[index + 1] ?? null;
20 }
21 if (part.startsWith("--id=")) {
22 return part.slice("--id=".length) || null;
23 }
24 }
25 return parts.find((part) => !part.startsWith("-")) ?? null;
26}
274
5/**
6 * The change request a pasted reference names: a host's change request URL, returned as is, or
7 * a bare number. A host's checkout command, such as `gh pr checkout 42`, is read for its
8 * argument first. Null for anything else, such as a branch name.
9 */
28export function parsePullRequestReference(input: string): string | null {10export function parsePullRequestReference(input: string): string | null {
29 const trimmed = input.trim();11 const trimmed = input.trim();
30 if (trimmed.length === 0) {12 if (trimmed.length === 0) {
31 return null;13 return null;
32 }14 }
3315
34 const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
35 const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
36 const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
37 const normalizedInput =16 const normalizedInput =
38 FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ??17 sourceControlClients.definitions
39 ghCliCheckoutMatch?.[1]?.trim() ??18 .map((definition) => definition.checkoutCommandArgument(trimmed))
40 glabCliCheckoutMatch?.[1]?.trim() ??19 .find((argument) => argument !== null) ?? trimmed;
41 (azureDevOpsCliCheckoutMatch?.[1]
42 ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1])
43 : null) ??
44 trimmed;
45 if (normalizedInput.length === 0) {20 if (normalizedInput.length === 0) {
46 return null;21 return null;
47 }22 }
4823
49 const urlMatch =24 if (
50 FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??25 sourceControlClients.definitions.some((definition) =>
51 GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??26 definition.isChangeRequestReference(normalizedInput),
52 GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??27 )
53 AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);28 ) {
54 if (urlMatch?.[1]) {
55 return normalizedInput;29 return normalizedInput;
56 }30 }
5731
packages/source-control-azure-devops/src/client/definition.ts+29
...@@ -7,9 +7,24 @@ import { SourceControlProviderKind } from "@t3tools/contracts";...@@ -7,9 +7,24 @@ import { SourceControlProviderKind } from "@t3tools/contracts";
7import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl";7import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl";
8import {8import {
9 defineSourceControlClient,9 defineSourceControlClient,
10 isChangeRequestAuthorityOfProject,
10 isChangeRequestPath,11 isChangeRequestPath,
11} from "@t3tools/source-control-core/client/definition";12} from "@t3tools/source-control-core/client/definition";
1213
14const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i;
15const CHANGE_REQUEST_REFERENCE =
16 /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i;
17
18/** `az repos pr checkout` names the pull request by `--id`, `-i`, or its first plain argument. */
19function checkoutCommandId(args: string): string | null {
20 const parts = args.trim().split(/\s+/).filter(Boolean);
21 for (const [index, part] of parts.entries()) {
22 if (part === "--id" || part === "-i") return parts[index + 1] ?? null;
23 if (part.startsWith("--id=")) return part.slice("--id=".length) || null;
24 }
25 return parts.find((part) => !part.startsWith("-")) ?? null;
26}
27
13export const definition = defineSourceControlClient({28export const definition = defineSourceControlClient({
14 kind: SourceControlProviderKind.make("azure-devops"),29 kind: SourceControlProviderKind.make("azure-devops"),
15 label: "Azure DevOps",30 label: "Azure DevOps",
...@@ -29,5 +44,19 @@ export const definition = defineSourceControlClient({...@@ -29,5 +44,19 @@ export const definition = defineSourceControlClient({
29 authorProfileUrl: () => null,44 authorProfileUrl: () => null,
30 referenceAutolinkRepositoryUrl: () => null,45 referenceAutolinkRepositoryUrl: () => null,
31 reviewSummaryRequired: () => false,46 reviewSummaryRequired: () => false,
47 checkoutCommandArgument: (input) => {
48 const args = CHECKOUT_COMMAND.exec(input)?.[1];
49 return args ? checkoutCommandId(args) : null;
50 },
51 isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url),
52 changeRequestUrlHost: (url) => url.hostname,
53 checkoutChangeRequestHost: () => null,
54 // The legacy and SSH spellings of one repository all canonicalise to dev.azure.com.
55 isChangeRequestInRepository: (identity, link) =>
56 isChangeRequestAuthorityOfProject(identity, link) &&
57 canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
58 canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()),
59 // `az repos` reads use the checkout's organization and project, not host-wide credentials.
60 canReadChangeRequestOnHost: () => false,
32 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"),61 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"),
33});62});
packages/source-control-bitbucket/src/client/definition.ts+14-1
...@@ -6,14 +6,18 @@...@@ -6,14 +6,18 @@
6import { SourceControlProviderKind } from "@t3tools/contracts";6import { SourceControlProviderKind } from "@t3tools/contracts";
7import {7import {
8 defineSourceControlClient,8 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
9 isChangeRequestPath,11 isChangeRequestPath,
10} from "@t3tools/source-control-core/client/definition";12} from "@t3tools/source-control-core/client/definition";
1113
12const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;14const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
13const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;15const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1416
17const KIND = SourceControlProviderKind.make("bitbucket");
18
15export const definition = defineSourceControlClient({19export const definition = defineSourceControlClient({
16 kind: SourceControlProviderKind.make("bitbucket"),20 kind: KIND,
17 label: "Bitbucket",21 label: "Bitbucket",
18 pickerLabel: "Bitbucket",22 pickerLabel: "Bitbucket",
19 icon: "bitbucket",23 icon: "bitbucket",
...@@ -35,5 +39,14 @@ export const definition = defineSourceControlClient({...@@ -35,5 +39,14 @@ export const definition = defineSourceControlClient({
35 authorProfileUrl: () => null,39 authorProfileUrl: () => null,
36 referenceAutolinkRepositoryUrl: () => null,40 referenceAutolinkRepositoryUrl: () => null,
37 reviewSummaryRequired: () => false,41 reviewSummaryRequired: () => false,
42 // No checkout CLI, and the reference field never accepted Bitbucket URLs.
43 checkoutCommandArgument: () => null,
44 isChangeRequestReference: () => false,
45 changeRequestUrlHost: (url) => url.hostname,
46 checkoutChangeRequestHost: () => null,
47 isChangeRequestInRepository: (identity, link) =>
48 isChangeRequestInProjectRepository(KIND, identity, link),
49 canReadChangeRequestOnHost: (identity, link) =>
50 isChangeRequestOnProjectHost(KIND, identity, link),
38 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),51 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),
39});52});
packages/source-control-core/src/client/definition.ts+115-1
...@@ -11,7 +11,13 @@...@@ -11,7 +11,13 @@
11 *11 *
12 * @module source-control-core/client/definition12 * @module source-control-core/client/definition
13 */13 */
14import { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts";14import {
15 pullRequestHostOf,
16 type PullRequestReviewVerdict,
17 type RepositoryIdentity,
18 SourceControlProviderKind,
19} from "@t3tools/contracts";
20import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
1521
16/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */22/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */
17export interface ChangeRequestTerminology {23export interface ChangeRequestTerminology {
...@@ -32,6 +38,12 @@ export interface ChangeRequestUrlInput {...@@ -32,6 +38,12 @@ export interface ChangeRequestUrlInput {
32 readonly webUrl?: string | undefined;38 readonly webUrl?: string | undefined;
33}39}
3440
41/** A project's repository, as a change request link is matched against it. */
42export type ChangeRequestProjectIdentity = Pick<
43 RepositoryIdentity,
44 "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name"
45>;
46
35/** The change request a checkout command is built for. */47/** The change request a checkout command is built for. */
36export interface ChangeRequestCheckoutInput {48export interface ChangeRequestCheckoutInput {
37 readonly number: number;49 readonly number: number;
...@@ -76,6 +88,43 @@ export interface SourceControlClientDefinition {...@@ -76,6 +88,43 @@ export interface SourceControlClientDefinition {
76 readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null;88 readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null;
77 /** Whether a review with this verdict must carry a summary, beyond what every host asks. */89 /** Whether a review with this verdict must carry a summary, beyond what every host asks. */
78 readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean;90 readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean;
91 /**
92 * The change request a pasted command names, such as `gh pr checkout 42`: its argument, which
93 * is a number or a URL. Null for anything that is not this host's checkout command.
94 */
95 readonly checkoutCommandArgument: (input: string) => string | null;
96 /**
97 * Whether a pasted URL is one of this host's change request URLs. Stricter than
98 * `isChangeRequestUrl`, which only tells hosts apart, because this decides what a reference
99 * field accepts.
100 */
101 readonly isChangeRequestReference: (url: string) => boolean;
102 /**
103 * Whether a change request link names this project's own repository. The link's host is where
104 * the repository is addressed, `link.authority` the web host and port where they differ.
105 */
106 readonly isChangeRequestInRepository: (
107 identity: ChangeRequestProjectIdentity,
108 link: ChangeRequestLink,
109 ) => boolean;
110 /**
111 * Whether this project can read a change request on the link's host on the link's behalf, for a
112 * repository nobody has checked out. False where the host's reads are scoped to the checkout.
113 */
114 readonly canReadChangeRequestOnHost: (
115 identity: ChangeRequestProjectIdentity,
116 link: ChangeRequestLink,
117 ) => boolean;
118 /**
119 * The host a change request URL addresses its repository below, as a `PullRequestRef.host`
120 * names it: the hostname, or the host and port for servers addressed by both.
121 */
122 readonly changeRequestUrlHost: (url: URL) => string;
123 /**
124 * The host a reference with none names, read from the checkout alone, or null where only the
125 * server can tell, such as when an SSH remote resolves to a different web host.
126 */
127 readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null;
79 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */128 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */
80 readonly isChangeRequestUrl: (url: string) => boolean;129 readonly isChangeRequestUrl: (url: string) => boolean;
81}130}
...@@ -100,6 +149,63 @@ export function defineSourceControlClient<const Definition extends SourceControl...@@ -100,6 +149,63 @@ export function defineSourceControlClient<const Definition extends SourceControl
100 return definition;149 return definition;
101}150}
102151
152/**
153 * Whether a project's HTTP remote names the link's web authority. A link carries one only where
154 * the host and port differ from where the repository is addressed, as on a Forgejo server with a
155 * port; an SSH remote says nothing about ports, so it does not rule the link out.
156 */
157export function isChangeRequestAuthorityOfProject(
158 identity: ChangeRequestProjectIdentity,
159 link: ChangeRequestLink,
160): boolean {
161 if (link.authority === undefined) return true;
162 try {
163 const remote = new URL(identity.locator.remoteUrl);
164 if (remote.protocol === "http:" || remote.protocol === "https:") {
165 return remote.host.toLowerCase() === link.authority;
166 }
167 } catch {
168 // SSH remotes do not specify the server's HTTP port.
169 }
170 return true;
171}
172
173/** Whether a project lives on the link's host, as `pullRequestHostOf` reads it from the checkout. */
174export function isChangeRequestOnProjectHost(
175 kind: SourceControlProviderKind,
176 identity: ChangeRequestProjectIdentity,
177 link: ChangeRequestLink,
178): boolean {
179 if (!isChangeRequestAuthorityOfProject(identity, link)) return false;
180 const host = pullRequestHostOf(identity, kind);
181 return host === link.host.toLowerCase() || host === link.authority;
182}
183
184/** The repository path a project's identity names below its host. */
185function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | null {
186 return (
187 identity.displayName ??
188 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null)
189 );
190}
191
192/**
193 * `isChangeRequestInRepository` for hosts that address a repository by its full path below the
194 * host, which is what nested GitLab groups need.
195 */
196export function isChangeRequestInProjectRepository(
197 kind: SourceControlProviderKind,
198 identity: ChangeRequestProjectIdentity,
199 link: ChangeRequestLink,
200): boolean {
201 const repository = projectRepositoryPath(identity);
202 return (
203 repository !== null &&
204 repository.toLowerCase() === link.repository.toLowerCase() &&
205 isChangeRequestOnProjectHost(kind, identity, link)
206 );
207}
208
103/** What clients show for a host they ship no definition for, including `unknown`. */209/** What clients show for a host they ship no definition for, including `unknown`. */
104export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {210export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
105 kind: SourceControlProviderKind.make("unknown"),211 kind: SourceControlProviderKind.make("unknown"),
...@@ -117,6 +223,14 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {...@@ -117,6 +223,14 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
117 authorProfileUrl: () => null,223 authorProfileUrl: () => null,
118 referenceAutolinkRepositoryUrl: () => null,224 referenceAutolinkRepositoryUrl: () => null,
119 reviewSummaryRequired: () => false,225 reviewSummaryRequired: () => false,
226 checkoutCommandArgument: () => null,
227 isChangeRequestReference: () => false,
228 changeRequestUrlHost: (url) => url.hostname,
229 checkoutChangeRequestHost: () => null,
230 isChangeRequestInRepository: (identity, link) =>
231 isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link),
232 canReadChangeRequestOnHost: (identity, link) =>
233 isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link),
120 isChangeRequestUrl: () => false,234 isChangeRequestUrl: () => false,
121};235};
122236
packages/source-control-forgejo/src/client/definition.ts+53-1
...@@ -5,12 +5,36 @@...@@ -5,12 +5,36 @@
5 */5 */
6import { SourceControlProviderKind } from "@t3tools/contracts";6import { SourceControlProviderKind } from "@t3tools/contracts";
7import {7import {
8 type ChangeRequestProjectIdentity,
8 defineSourceControlClient,9 defineSourceControlClient,
10 isChangeRequestAuthorityOfProject,
11 isChangeRequestInProjectRepository,
12 isChangeRequestOnProjectHost,
9 isChangeRequestPath,13 isChangeRequestPath,
10} from "@t3tools/source-control-core/client/definition";14} from "@t3tools/source-control-core/client/definition";
1115
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
12export const definition = defineSourceControlClient({36export const definition = defineSourceControlClient({
13 kind: SourceControlProviderKind.make("forgejo"),37 kind: KIND,
14 label: "Forgejo",38 label: "Forgejo",
15 pickerLabel: "Forgejo / Gitea",39 pickerLabel: "Forgejo / Gitea",
16 icon: "forgejo",40 icon: "forgejo",
...@@ -48,5 +72,33 @@ export const definition = defineSourceControlClient({...@@ -48,5 +72,33 @@ export const definition = defineSourceControlClient({
48 // Forgejo refuses a change request review without a summary, even with inline comments.72 // Forgejo refuses a change request review without a summary, even with inline comments.
49 referenceAutolinkRepositoryUrl: () => null,73 referenceAutolinkRepositoryUrl: () => null,
50 reviewSummaryRequired: (verdict) => verdict === "request-changes",74 reviewSummaryRequired: (verdict) => verdict === "request-changes",
75 checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null,
76 isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url),
77 // Forgejo servers are addressed with their port, so two on one hostname stay apart.
78 changeRequestUrlHost: (url) => url.host,
79 checkoutChangeRequestHost: () => null,
80 isChangeRequestInRepository: (identity, link) => {
81 if (!isChangeRequestAuthorityOfProject(identity, link)) return false;
82 const web = resolvedWebUrl(identity);
83 if (!web) return isChangeRequestInProjectRepository(KIND, identity, link);
84 return (
85 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
86 web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase()
87 );
88 },
89 // A server mounted below a path serves only repositories under that mount.
90 canReadChangeRequestOnHost: (identity, link) => {
91 const web = resolvedWebUrl(identity);
92 if (!web) return isChangeRequestOnProjectHost(KIND, identity, link);
93 const mount = web.pathname
94 .replace(/^\/+|\/+$/g, "")
95 .split("/")
96 .slice(0, -2)
97 .join("/");
98 return (
99 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
100 (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`))
101 );
102 },
51 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"),103 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"),
52});104});
packages/source-control-gitcafe/src/client/definition.ts+14-1
...@@ -6,14 +6,18 @@...@@ -6,14 +6,18 @@
6import { SourceControlProviderKind } from "@t3tools/contracts";6import { SourceControlProviderKind } from "@t3tools/contracts";
7import {7import {
8 defineSourceControlClient,8 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
9 isChangeRequestPath,11 isChangeRequestPath,
10} from "@t3tools/source-control-core/client/definition";12} from "@t3tools/source-control-core/client/definition";
1113
12/** GitCafe has no self-hosted installs: production and staging are its only hosts. */14/** GitCafe has no self-hosted installs: production and staging are its only hosts. */
13const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);15const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);
1416
17const KIND = SourceControlProviderKind.make("gitcafe");
18
15export const definition = defineSourceControlClient({19export const definition = defineSourceControlClient({
16 kind: SourceControlProviderKind.make("gitcafe"),20 kind: KIND,
17 label: "GitCafe",21 label: "GitCafe",
18 pickerLabel: "GitCafe",22 pickerLabel: "GitCafe",
19 icon: "gitcafe",23 icon: "gitcafe",
...@@ -32,6 +36,15 @@ export const definition = defineSourceControlClient({...@@ -32,6 +36,15 @@ export const definition = defineSourceControlClient({
32 referenceAutolinkRepositoryUrl: () => null,36 referenceAutolinkRepositoryUrl: () => null,
33 reviewSummaryRequired: () => false,37 reviewSummaryRequired: () => false,
34 // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it.38 // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it.
39 // `cafe pr checkout` and git.cafe URLs were never accepted in reference fields.
40 checkoutCommandArgument: () => null,
41 isChangeRequestReference: () => false,
42 changeRequestUrlHost: (url) => url.hostname,
43 checkoutChangeRequestHost: () => null,
44 isChangeRequestInRepository: (identity, link) =>
45 isChangeRequestInProjectRepository(KIND, identity, link),
46 canReadChangeRequestOnHost: (identity, link) =>
47 isChangeRequestOnProjectHost(KIND, identity, link),
35 isChangeRequestUrl: (url) => {48 isChangeRequestUrl: (url) => {
36 try {49 try {
37 return (50 return (
packages/source-control-github/src/client/definition.ts+19-2
...@@ -3,14 +3,22 @@...@@ -3,14 +3,22 @@
3 *3 *
4 * @module source-control-github/client/definition4 * @module source-control-github/client/definition
5 */5 */
6import { SourceControlProviderKind } from "@t3tools/contracts";6import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts";
7import {7import {
8 defineSourceControlClient,8 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
9 isChangeRequestPath,11 isChangeRequestPath,
10} from "@t3tools/source-control-core/client/definition";12} from "@t3tools/source-control-core/client/definition";
1113
14const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i;
15const CHANGE_REQUEST_REFERENCE =
16 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
17
18const KIND = SourceControlProviderKind.make("github");
19
12export const definition = defineSourceControlClient({20export const definition = defineSourceControlClient({
13 kind: SourceControlProviderKind.make("github"),21 kind: KIND,
14 label: "GitHub",22 label: "GitHub",
15 pickerLabel: "GitHub",23 pickerLabel: "GitHub",
16 icon: "github",24 icon: "github",
...@@ -29,5 +37,14 @@ export const definition = defineSourceControlClient({...@@ -29,5 +37,14 @@ export const definition = defineSourceControlClient({
29 : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(),37 : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(),
30 referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl,38 referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl,
31 reviewSummaryRequired: () => false,39 reviewSummaryRequired: () => false,
40 checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null,
41 isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url),
42 changeRequestUrlHost: (url) => url.hostname,
43 // A GitHub web host is the checkout's own host, Enterprise installs included.
44 checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND),
45 isChangeRequestInRepository: (identity, link) =>
46 isChangeRequestInProjectRepository(KIND, identity, link),
47 canReadChangeRequestOnHost: (identity, link) =>
48 isChangeRequestOnProjectHost(KIND, identity, link),
32 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"),49 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"),
33});50});
packages/source-control-gitlab/src/client/definition.ts+17-1
...@@ -6,11 +6,19 @@...@@ -6,11 +6,19 @@
6import { SourceControlProviderKind } from "@t3tools/contracts";6import { SourceControlProviderKind } from "@t3tools/contracts";
7import {7import {
8 defineSourceControlClient,8 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
9 isChangeRequestPath,11 isChangeRequestPath,
10} from "@t3tools/source-control-core/client/definition";12} from "@t3tools/source-control-core/client/definition";
1113
14const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i;
15const CHANGE_REQUEST_REFERENCE =
16 /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i;
17
18const KIND = SourceControlProviderKind.make("gitlab");
19
12export const definition = defineSourceControlClient({20export const definition = defineSourceControlClient({
13 kind: SourceControlProviderKind.make("gitlab"),21 kind: KIND,
14 label: "GitLab",22 label: "GitLab",
15 pickerLabel: "GitLab",23 pickerLabel: "GitLab",
16 icon: "gitlab",24 icon: "gitlab",
...@@ -26,5 +34,13 @@ export const definition = defineSourceControlClient({...@@ -26,5 +34,13 @@ export const definition = defineSourceControlClient({
26 authorProfileUrl: () => null,34 authorProfileUrl: () => null,
27 referenceAutolinkRepositoryUrl: () => null,35 referenceAutolinkRepositoryUrl: () => null,
28 reviewSummaryRequired: () => false,36 reviewSummaryRequired: () => false,
37 checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null,
38 isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url),
39 changeRequestUrlHost: (url) => url.hostname,
40 checkoutChangeRequestHost: () => null,
41 isChangeRequestInRepository: (identity, link) =>
42 isChangeRequestInProjectRepository(KIND, identity, link),
43 canReadChangeRequestOnHost: (identity, link) =>
44 isChangeRequestOnProjectHost(KIND, identity, link),
29 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"),45 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"),
30});46});