Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
87 changes: 6 additions & 81 deletions apps/web/src/lib/openPullRequestLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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)
);
});
}
Expand Down Expand Up @@ -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)
);
});
}
Expand Down
56 changes: 15 additions & 41 deletions apps/web/src/pullRequestReference.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
29 changes: 29 additions & 0 deletions packages/source-control-azure-devops/src/client/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/"),
});
15 changes: 14 additions & 1 deletion packages/source-control-bitbucket/src/client/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/"),
});
Loading
Loading