From eba2c49c070defe223cddbdb6d88873b33346f9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 21:35:45 -0700 Subject: [PATCH] refactor(source-control): forges parse their own change request URLs packages/shared/src/changeRequestUrl.ts re-implemented every forge's change request URL shapes behind a host and kind switch, duplicating (and drifting from) the hooks in each forge's client definition, and source-control-core imported its link type from shared. Each forge's client definition now owns parseChangeRequestUrl and changeRequestUrlFromReferenceAutolink, and changeRequestUrl builds below a repository web URL the same way everywhere. The registry picks the first definition that claims a URL, with GitCafe claiming before GitHub and Forgejo. isChangeRequestUrl and changeRequestUrlHost are gone. Server drivers carry their definition, so the server builds one registry from them, and so do the host secret fields. Thread pull request identity moves to source-control-core and is bound to a registry once per side. Migrations 050 and 064 keep a frozen copy of the parsing they shipped with. Behaviour changes: GitHub claims ///pull/ on any host, so GitHub Enterprise links on custom hostnames work. A link that matches no project still opens in the browser. On mobile, a pull request link opens in the app only under a project whose repository holds it; before, it opened under whichever project was current. The MCP link tools no longer invent a GitHub URL for a project on an unknown host. The "open on host" fallback in the pull request panel now covers every forge, not only GitHub. Co-Authored-By: Claude Opus 5.5 --- .../HardwareKeyboardCommandProvider.tsx | 2 +- .../pullRequestNavigation.test.ts | 51 ++- .../pull-requests/usePullRequestStack.ts | 6 +- .../pull-requests/usePullRequestThreads.ts | 6 +- .../threads/LinkedPullRequestList.tsx | 2 +- .../threads/new-task-context-presentation.ts | 4 +- apps/mobile/src/lib/pullRequestNavigation.ts | 23 +- apps/mobile/src/native/useOpenPullRequest.ts | 7 +- .../src/state/thread-pr-presentation.ts | 2 +- apps/server/src/git/GitManager.ts | 8 +- apps/server/src/git/linkCreatedPullRequest.ts | 8 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- .../toolkits/pullRequests/handlers.test.ts | 56 +-- .../src/mcp/toolkits/pullRequests/handlers.ts | 64 +-- .../src/mcp/toolkits/pullRequests/tools.ts | 10 + .../src/orchestration-v2/Orchestrator.ts | 4 +- .../src/orchestration-v2/ProjectionStore.ts | 4 +- .../PullRequestSyncReactor.ts | 20 +- .../PullRequestWatchReactor.ts | 2 +- .../legacy/LegacyV1ThreadImporter.ts | 2 +- .../050_ProjectionThreadPullRequests.ts | 2 +- .../064_CanonicalizeThreadPullRequestLinks.ts | 2 +- .../Migrations/legacyThreadPullRequestKey.ts | 109 +++++ apps/server/src/pullRequest/linkedThreads.ts | 2 +- .../src/pullRequest/pullRequestSyncKey.ts | 2 +- .../SourceControlDiscovery.test.ts | 16 +- .../src/sourceControl/builtInDrivers.ts | 16 +- .../src/sourceControl/sourceControlClients.ts | 27 ++ .../sourceControl/sourceControlHostSecrets.ts | 18 +- .../src/sourceControl/threadPullRequests.ts | 15 + .../BranchToolbarBranchSelector.tsx | 2 +- .../ChatMarkdown.permissions.browser.test.tsx | 12 +- apps/web/src/components/ChatMarkdown.tsx | 21 +- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 2 +- apps/web/src/components/RightPanelTabs.tsx | 2 +- .../src/components/ThreadStatusIndicators.tsx | 7 +- .../components/chat/ThreadDetailsPrRows.tsx | 9 +- .../desktop/DesktopWebLinkCoordinator.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 28 +- .../pullRequest/PullRequestThreadLinks.tsx | 6 +- .../PullRequestsUnavailableState.tsx | 12 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 5 +- .../pullRequest/pullRequestDetail.logic.ts | 16 +- .../components/sidebar/SidebarThreadRow.tsx | 2 +- .../src/hooks/useOpenPanelPullRequestUrl.ts | 8 +- .../src/hooks/usePullRequestLinking.test.ts | 6 +- apps/web/src/hooks/usePullRequestLinking.ts | 27 +- apps/web/src/lib/openPullRequestLink.test.ts | 316 +-------------- apps/web/src/lib/openPullRequestLink.ts | 70 +--- apps/web/src/state/pullRequests.ts | 2 +- packages/client-runtime/package.json | 12 + .../src/changeRequestLinks.test.ts | 104 +++++ .../client-runtime/src/changeRequestLinks.ts | 77 ++++ packages/client-runtime/src/promptLinks.ts | 2 +- .../src/pullRequestLinkInput.ts | 19 +- .../src/sourceControlClients.test.ts | 240 +++++++++++ .../src/sourceControlClients.ts | 8 +- packages/client-runtime/src/state/models.ts | 2 +- .../src/state/orchestrationV2Projection.ts | 2 +- .../src/threadPullRequestCompatibility.ts | 2 +- .../src/threadPullRequests.test.ts | 0 .../client-runtime/src/threadPullRequests.ts | 28 ++ .../src/threadReference.test.ts | 0 .../src/threadReference.ts | 0 .../src/work-log/presentation.ts | 7 +- packages/shared/package.json | 8 - packages/shared/src/changeRequestUrl.test.ts | 197 ---------- packages/shared/src/changeRequestUrl.ts | 216 ---------- .../orchestrationV2PendingBackgroundWork.ts | 5 +- packages/shared/src/threadPullRequests.ts | 343 +--------------- .../src/client/definition.test.ts | 104 +++++ .../src/client/definition.ts | 23 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 38 ++ .../src/client/definition.ts | 25 +- .../src/server/driver.ts | 6 +- packages/source-control-core/package.json | 4 + .../src/client/definition.ts | 139 ++++++- .../src/client/threadPullRequests.ts | 372 ++++++++++++++++++ .../source-control-core/src/server/driver.ts | 3 + .../src/client/definition.test.ts | 104 +++++ .../src/client/definition.ts | 31 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 49 +++ .../src/client/definition.ts | 31 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 96 +++++ .../src/client/definition.ts | 49 ++- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 50 +++ .../src/client/definition.ts | 16 +- .../GitLabSourceControlProvider.test.ts | 22 +- .../src/server/driver.ts | 6 +- 95 files changed, 2036 insertions(+), 1481 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts create mode 100644 apps/server/src/sourceControl/sourceControlClients.ts create mode 100644 apps/server/src/sourceControl/threadPullRequests.ts create mode 100644 packages/client-runtime/src/changeRequestLinks.test.ts create mode 100644 packages/client-runtime/src/changeRequestLinks.ts create mode 100644 packages/client-runtime/src/sourceControlClients.test.ts rename packages/{shared => client-runtime}/src/threadPullRequests.test.ts (100%) create mode 100644 packages/client-runtime/src/threadPullRequests.ts rename packages/{shared => client-runtime}/src/threadReference.test.ts (100%) rename packages/{shared => client-runtime}/src/threadReference.ts (100%) delete mode 100644 packages/shared/src/changeRequestUrl.test.ts delete mode 100644 packages/shared/src/changeRequestUrl.ts create mode 100644 packages/source-control-azure-devops/src/client/definition.test.ts create mode 100644 packages/source-control-bitbucket/src/client/definition.test.ts create mode 100644 packages/source-control-core/src/client/threadPullRequests.ts create mode 100644 packages/source-control-forgejo/src/client/definition.test.ts create mode 100644 packages/source-control-gitcafe/src/client/definition.test.ts create mode 100644 packages/source-control-github/src/client/definition.test.ts create mode 100644 packages/source-control-gitlab/src/client/definition.test.ts diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index ada99e156960..cdbfc7d446de 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,5 +1,5 @@ import { StackActions, useNavigation } from "@react-navigation/native"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { createContext, use, diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 23a2d4c8ed94..4d1a4ab5addb 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,26 +1,61 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; + const context = { environmentId: "remote", projectId: "project" }; +const project = (id: string, canonicalKey: string, provider: string, remoteUrl: string) => ({ + id: ProjectId.make(id), + environmentId: EnvironmentId.make("remote"), + repositoryIdentity: { + canonicalKey, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, + displayName: canonicalKey.split("/").slice(1).join("/"), + provider, + }, +}); +const projects = [ + project("project", "github.com/org/repo", "github", "git@github.com:org/repo.git"), + project("gitlab", "git.example/group/sub/repo", "gitlab", "git@git.example:group/sub/repo.git"), + project("forgejo", "forgejo.test/org/repo", "forgejo", "http://forgejo.test:3000/org/repo.git"), +]; + describe("native PR entry points", () => { it("keeps a thread link in its own environment and handles renamed host path shapes", () => { expect( - nativePullRequestDestination("https://github.com/org/repo/pull/12/files#diff", context), + nativePullRequestDestination( + "https://github.com/org/repo/pull/12/files#diff", + context, + projects, + ), ).toEqual({ ...context, host: "github.com", repository: "org/repo", number: 12 }); expect( - nativePullRequestDestination("https://git.example/group/sub/repo/-/merge_requests/9", context) - ?.repository, - ).toBe("group/sub/repo"); + nativePullRequestDestination( + "https://git.example/group/sub/repo/-/merge_requests/9", + context, + projects, + ), + ).toMatchObject({ projectId: "gitlab", repository: "group/sub/repo" }); expect( - nativePullRequestDestination("http://forgejo.test:3000/org/repo/pulls/3", context)?.host, + nativePullRequestDestination("http://forgejo.test:3000/org/repo/pulls/3", context, projects) + ?.host, ).toBe("forgejo.test:3000"); }); + + it("leaves pull requests from repositories without a project to the external opener", () => { + expect( + nativePullRequestDestination("https://github.com/someone/else/pull/12", context, projects), + ).toBeNull(); + }); + it("leaves issues, unknown sites, and unscoped links to the external opener", () => { expect( - nativePullRequestDestination("https://github.com/org/repo/issues/12", context), + nativePullRequestDestination("https://github.com/org/repo/issues/12", context, projects), + ).toBeNull(); + expect( + nativePullRequestDestination("https://example.com/org/repo/pull/12", context, projects), ).toBeNull(); expect( - nativePullRequestDestination("https://example.com/org/repo/pull/12", context), + nativePullRequestDestination("https://github.com/org/repo/pull/12", null, projects), ).toBeNull(); - expect(nativePullRequestDestination("https://github.com/org/repo/pull/12", null)).toBeNull(); }); }); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 85e38795d8cd..50a3d11f0d3e 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -1,6 +1,6 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import type { PullRequestDetail } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { useMemo } from "react"; import { useEnvironmentServerConfig, useNavigationThreadShells } from "../../state/entities"; import { pullRequestStackAtom } from "../../state/pull-requests"; @@ -18,7 +18,9 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe const reference = useMemo( () => ({ ...pullRequestTarget(params).input, - host: params.host ?? (pr ? parseChangeRequestUrl(pr.url)?.host : undefined), + host: + params.host ?? + (pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link.host : undefined), }), [params, pr], ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index e845c960e58a..ba8abcbff8ed 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -11,8 +11,8 @@ import { type PullRequestLinkedThreadsResult, } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; import { Platform } from "react-native"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; @@ -49,7 +49,7 @@ export function usePullRequestThreads( const target = pullRequestTarget(params); const config = useEnvironmentServerConfig(target.environmentId); const supported = threadPullRequestLinkMode(config?.environment.capabilities) === "multiple"; - const parsed = pr ? parseChangeRequestUrl(pr.url) : null; + const parsed = pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link : undefined; const reference = { ...target, input: { ...target.input, ...(parsed ? normalizeThreadPullRequestKey(parsed) : {}) }, diff --git a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx index 903e7fd574bb..2082b8753683 100644 --- a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx +++ b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx @@ -4,7 +4,7 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, type PullRequestListLine, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { memo, useMemo } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index bd2add22eb64..b1dbd5206ada 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,5 +1,5 @@ import { sanitizeNewRefName } from "@t3tools/shared/git"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; import type { ComposerDraftWorkspaceSelection } from "../../state/use-composer-drafts"; @@ -79,7 +79,7 @@ export function resolveNewTaskBranchLabel(input: { readonly pullRequestReference?: string; }): string { if (input.workspaceMode === "worktree" && input.pullRequestReference) { - const pr = parseChangeRequestUrl(input.pullRequestReference); + const pr = sourceControlClients.parseChangeRequestUrl(input.pullRequestReference)?.link; if (pr) return `From PR #${pr.number}`; } if (!input.branchName) { diff --git a/apps/mobile/src/lib/pullRequestNavigation.ts b/apps/mobile/src/lib/pullRequestNavigation.ts index 98cc6d7273d0..1c84cfb6876d 100644 --- a/apps/mobile/src/lib/pullRequestNavigation.ts +++ b/apps/mobile/src/lib/pullRequestNavigation.ts @@ -1,13 +1,30 @@ -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +/** + * Opens a change request in the app only under a project of the link's environment whose own + * repository holds it, preferring the one the link came from; anything else is the browser's. + */ export function nativePullRequestDestination( url: string, context: { environmentId: string; projectId: string } | null, + projects: ReadonlyArray>, ) { - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; if (!parsed || !context) return null; + const candidates = projects.filter((project) => project.environmentId === context.environmentId); + const project = findProjectForChangeRequest( + [ + ...candidates.filter((candidate) => candidate.id === context.projectId), + ...candidates.filter((candidate) => candidate.id !== context.projectId), + ], + parsed, + ); + if (project === undefined) return null; return { - ...context, + environmentId: context.environmentId, + projectId: String(project.id), repository: parsed.repository, number: parsed.number, host: parsed.authority ?? parsed.host, diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index 48d895de7a9f..096906a1716b 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -4,7 +4,7 @@ import { useCallback } from "react"; import { Platform } from "react-native"; import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useThreadShell } from "../state/entities"; +import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; @@ -13,10 +13,11 @@ type PullRequestContext = { readonly environmentId: string; readonly projectId: // route context for useRoute, can open pull requests for any thread. export function useNavigateToPullRequest(options?: { readonly replace?: boolean }) { const navigation = useNavigation(); + const projects = useProjects(); const replace = options?.replace; return useCallback( async (url: string, context: PullRequestContext | null) => { - const params = nativePullRequestDestination(url, context); + const params = nativePullRequestDestination(url, context, projects); if (!params) return tryOpenExternalUrl(url, "pull-request"); if (Platform.OS === "android") resetAndroidWorkspace(navigation, { name: "PullRequest", params }); @@ -24,7 +25,7 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean else navigation.navigate("PullRequest", params); return true; }, - [navigation, replace], + [navigation, projects, replace], ); } diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index bd6ed87e3bb1..7e4bcc505a9a 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -9,7 +9,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { resolveThreadCurrentPullRequestLink, resolveThreadPullRequestBadge, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; export type ThreadPr = NonNullable; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index ae1b22157a99..4691f81fda89 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -62,7 +62,6 @@ import { isSshRemoteUrl, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseGitHubRepositorySelector, @@ -71,7 +70,8 @@ import { import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import { @@ -1202,8 +1202,8 @@ export const make = Effect.gen(function* () { >(); const noteLookupState = (pr: PullRequestInfo) => Effect.suspend(() => { - const parsed = parseChangeRequestUrl(pr.url); - if (parsed === null || parsed.number !== pr.number) return Effect.void; + const parsed = sourceControlClients.parseChangeRequestUrl(pr.url)?.link; + if (parsed === undefined || parsed.number !== pr.number) return Effect.void; const key = normalizeThreadPullRequestKey(parsed); const id = threadPullRequestKeyOf(key); const previous = lookupStates.get(id); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 2b687d0832b3..29e80f9c13c4 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -7,13 +7,13 @@ import { type SourceControlProviderKind, type ThreadId, } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; export interface CreatedPullRequestKey { readonly host: string; @@ -30,8 +30,10 @@ export function createdPullRequestKey( if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) { return null; } - const parsed = parseChangeRequestUrl(url); - if (parsed !== null) return { ...parsed, url }; + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (parsed !== undefined) { + return { host: parsed.host, repository: parsed.repository, number: parsed.number, url }; + } const identity = project?.repositoryIdentity; const kind = identity?.provider as SourceControlProviderKind | undefined; const repository = sourceControlRepositorySelector(identity); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8f39c2e452a6..53d57406c7cb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -70,7 +70,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index c49715eb7eb7..11bce07bf5ba 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -390,44 +390,22 @@ describe("pull request toolkit handlers", () => { }); expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); expect(result.host).toBe("gitlab.com"); - }), - ); - - it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - project: makeProject({ - canonicalKey: "forge.example/git/owner/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "http://forge.example:3000/git/owner/repo.git", - }, - provider: "forgejo", - displayName: "git/owner/repo", - }), - }); - const result = yield* harness.call("link_pull_request", { - repository: "git/owner/repo", - number: 42, - }); - expect(result).toEqual({ - host: "forge.example:3000", - repository: "git/owner/repo", - number: 42, - url: "http://forge.example:3000/git/owner/repo/pulls/42", - alreadyLinked: false, - }); const other = yield* harness.call("link_pull_request", { - host: "other.example", - repository: "owner/repo", - number: 42, + host: "gitlab.example.test", + repository: "team/repo", + number: 7, + }); + expect(other.url).toBe("https://gitlab.example.test/team/repo/-/merge_requests/7"); + const onGitHub = yield* harness.call("link_pull_request", { + host: "github.com", + repository: "acme/web", + number: 9, }); - expect(other.url).toBe("https://other.example/owner/repo/pull/42"); + expect(onGitHub.url).toBe("https://github.com/acme/web/pull/9"); }), ); - it.effect("links Azure DevOps Server URLs and numeric references without a known provider", () => + it.effect("links a URL but not a bare number when the project's host is unknown", () => Effect.gen(function* () { const harness = yield* makeHarness({ project: makeProject({ @@ -441,13 +419,10 @@ describe("pull request toolkit handlers", () => { displayName: "tfs/collection/project/_git/repo", }), }); - const generated = - "http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"; - const fromNumber = yield* harness.call("link_pull_request", { - repository: "tfs/Collection/Project/_git/Repo", - number: 124, - }); - expect.soft(fromNumber.url).toBe(generated); + const error = yield* harness + .call("link_pull_request", { repository: "tfs/Collection/Project/_git/Repo", number: 124 }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestUrlUnavailableError" }); const supplied = "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123"; const fromUrl = yield* harness.call("link_pull_request", { url: supplied }); @@ -459,7 +434,6 @@ describe("pull request toolkit handlers", () => { alreadyLinked: false, }); expect(yield* Ref.get(harness.commands)).toMatchObject([ - { type: "thread.pull-request.link", number: 124, url: generated }, { type: "thread.pull-request.link", number: 123, url: supplied }, ]); }), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index c777214aad5c..ff323fcdd948 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,7 +1,3 @@ -import { - threadPullRequestsOf, - threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; import { CommandId, pullRequestHostOf, @@ -11,14 +7,7 @@ import { type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { - normalizeThreadPullRequestKey, - resolveThreadPullRequestChains, - threadPullRequestKeyOf, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -28,6 +17,15 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; import { pullRequestSyncKey } from "../../../pullRequest/pullRequestSyncKey.ts"; +import { sourceControlClients } from "../../../sourceControl/sourceControlClients.ts"; +import { + normalizeThreadPullRequestKey, + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + threadPullRequestsOf, + visibleThreadPullRequests, +} from "../../../sourceControl/threadPullRequests.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -36,6 +34,7 @@ import { PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestUnlinkFailedError, PullRequestListFailedError, PullRequestNotOpenError, @@ -75,8 +74,8 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( project: OrchestrationProjectShell | undefined, ) { if (input.url !== undefined) { - const parsed = parseChangeRequestUrl(input.url); - if (parsed === null) { + const parsed = sourceControlClients.parseChangeRequestUrl(input.url)?.link; + if (parsed === undefined) { return yield* new PullRequestUrlInvalidError({}); } return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget; @@ -90,24 +89,21 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( return yield* new PullRequestHostRequiredError({}); } const repository = input.repository.toLowerCase(); + // Without a URL, a public forge host says what shape its change request URLs take; + // any other host is taken to run the project's own forge. + const kind = sourceControlClients.findByPublicHost(host)?.kind ?? projectHost.kind; const url = - changeRequestUrlFor( - host === projectHost.host ? projectHost.kind : null, - host, - repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) ?? - (/(?:^|\/)_git\/[^/]+$/u.test(repository) - ? changeRequestUrlFor( - "azure-devops", + kind === null + ? null + : sourceControlClients.get(kind).changeRequestUrl({ host, repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) - : null) ?? - `https://${host}/${repository}/pull/${input.number}`; + number: input.number, + remoteUrl: project?.repositoryIdentity?.locator.remoteUrl, + }); + if (url === null) { + return yield* new PullRequestUrlUnavailableError({}); + } return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), url, @@ -314,8 +310,18 @@ const make = Effect.gen(function* () { .listShells() .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); const callerProject = known.find((project) => project.id === callerThread?.projectId); + // A pull request named by host and repository builds its URL through the project that holds + // it, which need not be the caller's. + const urlProject = (input: PullRequestTargetInput) => + input.url === undefined && input.host !== undefined && input.repository !== undefined + ? (projectForPullRequest( + known, + { host: input.host, repository: input.repository }, + callerProject, + ) ?? callerProject) + : callerProject; return yield* Effect.forEach(targets, (input) => - resolveTarget(input, callerProject).pipe( + resolveTarget(input, urlProject(input)).pipe( Effect.map((target) => ({ input, target, diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index a3a75eabaa38..510f6831d7eb 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -93,6 +93,15 @@ export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestUrlUnavailableError", + {}, +) { + override get message(): string { + return "This thread's project is on a host with no known pull request URL shape. Pass url."; + } +} + export class PullRequestThreadRequiredError extends Schema.TaggedError()( "PullRequestThreadRequiredError", {}, @@ -189,6 +198,7 @@ export const PullRequestToolError = Schema.Union([ PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestThreadRequiredError, PullRequestThreadNotFoundError, PullRequestLinkFailedError, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1a650c81f747..77533f593e9a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9,13 +9,13 @@ import { runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, threadPullRequestKeysEqual, legacyThreadPullRequestKey, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 8deb5bf7e93f..a50ce6ff3524 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,4 +1,4 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -14,7 +14,7 @@ import { threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import type { OrchestrationV2AppThread, OrchestrationV2Checkpoint, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index af7ecdadb12c..5e3dbbb6d937 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,4 +1,3 @@ -import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type PullRequestSummary, @@ -14,7 +13,8 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -53,6 +53,20 @@ interface LinkEntry { readonly link: ThreadPullRequestLink; } +/** Another layer of the stack `url` belongs to, in its host's canonical shape. */ +function siblingChangeRequestUrl(url: string, number: number): string | null { + if (!Number.isSafeInteger(number) || number < 1) return null; + const parsed = sourceControlClients.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { definition, link } = parsed; + return definition.changeRequestUrl({ + host: link.host, + repository: link.repository, + number, + webUrl: link.repositoryUrl, + }); +} + function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { return { state: summary.state, @@ -287,7 +301,7 @@ export const make = Effect.gen(function* () { ) { continue; } - const url = siblingPullRequestUrl(link.url, layer.number); + const url = siblingChangeRequestUrl(link.url, layer.number); if (url === null) continue; const uuid = yield* crypto.randomUUIDv4(); yield* engine.dispatch({ diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 30152d305382..37631ce41a07 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -13,7 +13,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 610be4c1ba3a..30403ea527f0 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -1,7 +1,7 @@ import { threadPullRequestKeysEqual, threadPullRequestsOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../../sourceControl/threadPullRequests.ts"; import { ChatAttachment, OrchestrationMessageContext, diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts index 0ee0f00c0b33..f130aae7e10a 100644 --- a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts @@ -1,4 +1,4 @@ -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts index 7135696246c6..9364a8d817c4 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts @@ -1,5 +1,5 @@ import { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts new file mode 100644 index 000000000000..c5c0f80b8d9d --- /dev/null +++ b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts @@ -0,0 +1,109 @@ +import type { ThreadLinkedPullRequest, ThreadPullRequestKey } from "@t3tools/contracts"; + +/** + * Frozen for migrations 050 and 064: the change request URL parsing and key normalization they + * shipped with. Migrations are history, so they must not follow the live source control hosts. + */ + +interface FrozenLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} + +function isHostOf(hostname: string, apex: string, label: string): boolean { + return hostname === apex || hostname.endsWith(`.${apex}`) || hostname.split(".").includes(label); +} + +function claim(host: string, match: RegExpExecArray | null): FrozenLink | null { + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { host, repository: repository.toLowerCase(), number } + : null; +} + +function parseChangeRequestUrl(targetUrl: string): FrozenLink | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } else { + if (isHostOf(host, "github.com", "github")) { + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) { + const link = claim(host, forgejo); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; + } + const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitlab) return claim(host, gitlab); + if (isHostOf(host, "bitbucket.org", "bitbucket")) { + const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + } + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + const link = claim(host, azure); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; +} + +function normalizeThreadPullRequestKey( + key: ThreadPullRequestKey & { readonly authority?: string }, +): ThreadPullRequestKey { + const canonical = canonicalRepositoryKey( + `${(key.authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; +} + +export function legacyThreadPullRequestKey( + linked: Pick, +): ThreadPullRequestKey { + const parsed = parseChangeRequestUrl(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host: string; + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; +} diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index fa51017ebcd5..c7a2b01946a2 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -1,7 +1,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { PullRequestLinkedThreadsResult, PullRequestOperationError, diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 3b5ecc380d03..90416e066ca0 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -6,7 +6,7 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index aad475ab54af..43f3a9ff8680 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -217,27 +217,27 @@ Logged in to gitlab.com as gitlab-user detail: Option.none(), }, { - kind: SourceControlProviderKind.make("azure-devops"), + kind: SourceControlProviderKind.make("gitcafe"), auth: "authenticated", - account: Option.some("azure-user@example.com"), + account: Option.some("cafe-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("bitbucket"), + kind: SourceControlProviderKind.make("forgejo"), auth: "authenticated", - account: Option.some("bitbucket-user"), + account: Option.some("forgejo-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("forgejo"), + kind: SourceControlProviderKind.make("bitbucket"), auth: "authenticated", - account: Option.some("forgejo-user"), + account: Option.some("bitbucket-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("gitcafe"), + kind: SourceControlProviderKind.make("azure-devops"), auth: "authenticated", - account: Option.some("cafe-user"), + account: Option.some("azure-user@example.com"), detail: Option.none(), }, ], diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9af54853a049..7941c7f3eadc 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,34 +1,20 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; - -const drivers = [ - GitHubDriver.driver, - GitLabDriver.driver, - AzureDevOpsDriver.driver, - BitbucketDriver.driver, - ForgejoDriver.driver, - GitCafeDriver.driver, -]; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST as drivers } from "./sourceControlClients.ts"; export type BuiltInSourceControlDriversEnv = (typeof drivers)[number] extends SourceControlDriver ? R : never; diff --git a/apps/server/src/sourceControl/sourceControlClients.ts b/apps/server/src/sourceControl/sourceControlClients.ts new file mode 100644 index 000000000000..bfe6cc8fecaa --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlClients.ts @@ -0,0 +1,27 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; + +/** + * The same order as the clients' registry in `@t3tools/client-runtime/source-control-clients`. + * A change request URL goes to the first host whose shape it has, so GitCafe, which claims + * `/pull/` and `/pulls/` only on its own hostnames, claims before GitHub and Forgejo, which + * claim them everywhere. + */ +export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ + GitHubDriver.driver, + GitLabDriver.driver, + GitCafeDriver.driver, + ForgejoDriver.driver, + BitbucketDriver.driver, + AzureDevOpsDriver.driver, +] as const; + +export const sourceControlClients = makeSourceControlClientRegistry( + BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition), + { claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind] }, +); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 8ac8046985ce..ab366398be3d 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,26 +1,12 @@ import { secretSettingsFields } from "@t3tools/contracts"; -import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; -import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; -import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; -import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; -import * as GitHub from "@t3tools/source-control-github/client/definition"; -import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; -const definitions: ReadonlyArray = [ - GitHub.definition, - GitLab.definition, - AzureDevOps.definition, - Bitbucket.definition, - Forgejo.definition, - GitCafe.definition, -]; +import { sourceControlClients } from "./sourceControlClients.ts"; export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< string, ReadonlyArray > = new Map( - definitions.flatMap((definition) => { + sourceControlClients.definitions.flatMap((definition) => { const fields = definition.settings ? secretSettingsFields(definition.settings) : []; return fields.length > 0 ? [[definition.kind, fields] as const] : []; }), diff --git a/apps/server/src/sourceControl/threadPullRequests.ts b/apps/server/src/sourceControl/threadPullRequests.ts new file mode 100644 index 000000000000..148fc92ac9e2 --- /dev/null +++ b/apps/server/src/sourceControl/threadPullRequests.ts @@ -0,0 +1,15 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/client/threadPullRequests"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +export { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadPullRequestChains, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlClients); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 84cb807f45fb..cb3df202217f 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,7 +1,7 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index de274fdd433b..2ea19680499b 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -95,20 +95,10 @@ vi.mock(import("../localApi"), async (original) => ({ shell: { openExternal: state.openExternal }, }) as never, })); -vi.mock(import("~/lib/openPullRequestLink"), async (original) => ({ +vi.mock(import("@t3tools/client-runtime/change-request-links"), async (original) => ({ ...(await original()), resolvePullRequestPreviewTarget: () => null, findProjectForChangeRequest: ((projects: readonly { id: ProjectId }[]) => projects[0]) as never, - matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => - candidate.url === href, - parseChangeRequestUrl: ((href: string) => - href === linkedPullRequest.url - ? { - host: "github.com", - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, - } - : null) as never, })); import ChatMarkdown from "./ChatMarkdown"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 91acb81d1730..e46e3092d6b0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -167,12 +167,9 @@ import { stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "@t3tools/client-runtime/workspace-basename-lookup"; -import { - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, - resolvePullRequestPreviewTarget, - useOpenChangeRequestLink, -} from "~/lib/openPullRequestLink"; +import { resolvePullRequestPreviewTarget } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -679,14 +676,14 @@ function useChatMarkdownState({ !pullRequestLinking.canLink(href) ) return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; + const parsed = sourceControlClients.parseChangeRequestUrl(href)?.link; + return parsed === undefined ? null : { ...parsed, url: href }; }; const linkedThreadPullRequestFor = (href: string) => { if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; + const parsed = sourceControlClients.parseChangeRequestUrl(href)?.link; + return parsed === undefined ? null : { ...parsed, url: href }; }; const updateThreadPullRequestLink = async (href: string, linked: boolean) => { if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; @@ -1109,7 +1106,9 @@ const CHAT_MARKDOWN_COMPONENTS = { const isPullRequestAutolink = pullRequestCopy !== undefined; const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = - confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; + confirmBeforeOpen && href + ? sourceControlClients.changeRequestUrlFromReferenceAutolink(href) + : href; const pullRequestPreviewTarget = pullRequestCandidateUrl ? resolvePullRequestPreviewTarget({ environmentId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 62223ba82096..ec4fe2a5511d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,7 +102,7 @@ import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/mod import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 64b2b4edf7c9..53a39007574e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -22,7 +22,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4072f3aad541..d9bf14748258 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,5 +1,5 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4c1059eaeeb4..c4577f64fe51 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -4,7 +4,7 @@ import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities import { threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import type { ContextMenuItem, EnvironmentId, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 00710286a6bd..7a66512ccadc 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -27,13 +27,12 @@ import { resolveThreadPullRequestChains, type ThreadPullRequestBadge, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { useRender } from "@base-ui/react/use-render"; import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { revealThreadTerminal } from "../lib/revealThreadTerminal"; -import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -120,7 +119,7 @@ export function useLinkedThreadPullRequest( } function linkedPullRequestReference(fallback: ThreadLinkedPullRequest) { - const host = parseChangeRequestUrl(fallback.url)?.host; + const host = sourceControlClients.parseChangeRequestUrl(fallback.url)?.link.host; return { ...fallback, ...(host === undefined ? {} : { host }) }; } @@ -150,7 +149,7 @@ export function linkedPullRequestSnapshotStatus( export { resolveThreadPullRequestBadge, type ThreadPullRequestBadge, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; export interface ThreadPullRequestBadgePresentation { readonly Icon: PullRequestGlyphIcon; diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index 8dfb4623801f..f08251b18425 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -5,11 +5,12 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { Minus, Plus } from "lucide"; import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; -import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { findProjectOnChangeRequestHost } from "~/lib/openPullRequestLink"; import { useProjects } from "~/state/entities"; import { threadEnvironment } from "~/state/threads"; @@ -34,9 +35,9 @@ function ThreadDetailsPrLinkRow({ onStopWatching?: (() => void) | undefined; }) { const projects = useProjects(); - const parsed = parseChangeRequestUrl(link.url); + const parsed = sourceControlClients.parseChangeRequestUrl(link.url)?.link; const project = - parsed === null + parsed === undefined ? null : (findProjectOnChangeRequestHost( projects.filter((candidate) => candidate.environmentId === environmentId), diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index 6e42b7257c3f..8706f374522f 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -1,7 +1,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import { useRouter } from "@tanstack/react-router"; import * as Effect from "effect/Effect"; @@ -155,7 +155,7 @@ export function DesktopWebLinkCoordinator() { // delivered at launch would miss its project and fall through to the browser. Hold PR // links, and only those, until discovery settles; an ordinary link never waits for it. const hold = - openPullRequestsInApp && parseChangeRequestUrl(url) !== null + openPullRequestsInApp && sourceControlClients.parseChangeRequestUrl(url) !== null ? projectsSettled() : Promise.resolve(); void hold.then(() => { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 0e3372b81a1a..f715ecef5153 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,4 @@ -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { @@ -53,7 +53,6 @@ import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings import { primaryServerKeybindingsAtom } from "~/state/server"; import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; import { OFF_HEAD_PULL_REQUEST_CHECKOUT_TOAST } from "./pullRequestCheckoutToast"; -import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -252,7 +251,10 @@ function pullRequestStackReference( ) { return detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests ? null - : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }; + : { + ...reference, + host: reference.host ?? sourceControlClients.parseChangeRequestUrl(detail.url)?.link.host, + }; } export function PullRequestDetailPanel({ @@ -305,7 +307,7 @@ export function PullRequestDetailPanel({ supportsThreadPullRequests, ); const loadingCheckoutCommand = loadingPullRequestCheckoutCommand(reference, repositoryIdentity); - const unavailableGitHubUrl = gitHubPullRequestBrowserUrl( + const unavailableHostUrl = projectChangeRequestUrl( repositoryIdentity, reference.repository, reference.number, @@ -469,7 +471,10 @@ export function PullRequestDetailPanel({ setSeenAutoMergeKey(autoMergeKey); if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); } - const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const repositoryUrl = + detail === null + ? null + : (sourceControlClients.parseChangeRequestUrl(detail.url)?.link.repositoryUrl ?? null); const hostClient = sourceControlClients.get(detail?.provider); const openOnHostText = openOnHostLabel(hostClient); const markdownContext = pullRequestMarkdownContext(hostClient, repositoryUrl, threadRef); @@ -483,7 +488,7 @@ export function PullRequestDetailPanel({ handoffSummary.number, handoffSummary.headBranch, detail?.headRepositoryNameWithOwner, - changeRequestRepositoryUrl(handoffSummary.url), + sourceControlClients.parseChangeRequestUrl(handoffSummary.url)?.link.repositoryUrl ?? null, ) : loadingCheckoutCommand; const branchRefsQuery = useEnvironmentQuery( @@ -1695,7 +1700,16 @@ export function PullRequestDetailPanel({ : { error: detailQuery.error })} refreshing={detailQuery.isPending} onRetry={refreshDetail} - {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} + {...(unavailableHostUrl + ? { + host: { + url: unavailableHostUrl, + openLabel: openOnHostLabel( + sourceControlClients.get(repositoryIdentity?.provider), + ), + }, + } + : {})} /> ) : detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index f9dff552a95c..6b70218b91da 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -7,8 +7,8 @@ import { useState } from "react"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -49,7 +49,7 @@ function EnabledPullRequestThreadLinks({ display, onPickerOpenChange, }: PullRequestThreadLinksProps) { - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link ?? null; const thread = useThreadShell(threadRef?.environmentId === environmentId ? threadRef : null); const currentThreadRef = thread === null ? null : threadRef; const linking = usePullRequestLinking(environmentId); diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 6c84b59fecf9..b207e5a048c9 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -10,13 +10,13 @@ export function PullRequestsUnavailableState({ error, onRetry, refreshing = false, - gitHubUrl, + host, }: { title?: string; error: string; onRetry?: () => void; refreshing?: boolean; - gitHubUrl?: string; + host?: { readonly url: string; readonly openLabel: string }; }) { return ( @@ -29,7 +29,7 @@ export function PullRequestsUnavailableState({ shows its message rather than trying to infer one from the failure text. */} {error} - {onRetry || gitHubUrl ? ( + {onRetry || host ? (
{onRetry ? ( ) : null}
diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index f52e5b7c4312..41455322e6ca 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -8,7 +8,7 @@ import { pullRequestListLines, resolveThreadPullRequestChains, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { ArrowUpRightIcon, EyeIcon, @@ -25,7 +25,8 @@ import { encodePullRequestDragPayload, } from "~/components/chat/composerMentionDrag"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "~/shortcutModifierState"; import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ede70552a7c6..dd29162fbde4 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -34,7 +34,7 @@ import { resolveThreadCurrentPullRequestLink, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; import { reviewCommentContextId } from "~/lib/composerContextRecords"; @@ -1409,16 +1409,10 @@ export function resolveDisplayedPullRequestDetail(input: { return null; } if (input.reference.host === undefined) return input.cached; - try { - const url = new URL(input.cached.url); - const host = sourceControlClients.get(input.cached.provider).changeRequestUrlHost(url); - return (url.protocol === "https:" || url.protocol === "http:") && - host.toLowerCase() === input.reference.host.toLowerCase() - ? input.cached - : null; - } catch { - return null; - } + const link = sourceControlClients.parseChangeRequestUrl(input.cached.url)?.link; + return link !== undefined && (link.authority ?? link.host) === input.reference.host.toLowerCase() + ? input.cached + : null; } export function resolvePullRequestDetailChecks( diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index cfab5ca8b0c1..51d913f61b99 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -3,7 +3,7 @@ import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 7edbd93a0e0c..255452cb2dd5 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -5,7 +5,7 @@ import { readPullRequestDetailSnapshot, resolvePullRequestReferenceHost, } from "../components/pullRequest/pullRequestDetail.logic"; -import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; +import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; import { useProject } from "../state/entities"; import { pullRequestEnvironment } from "../state/pullRequests"; @@ -71,11 +71,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { : null; const fallbackUrl = reference ? (requestedReference?.url ?? - gitHubPullRequestBrowserUrl( - project?.repositoryIdentity, - reference.repository, - reference.number, - )) + projectChangeRequestUrl(project?.repositoryIdentity, reference.repository, reference.number)) : undefined; const detail = useEnvironmentQuery( reference && environmentId diff --git a/apps/web/src/hooks/usePullRequestLinking.test.ts b/apps/web/src/hooks/usePullRequestLinking.test.ts index 5cb7138f1d13..107cb0f56afd 100644 --- a/apps/web/src/hooks/usePullRequestLinking.test.ts +++ b/apps/web/src/hooks/usePullRequestLinking.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import { canLinkChangeRequest } from "./usePullRequestLinking"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; describe("canLinkChangeRequest (#9435 / #9440)", () => { const nonGitProject = { id: "scratch-project" } as never; - const link = parseChangeRequestUrl("https://github.com/pingdotgg/t3code/pull/15111")!; + const link = sourceControlClients.parseChangeRequestUrl( + "https://github.com/pingdotgg/t3code/pull/15111", + )!.link; it("links a pasted pull request URL when the server tracks links on the thread, even with no matching project", () => { expect(canLinkChangeRequest("multiple", [nonGitProject], link)).toBe(true); diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts index a9ee2163a8be..18c31cd3714d 100644 --- a/apps/web/src/hooks/usePullRequestLinking.ts +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -17,13 +17,12 @@ import { import { threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; import { - findProjectForChangeRequest, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, + sourceControlClients, type ChangeRequestLink, -} from "~/lib/openPullRequestLink"; +} from "@t3tools/client-runtime/source-control-clients"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -62,8 +61,8 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const environmentProjects = projects.filter((project) => project.environmentId === environmentId); const canLink = (url: string) => { - const parsed = parseChangeRequestUrl(url); - return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + return parsed !== undefined && canLinkChangeRequest(mode, environmentProjects, parsed); }; const isLinked = ( thread: { @@ -76,19 +75,23 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde if (mode !== "multiple") return ( thread.linkedPullRequest != null && - matchesLinkedPullRequestUrl(thread.linkedPullRequest, url) + sourceControlClients.isSameChangeRequest(thread.linkedPullRequest.url, url) ); - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; return ( - parsed !== null && + parsed !== undefined && visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) => threadPullRequestKeysEqual(entry, parsed), ) ); }; const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => { - const parsed = parseChangeRequestUrl(url); - if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url))) + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if ( + parsed === undefined || + threadRef.environmentId !== environmentId || + (linked && !canLink(url)) + ) throw new Error("The pull request is not available in this environment."); const legacyProject = findProjectForChangeRequest(environmentProjects, parsed); const mutation = planThreadPullRequestMutation({ diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 7489d60006c3..4b0de9244c54 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,190 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - changeRequestRepositoryUrl, - findProjectForChangeRequest, - findProjectOnChangeRequestHost, - gitHubPullRequestBrowserUrl, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, -} from "./openPullRequestLink"; -import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; - -function repositoryIdentity( - provider: string, - canonicalKey: string, - remoteUrl: string, -): RepositoryIdentity { - return { - canonicalKey, - provider, - locator: { source: "git-remote", remoteName: "origin", remoteUrl }, - }; -} - -describe("gitHubPullRequestBrowserUrl", () => { - it("uses the requested GitHub repository instead of the project's default repository", () => { - const identity = repositoryIdentity( - "github", - "github.com/acme/default", - "https://github.com/acme/default.git", - ); - - expect(gitHubPullRequestBrowserUrl(identity, "acme/other", 42)).toBe( - "https://github.com/acme/other/pull/42", - ); - }); - - it("preserves a custom GitHub HTTP origin without its credentials", () => { - const identity = repositoryIdentity( - "github", - "github.acme.test/team/default", - "http://token@github.acme.test:8443/team/default.git", - ); - - expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 7)).toBe( - "http://github.acme.test:8443/platform/api/pull/7", - ); - }); - - it.each([ - { - name: "SSH", - remoteUrl: "git@github.acme.test:team/default.git", - }, - { - name: "git protocol", - remoteUrl: "git://github.acme.test/team/default.git", - }, - ])("uses the normalized host for a $name remote", ({ remoteUrl }) => { - const identity = repositoryIdentity("github", "github.acme.test/team/default", remoteUrl); - - expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 9)).toBe( - "https://github.acme.test/platform/api/pull/9", - ); - }); - - // regression: #8507 the browser fallback must not open a bogus URL - it("returns null for missing or invalid GitHub data", () => { - expect(gitHubPullRequestBrowserUrl(null, "acme/repository", 1)).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "acme", - 1, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "../repository", - 1, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "acme/repository", - 0, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "bad host/acme/repository", "not a remote"), - "acme/repository", - 1, - ), - ).toBeNull(); - }); - - // regression: #8507 the browser fallback for failed PR loads must only target GitHub - it.each(["gitlab", "bitbucket", "azure-devops", "unknown"])( - "does not build a fallback for %s", - (provider) => { - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity(provider, "github.com/acme/repository", "https://github.com/a/b"), - "acme/repository", - 1, - ), - ).toBeNull(); - }, - ); -}); - -describe("changeRequestRepositoryUrl", () => { - it("keeps pull-like segments inside nested GitLab repository paths", () => { - expect( - changeRequestRepositoryUrl( - "https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42", - ), - ).toBe("https://gitlab.example.test/group/pull/123/repo"); - }); -}); - -describe("pullRequestCandidateUrlFromReferenceAutolink", () => { - it("turns GitHub's shared issue route into a pull request candidate", () => { - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1", - ), - ).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1"); - }); - - it("does not reinterpret other issue hosts or malformed references", () => { - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://gitlab.com/pingdotgg/t3code/-/issues/8600", - ), - ).toBeNull(); - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://github.com/pingdotgg/t3code/issues/not-a-number", - ), - ).toBeNull(); - }); -}); - -describe("matchesLinkedPullRequestUrl", () => { - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - - it("matches the same pull request without looking up its project", () => { - expect( - matchesLinkedPullRequestUrl( - linkedPullRequest, - "https://github.com/PingDotGG/T3Code/pull/42/files", - ), - ).toBe(true); - }); - - it.each([ - ["http://forge.example:3000/git/team/repo/pulls/42/files", true], - ["http://forge.example:4000/git/team/repo/pulls/42", false], - ["http://forge.example/git/team/repo/pulls/42", false], - ])("matches Forgejo links by web authority: %s", (url, expected) => { - expect( - matchesLinkedPullRequestUrl( - { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" }, - url, - ), - ).toBe(expected); - }); - - it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { - expect( - matchesLinkedPullRequestUrl( - { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" }, - "https://github.internal/team/repo/pull/42", - ), - ).toBe(false); - }); -}); +import { findProjectOnChangeRequestHost } from "./openPullRequestLink"; describe("findProjectOnChangeRequestHost", () => { const project = (id: string, identity: Record) => @@ -202,41 +18,6 @@ describe("findProjectOnChangeRequestHost", () => { name: "backend", }); - it.each([ - ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], - [ - "vs-ssh.visualstudio.com/v3/org-a/project/web", - "git@vs-ssh.visualstudio.com:v3/org-a/project/web", - ], - [ - "org-a.visualstudio.com/defaultcollection/project/_git/web", - "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", - ], - ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], - ])("matches Azure browser references against %s", (canonicalKey, remoteUrl) => { - const checkout = project("azure", { - ...repositoryIdentity("azure-devops", canonicalKey, remoteUrl), - displayName: canonicalKey.split("/").slice(1).join("/"), - }); - const reference = parseChangeRequestUrl( - "https://dev.azure.com/org-a/project/_git/web/pullrequest/42", - )!; - expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); - expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout); - expect( - findProjectOnChangeRequestHost([checkout], { - ...reference, - repository: "org-b/project/_git/web", - }), - ).toBeUndefined(); - expect( - findProjectOnChangeRequestHost([checkout], { - ...reference, - repository: "org-a/other-project/_git/web", - }), - ).toBeUndefined(); - }); - it("prefers the project checked out from the link's own repository", () => { expect( findProjectOnChangeRequestHost([frontend, backend], { @@ -247,59 +28,6 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(backend); }); - it("selects the Forgejo HTTP port for repository and host-level matches", () => { - const projects = [3000, 4000].map((port) => - project(`forgejo-${port}`, { - canonicalKey: "forge.example/git/team/repo", - provider: "forgejo", - displayName: "git/team/repo", - locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` }, - }), - ); - const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; - expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]); - expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]); - expect( - findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }), - ).toBe(projects[1]); - expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined(); - }); - - it("lets tea resolve the web port for Forgejo SSH remotes", () => { - const checkout = project("forgejo-ssh", { - canonicalKey: "forge.example/git/team/repo", - provider: "forgejo", - displayName: "git/team/repo", - locator: { remoteUrl: "git@forge.example:git/team/repo.git" }, - }); - const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; - expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); - const aliased = project("forgejo-alias", { - canonicalKey: "ssh.forge.example/team/repo", - provider: "forgejo", - displayName: "team/repo", - locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" }, - webUrl: "http://forge.example:4000/git/team/repo", - }); - expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased); - expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased); - expect( - findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }), - ).toBe(aliased); - for (const url of [ - "http://other.example:4000/git/team/repo/pulls/42", - "http://forge.example:3000/git/team/repo/pulls/42", - ]) { - const other = parseChangeRequestUrl(url)!; - expect(findProjectForChangeRequest([aliased], other)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined(); - } - const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!; - expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined(); - }); - it("lends any project on the host to a repository nobody has checked out", () => { expect( findProjectOnChangeRequestHost([frontend], { @@ -310,45 +38,3 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(frontend); }); }); - -describe("findProjectForChangeRequest", () => { - const project = (identity: Record) => - ({ id: "p1", repositoryIdentity: identity }) as never; - - it("matches a nested GitLab group by the whole path below the host", () => { - const projects = [ - project({ - canonicalKey: "gitlab.com/t3tools/platform/t3code", - provider: "gitlab", - displayName: "t3tools/platform/t3code", - owner: "t3tools", - name: "t3code", - }), - ]; - expect( - findProjectForChangeRequest(projects, { - host: "gitlab.com", - repository: "t3tools/platform/t3code", - number: 42, - }), - ).toBe(projects[0]); - }); - - it("claims nothing for a lookalike host, which is what keeps a link a link", () => { - const projects = [ - project({ - canonicalKey: "github.com/pingdotgg/t3code", - provider: "github", - owner: "pingdotgg", - name: "t3code", - }), - ]; - expect( - findProjectForChangeRequest(projects, { - host: "github.com-evil.test", - repository: "pingdotgg/t3code", - number: 1, - }), - ).toBeUndefined(); - }); -}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index ce5fb92778b9..0d3361e73de2 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,10 +1,16 @@ -import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent } from "react"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import { + findProjectForChangeRequest, + resolvePullRequestPreviewTarget, +} from "@t3tools/client-runtime/change-request-links"; +import { + sourceControlClients, + type ChangeRequestLink, +} from "@t3tools/client-runtime/source-control-clients"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; @@ -18,58 +24,6 @@ import { readProjects, useProjects } from "../state/entities"; import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; -export { - parseChangeRequestUrl, - type ChangeRequestLink, - gitHubPullRequestBrowserUrl, - pullRequestCandidateUrlFromReferenceAutolink, - matchesLinkedPullRequestUrl, - changeRequestRepositoryUrl, -} from "@t3tools/shared/changeRequestUrl"; - -export function findProjectForChangeRequest( - projects: ReadonlyArray, - link: ChangeRequestLink, -): EnvironmentProject | undefined { - return projects.find((project) => { - const identity = project.repositoryIdentity; - return ( - identity?.provider !== undefined && - sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) - ); - }); -} - -export function resolvePullRequestPreviewTarget({ - environmentId, - projects, - pullRequestsEnabled, - url, -}: { - environmentId: EnvironmentId | null; - projects: ReadonlyArray; - pullRequestsEnabled: boolean; - url: string; -}): { environmentId: EnvironmentId; input: PullRequestRef } | null { - if (!pullRequestsEnabled || environmentId === null) return null; - const parsed = parseChangeRequestUrl(url); - if (parsed === null) return null; - const project = findProjectForChangeRequest( - projects.filter((candidate) => candidate.environmentId === environmentId), - parsed, - ); - if (project === undefined) return null; - return { - environmentId, - input: { - projectId: project.id, - host: parsed.authority ?? parsed.host, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, - number: parsed.number, - }, - }; -} - export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { const projects = useProjects(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); @@ -96,7 +50,7 @@ export function findProjectOnChangeRequestHost( }); } -export function shouldOpenPullRequestExternally( +function shouldOpenPullRequestExternally( event: Pick, "metaKey" | "ctrlKey"> & Partial, "button">>, ): boolean { @@ -127,8 +81,8 @@ export function useOpenChangeRequestLink( const allProjects = readProjects(); const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const parsed = parseChangeRequestUrl(targetUrl); - if (parsed === null) return false; + const parsed = sourceControlClients.parseChangeRequestUrl(targetUrl)?.link; + if (parsed === undefined) return false; const reads = (environmentId: string) => serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === true; diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 8757c4b6caf0..caf610184557 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -19,7 +19,7 @@ import type { import { threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useLayoutEffect } from "react"; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7bec2b2c0831..81a91d6057aa 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -367,6 +367,18 @@ "types": "./src/pullRequestLinkInput.ts", "default": "./src/pullRequestLinkInput.ts" }, + "./change-request-links": { + "types": "./src/changeRequestLinks.ts", + "default": "./src/changeRequestLinks.ts" + }, + "./thread-pull-requests": { + "types": "./src/threadPullRequests.ts", + "default": "./src/threadPullRequests.ts" + }, + "./thread-reference": { + "types": "./src/threadReference.ts", + "default": "./src/threadReference.ts" + }, "./thread-pull-request-compatibility": { "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts new file mode 100644 index 000000000000..f5d39305da2e --- /dev/null +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -0,0 +1,104 @@ +import { EnvironmentId, ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + findProjectForChangeRequest, + projectChangeRequestUrl, + resolvePullRequestPreviewTarget, +} from "./changeRequestLinks.ts"; + +describe("findProjectForChangeRequest", () => { + const project = (identity: Record) => + ({ id: "p1", repositoryIdentity: identity }) as never; + + it("matches a nested GitLab group by the whole path below the host", () => { + const projects = [ + project({ + canonicalKey: "gitlab.com/t3tools/platform/t3code", + provider: "gitlab", + displayName: "t3tools/platform/t3code", + owner: "t3tools", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + }), + ).toBe(projects[0]); + }); + + it("claims nothing for a lookalike host, which is what keeps a link a link", () => { + const projects = [ + project({ + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + owner: "pingdotgg", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "github.com-evil.test", + repository: "pingdotgg/t3code", + number: 1, + }), + ).toBeUndefined(); + }); +}); + +describe("resolvePullRequestPreviewTarget", () => { + const environmentId = EnvironmentId.make("env-1"); + const project = { + id: ProjectId.make("project-1"), + environmentId, + repositoryIdentity: { + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:acme/web" }, + } satisfies RepositoryIdentity, + }; + + it("previews a pull request only under the project that holds its repository", () => { + const target = (url: string) => + resolvePullRequestPreviewTarget({ + environmentId, + projects: [project], + pullRequestsEnabled: true, + url, + }); + expect(target("https://github.com/acme/web/pull/7")).toEqual({ + environmentId, + input: { projectId: project.id, host: "github.com", repository: "acme/web", number: 7 }, + }); + expect(target("https://github.com/acme/other/pull/7")).toBeNull(); + }); +}); + +describe("projectChangeRequestUrl", () => { + const identity = (provider: string): RepositoryIdentity => ({ + canonicalKey: "github.com/acme/default", + provider, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:acme/default", + }, + }); + + it("builds the requested repository's URL on the project's host", () => { + expect(projectChangeRequestUrl(identity("github"), "acme/other", 42)).toBe( + "https://github.com/acme/other/pull/42", + ); + }); + + it("builds nothing without a project host that knows its URL shape", () => { + expect(projectChangeRequestUrl(null, "acme/repository", 1)).toBeNull(); + expect(projectChangeRequestUrl(identity("unknown"), "acme/repository", 1)).toBeNull(); + const { provider: _provider, ...withoutProvider } = identity("github"); + expect(projectChangeRequestUrl(withoutProvider, "acme/repository", 1)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts new file mode 100644 index 000000000000..36c9097c951e --- /dev/null +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -0,0 +1,77 @@ +import type { + EnvironmentId, + ProjectId, + PullRequestRef, + RepositoryIdentity, +} from "@t3tools/contracts"; +import type { ChangeRequestLink } from "@t3tools/source-control-core/client/definition"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +interface ChangeRequestProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: RepositoryIdentity | null | undefined; +} + +/** The project whose own repository holds the change request, not just one on its host. */ +export function findProjectForChangeRequest( + projects: ReadonlyArray, + link: ChangeRequestLink, +): Project | undefined { + return projects.find((project) => { + const identity = project.repositoryIdentity; + return ( + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) + ); + }); +} + +export function resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled, + url, +}: { + environmentId: EnvironmentId | null; + projects: ReadonlyArray; + pullRequestsEnabled: boolean; + url: string; +}): { environmentId: EnvironmentId; input: PullRequestRef } | null { + if (!pullRequestsEnabled || environmentId === null) return null; + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (parsed === undefined) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === environmentId), + parsed, + ); + if (project === undefined) return null; + return { + environmentId, + input: { + projectId: project.id, + host: parsed.authority ?? parsed.host, + repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + number: parsed.number, + }, + }; +} + +/** The project host's browser URL for one of its change requests, before its detail loads. */ +export function projectChangeRequestUrl( + identity: RepositoryIdentity | null | undefined, + repository: string, + number: number, +): string | null { + if (identity?.provider === undefined) return null; + const host = identity.canonicalKey.split("/")[0]; + if (!host) return null; + return sourceControlClients.get(identity.provider).changeRequestUrl({ + host, + repository, + number, + remoteUrl: identity.locator.remoteUrl.trim(), + }); +} diff --git a/packages/client-runtime/src/promptLinks.ts b/packages/client-runtime/src/promptLinks.ts index 4e4c1214180e..175b3aa5cb6f 100644 --- a/packages/client-runtime/src/promptLinks.ts +++ b/packages/client-runtime/src/promptLinks.ts @@ -12,7 +12,7 @@ import { threadPullRequestKeyOf, threadPullRequestsOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "./threadPullRequests.ts"; const repositoryKey = (repository: PromptLinkRepository) => `github.com/${repository.owner}/${repository.name}`.toLowerCase(); diff --git a/packages/client-runtime/src/pullRequestLinkInput.ts b/packages/client-runtime/src/pullRequestLinkInput.ts index a72701adc903..c897dc458878 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.ts @@ -1,5 +1,4 @@ import { pullRequestHostOf, type RepositoryIdentity } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -75,12 +74,12 @@ export function resolveLinkPullRequestInput(input: { readonly hasProject: (reference: ResolvedLink) => boolean; }): { link: ResolvedLink } | { error: string } | null { const parsed = - parseChangeRequestUrl(input.reference.trim()) !== null + sourceControlClients.parseChangeRequestUrl(input.reference.trim()) !== null ? input.reference.trim() : parsePullRequestReference(input.reference); if (parsed === null) return null; - const url = parseChangeRequestUrl(parsed); - if (url !== null) { + const url = sourceControlClients.parseChangeRequestUrl(parsed)?.link; + if (url !== undefined) { if (!input.hasProject({ ...url, url: parsed })) { return { error: `No project in this environment can read ${url.host}/${url.repository}.` }; } @@ -97,11 +96,17 @@ export function resolveLinkPullRequestInput(input: { }; } const webUrl = input.project.webUrl(number); - const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl); - if (webUrl === null || webReference === null) { + const webReference = + webUrl === null ? undefined : sourceControlClients.parseChangeRequestUrl(webUrl)?.link; + if (webUrl === null || webReference === undefined) { return { error: "Paste a full URL; this project's host has no known pull request URL." }; } return { - link: { ...webReference, url: webUrl }, + link: { + host: webReference.host, + repository: webReference.repository, + number: webReference.number, + url: webUrl, + }, }; } diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts new file mode 100644 index 000000000000..4a646bc9caba --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -0,0 +1,240 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceControlClients } from "./sourceControlClients.ts"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; + +const parse = (url: string) => { + const parsed = sourceControlClients.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { repositoryUrl: _repositoryUrl, ...link } = parsed.link; + return link; +}; +const kindOf = (url: string) => sourceControlClients.parseChangeRequestUrl(url)?.definition.kind; + +describe("sourceControlClients.parseChangeRequestUrl", () => { + it("reads a GitHub pull request, lower-casing the repository", () => { + expect(parse("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => { + for (const path of ["pull", "pulls"]) { + const url = `https://git.cafe/PingDotGG/T3Code/${path}/23897`; + expect(parse(url), path).toEqual({ + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 23897, + }); + expect(kindOf(url), path).toBe("gitcafe"); + } + expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({ + host: "staging.git.cafe", + repository: "team/repo", + number: 7, + }); + }); + + it("reads Forgejo URLs even when the hostname contains github", () => { + expect(parse("https://github.internal/team/repo/pulls/7")).toEqual({ + host: "github.internal", + authority: "github.internal", + repository: "team/repo", + number: 7, + }); + }); + + it("reads a GitHub pull request on any host, so GitHub Enterprise works on its own name", () => { + expect(parse("https://code.github.example.com/acme/web/pull/42")).toEqual({ + host: "code.github.example.com", + repository: "acme/web", + number: 42, + }); + expect(kindOf("https://scm.example.test/acme/web/pull/42")).toBe("github"); + expect( + sourceControlClients.changeRequestUrlFromReferenceAutolink( + "https://code.github.example.com/acme/web/issues/42", + ), + ).toBe("https://code.github.example.com/acme/web/pull/42"); + }); + + it("reads a GitLab merge request on any host, nested groups and all", () => { + expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({ + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + }); + expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toEqual({ + host: "code.acme.test", + repository: "team/project", + number: 9, + }); + }); + + it("reads Bitbucket and both Azure DevOps URL forms", () => { + expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + }); + expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + }); + expect(parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); + }); + + it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { + for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { + const repository = "tfs/collection/project/_git/repo"; + const linked = { + projectId: ProjectId.make("project-1"), + host, + repository, + number: 123, + url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, + }; + const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; + expect.soft(parse(linked.url), host).toEqual({ + host, + authority: `${host}:8443`, + repository, + number: 123, + }); + expect + .soft( + sourceControlClients.isSameChangeRequest(linked.url, `${linked.url}?view=files`), + host, + ) + .toBe(true); + expect + .soft(sourceControlClients.isSameChangeRequest(linked.url, other.url), host) + .toBe(false); + expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ + { host: `${host}:8443`, repository, number: 123 }, + { host: `${host}:9443`, repository, number: 123 }, + ]); + } + }); + + it("survives trailing segments, a trailing slash and a query string", () => { + expect(parse("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + expect(parse("https://github.com/t3tools/t3code/pull/123/")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("claims nothing it cannot be sure of", () => { + for (const link of [ + "https://github.com/t3tools/t3code/issues/123", + "https://git.cafe/pingdotgg/t3code/issues/23897", + "https://git.cafe/pingdotgg/t3code/pull/abc", + "https://git.cafe/pingdotgg/t3code", + "https://github.com/t3tools/t3code/commit/0a1b2c3", + "https://github.com/t3tools/t3code", + "https://github.com/t3tools/t3code/pull/abc", + "https://gitlab.com/t3tools/t3code/-/issues/12", + "https://blog.example.test/2026/updates/archive/pull/3", + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", + "javascript:alert(1)//github.com/t3tools/t3code/pull/1", + "not a url", + ]) { + expect(parse(link), link).toBeNull(); + } + }); +}); + +describe("sourceControlClients.isSameChangeRequest", () => { + const linked = "https://github.com/pingdotgg/t3code/pull/42"; + + it("matches the same pull request whatever its case or trailing path", () => { + expect( + sourceControlClients.isSameChangeRequest( + linked, + "https://github.com/PingDotGG/T3Code/pull/42/files", + ), + ).toBe(true); + }); + + it.each([ + ["http://forge.example:3000/git/team/repo/pulls/42/files", true], + ["http://forge.example:4000/git/team/repo/pulls/42", false], + ["http://forge.example/git/team/repo/pulls/42", false], + ])("matches Forgejo links by web authority: %s", (url, expected) => { + expect( + sourceControlClients.isSameChangeRequest( + "http://forge.example:3000/git/team/repo/pulls/42", + url, + ), + ).toBe(expected); + }); + + it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { + expect( + sourceControlClients.isSameChangeRequest( + "https://github.internal/team/repo/pulls/42", + "https://github.internal/team/repo/pull/42", + ), + ).toBe(false); + }); +}); + +describe("change request URLs built from a parsed link", () => { + const sibling = (url: string, number: number) => { + const parsed = sourceControlClients.parseChangeRequestUrl(url)!; + return parsed.definition.changeRequestUrl({ + host: parsed.link.host, + repository: parsed.link.repository, + number, + webUrl: parsed.link.repositoryUrl, + }); + }; + + it.each([ + [ + "http://git.example.test:3000/team/repo/pulls/42/files", + "http://git.example.test:3000/team/repo/pulls/43", + ], + ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], + [ + "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files", + "https://dev.azure.com/acme/project/_git/web/pullrequest/43", + ], + ])("builds a canonical sibling of %s", (url, expected) => { + expect(sibling(url, 43)).toBe(expected); + }); + + it.each([ + ["ssh.dev.azure.com", "v3/org/project/web"], + ["org.visualstudio.com", "defaultcollection/project/_git/web"], + ["dev.azure.com", "org/project/_git/web"], + ])("reads back the Azure browser URL built from the remote %s/%s", (host, repository) => { + const url = sourceControlClients + .get("azure-devops") + .changeRequestUrl({ host, repository, number: 42 }); + expect(parse(url!)).toEqual({ + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 42, + }); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 55c2cde6456e..7faf3abcc903 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -8,7 +8,9 @@ import type { ServerSettings } from "@t3tools/contracts"; export { UNKNOWN_SOURCE_CONTROL_CLIENT, + type ChangeRequestLink, type ChangeRequestTerminology, + type ParsedChangeRequestUrl, type SourceControlClientDefinition, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; @@ -19,8 +21,9 @@ import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; /** * GitHub comes first because a repository that has not reported its host yet reads as GitHub, * which is what clients have always shown there. A change request URL goes to the first host - * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before - * Forgejo, which claims it everywhere. + * whose shape it has, so GitCafe, which claims `/pull/` and `/pulls/` only on its own hostnames, + * claims before GitHub and Forgejo, which claim them everywhere. The server's + * `sourceControl/sourceControlClients.ts` keeps the same order. */ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ GitHub.definition, @@ -33,6 +36,7 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, + { claimChangeRequestUrlsFirst: [GitCafe.definition.kind] }, ); export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ad011530b5d6..2474f3852efc 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,5 +1,5 @@ import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../threadPullRequests.ts"; import type { ThreadLinkedPullRequest, EnvironmentId, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 491d158700f2..506afe1394dd 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -1,4 +1,4 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { applyThreadPullRequestLinkUpdate } from "../threadPullRequests.ts"; import type { OrchestrationV2DomainEvent, OrchestrationV2Run, diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts index 922fc119c993..82915bd6deed 100644 --- a/packages/client-runtime/src/threadPullRequestCompatibility.ts +++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts @@ -4,7 +4,7 @@ import type { ThreadId, ThreadPullRequestKey, } from "@t3tools/contracts"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; type LinkingCapabilities = Pick< ExecutionEnvironmentCapabilities, diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/client-runtime/src/threadPullRequests.test.ts similarity index 100% rename from packages/shared/src/threadPullRequests.test.ts rename to packages/client-runtime/src/threadPullRequests.test.ts diff --git a/packages/client-runtime/src/threadPullRequests.ts b/packages/client-runtime/src/threadPullRequests.ts new file mode 100644 index 000000000000..c4443728930e --- /dev/null +++ b/packages/client-runtime/src/threadPullRequests.ts @@ -0,0 +1,28 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/client/threadPullRequests"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +export { + threadPullRequestSearchTerms, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +export { + pullRequestListLines, + type PullRequestListLine, + type ThreadCurrentPullRequest, + type ThreadPullRequestBadge, + type ThreadPullRequestChain, +} from "@t3tools/source-control-core/client/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadCurrentPullRequest, + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + resolveThreadPullRequestBadge, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlClients); diff --git a/packages/shared/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts similarity index 100% rename from packages/shared/src/threadReference.test.ts rename to packages/client-runtime/src/threadReference.test.ts diff --git a/packages/shared/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts similarity index 100% rename from packages/shared/src/threadReference.ts rename to packages/client-runtime/src/threadReference.ts diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 57bbc2db2b6e..146fffde2c79 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/shared/t3McpToolPresentation"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { formatTokens } from "@t3tools/shared/usageFormat"; import { classifyToolActivity } from "@t3tools/shared/toolActivity"; @@ -184,7 +184,10 @@ function resolveT3McpToolPresentation( const payload = asRecord(data); const input = asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); - const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null; + const urlTarget = + typeof input?.url === "string" + ? sourceControlClients.parseChangeRequestUrl(input.url)?.link + : null; const number = urlTarget?.number ?? input?.number; const target = actionKind !== undefined && diff --git a/packages/shared/package.json b/packages/shared/package.json index 0254443e1b46..3fe44db4f345 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -239,10 +239,6 @@ "types": "./src/threadTranscript.ts", "import": "./src/threadTranscript.ts" }, - "./threadReference": { - "types": "./src/threadReference.ts", - "import": "./src/threadReference.ts" - }, "./paletteSearch": { "types": "./src/paletteSearch.ts", "import": "./src/paletteSearch.ts" @@ -251,10 +247,6 @@ "types": "./src/threadPullRequests.ts", "import": "./src/threadPullRequests.ts" }, - "./changeRequestUrl": { - "types": "./src/changeRequestUrl.ts", - "import": "./src/changeRequestUrl.ts" - }, "./composerTrigger": { "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts deleted file mode 100644 index 0c7a469753ee..000000000000 --- a/packages/shared/src/changeRequestUrl.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { ProjectId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - changeRequestUrlFor, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, - siblingPullRequestUrl, -} from "./changeRequestUrl.ts"; -import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; - -describe("parseChangeRequestUrl", () => { - it("reads a GitHub pull request, lower-casing the repository", () => { - expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - }); - - it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => { - for (const path of ["pull", "pulls"]) { - expect( - parseChangeRequestUrl(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), - path, - ).toEqual({ host: "git.cafe", repository: "pingdotgg/t3code", number: 23897 }); - } - expect(parseChangeRequestUrl("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({ - host: "staging.git.cafe", - repository: "team/repo", - number: 7, - }); - }); - - it("reads Forgejo URLs even when the hostname contains github", () => { - expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({ - host: "github.internal", - authority: "github.internal", - repository: "team/repo", - number: 7, - }); - }); - - it("reads a supported GitHub host with a middle DNS label", () => { - expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({ - host: "code.github.example.com", - repository: "acme/web", - number: 42, - }); - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://code.github.example.com/acme/web/issues/42", - ), - ).toBe("https://code.github.example.com/acme/web/pull/42"); - }); - - it("reads a GitLab merge request on any host, nested groups and all", () => { - expect( - parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"), - ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 }); - expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual( - { host: "code.acme.test", repository: "team/project", number: 9 }, - ); - }); - - it("reads Bitbucket and both Azure DevOps URL forms", () => { - expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ - host: "bitbucket.org", - repository: "workspace/repo", - number: 5, - }); - expect( - parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 }); - expect( - parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); - }); - - it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { - for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { - const repository = "tfs/collection/project/_git/repo"; - const linked = { - projectId: ProjectId.make("project-1"), - host, - repository, - number: 123, - url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, - }; - const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; - expect.soft(parseChangeRequestUrl(linked.url), host).toEqual({ - host, - authority: `${host}:8443`, - repository, - number: 123, - }); - expect.soft(matchesLinkedPullRequestUrl(linked, `${linked.url}?view=files`), host).toBe(true); - expect.soft(matchesLinkedPullRequestUrl(linked, other.url), host).toBe(false); - expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ - { host: `${host}:8443`, repository, number: 123 }, - { host: `${host}:9443`, repository, number: 123 }, - ]); - } - }); - - it("survives trailing segments, a trailing slash and a query string", () => { - expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - }); - - it("claims nothing it cannot be sure of", () => { - for (const link of [ - "https://github.com/t3tools/t3code/issues/123", - "https://git.cafe/pingdotgg/t3code/issues/23897", - "https://git.cafe/pingdotgg/t3code/pull/abc", - "https://git.cafe/pingdotgg/t3code", - "https://github.com/t3tools/t3code/commit/0a1b2c3", - "https://github.com/t3tools/t3code", - "https://github.com/t3tools/t3code/pull/abc", - "https://gitlab.com/t3tools/t3code/-/issues/12", - "https://blog.example.test/2026/updates/pull/3", - "https://scm.example.test/tfs/Collection/Project/_git/Repo", - "https://scm.example.test/tfs/Collection/Project/pullrequest/123", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", - "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", - "javascript:alert(1)//github.com/t3tools/t3code/pull/1", - "not a url", - ]) { - expect(parseChangeRequestUrl(link), link).toBeNull(); - } - }); -}); - -describe("siblingPullRequestUrl", () => { - it.each([ - [ - "http://git.example.test:3000/team/repo/pulls/42/files", - "http://git.example.test:3000/team/repo/pulls/43", - ], - ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], - [ - "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files", - "https://dev.azure.com/acme/project/_git/web/pullrequest/43", - ], - ])("builds a canonical sibling of %s", (url, expected) => { - expect(siblingPullRequestUrl(url, 43)).toBe(expected); - }); -}); - -describe("changeRequestUrlFor", () => { - it("preserves the origin when the Forgejo host already contains its port", () => { - expect( - changeRequestUrlFor( - "forgejo", - "forge.example:3000", - "team/repo", - 42, - "http://forge.example:3000/team/repo.git", - ), - ).toBe("http://forge.example:3000/team/repo/pulls/42"); - }); - - it.each([ - ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], - ["git@forge.example:git/owner/repo.git", "https://forge.example"], - ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], - ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => { - expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe( - `${origin}/git/owner/repo/pulls/42`, - ); - }); - - it.each([ - ["ssh.dev.azure.com", "v3/org/project/web"], - ["org.visualstudio.com", "defaultcollection/project/_git/web"], - ["dev.azure.com", "org/project/_git/web"], - ])("builds a browser URL from the Azure remote %s/%s", (host, repository) => { - const url = changeRequestUrlFor("azure-devops", host, repository, 42); - expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42"); - expect(parseChangeRequestUrl(url!)).toEqual({ - host: "dev.azure.com", - repository: "org/project/_git/web", - number: 42, - }); - }); -}); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts deleted file mode 100644 index 129e4d090ee0..000000000000 --- a/packages/shared/src/changeRequestUrl.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; - -/** - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote — lower case, no port, the full path below the host — because links are - * matched against those. Anything else matches nothing. - */ -export interface ChangeRequestLink { - readonly host: string; - readonly repository: string; - readonly number: number; - readonly authority?: string; -} - -function isHostOf(hostname: string, apex: string, label?: string): boolean { - if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; - return label !== undefined && hostname.split(".").includes(label); -} - -export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - const host = url.hostname.toLowerCase(); - - if (host === "git.cafe" || host === "staging.git.cafe") { - // GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. - const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } else { - if (isHostOf(host, "github.com", "github")) { - const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } - const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (forgejo) { - const link = claim(host, forgejo); - return link === null ? null : { ...link, authority: url.host.toLowerCase() }; - } - const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitlab) return claim(host, gitlab); - if (isHostOf(host, "bitbucket.org", "bitbucket")) { - const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } - } - // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. - const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - const link = claim(host, azure); - return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; -} - -function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { - const repository = match?.[1]; - const number = Number(match?.[2]); - return repository && Number.isSafeInteger(number) && number > 0 - ? { host, repository: repository.toLowerCase(), number } - : null; -} - -function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return remote.origin; - } - } catch { - // SSH remotes do not specify the server's web origin. - } - return `https://${host}`; -} - -export function changeRequestUrlFor( - kind: string | null | undefined, - host: string, - repository: string, - number: number, - remoteUrl?: string, -): string | null { - switch (kind) { - case "github": - return `https://${host}/${repository}/pull/${number}`; - case "gitcafe": - return `https://${host}/${repository}/pulls/${number}`; - case "forgejo": - return `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`; - case "gitlab": - return `https://${host}/${repository}/-/merge_requests/${number}`; - case "bitbucket": - return `https://${host}/${repository}/pull-requests/${number}`; - case "azure-devops": { - const [canonicalHost = host, ...path] = canonicalRepositoryKey( - `${host}/${repository}`.toLowerCase(), - ).split("/"); - return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; - } - default: - return null; - } -} - -export function gitHubPullRequestBrowserUrl( - identity: RepositoryIdentity | null | undefined, - repository: string, - number: number, -): string | null { - if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; - const repositoryPath = repository.split("/"); - if ( - repositoryPath.length !== 2 || - repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - return null; - } - - let origin: string | null = null; - try { - const remoteUrl = new URL(identity.locator.remoteUrl.trim()); - if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { - origin = remoteUrl.origin; - } - } catch {} - const hostname = identity.canonicalKey.split("/")[0]; - if (origin === null && !hostname) return null; - - try { - const url = new URL(origin ?? `https://${hostname}`); - url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; - return url.toString(); - } catch { - return null; - } -} - -/** - * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare - * reference through `/issues/`, including pull requests, so this only builds a candidate: the - * caller must successfully read it as a pull request before treating it as one. - */ -export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - !( - url.hostname.toLowerCase() === "github.com" || - url.hostname.toLowerCase().endsWith(".github.com") || - url.hostname.toLowerCase().split(".").includes("github") - ) - ) { - return null; - } - const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match?.[1] === undefined || match[2] === undefined) return null; - url.pathname = `/${match[1]}/pull/${match[2]}`; - return url.toString(); -} - -export function matchesLinkedPullRequestUrl( - linkedPullRequest: ThreadLinkedPullRequest, - targetUrl: string, -): boolean { - const linked = parseChangeRequestUrl(linkedPullRequest.url); - const target = parseChangeRequestUrl(targetUrl); - return ( - linked !== null && - target !== null && - linked.host === target.host && - linked.repository === target.repository && - linked.number === target.number && - linked.authority === target.authority - ); -} - -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - const repositoryPath = - /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( - url.pathname, - )?.[1]; - if (!repositoryPath) return null; - url.pathname = repositoryPath; - url.search = ""; - url.hash = ""; - return url.toString(); -} - -export function siblingPullRequestUrl(url: string, number: number): string | null { - const reference = parseChangeRequestUrl(url); - if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; - const sibling = new URL(url); - const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( - sibling.pathname.slice(reference.repository.length + 1), - )?.[1]; - if (route === undefined) return null; - sibling.pathname = `/${reference.repository}/${route}/${number}`; - sibling.search = ""; - sibling.hash = ""; - return sibling.toString(); -} diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 8073bcccd0bb..f96662e57b24 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -10,7 +10,6 @@ import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; import * as Predicate from "effect/Predicate"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; -import { threadPullRequestKeyOf } from "./threadPullRequests.ts"; const BACKGROUND_TURN_ITEM_TYPES = new Set([ "command_execution", @@ -303,7 +302,9 @@ function pullRequestWatchTasks( ? [] : [ { - taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, + // Links store their normalized identity, and this id only has to be unique within the + // thread, so it reads those fields instead of parsing the URL. + taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, }, diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 83c5a60c0cee..c86fcc1158bf 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -1,88 +1,4 @@ -import type { - OrchestrationV2AppThread, - ThreadLinkedPullRequest, - ThreadPullRequestKey, - ThreadPullRequestLink, -} from "@t3tools/contracts"; - -import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; - -type ThreadPullRequestKeySource = ThreadPullRequestKey & { - readonly authority?: string; - readonly url?: string; -}; - -export function normalizeThreadPullRequestKey( - key: ThreadPullRequestKeySource, -): ThreadPullRequestKey { - const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url); - const authority = - key.authority ?? - (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number - ? parsed.authority - : undefined); - const canonical = canonicalRepositoryKey( - `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, - ); - const separator = canonical.indexOf("/"); - return { - host: canonical.slice(0, separator), - repository: canonical.slice(separator + 1), - number: key.number, - }; -} - -export function legacyThreadPullRequestKey( - linked: Pick, - fallbackHost?: string, -): ThreadPullRequestKey { - const parsed = parseChangeRequestUrl(linked.url); - if (parsed !== null && parsed.number === linked.number) { - const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { - return normalizeThreadPullRequestKey(parsed); - } - } - let host = fallbackHost; - if (host === undefined) { - try { - host = new URL(linked.url).hostname; - } catch { - host = "unknown"; - } - } - return { - host: host.trim().toLowerCase() || "unknown", - repository: linked.repository.trim().toLowerCase(), - number: linked.number, - }; -} - -export function threadPullRequestKeysEqual( - left: ThreadPullRequestKeySource, - right: ThreadPullRequestKeySource, -): boolean { - return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); -} - -// Clients re-derive chains for every link on each render, and normalizing parses the -// link's URL. Links are immutable and keep their identity until they change. -const normalizedKeys = new WeakMap(); - -function normalizedKeyOf(key: ThreadPullRequestKeySource): ThreadPullRequestKey { - let normalized = normalizedKeys.get(key); - if (normalized === undefined) { - normalized = normalizeThreadPullRequestKey(key); - normalizedKeys.set(key, normalized); - } - return normalized; -} - -export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { - const normalized = normalizedKeyOf(key); - return `${normalized.host}/${normalized.repository}#${normalized.number}`; -} +import type { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; /** Tombstoned stack members stay in the array only so the * sync reactor does not re-add them. */ @@ -92,202 +8,12 @@ export function visibleThreadPullRequests( return links.filter((link) => link.source !== "stack-dismissed"); } -function isOpen(link: ThreadPullRequestLink): boolean { - return link.snapshot === null || link.snapshot.state === "open"; -} - function latestUpdatedAt(link: ThreadPullRequestLink): number { const value = link.snapshot?.updatedAt ?? link.linkedAt; const ms = Date.parse(value); return Number.isNaN(ms) ? 0 : ms; } -export type ThreadCurrentPullRequest = - | { readonly kind: "single"; readonly link: ThreadPullRequestLink } - | { - readonly kind: "stack"; - readonly open: ReadonlyArray; - readonly top: ThreadPullRequestLink; - }; - -function layersFromTop(chain: ThreadPullRequestChain): Array { - // `.sort()` on a fresh array, not `.toSorted()`: this runs on Hermes, which has no ES2023 - // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. - return chain.layers - .map((link, index) => ({ link, index, depth: chain.depths[index]! })) - .sort((left, right) => right.depth - left.depth || right.index - left.index) - .map((entry) => entry.link); -} - -export function resolveThreadCurrentPullRequest( - links: ReadonlyArray, -): ThreadCurrentPullRequest | null { - const visible = visibleThreadPullRequests(links); - if (visible.length === 0) return null; - const open = visible.filter(isOpen); - if (open.length === 1) return { kind: "single", link: open[0]! }; - const chains = resolveThreadPullRequestChains(visible); - if (open.length > 1) { - const openChains = chains - .map((chain) => layersFromTop(chain).filter(isOpen)) - .filter((layers) => layers.length > 0) - .sort( - (left, right) => - Math.max(...right.map((link) => Date.parse(link.linkedAt))) - - Math.max(...left.map((link) => Date.parse(link.linkedAt))), - ); - const ordered = openChains.flat(); - return { kind: "stack", open: ordered, top: ordered[0]! }; - } - if (chains.length === 1) { - return { kind: "single", link: layersFromTop(chains[0]!)[0]! }; - } - const terminal = [...visible].sort( - (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), - ); - return { kind: "single", link: terminal[0]! }; -} - -export function resolveThreadCurrentPullRequestLink( - links: ReadonlyArray, -): ThreadPullRequestLink | null { - const current = resolveThreadCurrentPullRequest(links); - if (current === null) return null; - return current.kind === "single" ? current.link : current.top; -} - -export interface ThreadPullRequestChain { - readonly kind: "native" | "derived"; - readonly layers: ReadonlyArray; - /** One per layer: 0 for the base, parent's depth + 1 above it. Siblings share a depth. */ - readonly depths: ReadonlyArray; -} - -export function resolveThreadPullRequestChains( - links: ReadonlyArray, -): ReadonlyArray { - const visible = visibleThreadPullRequests(links); - const chains: Array = []; - const placed = new Set(); - - const nativeStacks = new Map>(); - for (const link of visible) { - if (link.stack === null) continue; - const key = normalizedKeyOf(link); - const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; - const members = nativeStacks.get(stackKey) ?? []; - members.push(link); - nativeStacks.set(stackKey, members); - } - for (const members of nativeStacks.values()) { - // Saved snapshots can lag behind newer members of the same stack. - const memberNumbers = new Set(members.map((member) => member.number)); - let layers = members[0]!.stack!.layers; - let coverage = layers.filter((layer) => memberNumbers.has(layer.number)).length; - for (const member of members.slice(1)) { - const candidate = member.stack!.layers; - const candidateCoverage = candidate.filter((layer) => memberNumbers.has(layer.number)).length; - if ( - candidateCoverage > coverage || - (candidateCoverage === coverage && candidate.length > layers.length) - ) { - layers = candidate; - coverage = candidateCoverage; - } - } - const order = new Map(layers.map((layer, index) => [layer.number, index])); - members.sort( - (left, right) => - (order.get(left.number) ?? layers.length) - (order.get(right.number) ?? layers.length), - ); - for (const member of members) placed.add(threadPullRequestKeyOf(member)); - chains.push({ kind: "native", layers: members, depths: members.map((_, index) => index) }); - } - - const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); - const branchKey = (link: ThreadPullRequestLink, branch: string) => { - const key = normalizedKeyOf(link); - return `${key.host}/${key.repository}:${branch}`; - }; - const byHead = new Map(); - for (const link of remaining) { - if (link.snapshot === null) continue; - const key = branchKey(link, link.snapshot.headBranch); - byHead.set(key, byHead.has(key) ? null : link); - } - const parentOf = (link: ThreadPullRequestLink) => { - if (link.snapshot === null) return undefined; - const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); - return parent == null || parent === link ? undefined : parent; - }; - const children = new Map>(); - for (const link of remaining) { - const parent = parentOf(link); - if (parent === undefined) continue; - const key = threadPullRequestKeyOf(parent); - const siblings = children.get(key) ?? []; - siblings.push(link); - children.set(key, siblings); - } - for (const base of remaining) { - if (parentOf(base) !== undefined) continue; - const layers: Array = []; - const depths: Array = []; - const pending: Array<{ readonly link: ThreadPullRequestLink; readonly depth: number }> = [ - { link: base, depth: 0 }, - ]; - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - const key = threadPullRequestKeyOf(next.link); - // Two links with one identity would otherwise share a child list and expand forever. - if (placed.has(key)) continue; - placed.add(key); - layers.push(next.link); - depths.push(next.depth); - const below = children.get(key) ?? []; - for (let index = below.length - 1; index >= 0; index -= 1) { - pending.push({ link: below[index]!, depth: next.depth + 1 }); - } - } - if (layers.length > 0) chains.push({ kind: "derived", layers, depths }); - } - for (const link of remaining) { - if (!placed.has(threadPullRequestKeyOf(link))) { - chains.push({ kind: "derived", layers: [link], depths: [0] }); - } - } - return chains; -} - -export type ThreadPullRequestBadge = { - readonly state: "open" | "closed" | "merged" | "draft"; -} & ( - | { - readonly kind: "stack"; - readonly layers: number; - } - | { readonly kind: "pull-request"; readonly others: number } -); - -export function resolveThreadPullRequestBadge( - pullRequests: ReadonlyArray | undefined, -): ThreadPullRequestBadge | null { - const visible = visibleThreadPullRequests(pullRequests ?? []); - if (visible.length === 0) return null; - const states = visible.map((link) => link.snapshot?.state ?? "open"); - const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) - ? "draft" - : states.includes("open") - ? "open" - : states.every((entry) => entry === "merged") - ? "merged" - : "closed"; - const chains = resolveThreadPullRequestChains(visible); - if (visible.length > 1 && chains.length === 1) { - return { kind: "stack", layers: visible.length, state }; - } - return { kind: "pull-request", others: visible.length - 1, state }; -} - export interface ThreadSearchPullRequest { readonly number: number; readonly title: string | null; @@ -338,70 +64,3 @@ export function threadPullRequestSearchTerms(thread: { ...(pullRequest.title ? [pullRequest.title] : []), ]); } - -/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ -export function threadPullRequestsOf(thread: { - readonly pullRequests?: ReadonlyArray | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; -}): ReadonlyArray { - if (thread.pullRequests !== undefined) return thread.pullRequests; - const linked = thread.linkedPullRequest; - return linked == null - ? [] - : [ - { - ...legacyThreadPullRequestKey(linked), - url: linked.url, - source: "manual", - linkedAt: "1970-01-01T00:00:00.000Z", - snapshot: null, - stack: null, - }, - ]; -} - -export function applyThreadPullRequestLinkUpdate( - thread: OrchestrationV2AppThread, - updated: ThreadPullRequestLink, -): OrchestrationV2AppThread { - return { - ...thread, - pullRequests: threadPullRequestsOf(thread).map((link) => - threadPullRequestKeysEqual(link, updated) ? updated : link, - ), - }; -} - -export interface PullRequestListLine { - readonly link: ThreadPullRequestLink; - readonly depth: number; - readonly chainKey: string; - readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; -} - -function chainKeyOf(chain: ThreadPullRequestChain): string { - const bottom = chain.layers[0]!; - return `${bottom.host}/${bottom.repository}#${bottom.number}`; -} - -export function pullRequestListLines( - chains: ReadonlyArray, -): ReadonlyArray { - const ordered = [...chains].sort( - (left, right) => - Math.max(...right.layers.map(latestUpdatedAt)) - - Math.max(...left.layers.map(latestUpdatedAt)), - ); - return ordered.flatMap((chain) => { - const chainKey = chainKeyOf(chain); - return chain.layers.map((link, index) => ({ - link, - depth: chain.depths[index]!, - chainKey, - stack: - index === 0 && chain.layers.length > 1 - ? { kind: chain.kind, size: chain.layers.length } - : null, - })); - }); -} diff --git a/packages/source-control-azure-devops/src/client/definition.test.ts b/packages/source-control-azure-devops/src/client/definition.test.ts new file mode 100644 index 000000000000..c4a0e4fd526b --- /dev/null +++ b/packages/source-control-azure-devops/src/client/definition.test.ts @@ -0,0 +1,104 @@ +import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("Azure DevOps change request URLs", () => { + it("reads both hosted URL forms", () => { + expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + repositoryUrl: "https://dev.azure.com/acme/platform/_git/t3code", + }); + expect( + parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), + ).toMatchObject({ + host: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); + }); + + it("keeps Azure DevOps Server ports in the authority, on any hostname", () => { + expect( + parse("https://GIT.CAFE:8443/tfs/Collection/Project/_git/Repo/pullrequest/123?view=files"), + ).toEqual({ + host: "git.cafe", + authority: "git.cafe:8443", + repository: "tfs/collection/project/_git/repo", + number: 123, + repositoryUrl: "https://git.cafe:8443/tfs/Collection/Project/_git/Repo", + }); + expect(parse("https://scm.example.test/tfs/c/p/_git/r/pullrequest/1")).not.toHaveProperty( + "authority", + ); + }); + + it("claims nothing without the _git route or a valid number", () => { + for (const url of [ + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it.each([ + ["ssh.dev.azure.com", "v3/org/project/web"], + ["org.visualstudio.com", "defaultcollection/project/_git/web"], + ["dev.azure.com", "org/project/_git/web"], + ])("builds a canonical browser URL from the remote %s/%s", (host, repository) => { + expect(definition.changeRequestUrl({ host, repository, number: 42 })).toBe( + "https://dev.azure.com/org/project/_git/web/pullrequest/42", + ); + }); + + it("keeps an Azure DevOps Server's HTTP origin and builds below a web URL", () => { + expect( + definition.changeRequestUrl({ + host: "scm.example.test", + repository: "tfs/collection/project/_git/repo", + number: 124, + remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", + }), + ).toBe("http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"); + expect( + definition.changeRequestUrl({ + host: "dev.azure.com", + repository: "acme/project/_git/web", + number: 43, + webUrl: "https://dev.azure.com/acme/project/_git/web", + }), + ).toBe("https://dev.azure.com/acme/project/_git/web/pullrequest/43"); + }); + + it.each([ + ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], + [ + "vs-ssh.visualstudio.com/v3/org-a/project/web", + "git@vs-ssh.visualstudio.com:v3/org-a/project/web", + ], + [ + "org-a.visualstudio.com/defaultcollection/project/_git/web", + "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", + ], + ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], + ])("matches browser references against the checkout %s", (canonicalKey, remoteUrl) => { + const identity: ChangeRequestProjectIdentity = { + canonicalKey, + displayName: canonicalKey.split("/").slice(1).join("/"), + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + }; + const link = parse("https://dev.azure.com/org-a/project/_git/web/pullrequest/42")!; + expect(definition.isChangeRequestInRepository(identity, link)).toBe(true); + for (const repository of ["org-b/project/_git/web", "org-a/other-project/_git/web"]) { + expect(definition.isChangeRequestInRepository(identity, { ...link, repository })).toBe(false); + } + }); +}); diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 0149e9fa928a..ac83fb8fc3af 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -1,11 +1,15 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestAuthorityOfProject, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +// Azure DevOps Server runs on any hostname; its `_git` route identifies it. +const CHANGE_REQUEST_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u; 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; @@ -33,8 +37,19 @@ export const definition = defineSourceControlClient({ publishHost: () => "dev.azure.com", newRepositoryOwner: () => null, defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (webUrl) return changeRequestUrlBelow(webUrl, `pullrequest/${number}`); + const [canonicalHost = host, ...path] = canonicalRepositoryKey( + `${host}/${repository}`.toLowerCase(), + ).split("/"); + return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; + }, + // Servers on different ports are different servers, so a port stays in the link. + parseChangeRequestUrl: (url) => { + const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; + }, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", "ready", @@ -53,12 +68,10 @@ export const definition = defineSourceControlClient({ return args ? checkoutCommandId(args) : null; }, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestAuthorityOfProject(identity, link) && canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), canReadChangeRequestOnHost: () => false, - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), }); diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 3932e8425437..1d218c303854 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("azure-devops"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: AzureDevOpsSourceControlProvider.make, discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), diff --git a/packages/source-control-bitbucket/src/client/definition.test.ts b/packages/source-control-bitbucket/src/client/definition.test.ts new file mode 100644 index 000000000000..5c53884c3376 --- /dev/null +++ b/packages/source-control-bitbucket/src/client/definition.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("Bitbucket change request URLs", () => { + it("reads a pull request on Bitbucket hosts", () => { + expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5/diff")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + repositoryUrl: "https://bitbucket.org/workspace/repo", + }); + expect(parse("https://code.bitbucket.example.test/team/repo/pull-requests/6")).toMatchObject({ + host: "code.bitbucket.example.test", + number: 6, + }); + }); + + it("leaves other hosts alone", () => { + expect(parse("https://scm.example.test/workspace/repo/pull-requests/5")).toBeNull(); + }); + + it("builds pull request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ host: "bitbucket.org", repository: "w/repo", number: 5 }), + ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); + expect( + definition.changeRequestUrl({ + host: "bitbucket.org", + repository: "w/repo", + number: 5, + webUrl: "https://bitbucket.org/w/repo", + }), + ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); + }); +}); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 65d11a707596..2d423fbb2279 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -6,17 +6,29 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("bitbucket"); +function isBitbucketHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "bitbucket.org" || + host.endsWith(".bitbucket.org") || + host.split(".").includes("bitbucket") + ); +} + const text = (annotations: Schema.Annotations.Key) => TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), @@ -62,8 +74,13 @@ export const definition = defineSourceControlClient({ // A new repository belongs to a workspace, which the signed-in account does not name. newRepositoryOwner: () => null, defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pull-requests/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pull-requests/${number}`) + : `https://${host}/${repository}/pull-requests/${number}`, + parseChangeRequestUrl: (url) => + isBitbucketHost(url.hostname) ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) : null, + changeRequestUrlFromReferenceAutolink: () => null, // No endpoint reopens a declined pull request, and nothing documented moves one in or out of // draft, so neither is offered rather than failing when pressed. changeRequestActions: new Set(["merge", "close"] as const), @@ -79,12 +96,10 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, 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/"), settings, }); diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 381ce7bf206f..19d35d82015c 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("bitbucket"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: BitbucketSourceControlProvider.make, discovery: BitbucketSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json index d463190918b2..4ecd07cc8678 100644 --- a/packages/source-control-core/package.json +++ b/packages/source-control-core/package.json @@ -7,6 +7,10 @@ "types": "./src/client/definition.ts", "import": "./src/client/definition.ts" }, + "./client/threadPullRequests": { + "types": "./src/client/threadPullRequests.ts", + "import": "./src/client/threadPullRequests.ts" + }, "./server/diffFileRevisions": { "types": "./src/server/diffFileRevisions.ts", "import": "./src/server/diffFileRevisions.ts" diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index fde7ab9fba00..69a4fa402627 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -5,9 +5,25 @@ import { type RepositoryIdentity, SourceControlProviderKind, } from "@t3tools/contracts"; -import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; import * as Schema from "effect/Schema"; +/** + * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce + * from a git remote — lower case, no port, the full path below the host — because links are + * matched against those. `authority` keeps the port for hosts whose identity includes it. + */ +export interface ChangeRequestLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +export interface ParsedChangeRequestUrl extends ChangeRequestLink { + /** The repository's browser URL, without query or hash. */ + readonly repositoryUrl: string; +} + export interface ChangeRequestTerminology { readonly shortLabel: string; readonly singular: string; @@ -18,6 +34,7 @@ export interface ChangeRequestUrlInput { readonly repository: string; readonly number: number; readonly remoteUrl?: string | undefined; + /** The repository's browser URL; when given, the result is this plus the host's route. */ readonly webUrl?: string | undefined; } @@ -46,6 +63,14 @@ export interface SourceControlClientDefinition { readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; readonly defaultCloneTransport: "https" | "ssh"; readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + /** Null when the URL is not one of this host's change requests. */ + readonly parseChangeRequestUrl: (url: URL) => ParsedChangeRequestUrl | null; + /** + * The change request URL a bare `#123` autolink might name, for hosts that write every + * reference through their issue route. Only a candidate: the caller must read it as a change + * request before treating it as one. + */ + readonly changeRequestUrlFromReferenceAutolink: (url: URL) => string | null; /** * The actions this host can take on a change request, the same list its server provider * declares in `capabilities.actions`. @@ -65,9 +90,7 @@ export interface SourceControlClientDefinition { identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ) => boolean; - readonly changeRequestUrlHost: (url: URL) => string; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; - readonly isChangeRequestUrl: (url: string) => boolean; readonly settings?: SourceControlHostSettingsSchema; } @@ -75,14 +98,44 @@ export type SourceControlHostSettingsSchema = { readonly fields: Readonly>; } & Schema.Decoder; -export function isChangeRequestPath(url: string, route: string): boolean { +/** + * Reads a change request from `url` with `route`, whose first group is the repository path and + * second the number. + */ +export function parseChangeRequestPath(url: URL, route: RegExp): ParsedChangeRequestUrl | null { + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const match = route.exec(url.pathname); + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { + host: url.hostname.toLowerCase(), + repository: repository.toLowerCase(), + number, + repositoryUrl: `${url.origin}/${repository}`, + } + : null; +} + +export function changeRequestUrlBelow(webUrl: string, route: string): string { + return `${webUrl.replace(/\/+$/u, "")}/${route}`; +} + +/** An HTTP remote on `host` names the server's web origin, port included; SSH remotes do not. */ +export function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { try { - const path = new URL(url).pathname; - const at = path.indexOf(route); - return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length)); + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return remote.origin; + } } catch { - return false; + // SSH remotes do not specify the server's web origin. } + return `https://${host}`; } export function defineSourceControlClient( @@ -150,6 +203,8 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: () => null, + parseChangeRequestUrl: () => null, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, @@ -157,26 +212,49 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { 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, }; +export interface ParsedChangeRequest { + readonly definition: SourceControlClientDefinition; + readonly link: ParsedChangeRequestUrl; +} + export interface SourceControlClientRegistry { readonly definitions: ReadonlyArray; readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; readonly find: (kind: string) => SourceControlClientDefinition | undefined; readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; + /** The first host, in registry order, that reads `url` as one of its change requests. */ + readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; readonly hostLabelForChangeRequestUrl: (url: string) => string; + readonly isSameChangeRequest: (left: string, right: string) => boolean; + readonly changeRequestUrlFromReferenceAutolink: (url: string) => string | null; +} + +function httpUrl(value: string): URL | null { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url : null; + } catch { + return null; + } } +/** + * `definitions` is display order, and its first entry is what `get` returns for a repository + * that has not reported its host yet. A change request URL goes to the first definition that + * reads it, trying `claimChangeRequestUrlsFirst` before the rest, so a host that claims a route + * only on its own hostnames can precede one that claims the route everywhere. + */ export function makeSourceControlClientRegistry( definitions: ReadonlyArray, + options?: { readonly claimChangeRequestUrlsFirst?: ReadonlyArray }, ): SourceControlClientRegistry { const byKind = new Map(); for (const definition of definitions) { @@ -185,6 +263,20 @@ export function makeSourceControlClientRegistry( } byKind.set(definition.kind, definition); } + const first = new Set(options?.claimChangeRequestUrlsFirst); + const claimOrder = [ + ...definitions.filter((definition) => first.has(definition.kind)), + ...definitions.filter((definition) => !first.has(definition.kind)), + ]; + const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of claimOrder) { + const link = definition.parseChangeRequestUrl(url); + if (link !== null) return { definition, link }; + } + return null; + }; return { definitions, get: (kind) => @@ -194,10 +286,31 @@ export function makeSourceControlClientRegistry( const host = hostname.toLowerCase(); return definitions.find((definition) => definition.publicHost === host); }, - findByChangeRequestUrl: (url) => - definitions.find((definition) => definition.isChangeRequestUrl(url)), + parseChangeRequestUrl, + findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => - definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", + parseChangeRequestUrl(url)?.definition.label ?? "the host", + isSameChangeRequest: (left, right) => { + const a = parseChangeRequestUrl(left)?.link; + const b = parseChangeRequestUrl(right)?.link; + return ( + a !== undefined && + b !== undefined && + a.host === b.host && + a.repository === b.repository && + a.number === b.number && + a.authority === b.authority + ); + }, + changeRequestUrlFromReferenceAutolink: (value) => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of claimOrder) { + const candidate = definition.changeRequestUrlFromReferenceAutolink(url); + if (candidate !== null) return candidate; + } + return null; + }, }; } diff --git a/packages/source-control-core/src/client/threadPullRequests.ts b/packages/source-control-core/src/client/threadPullRequests.ts new file mode 100644 index 000000000000..a16fbae8572f --- /dev/null +++ b/packages/source-control-core/src/client/threadPullRequests.ts @@ -0,0 +1,372 @@ +import type { + OrchestrationV2AppThread, + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +import type { SourceControlClientRegistry } from "./definition.ts"; + +type ThreadPullRequestKeySource = ThreadPullRequestKey & { + readonly authority?: string; + readonly url?: string; +}; + +function isOpen(link: ThreadPullRequestLink): boolean { + return link.snapshot === null || link.snapshot.state === "open"; +} + +function latestUpdatedAt(link: ThreadPullRequestLink): number { + const value = link.snapshot?.updatedAt ?? link.linkedAt; + const ms = Date.parse(value); + return Number.isNaN(ms) ? 0 : ms; +} + +export type ThreadCurrentPullRequest = + | { readonly kind: "single"; readonly link: ThreadPullRequestLink } + | { + readonly kind: "stack"; + readonly open: ReadonlyArray; + readonly top: ThreadPullRequestLink; + }; + +function layersFromTop(chain: ThreadPullRequestChain): Array { + // `.sort()` on a fresh array, not `.toSorted()`: this runs on Hermes, which has no ES2023 + // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. + return chain.layers + .map((link, index) => ({ link, index, depth: chain.depths[index]! })) + .sort((left, right) => right.depth - left.depth || right.index - left.index) + .map((entry) => entry.link); +} + +export interface ThreadPullRequestChain { + readonly kind: "native" | "derived"; + readonly layers: ReadonlyArray; + /** One per layer: 0 for the base, parent's depth + 1 above it. Siblings share a depth. */ + readonly depths: ReadonlyArray; +} + +export type ThreadPullRequestBadge = { + readonly state: "open" | "closed" | "merged" | "draft"; +} & ( + | { + readonly kind: "stack"; + readonly layers: number; + } + | { readonly kind: "pull-request"; readonly others: number } +); + +/** + * Thread pull request identity reads a link's URL through the hosts in `registry`, so each side + * binds these once to its own registry. + */ +export function makeThreadPullRequests( + registry: Pick, +) { + const parse = (url: string) => registry.parseChangeRequestUrl(url)?.link ?? null; + + function normalizeThreadPullRequestKey(key: ThreadPullRequestKeySource): ThreadPullRequestKey { + const parsed = key.url === undefined ? null : parse(key.url); + const authority = + key.authority ?? + (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number + ? parsed.authority + : undefined); + const canonical = canonicalRepositoryKey( + `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; + } + + function legacyThreadPullRequestKey( + linked: Pick, + fallbackHost?: string, + ): ThreadPullRequestKey { + const parsed = parse(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host = fallbackHost; + if (host === undefined) { + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; + } + + function threadPullRequestKeysEqual( + left: ThreadPullRequestKeySource, + right: ThreadPullRequestKeySource, + ): boolean { + return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); + } + + // Clients re-derive chains for every link on each render, and normalizing parses the + // link's URL. Links are immutable and keep their identity until they change. + const normalizedKeys = new WeakMap(); + + function normalizedKeyOf(key: ThreadPullRequestKeySource): ThreadPullRequestKey { + let normalized = normalizedKeys.get(key); + if (normalized === undefined) { + normalized = normalizeThreadPullRequestKey(key); + normalizedKeys.set(key, normalized); + } + return normalized; + } + + function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { + const normalized = normalizedKeyOf(key); + return `${normalized.host}/${normalized.repository}#${normalized.number}`; + } + + function resolveThreadCurrentPullRequest( + links: ReadonlyArray, + ): ThreadCurrentPullRequest | null { + const visible = visibleThreadPullRequests(links); + if (visible.length === 0) return null; + const open = visible.filter(isOpen); + if (open.length === 1) return { kind: "single", link: open[0]! }; + const chains = resolveThreadPullRequestChains(visible); + if (open.length > 1) { + const openChains = chains + .map((chain) => layersFromTop(chain).filter(isOpen)) + .filter((layers) => layers.length > 0) + .sort( + (left, right) => + Math.max(...right.map((link) => Date.parse(link.linkedAt))) - + Math.max(...left.map((link) => Date.parse(link.linkedAt))), + ); + const ordered = openChains.flat(); + return { kind: "stack", open: ordered, top: ordered[0]! }; + } + if (chains.length === 1) { + return { kind: "single", link: layersFromTop(chains[0]!)[0]! }; + } + const terminal = [...visible].sort( + (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), + ); + return { kind: "single", link: terminal[0]! }; + } + + function resolveThreadCurrentPullRequestLink( + links: ReadonlyArray, + ): ThreadPullRequestLink | null { + const current = resolveThreadCurrentPullRequest(links); + if (current === null) return null; + return current.kind === "single" ? current.link : current.top; + } + + function resolveThreadPullRequestChains( + links: ReadonlyArray, + ): ReadonlyArray { + const visible = visibleThreadPullRequests(links); + const chains: Array = []; + const placed = new Set(); + + const nativeStacks = new Map>(); + for (const link of visible) { + if (link.stack === null) continue; + const key = normalizedKeyOf(link); + const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; + const members = nativeStacks.get(stackKey) ?? []; + members.push(link); + nativeStacks.set(stackKey, members); + } + for (const members of nativeStacks.values()) { + // Saved snapshots can lag behind newer members of the same stack. + const memberNumbers = new Set(members.map((member) => member.number)); + let layers = members[0]!.stack!.layers; + let coverage = layers.filter((layer) => memberNumbers.has(layer.number)).length; + for (const member of members.slice(1)) { + const candidate = member.stack!.layers; + const candidateCoverage = candidate.filter((layer) => + memberNumbers.has(layer.number), + ).length; + if ( + candidateCoverage > coverage || + (candidateCoverage === coverage && candidate.length > layers.length) + ) { + layers = candidate; + coverage = candidateCoverage; + } + } + const order = new Map(layers.map((layer, index) => [layer.number, index])); + members.sort( + (left, right) => + (order.get(left.number) ?? layers.length) - (order.get(right.number) ?? layers.length), + ); + for (const member of members) placed.add(threadPullRequestKeyOf(member)); + chains.push({ kind: "native", layers: members, depths: members.map((_, index) => index) }); + } + + const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); + const branchKey = (link: ThreadPullRequestLink, branch: string) => { + const key = normalizedKeyOf(link); + return `${key.host}/${key.repository}:${branch}`; + }; + const byHead = new Map(); + for (const link of remaining) { + if (link.snapshot === null) continue; + const key = branchKey(link, link.snapshot.headBranch); + byHead.set(key, byHead.has(key) ? null : link); + } + const parentOf = (link: ThreadPullRequestLink) => { + if (link.snapshot === null) return undefined; + const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); + return parent == null || parent === link ? undefined : parent; + }; + const children = new Map>(); + for (const link of remaining) { + const parent = parentOf(link); + if (parent === undefined) continue; + const key = threadPullRequestKeyOf(parent); + const siblings = children.get(key) ?? []; + siblings.push(link); + children.set(key, siblings); + } + for (const base of remaining) { + if (parentOf(base) !== undefined) continue; + const layers: Array = []; + const depths: Array = []; + const pending: Array<{ readonly link: ThreadPullRequestLink; readonly depth: number }> = [ + { link: base, depth: 0 }, + ]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const key = threadPullRequestKeyOf(next.link); + // Two links with one identity would otherwise share a child list and expand forever. + if (placed.has(key)) continue; + placed.add(key); + layers.push(next.link); + depths.push(next.depth); + const below = children.get(key) ?? []; + for (let index = below.length - 1; index >= 0; index -= 1) { + pending.push({ link: below[index]!, depth: next.depth + 1 }); + } + } + if (layers.length > 0) chains.push({ kind: "derived", layers, depths }); + } + for (const link of remaining) { + if (!placed.has(threadPullRequestKeyOf(link))) { + chains.push({ kind: "derived", layers: [link], depths: [0] }); + } + } + return chains; + } + + function resolveThreadPullRequestBadge( + pullRequests: ReadonlyArray | undefined, + ): ThreadPullRequestBadge | null { + const visible = visibleThreadPullRequests(pullRequests ?? []); + if (visible.length === 0) return null; + const states = visible.map((link) => link.snapshot?.state ?? "open"); + const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) + ? "draft" + : states.includes("open") + ? "open" + : states.every((entry) => entry === "merged") + ? "merged" + : "closed"; + const chains = resolveThreadPullRequestChains(visible); + if (visible.length > 1 && chains.length === 1) { + return { kind: "stack", layers: visible.length, state }; + } + return { kind: "pull-request", others: visible.length - 1, state }; + } + + /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ + function threadPullRequestsOf(thread: { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + }): ReadonlyArray { + if (thread.pullRequests !== undefined) return thread.pullRequests; + const linked = thread.linkedPullRequest; + return linked == null + ? [] + : [ + { + ...legacyThreadPullRequestKey(linked), + url: linked.url, + source: "manual", + linkedAt: "1970-01-01T00:00:00.000Z", + snapshot: null, + stack: null, + }, + ]; + } + + function applyThreadPullRequestLinkUpdate( + thread: OrchestrationV2AppThread, + updated: ThreadPullRequestLink, + ): OrchestrationV2AppThread { + return { + ...thread, + pullRequests: threadPullRequestsOf(thread).map((link) => + threadPullRequestKeysEqual(link, updated) ? updated : link, + ), + }; + } + + return { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadCurrentPullRequest, + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + resolveThreadPullRequestBadge, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, + }; +} + +export interface PullRequestListLine { + readonly link: ThreadPullRequestLink; + readonly depth: number; + readonly chainKey: string; + readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; +} + +function chainKeyOf(chain: ThreadPullRequestChain): string { + const bottom = chain.layers[0]!; + return `${bottom.host}/${bottom.repository}#${bottom.number}`; +} + +export function pullRequestListLines( + chains: ReadonlyArray, +): ReadonlyArray { + const ordered = [...chains].sort( + (left, right) => + Math.max(...right.layers.map(latestUpdatedAt)) - + Math.max(...left.layers.map(latestUpdatedAt)), + ); + return ordered.flatMap((chain) => { + const chainKey = chainKeyOf(chain); + return chain.layers.map((link, index) => ({ + link, + depth: chain.depths[index]!, + chainKey, + stack: + index === 0 && chain.layers.length > 1 + ? { kind: chain.kind, size: chain.layers.length } + : null, + })); + }); +} diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index aef99bf5e4f9..360868770abf 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,6 +1,8 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; +import type { SourceControlClientDefinition } from "../client/definition.ts"; + import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -14,6 +16,7 @@ export interface SourceControlDriverInstance { export interface SourceControlDriver { readonly kind: SourceControlProviderKind; + readonly definition: SourceControlClientDefinition; readonly make: Effect.Effect; } diff --git a/packages/source-control-forgejo/src/client/definition.test.ts b/packages/source-control-forgejo/src/client/definition.test.ts new file mode 100644 index 000000000000..74d4cddbcd4d --- /dev/null +++ b/packages/source-control-forgejo/src/client/definition.test.ts @@ -0,0 +1,104 @@ +import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url))!; + +const identity = ( + remoteUrl: string, + fields: Partial = {}, +): ChangeRequestProjectIdentity => ({ + canonicalKey: "forge.example/git/team/repo", + displayName: "git/team/repo", + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + ...fields, +}); + +const onHost = (project: ChangeRequestProjectIdentity, link: ReturnType) => + definition.isChangeRequestInRepository(project, link) || + definition.canReadChangeRequestOnHost(project, link); + +describe("Forgejo change request URLs", () => { + it("reads nested paths and keeps the server's port as the authority", () => { + expect(parse("http://git.example.test:3000/Team/Repo/pulls/42/files")).toEqual({ + host: "git.example.test", + authority: "git.example.test:3000", + repository: "team/repo", + number: 42, + repositoryUrl: "http://git.example.test:3000/Team/Repo", + }); + expect(parse("https://github.internal/team/repo/pulls/7")).toMatchObject({ + host: "github.internal", + authority: "github.internal", + }); + }); + + it("claims only its own route", () => { + for (const url of [ + "https://forge.example/team/repo/pull/7", + "https://forge.example/repo/pulls/7", + "https://forge.example/team/repo/pulls/abc", + ]) { + expect(definition.parseChangeRequestUrl(new URL(url)), url).toBeNull(); + } + }); + + it.each([ + ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], + ["git@forge.example:git/owner/repo.git", "https://forge.example"], + ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], + ])("keeps the matching web origin from %s", (remoteUrl, origin) => { + expect( + definition.changeRequestUrl({ + host: "forge.example", + repository: "git/owner/repo", + number: 42, + remoteUrl, + }), + ).toBe(`${origin}/git/owner/repo/pulls/42`); + }); + + it("keeps the origin when the host already carries its port", () => { + expect( + definition.changeRequestUrl({ + host: "forge.example:3000", + repository: "team/repo", + number: 42, + remoteUrl: "http://forge.example:3000/team/repo.git", + }), + ).toBe("http://forge.example:3000/team/repo/pulls/42"); + }); + + it("matches projects by HTTP port at repository and host level", () => { + const [atThree, atFour] = [3000, 4000].map((port) => + identity(`http://forge.example:${port}/git/team/repo.git`), + ); + const link = parse("http://forge.example:4000/git/team/repo/pulls/42"); + expect(definition.isChangeRequestInRepository(atFour!, link)).toBe(true); + expect(onHost(atFour!, { ...link, repository: "git/team/other" })).toBe(true); + expect(definition.isChangeRequestInRepository(atThree!, link)).toBe(false); + expect(onHost(atThree!, link)).toBe(false); + }); + + it("lets tea resolve the web port for SSH remotes, and honours a resolved web URL", () => { + const link = parse("http://forge.example:4000/git/team/repo/pulls/42"); + expect( + definition.isChangeRequestInRepository(identity("git@forge.example:git/team/repo.git"), link), + ).toBe(true); + const aliased = identity("git@ssh.forge.example:team/repo.git", { + canonicalKey: "ssh.forge.example/team/repo", + displayName: "team/repo", + webUrl: "http://forge.example:4000/git/team/repo", + }); + expect(definition.isChangeRequestInRepository(aliased, link)).toBe(true); + expect(onHost(aliased, { ...link, repository: "git/team/other" })).toBe(true); + for (const url of [ + "http://other.example:4000/git/team/repo/pulls/42", + "http://forge.example:3000/git/team/repo/pulls/42", + "http://forge.example:4000/other/team/repo/pulls/42", + ]) { + expect(onHost(aliased, parse(url)), url).toBe(false); + } + }); +}); diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index f9aa6df2a1d2..9f8c2aa3573e 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -1,17 +1,22 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + changeRequestUrlBelow, type ChangeRequestProjectIdentity, defineSourceControlClient, isChangeRequestAuthorityOfProject, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } 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; +// Forgejo and Gitea run on any hostname, possibly mounted below a path. +const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("forgejo"); /** @@ -43,20 +48,16 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "https", // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the // origin, which may carry a port. SSH remotes say nothing about the web origin. - changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { - if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`; - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return `${remote.origin}/${repository}/pulls/${number}`; - } - } catch {} - return `https://${host}/${repository}/pulls/${number}`; + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pulls/${number}`) + : `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`, + // A server's identity includes its port, so the link carries it. + parseChangeRequestUrl: (url) => { + const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; }, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => @@ -69,7 +70,6 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: (verdict) => verdict === "request-changes", checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.host, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; @@ -94,5 +94,4 @@ export const definition = defineSourceControlClient({ (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) ); }, - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), }); diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index dc5fa6a69116..7324741656c1 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("forgejo"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: ForgejoSourceControlProvider.make, discovery: ForgejoSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitcafe/src/client/definition.test.ts b/packages/source-control-gitcafe/src/client/definition.test.ts new file mode 100644 index 000000000000..4bc0c017aaa1 --- /dev/null +++ b/packages/source-control-gitcafe/src/client/definition.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitCafe change request URLs", () => { + it("reads its canonical /pull/ path and the older /pulls/ path on its own hosts", () => { + for (const path of ["pull", "pulls"]) { + expect(parse(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), path).toEqual({ + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 23897, + repositoryUrl: "https://git.cafe/PingDotGG/T3Code", + }); + } + expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toMatchObject({ + host: "staging.git.cafe", + repository: "team/repo", + number: 7, + }); + }); + + it("leaves other hosts and routes alone", () => { + for (const url of [ + "https://github.com/team/repo/pull/7", + "https://forge.example/team/repo/pulls/7", + "https://git.cafe/pingdotgg/t3code/issues/23897", + "https://git.cafe/pingdotgg/t3code/pull/abc", + "https://git.cafe/pingdotgg/t3code", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it("builds pull request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ host: "git.cafe", repository: "team/repo", number: 8 }), + ).toBe("https://git.cafe/team/repo/pulls/8"); + expect( + definition.changeRequestUrl({ + host: "git.cafe", + repository: "team/repo", + number: 8, + webUrl: "https://git.cafe/Team/Repo/", + }), + ).toBe("https://git.cafe/Team/Repo/pulls/8"); + }); +}); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index c33cb45d5174..ac7736d541b7 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -7,15 +7,19 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } 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"]); +/** GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. */ +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("gitcafe"); export const settings = makeProviderSettingsSchema({ @@ -44,8 +48,17 @@ export const definition = defineSourceControlClient({ publishHost: () => "git.cafe", newRepositoryOwner: (account) => (account ? { owner: account } : null), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pulls/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pulls/${number}`) + : `https://${host}/${repository}/pulls/${number}`, + // `/pull/` and `/pulls/` are GitHub's and Forgejo's shapes too, so only GitCafe's own hosts + // claim them. + parseChangeRequestUrl: (url) => + GITCAFE_HOSTS.has(url.hostname.toLowerCase()) + ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) + : null, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "ready", "draft", @@ -58,24 +71,12 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, - // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. 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 ( - GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) && - (isChangeRequestPath(url, "/pull/") || isChangeRequestPath(url, "/pulls/")) - ); - } catch { - return false; - } - }, settings, }); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 6fce65b345a4..dca35f45b75a 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("gitcafe"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, discovery: GitCafeSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts new file mode 100644 index 000000000000..5e2153bbb056 --- /dev/null +++ b/packages/source-control-github/src/client/definition.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitHub change request URLs", () => { + it("reads a pull request on any host, lower-casing host and repository", () => { + expect(parse("https://github.com/T3Tools/T3Code/pull/123/files?w=1#diff")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + repositoryUrl: "https://github.com/T3Tools/T3Code", + }); + expect(parse("https://GHE.Example.com:8443/acme/web/pull/42/")).toEqual({ + host: "ghe.example.com", + repository: "acme/web", + number: 42, + repositoryUrl: "https://ghe.example.com:8443/acme/web", + }); + }); + + it("claims only the two-segment pull route", () => { + for (const url of [ + "https://github.com/t3tools/t3code/issues/123", + "https://github.com/t3tools/t3code/pulls/123", + "https://github.com/t3tools/t3code/pull/abc", + "https://github.com/t3tools/t3code/pull/0", + "https://github.com/group/sub/repo/pull/1", + "ftp://github.com/t3tools/t3code/pull/1", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it("builds pull request URLs below the repository, keeping an HTTP remote's origin", () => { + expect( + definition.changeRequestUrl({ + host: "github.acme.test", + repository: "platform/api", + number: 7, + remoteUrl: "http://token@github.acme.test:8443/team/default.git", + }), + ).toBe("http://github.acme.test:8443/platform/api/pull/7"); + for (const remoteUrl of [ + "git@github.acme.test:team/default.git", + "git://github.acme.test/team/default.git", + "http://other.test/team/default.git", + ]) { + expect( + definition.changeRequestUrl({ + host: "github.acme.test", + repository: "platform/api", + number: 9, + remoteUrl, + }), + remoteUrl, + ).toBe("https://github.acme.test/platform/api/pull/9"); + } + expect( + definition.changeRequestUrl({ + host: "github.com", + repository: "pull/1", + number: 43, + webUrl: "https://github.com/pull/1", + }), + ).toBe("https://github.com/pull/1/pull/43"); + }); + + // regression: #8507 the browser fallback must not open a bogus URL + it("builds nothing from an invalid repository, number, or host", () => { + for (const [host, repository, number] of [ + ["github.com", "acme", 1], + ["github.com", "../repository", 1], + ["github.com", "acme/%2e%2e", 1], + ["github.com", "acme/repository?tab=1", 1], + ["github.com", "acme/repository", 0], + ["bad host", "acme/repository", 1], + ] as const) { + expect(definition.changeRequestUrl({ host, repository, number })).toBeNull(); + } + }); + + it("turns GitHub's shared issue route into a pull request candidate", () => { + const candidate = (url: string) => + definition.changeRequestUrlFromReferenceAutolink(new URL(url)); + expect(candidate("https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1")).toBe( + "https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1", + ); + expect(candidate("https://code.github.example.com/acme/web/issues/42")).toBe( + "https://code.github.example.com/acme/web/pull/42", + ); + expect(candidate("https://github.com/pingdotgg/t3code/issues/not-a-number")).toBeNull(); + expect(candidate("https://code.example.test/acme/web/issues/42")).toBeNull(); + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 66c1cd003968..f9db4b87a3a0 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -10,18 +10,42 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } 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; +// GitHub Enterprise Server runs on any hostname, so the `//pull/` route alone +// identifies a GitHub pull request. +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u; +const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("github"); +function isGitHubHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "github.com" || host.endsWith(".github.com") || host.split(".").includes("github") + ); +} + +function isRepositoryPath(repository: string): boolean { + const segments = repository.split("/"); + return ( + segments.length === 2 && + // GitHub owner and repository names are only these characters, so nothing here can + // be an encoded dot segment, query or fragment that `new URL` would reinterpret. + segments.every((segment) => /^[\w.-]+$/u.test(segment) && segment !== "." && segment !== "..") + ); +} + export const GitHubHostChoice = Schema.Struct({ account: Schema.optionalKey(TrimmedNonEmptyString), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -53,8 +77,25 @@ export const definition = defineSourceControlClient({ publishHost: () => "github.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "https", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pull/${number}`, + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (!Number.isSafeInteger(number) || number < 1) return null; + if (webUrl) return changeRequestUrlBelow(webUrl, `pull/${number}`); + if (!isRepositoryPath(repository)) return null; + try { + return new URL(`/${repository}/pull/${number}`, remoteWebOrigin(host, remoteUrl)).toString(); + } catch { + return null; + } + }, + parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + // GitHub writes every bare reference through `/issues/`, including pull requests. + changeRequestUrlFromReferenceAutolink: (url) => { + const match = isGitHubHost(url.hostname) ? ISSUE_PATH.exec(url.pathname) : null; + if (match?.[1] === undefined || match[2] === undefined) return null; + const candidate = new URL(url); + candidate.pathname = `/${match[1]}/pull/${match[2]}`; + return candidate.toString(); + }, changeRequestActions: new Set([ "merge", "ready", @@ -77,12 +118,10 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), settings, }); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index 8aec1f6c40ac..d5247732936f 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("github"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitlab/src/client/definition.test.ts b/packages/source-control-gitlab/src/client/definition.test.ts new file mode 100644 index 000000000000..4184084e3864 --- /dev/null +++ b/packages/source-control-gitlab/src/client/definition.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitLab change request URLs", () => { + it("reads a merge request on any host, nested groups and all", () => { + expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({ + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + repositoryUrl: "https://gitlab.com/t3tools/platform/t3code", + }); + expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toMatchObject({ + host: "code.acme.test", + repository: "team/project", + number: 9, + }); + }); + + it("keeps pull-like segments inside nested repository paths", () => { + expect( + parse("https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42")?.repositoryUrl, + ).toBe("https://gitlab.example.test/group/pull/123/repo"); + }); + + it("leaves issues and single-segment paths alone", () => { + expect(parse("https://gitlab.com/t3tools/t3code/-/issues/12")).toBeNull(); + expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); + }); + + it("builds merge request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ + host: "gitlab.com", + repository: "group/sub/project", + number: 4, + }), + ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + expect( + definition.changeRequestUrl({ + host: "gitlab.com", + repository: "group/sub/project", + number: 4, + webUrl: "https://gitlab.com/group/sub/project", + }), + ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 67cbc507cffc..a3f4a153a1a0 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -1,15 +1,19 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } 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; +// Self-managed GitLab runs on any hostname and nests projects in groups. +const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("gitlab"); export const definition = defineSourceControlClient({ @@ -24,8 +28,12 @@ export const definition = defineSourceControlClient({ publishHost: () => "gitlab.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/-/merge_requests/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) + : `https://${host}/${repository}/-/merge_requests/${number}`, + parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", "ready", @@ -42,11 +50,9 @@ export const definition = defineSourceControlClient({ 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/"), }); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 23728f63127c..93defc897d79 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,5 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { changeRequestUrlFor } from "@t3tools/shared/changeRequestUrl"; import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, @@ -16,6 +15,7 @@ import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSour import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import { definition } from "../client/definition.ts"; function makeProvider(gitlab: Partial) { return GitLabSourceControlProvider.make.pipe( @@ -166,15 +166,17 @@ it.effect.each([ ), }) ?? Effect.succeed(identity) ); - const [host, ...repository] = refined.canonicalKey.split("/"); - assert.strictEqual( - changeRequestUrlFor(refined.provider, host!, repository.join("/"), 42), - expectedProvider === "gitlab" - ? "https://code.example.test/team/repo/-/merge_requests/42" - : expectedProvider === "github" - ? "https://github.com/team/repo/pull/42" - : null, - ); + if (expectedProvider === "gitlab") { + const [host, ...repository] = refined.canonicalKey.split("/"); + assert.strictEqual( + definition.changeRequestUrl({ + host: host!, + repository: repository.join("/"), + number: 42, + }), + "https://code.example.test/team/repo/-/merge_requests/42", + ); + } assert.deepStrictEqual(refined, { ...identity, provider: expectedProvider }); if (expectedProvider === "github") assert.strictEqual(authCalls, 0); }), diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 2b555786015a..175efc16269e 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("gitlab"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitLabSourceControlProvider.make, discovery: Effect.succeed(GitLabSourceControlProvider.discovery),