| author | |
| committer | |
| log | b0249ac8711703f1a1b8cd0f3acd11155f5bf172 |
| tree | b40040b1ab7d3ea245dee98bad131e31eb235d90 |
| parent | 579159d6c6e96f9c18358e492fbf0b8af740e80c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1288 | reference: PullRequestRef, |
| 1289 | 1289 | identity: RepositoryIdentity | null | undefined, |
| 1290 | 1290 | ): 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 }; | |
| 1297 | 1294 | } |
| 1298 | 1295 | |
| 1299 | 1296 | export interface PullRequestDetailSnapshotRef { |
| ... | ... | @@ -1377,7 +1374,7 @@ export function resolveDisplayedPullRequestDetail(input: { |
| 1377 | 1374 | if (input.reference.host === undefined) return input.cached; |
| 1378 | 1375 | try { |
| 1379 | 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 | 1378 | return (url.protocol === "https:" || url.protocol === "http:") && |
| 1382 | 1379 | host.toLowerCase() === input.reference.host.toLowerCase() |
| 1383 | 1380 | ? input.cached |
apps/web/src/lib/openPullRequestLink.ts+6-81| ... | ... | @@ -3,12 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | import { type MouseEvent, useCallback, useMemo } from "react"; |
| 5 | 5 | |
| 6 | import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; | |
| 6 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | |
| 7 | 7 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; |
| 8 | import { | |
| 9 | canonicalRepositoryKey, | |
| 10 | sourceControlRepositorySelector, | |
| 11 | } from "@t3tools/shared/sourceControl"; | |
| 8 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | |
| 12 | 9 | |
| 13 | 10 | import { useOpenLink } from "../browser/useOpenLink"; |
| 14 | 11 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| ... | ... | @@ -28,34 +25,6 @@ export { |
| 28 | 25 | changeRequestRepositoryUrl, |
| 29 | 26 | } from "@t3tools/shared/changeRequestUrl"; |
| 30 | 27 | |
| 31 | function 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. */ | |
| 43 | function 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 | 29 | * The project a link belongs to, or nothing. Matched the way the server matches: the repository |
| 61 | 30 | * 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 | 37 | ): EnvironmentProject | undefined { |
| 69 | 38 | return projects.find((project) => { |
| 70 | 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 | 40 | 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) | |
| 94 | 43 | ); |
| 95 | 44 | }); |
| 96 | 45 | } |
| ... | ... | @@ -151,35 +100,11 @@ export function findProjectOnChangeRequestHost( |
| 151 | 100 | ): EnvironmentProject | undefined { |
| 152 | 101 | const own = findProjectForChangeRequest(projects, link); |
| 153 | 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 | 103 | return projects.find((project) => { |
| 162 | 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 | 105 | 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) | |
| 183 | 108 | ); |
| 184 | 109 | }); |
| 185 | 110 | } |
apps/web/src/pullRequestReference.ts+15-41| ... | ... | @@ -1,57 +1,31 @@ |
| 1 | const FORGEJO_PULL_REQUEST_URL_PATTERN = | |
| 2 | /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; | |
| 3 | const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; | |
| 4 | const GITHUB_PULL_REQUEST_URL_PATTERN = | |
| 5 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; | |
| 6 | const GITLAB_MERGE_REQUEST_URL_PATTERN = | |
| 7 | /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; | |
| 8 | const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = | |
| 9 | /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; | |
| 10 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; | |
| 11 | const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; | |
| 12 | const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; | |
| 13 | const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; | |
| 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | |
| 14 | 2 | |
| 15 | function 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 | } | |
| 3 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; | |
| 27 | 4 | |
| 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 | */ | |
| 28 | 10 | export function parsePullRequestReference(input: string): string | null { |
| 29 | 11 | const trimmed = input.trim(); |
| 30 | 12 | if (trimmed.length === 0) { |
| 31 | 13 | return null; |
| 32 | 14 | } |
| 33 | 15 | |
| 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 | 16 | 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; | |
| 45 | 20 | if (normalizedInput.length === 0) { |
| 46 | 21 | return null; |
| 47 | 22 | } |
| 48 | 23 | |
| 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 | ) { | |
| 55 | 29 | return normalizedInput; |
| 56 | 30 | } |
| 57 | 31 |
packages/source-control-azure-devops/src/client/definition.ts+29| ... | ... | @@ -7,9 +7,24 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | 7 | import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; |
| 8 | 8 | import { |
| 9 | 9 | defineSourceControlClient, |
| 10 | isChangeRequestAuthorityOfProject, | |
| 10 | 11 | isChangeRequestPath, |
| 11 | 12 | } from "@t3tools/source-control-core/client/definition"; |
| 12 | 13 | |
| 14 | const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; | |
| 15 | const 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. */ | |
| 19 | function 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 | ||
| 13 | 28 | export const definition = defineSourceControlClient({ |
| 14 | 29 | kind: SourceControlProviderKind.make("azure-devops"), |
| 15 | 30 | label: "Azure DevOps", |
| ... | ... | @@ -29,5 +44,19 @@ export const definition = defineSourceControlClient({ |
| 29 | 44 | authorProfileUrl: () => null, |
| 30 | 45 | referenceAutolinkRepositoryUrl: () => null, |
| 31 | 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 | 61 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), |
| 33 | 62 | }); |
packages/source-control-bitbucket/src/client/definition.ts+14-1| ... | ... | @@ -6,14 +6,18 @@ |
| 6 | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | 8 | defineSourceControlClient, |
| 9 | isChangeRequestInProjectRepository, | |
| 10 | isChangeRequestOnProjectHost, | |
| 9 | 11 | isChangeRequestPath, |
| 10 | 12 | } from "@t3tools/source-control-core/client/definition"; |
| 11 | 13 | |
| 12 | 14 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; |
| 13 | 15 | const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; |
| 14 | 16 | |
| 17 | const KIND = SourceControlProviderKind.make("bitbucket"); | |
| 18 | ||
| 15 | 19 | export const definition = defineSourceControlClient({ |
| 16 | kind: SourceControlProviderKind.make("bitbucket"), | |
| 20 | kind: KIND, | |
| 17 | 21 | label: "Bitbucket", |
| 18 | 22 | pickerLabel: "Bitbucket", |
| 19 | 23 | icon: "bitbucket", |
| ... | ... | @@ -35,5 +39,14 @@ export const definition = defineSourceControlClient({ |
| 35 | 39 | authorProfileUrl: () => null, |
| 36 | 40 | referenceAutolinkRepositoryUrl: () => null, |
| 37 | 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 | 51 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), |
| 39 | 52 | }); |
packages/source-control-core/src/client/definition.ts+115-1| ... | ... | @@ -11,7 +11,13 @@ |
| 11 | 11 | * |
| 12 | 12 | * @module source-control-core/client/definition |
| 13 | 13 | */ |
| 14 | import { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts"; | |
| 14 | import { | |
| 15 | pullRequestHostOf, | |
| 16 | type PullRequestReviewVerdict, | |
| 17 | type RepositoryIdentity, | |
| 18 | SourceControlProviderKind, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; | |
| 15 | 21 | |
| 16 | 22 | /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ |
| 17 | 23 | export interface ChangeRequestTerminology { |
| ... | ... | @@ -32,6 +38,12 @@ export interface ChangeRequestUrlInput { |
| 32 | 38 | readonly webUrl?: string | undefined; |
| 33 | 39 | } |
| 34 | 40 | |
| 41 | /** A project's repository, as a change request link is matched against it. */ | |
| 42 | export type ChangeRequestProjectIdentity = Pick< | |
| 43 | RepositoryIdentity, | |
| 44 | "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" | |
| 45 | >; | |
| 46 | ||
| 35 | 47 | /** The change request a checkout command is built for. */ |
| 36 | 48 | export interface ChangeRequestCheckoutInput { |
| 37 | 49 | readonly number: number; |
| ... | ... | @@ -76,6 +88,43 @@ export interface SourceControlClientDefinition { |
| 76 | 88 | readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; |
| 77 | 89 | /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ |
| 78 | 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 | 128 | /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ |
| 80 | 129 | readonly isChangeRequestUrl: (url: string) => boolean; |
| 81 | 130 | } |
| ... | ... | @@ -100,6 +149,63 @@ export function defineSourceControlClient<const Definition extends SourceControl |
| 100 | 149 | return definition; |
| 101 | 150 | } |
| 102 | 151 | |
| 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 | */ | |
| 157 | export 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. */ | |
| 174 | export 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. */ | |
| 185 | function 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 | */ | |
| 196 | export 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 | 209 | /** What clients show for a host they ship no definition for, including `unknown`. */ |
| 104 | 210 | export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { |
| 105 | 211 | kind: SourceControlProviderKind.make("unknown"), |
| ... | ... | @@ -117,6 +223,14 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { |
| 117 | 223 | authorProfileUrl: () => null, |
| 118 | 224 | referenceAutolinkRepositoryUrl: () => null, |
| 119 | 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 | 234 | isChangeRequestUrl: () => false, |
| 121 | 235 | }; |
| 122 | 236 |
packages/source-control-forgejo/src/client/definition.ts+53-1| ... | ... | @@ -5,12 +5,36 @@ |
| 5 | 5 | */ |
| 6 | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | type ChangeRequestProjectIdentity, | |
| 8 | 9 | defineSourceControlClient, |
| 10 | isChangeRequestAuthorityOfProject, | |
| 11 | isChangeRequestInProjectRepository, | |
| 12 | isChangeRequestOnProjectHost, | |
| 9 | 13 | isChangeRequestPath, |
| 10 | 14 | } from "@t3tools/source-control-core/client/definition"; |
| 11 | 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 | ||
| 12 | 36 | export const definition = defineSourceControlClient({ |
| 13 | kind: SourceControlProviderKind.make("forgejo"), | |
| 37 | kind: KIND, | |
| 14 | 38 | label: "Forgejo", |
| 15 | 39 | pickerLabel: "Forgejo / Gitea", |
| 16 | 40 | icon: "forgejo", |
| ... | ... | @@ -48,5 +72,33 @@ export const definition = defineSourceControlClient({ |
| 48 | 72 | // Forgejo refuses a change request review without a summary, even with inline comments. |
| 49 | 73 | referenceAutolinkRepositoryUrl: () => null, |
| 50 | 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 | 103 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), |
| 52 | 104 | }); |
packages/source-control-gitcafe/src/client/definition.ts+14-1| ... | ... | @@ -6,14 +6,18 @@ |
| 6 | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | 8 | defineSourceControlClient, |
| 9 | isChangeRequestInProjectRepository, | |
| 10 | isChangeRequestOnProjectHost, | |
| 9 | 11 | isChangeRequestPath, |
| 10 | 12 | } from "@t3tools/source-control-core/client/definition"; |
| 11 | 13 | |
| 12 | 14 | /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ |
| 13 | 15 | const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); |
| 14 | 16 | |
| 17 | const KIND = SourceControlProviderKind.make("gitcafe"); | |
| 18 | ||
| 15 | 19 | export const definition = defineSourceControlClient({ |
| 16 | kind: SourceControlProviderKind.make("gitcafe"), | |
| 20 | kind: KIND, | |
| 17 | 21 | label: "GitCafe", |
| 18 | 22 | pickerLabel: "GitCafe", |
| 19 | 23 | icon: "gitcafe", |
| ... | ... | @@ -32,6 +36,15 @@ export const definition = defineSourceControlClient({ |
| 32 | 36 | referenceAutolinkRepositoryUrl: () => null, |
| 33 | 37 | reviewSummaryRequired: () => false, |
| 34 | 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 | 48 | isChangeRequestUrl: (url) => { |
| 36 | 49 | try { |
| 37 | 50 | return ( |
packages/source-control-github/src/client/definition.ts+19-2| ... | ... | @@ -3,14 +3,22 @@ |
| 3 | 3 | * |
| 4 | 4 | * @module source-control-github/client/definition |
| 5 | 5 | */ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 6 | import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | 7 | import { |
| 8 | 8 | defineSourceControlClient, |
| 9 | isChangeRequestInProjectRepository, | |
| 10 | isChangeRequestOnProjectHost, | |
| 9 | 11 | isChangeRequestPath, |
| 10 | 12 | } from "@t3tools/source-control-core/client/definition"; |
| 11 | 13 | |
| 14 | const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; | |
| 15 | const CHANGE_REQUEST_REFERENCE = | |
| 16 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; | |
| 17 | ||
| 18 | const KIND = SourceControlProviderKind.make("github"); | |
| 19 | ||
| 12 | 20 | export const definition = defineSourceControlClient({ |
| 13 | kind: SourceControlProviderKind.make("github"), | |
| 21 | kind: KIND, | |
| 14 | 22 | label: "GitHub", |
| 15 | 23 | pickerLabel: "GitHub", |
| 16 | 24 | icon: "github", |
| ... | ... | @@ -29,5 +37,14 @@ export const definition = defineSourceControlClient({ |
| 29 | 37 | : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), |
| 30 | 38 | referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, |
| 31 | 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 | 49 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), |
| 33 | 50 | }); |
packages/source-control-gitlab/src/client/definition.ts+17-1| ... | ... | @@ -6,11 +6,19 @@ |
| 6 | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | 8 | defineSourceControlClient, |
| 9 | isChangeRequestInProjectRepository, | |
| 10 | isChangeRequestOnProjectHost, | |
| 9 | 11 | isChangeRequestPath, |
| 10 | 12 | } from "@t3tools/source-control-core/client/definition"; |
| 11 | 13 | |
| 14 | const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; | |
| 15 | const CHANGE_REQUEST_REFERENCE = | |
| 16 | /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; | |
| 17 | ||
| 18 | const KIND = SourceControlProviderKind.make("gitlab"); | |
| 19 | ||
| 12 | 20 | export const definition = defineSourceControlClient({ |
| 13 | kind: SourceControlProviderKind.make("gitlab"), | |
| 21 | kind: KIND, | |
| 14 | 22 | label: "GitLab", |
| 15 | 23 | pickerLabel: "GitLab", |
| 16 | 24 | icon: "gitlab", |
| ... | ... | @@ -26,5 +34,13 @@ export const definition = defineSourceControlClient({ |
| 26 | 34 | authorProfileUrl: () => null, |
| 27 | 35 | referenceAutolinkRepositoryUrl: () => null, |
| 28 | 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 | 45 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), |
| 30 | 46 | }); |