| 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,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 | } |
| 1298 | 1295 | ||
| 1299 | export interface PullRequestDetailSnapshotRef { | 1296 | export 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.cached | 1380 | ? 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"; |
| 3 | import { useNavigate } from "@tanstack/react-router"; | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | import { type MouseEvent, useCallback, useMemo } from "react"; | 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 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; | 7 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; |
| 8 | import { | 8 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; |
| 9 | canonicalRepositoryKey, | ||
| 10 | sourceControlRepositorySelector, | ||
| 11 | } from "@t3tools/shared/sourceControl"; | ||
| 12 | 9 | ||
| 13 | import { useOpenLink } from "../browser/useOpenLink"; | 10 | import { useOpenLink } from "../browser/useOpenLink"; |
| 14 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 11 | import { 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"; |
| 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 | * The project a link belongs to, or nothing. Matched the way the server matches: the repository | 29 | * 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 GitLab | 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,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 @@ |
| 1 | const FORGEJO_PULL_REQUEST_URL_PATTERN = | 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 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; | ||
| 14 | 2 | ||
| 15 | function parseAzureDevOpsCheckoutReference(args: string): string | null { | 3 | const 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 | } | ||
| 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 | export function parsePullRequestReference(input: string): string | null { | 10 | export 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 | } |
| 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 | 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 | } |
| 48 | 23 | ||
| 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 | } |
| 57 | 31 |
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"; |
| 7 | import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; | 7 | import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; |
| 8 | import { | 8 | import { |
| 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"; |
| 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 | export const definition = defineSourceControlClient({ | 28 | export 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 @@ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 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"; |
| 11 | 13 | ||
| 12 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; | 14 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; |
| 13 | const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | 15 | const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; |
| 14 | 16 | ||
| 17 | const KIND = SourceControlProviderKind.make("bitbucket"); | ||
| 18 | |||
| 15 | export const definition = defineSourceControlClient({ | 19 | export 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/definition | 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 | /** 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. */ |
| 17 | export interface ChangeRequestTerminology { | 23 | export 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 | } |
| 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 | /** The change request a checkout command is built for. */ | 47 | /** The change request a checkout command is built for. */ |
| 36 | export interface ChangeRequestCheckoutInput { | 48 | export 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 | } |
| 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 | /** 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`. */ |
| 104 | export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { | 210 | export 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 | }; |
| 122 | 236 |
packages/source-control-forgejo/src/client/definition.ts+53-1| ... | @@ -5,12 +5,36 @@ | ... | @@ -5,12 +5,36 @@ |
| 5 | */ | 5 | */ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 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"; |
| 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 | export const definition = defineSourceControlClient({ | 36 | export 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 @@ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 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"; |
| 11 | 13 | ||
| 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. */ |
| 13 | const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); | 15 | const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); |
| 14 | 16 | ||
| 17 | const KIND = SourceControlProviderKind.make("gitcafe"); | ||
| 18 | |||
| 15 | export const definition = defineSourceControlClient({ | 19 | export 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/definition | 4 | * @module source-control-github/client/definition |
| 5 | */ | 5 | */ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 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"; |
| 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 | export const definition = defineSourceControlClient({ | 20 | export 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 @@ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 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"; |
| 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 | export const definition = defineSourceControlClient({ | 20 | export 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 | }); |