diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index cd9d25220649..7faf9b651240 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1288,12 +1288,9 @@ export function resolvePullRequestReferenceHost( reference: PullRequestRef, identity: RepositoryIdentity | null | undefined, ): PullRequestRef { - // Other providers may resolve an SSH remote to a different web authority on the server. - if (reference.host !== undefined || identity?.provider !== "github") return reference; - return { - ...reference, - host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")), - }; + if (reference.host !== undefined || !identity?.provider) return reference; + const host = sourceControlClients.get(identity.provider).checkoutChangeRequestHost(identity); + return host === null ? reference : { ...reference, host }; } export interface PullRequestDetailSnapshotRef { @@ -1377,7 +1374,7 @@ export function resolveDisplayedPullRequestDetail(input: { if (input.reference.host === undefined) return input.cached; try { const url = new URL(input.cached.url); - const host = input.cached.provider === "forgejo" ? url.host : url.hostname; + const host = sourceControlClients.get(input.cached.provider).changeRequestUrlHost(url); return (url.protocol === "https:" || url.protocol === "http:") && host.toLowerCase() === input.reference.host.toLowerCase() ? input.cached diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index da67d0b043a4..1c701fb4f0fe 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -3,12 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent, useCallback, useMemo } from "react"; -import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; -import { - canonicalRepositoryKey, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -28,34 +25,6 @@ export { changeRequestRepositoryUrl, } from "@t3tools/shared/changeRequestUrl"; -function resolvedForgejoRepository(project: EnvironmentProject): URL | null { - const identity = project.repositoryIdentity; - if (identity?.provider !== "forgejo" || !identity.webUrl) return null; - try { - const url = new URL(identity.webUrl); - return url.protocol === "http:" || url.protocol === "https:" ? url : null; - } catch { - return null; - } -} - -/** Keep Forgejo servers on different HTTP ports separate when selecting a project. */ -function matchesChangeRequestAuthority( - project: EnvironmentProject, - link: ChangeRequestLink, -): boolean { - if (link.authority === undefined) return true; - try { - const remote = new URL(project.repositoryIdentity?.locator.remoteUrl ?? ""); - if (remote.protocol === "http:" || remote.protocol === "https:") { - return remote.host.toLowerCase() === link.authority; - } - } catch { - // SSH remotes do not specify the server's HTTP port; tea resolves the configured login. - } - return true; -} - /** * The project a link belongs to, or nothing. Matched the way the server matches: the repository * identity is the full path below the host where one was recorded — which is what nested GitLab @@ -68,29 +37,9 @@ export function findProjectForChangeRequest( ): EnvironmentProject | undefined { return projects.find((project) => { const identity = project.repositoryIdentity; - if (!identity || !matchesChangeRequestAuthority(project, link)) return false; - const kind = identity.provider as SourceControlProviderKind | undefined; - if (kind === undefined) return false; - const web = resolvedForgejoRepository(project); - if (web) - return ( - web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && - web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() - ); - if (kind === "azure-devops") { - return ( - canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === - canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()) - ); - } - const repository = - identity.displayName ?? - (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); return ( - repository !== null && - repository.toLowerCase() === link.repository.toLowerCase() && - (pullRequestHostOf(identity, kind) === link.host.toLowerCase() || - pullRequestHostOf(identity, kind) === link.authority) + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) ); }); } @@ -151,35 +100,11 @@ export function findProjectOnChangeRequestHost( ): EnvironmentProject | undefined { const own = findProjectForChangeRequest(projects, link); if (own !== undefined) return own; - // Azure CLI reads use the checkout's organization and project, not host-wide credentials. - if ( - canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith( - "dev.azure.com/", - ) - ) - return undefined; return projects.find((project) => { const identity = project.repositoryIdentity; - const kind = identity?.provider as SourceControlProviderKind | undefined; - const web = resolvedForgejoRepository(project); - if (web) { - const mount = web.pathname - .replace(/^\/+|\/+$/g, "") - .split("/") - .slice(0, -2) - .join("/"); - return ( - web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && - (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) - ); - } return ( - identity != null && - kind !== undefined && - kind !== "azure-devops" && - matchesChangeRequestAuthority(project, link) && - (pullRequestHostOf(identity, kind) === link.host.toLowerCase() || - pullRequestHostOf(identity, kind) === link.authority) + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link) ); }); } diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index 421f60f03687..561bf19e86ee 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,57 +1,31 @@ -const FORGEJO_PULL_REQUEST_URL_PATTERN = - /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; -const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; -const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; -const GITLAB_MERGE_REQUEST_URL_PATTERN = - /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; -const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = - /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; -const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; -const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; -const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; -const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -function parseAzureDevOpsCheckoutReference(args: string): string | null { - const parts = args.trim().split(/\s+/).filter(Boolean); - for (const [index, part] of parts.entries()) { - if (part === "--id" || part === "-i") { - return parts[index + 1] ?? null; - } - if (part.startsWith("--id=")) { - return part.slice("--id=".length) || null; - } - } - return parts.find((part) => !part.startsWith("-")) ?? null; -} +const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; +/** + * The change request a pasted reference names: a host's change request URL, returned as is, or + * a bare number. A host's checkout command, such as `gh pr checkout 42`, is read for its + * argument first. Null for anything else, such as a branch name. + */ export function parsePullRequestReference(input: string): string | null { const trimmed = input.trim(); if (trimmed.length === 0) { return null; } - const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); - const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); - const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); const normalizedInput = - FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ?? - ghCliCheckoutMatch?.[1]?.trim() ?? - glabCliCheckoutMatch?.[1]?.trim() ?? - (azureDevOpsCliCheckoutMatch?.[1] - ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) - : null) ?? - trimmed; + sourceControlClients.definitions + .map((definition) => definition.checkoutCommandArgument(trimmed)) + .find((argument) => argument !== null) ?? trimmed; if (normalizedInput.length === 0) { return null; } - const urlMatch = - FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); - if (urlMatch?.[1]) { + if ( + sourceControlClients.definitions.some((definition) => + definition.isChangeRequestReference(normalizedInput), + ) + ) { return normalizedInput; } diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 5b935c8925dc..57c48b9a1992 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -7,9 +7,24 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { defineSourceControlClient, + isChangeRequestAuthorityOfProject, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; + +/** `az repos pr checkout` names the pull request by `--id`, `-i`, or its first plain argument. */ +function checkoutCommandId(args: string): string | null { + const parts = args.trim().split(/\s+/).filter(Boolean); + for (const [index, part] of parts.entries()) { + if (part === "--id" || part === "-i") return parts[index + 1] ?? null; + if (part.startsWith("--id=")) return part.slice("--id=".length) || null; + } + return parts.find((part) => !part.startsWith("-")) ?? null; +} + export const definition = defineSourceControlClient({ kind: SourceControlProviderKind.make("azure-devops"), label: "Azure DevOps", @@ -29,5 +44,19 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + checkoutCommandArgument: (input) => { + const args = CHECKOUT_COMMAND.exec(input)?.[1]; + return args ? checkoutCommandId(args) : null; + }, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + // The legacy and SSH spellings of one repository all canonicalise to dev.azure.com. + isChangeRequestInRepository: (identity, link) => + isChangeRequestAuthorityOfProject(identity, link) && + canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === + canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), + // `az repos` reads use the checkout's organization and project, not host-wide credentials. + canReadChangeRequestOnHost: () => false, isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), }); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 25010be99e93..a513d86e1001 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -6,14 +6,18 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const KIND = SourceControlProviderKind.make("bitbucket"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("bitbucket"), + kind: KIND, label: "Bitbucket", pickerLabel: "Bitbucket", icon: "bitbucket", @@ -35,5 +39,14 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + // No checkout CLI, and the reference field never accepted Bitbucket URLs. + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), }); diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 22bb09957085..77d241a8a6ba 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -11,7 +11,13 @@ * * @module source-control-core/client/definition */ -import { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts"; +import { + pullRequestHostOf, + type PullRequestReviewVerdict, + type RepositoryIdentity, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ export interface ChangeRequestTerminology { @@ -32,6 +38,12 @@ export interface ChangeRequestUrlInput { readonly webUrl?: string | undefined; } +/** A project's repository, as a change request link is matched against it. */ +export type ChangeRequestProjectIdentity = Pick< + RepositoryIdentity, + "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" +>; + /** The change request a checkout command is built for. */ export interface ChangeRequestCheckoutInput { readonly number: number; @@ -76,6 +88,43 @@ export interface SourceControlClientDefinition { readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; + /** + * The change request a pasted command names, such as `gh pr checkout 42`: its argument, which + * is a number or a URL. Null for anything that is not this host's checkout command. + */ + readonly checkoutCommandArgument: (input: string) => string | null; + /** + * Whether a pasted URL is one of this host's change request URLs. Stricter than + * `isChangeRequestUrl`, which only tells hosts apart, because this decides what a reference + * field accepts. + */ + readonly isChangeRequestReference: (url: string) => boolean; + /** + * Whether a change request link names this project's own repository. The link's host is where + * the repository is addressed, `link.authority` the web host and port where they differ. + */ + readonly isChangeRequestInRepository: ( + identity: ChangeRequestProjectIdentity, + link: ChangeRequestLink, + ) => boolean; + /** + * Whether this project can read a change request on the link's host on the link's behalf, for a + * repository nobody has checked out. False where the host's reads are scoped to the checkout. + */ + readonly canReadChangeRequestOnHost: ( + identity: ChangeRequestProjectIdentity, + link: ChangeRequestLink, + ) => boolean; + /** + * The host a change request URL addresses its repository below, as a `PullRequestRef.host` + * names it: the hostname, or the host and port for servers addressed by both. + */ + readonly changeRequestUrlHost: (url: URL) => string; + /** + * The host a reference with none names, read from the checkout alone, or null where only the + * server can tell, such as when an SSH remote resolves to a different web host. + */ + readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ readonly isChangeRequestUrl: (url: string) => boolean; } @@ -100,6 +149,63 @@ export function defineSourceControlClient null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link), isChangeRequestUrl: () => false, }; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index aab19fcde22d..3ec31d39270d 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -5,12 +5,36 @@ */ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + type ChangeRequestProjectIdentity, defineSourceControlClient, + isChangeRequestAuthorityOfProject, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; + +const KIND = SourceControlProviderKind.make("forgejo"); + +/** + * The repository's browser URL the server resolved from the signed-in account, which is the + * only place a Forgejo mounted below a path says so. + */ +function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null { + if (!identity.webUrl) return null; + try { + const url = new URL(identity.webUrl); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; + } catch { + return null; + } +} + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("forgejo"), + kind: KIND, label: "Forgejo", pickerLabel: "Forgejo / Gitea", icon: "forgejo", @@ -48,5 +72,33 @@ export const definition = defineSourceControlClient({ // Forgejo refuses a change request review without a summary, even with inline comments. referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: (verdict) => verdict === "request-changes", + checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + // Forgejo servers are addressed with their port, so two on one hostname stay apart. + changeRequestUrlHost: (url) => url.host, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => { + if (!isChangeRequestAuthorityOfProject(identity, link)) return false; + const web = resolvedWebUrl(identity); + if (!web) return isChangeRequestInProjectRepository(KIND, identity, link); + return ( + web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && + web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() + ); + }, + // A server mounted below a path serves only repositories under that mount. + canReadChangeRequestOnHost: (identity, link) => { + const web = resolvedWebUrl(identity); + if (!web) return isChangeRequestOnProjectHost(KIND, identity, link); + const mount = web.pathname + .replace(/^\/+|\/+$/g, "") + .split("/") + .slice(0, -2) + .join("/"); + return ( + web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && + (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) + ); + }, isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), }); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 6407d57828d1..38d03014dd3c 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -6,14 +6,18 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); +const KIND = SourceControlProviderKind.make("gitcafe"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("gitcafe"), + kind: KIND, label: "GitCafe", pickerLabel: "GitCafe", icon: "gitcafe", @@ -32,6 +36,15 @@ export const definition = defineSourceControlClient({ referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. + // `cafe pr checkout` and git.cafe URLs were never accepted in reference fields. + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => { try { return ( diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index fe79b7f91cc3..9e6adcff8fbe 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -3,14 +3,22 @@ * * @module source-control-github/client/definition */ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; + +const KIND = SourceControlProviderKind.make("github"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("github"), + kind: KIND, label: "GitHub", pickerLabel: "GitHub", icon: "github", @@ -29,5 +37,14 @@ export const definition = defineSourceControlClient({ : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, reviewSummaryRequired: () => false, + checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + changeRequestUrlHost: (url) => url.hostname, + // A GitHub web host is the checkout's own host, Enterprise installs included. + checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), }); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 23c88b1b6d2f..e1d875b56827 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -6,11 +6,19 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; + +const KIND = SourceControlProviderKind.make("gitlab"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("gitlab"), + kind: KIND, label: "GitLab", pickerLabel: "GitLab", icon: "gitlab", @@ -26,5 +34,13 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), });