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(
12881288 reference: PullRequestRef,
12891289 identity: RepositoryIdentity | null | undefined,
12901290): PullRequestRef {
1291 // Other providers may resolve an SSH remote to a different web authority on the server.
1292 if (reference.host !== undefined || identity?.provider !== "github") return reference;
1293 return {
1294 ...reference,
1295 host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")),
1296 };
1291 if (reference.host !== undefined || !identity?.provider) return reference;
1292 const host = sourceControlClients.get(identity.provider).checkoutChangeRequestHost(identity);
1293 return host === null ? reference : { ...reference, host };
12971294}
12981295
12991296export interface PullRequestDetailSnapshotRef {
......@@ -1377,7 +1374,7 @@ export function resolveDisplayedPullRequestDetail(input: {
13771374 if (input.reference.host === undefined) return input.cached;
13781375 try {
13791376 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);
13811378 return (url.protocol === "https:" || url.protocol === "http:") &&
13821379 host.toLowerCase() === input.reference.host.toLowerCase()
13831380 ? input.cached
apps/web/src/lib/openPullRequestLink.ts+6-81
......@@ -3,12 +3,9 @@ import { useAtomValue } from "@effect/atom-react";
33import { useNavigate } from "@tanstack/react-router";
44import { type MouseEvent, useCallback, useMemo } from "react";
55
6import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
6import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
77import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
8import {
9 canonicalRepositoryKey,
10 sourceControlRepositorySelector,
11} from "@t3tools/shared/sourceControl";
8import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
129
1310import { useOpenLink } from "../browser/useOpenLink";
1411import { stackedThreadToast, toastManager } from "../components/ui/toast";
......@@ -28,34 +25,6 @@ export {
2825 changeRequestRepositoryUrl,
2926} 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
5928/**
6029 * The project a link belongs to, or nothing. Matched the way the server matches: the repository
6130 * identity is the full path below the host where one was recorded — which is what nested GitLab
......@@ -68,29 +37,9 @@ export function findProjectForChangeRequest(
6837): EnvironmentProject | undefined {
6938 return projects.find((project) => {
7039 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);
8940 return (
90 repository !== null &&
91 repository.toLowerCase() === link.repository.toLowerCase() &&
92 (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
93 pullRequestHostOf(identity, kind) === link.authority)
41 identity?.provider !== undefined &&
42 sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link)
9443 );
9544 });
9645}
......@@ -151,35 +100,11 @@ export function findProjectOnChangeRequestHost(
151100): EnvironmentProject | undefined {
152101 const own = findProjectForChangeRequest(projects, link);
153102 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;
161103 return projects.find((project) => {
162104 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 }
176105 return (
177 identity != null &&
178 kind !== undefined &&
179 kind !== "azure-devops" &&
180 matchesChangeRequestAuthority(project, link) &&
181 (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
182 pullRequestHostOf(identity, kind) === link.authority)
106 identity?.provider !== undefined &&
107 sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link)
183108 );
184109 });
185110}
apps/web/src/pullRequestReference.ts+15-41
......@@ -1,57 +1,31 @@
1const FORGEJO_PULL_REQUEST_URL_PATTERN =
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;
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
142
15function parseAzureDevOpsCheckoutReference(args: string): string | null {
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}
3const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
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 */
2810export function parsePullRequestReference(input: string): string | null {
2911 const trimmed = input.trim();
3012 if (trimmed.length === 0) {
3113 return null;
3214 }
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);
3716 const normalizedInput =
38 FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ??
39 ghCliCheckoutMatch?.[1]?.trim() ??
40 glabCliCheckoutMatch?.[1]?.trim() ??
41 (azureDevOpsCliCheckoutMatch?.[1]
42 ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1])
43 : null) ??
44 trimmed;
17 sourceControlClients.definitions
18 .map((definition) => definition.checkoutCommandArgument(trimmed))
19 .find((argument) => argument !== null) ?? trimmed;
4520 if (normalizedInput.length === 0) {
4621 return null;
4722 }
4823
49 const urlMatch =
50 FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
51 GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
52 GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
53 AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
54 if (urlMatch?.[1]) {
24 if (
25 sourceControlClients.definitions.some((definition) =>
26 definition.isChangeRequestReference(normalizedInput),
27 )
28 ) {
5529 return normalizedInput;
5630 }
5731
packages/source-control-azure-devops/src/client/definition.ts+29
......@@ -7,9 +7,24 @@ import { SourceControlProviderKind } from "@t3tools/contracts";
77import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl";
88import {
99 defineSourceControlClient,
10 isChangeRequestAuthorityOfProject,
1011 isChangeRequestPath,
1112} 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
1328export const definition = defineSourceControlClient({
1429 kind: SourceControlProviderKind.make("azure-devops"),
1530 label: "Azure DevOps",
......@@ -29,5 +44,19 @@ export const definition = defineSourceControlClient({
2944 authorProfileUrl: () => null,
3045 referenceAutolinkRepositoryUrl: () => null,
3146 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,
3261 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"),
3362});
packages/source-control-bitbucket/src/client/definition.ts+14-1
......@@ -6,14 +6,18 @@
66import { SourceControlProviderKind } from "@t3tools/contracts";
77import {
88 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
911 isChangeRequestPath,
1012} from "@t3tools/source-control-core/client/definition";
1113
1214const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
1315const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1416
17const KIND = SourceControlProviderKind.make("bitbucket");
18
1519export const definition = defineSourceControlClient({
16 kind: SourceControlProviderKind.make("bitbucket"),
20 kind: KIND,
1721 label: "Bitbucket",
1822 pickerLabel: "Bitbucket",
1923 icon: "bitbucket",
......@@ -35,5 +39,14 @@ export const definition = defineSourceControlClient({
3539 authorProfileUrl: () => null,
3640 referenceAutolinkRepositoryUrl: () => null,
3741 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),
3851 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),
3952});
packages/source-control-core/src/client/definition.ts+115-1
......@@ -11,7 +11,13 @@
1111 *
1212 * @module source-control-core/client/definition
1313 */
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
1622/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */
1723export interface ChangeRequestTerminology {
......@@ -32,6 +38,12 @@ export interface ChangeRequestUrlInput {
3238 readonly webUrl?: string | undefined;
3339}
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
3547/** The change request a checkout command is built for. */
3648export interface ChangeRequestCheckoutInput {
3749 readonly number: number;
......@@ -76,6 +88,43 @@ export interface SourceControlClientDefinition {
7688 readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null;
7789 /** Whether a review with this verdict must carry a summary, beyond what every host asks. */
7890 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;
79128 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */
80129 readonly isChangeRequestUrl: (url: string) => boolean;
81130}
......@@ -100,6 +149,63 @@ export function defineSourceControlClient<const Definition extends SourceControl
100149 return definition;
101150}
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
103209/** What clients show for a host they ship no definition for, including `unknown`. */
104210export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
105211 kind: SourceControlProviderKind.make("unknown"),
......@@ -117,6 +223,14 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
117223 authorProfileUrl: () => null,
118224 referenceAutolinkRepositoryUrl: () => null,
119225 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),
120234 isChangeRequestUrl: () => false,
121235};
122236
packages/source-control-forgejo/src/client/definition.ts+53-1
......@@ -5,12 +5,36 @@
55 */
66import { SourceControlProviderKind } from "@t3tools/contracts";
77import {
8 type ChangeRequestProjectIdentity,
89 defineSourceControlClient,
10 isChangeRequestAuthorityOfProject,
11 isChangeRequestInProjectRepository,
12 isChangeRequestOnProjectHost,
913 isChangeRequestPath,
1014} 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
1236export const definition = defineSourceControlClient({
13 kind: SourceControlProviderKind.make("forgejo"),
37 kind: KIND,
1438 label: "Forgejo",
1539 pickerLabel: "Forgejo / Gitea",
1640 icon: "forgejo",
......@@ -48,5 +72,33 @@ export const definition = defineSourceControlClient({
4872 // Forgejo refuses a change request review without a summary, even with inline comments.
4973 referenceAutolinkRepositoryUrl: () => null,
5074 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 },
51103 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"),
52104});
packages/source-control-gitcafe/src/client/definition.ts+14-1
......@@ -6,14 +6,18 @@
66import { SourceControlProviderKind } from "@t3tools/contracts";
77import {
88 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
911 isChangeRequestPath,
1012} from "@t3tools/source-control-core/client/definition";
1113
1214/** GitCafe has no self-hosted installs: production and staging are its only hosts. */
1315const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);
1416
17const KIND = SourceControlProviderKind.make("gitcafe");
18
1519export const definition = defineSourceControlClient({
16 kind: SourceControlProviderKind.make("gitcafe"),
20 kind: KIND,
1721 label: "GitCafe",
1822 pickerLabel: "GitCafe",
1923 icon: "gitcafe",
......@@ -32,6 +36,15 @@ export const definition = defineSourceControlClient({
3236 referenceAutolinkRepositoryUrl: () => null,
3337 reviewSummaryRequired: () => false,
3438 // `/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),
3548 isChangeRequestUrl: (url) => {
3649 try {
3750 return (
packages/source-control-github/src/client/definition.ts+19-2
......@@ -3,14 +3,22 @@
33 *
44 * @module source-control-github/client/definition
55 */
6import { SourceControlProviderKind } from "@t3tools/contracts";
6import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts";
77import {
88 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
911 isChangeRequestPath,
1012} 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
1220export const definition = defineSourceControlClient({
13 kind: SourceControlProviderKind.make("github"),
21 kind: KIND,
1422 label: "GitHub",
1523 pickerLabel: "GitHub",
1624 icon: "github",
......@@ -29,5 +37,14 @@ export const definition = defineSourceControlClient({
2937 : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(),
3038 referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl,
3139 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),
3249 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"),
3350});
packages/source-control-gitlab/src/client/definition.ts+17-1
......@@ -6,11 +6,19 @@
66import { SourceControlProviderKind } from "@t3tools/contracts";
77import {
88 defineSourceControlClient,
9 isChangeRequestInProjectRepository,
10 isChangeRequestOnProjectHost,
911 isChangeRequestPath,
1012} 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
1220export const definition = defineSourceControlClient({
13 kind: SourceControlProviderKind.make("gitlab"),
21 kind: KIND,
1422 label: "GitLab",
1523 pickerLabel: "GitLab",
1624 icon: "gitlab",
......@@ -26,5 +34,13 @@ export const definition = defineSourceControlClient({
2634 authorProfileUrl: () => null,
2735 referenceAutolinkRepositoryUrl: () => null,
2836 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),
2945 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"),
3046});