From 8044b7af0618a8bfae83852ec1d0a5d05de0cc04 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Mon, 21 Sep 2026 17:06:18 -0600 Subject: [PATCH 1/2] fix(pullRequest): refine self-hosted GitHub remotes and support fallback URLs - Register refineUnknownRemote on GitHubSourceControlProvider to test authenticated hosts from gh auth status - Propagate refined provider kind to project repository identity in PullRequestService - Add fallbackPullRequestBrowserUrl and externalLink support in web client so self-hosted GitHub and GitLab pull requests can be opened externally in unavailable states - Closes #155 --- .../pullRequest/PullRequestService.test.ts | 35 ++++++ .../src/pullRequest/PullRequestService.ts | 12 +- .../GitHubSourceControlProvider.test.ts | 67 +++++++++++ .../GitHubSourceControlProvider.ts | 19 ++++ apps/web/src/components/ChatView.tsx | 47 +++++--- .../pullRequest/PullRequestDetailPanel.tsx | 31 +++++- .../PullRequestsUnavailableState.tsx | 17 ++- .../src/hooks/useOpenPanelPullRequestUrl.ts | 12 +- apps/web/src/lib/openPullRequestLink.test.ts | 35 ++++++ apps/web/src/lib/openPullRequestLink.ts | 2 + apps/web/src/state/pullRequests.ts | 28 +++++ packages/shared/src/changeRequestUrl.test.ts | 105 ++++++++++++++++++ packages/shared/src/changeRequestUrl.ts | 97 ++++++++++++---- 13 files changed, 458 insertions(+), 49 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 91b02d2910..a4f866b59a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -244,6 +244,41 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ }), ); +it.effect("refines unknown self-hosted GitHub projects before listing pull requests", () => + Effect.gen(function* () { + let refinementCalls = 0; + const selfHosted = project({ + id: "p1", + title: "self-hosted-gh", + workspaceRoot: "/github-enterprise", + repository: "corp/platform", + provider: "unknown", + host: "git.enterprise.test", + }); + const service = yield* makeService({ + projects: [ + selfHosted, + { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/github-enterprise-worktree" }, + ], + providers: [fakeProvider("github")], + resolveHandle: ({ context }) => { + refinementCalls += 1; + assert.strictEqual(context?.remoteUrl, "https://git.enterprise.test/corp/platform.git"); + return Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "github" } }, + provider: undefined as never, + }); + }, + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(refinementCalls, 1); + assert.strictEqual(result.providers[0]?.host, "git.enterprise.test"); + assert.strictEqual(result.providers[0]?.kind, "github"); + }), +); + it.effect("derives a legacy repository host after refining its provider", () => Effect.gen(function* () { const current = project({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 7af24d5081..c61ce781a8 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -677,9 +677,19 @@ export const make = Effect.gen(function* () { else counted.projectCount += 1; continue; } + const refinedProject = + kind !== identity.provider + ? { + ...project, + repositoryIdentity: { + ...identity, + provider: kind, + }, + } + : project; supported.push({ cursorKey: key, - project, + project: refinedProject, api: withRateLimitBackoff(api, host, rateLimits), repository, host, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 24768f5390..512bfa0aae 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -427,3 +427,70 @@ it("reports an update hint instead of unauthenticated when gh predates --json", /2\.81\.0/, ); }); + +it("refines unknown GitHub remotes with mixed-case provider hosts", () => { + const provider = GitHubSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "Enterprise.Example.Test", + baseUrl: "https://Enterprise.Example.Test", + }, + remoteName: "origin", + remoteUrl: "https://Enterprise.Example.Test/org/repo.git", + }, + auth: processResult( + JSON.stringify({ + hosts: { + "enterprise.example.test": [ + { + state: "success", + active: true, + host: "enterprise.example.test", + login: "enterprise-user", + }, + ], + }, + }), + ), + }); + + assert.deepStrictEqual(provider, { + kind: "github", + name: "GitHub Enterprise", + baseUrl: "https://Enterprise.Example.Test", + }); +}); + +it("returns null when refining unknown remote with unauthenticated host", () => { + const provider = GitHubSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "enterprise.example.test", + baseUrl: "https://enterprise.example.test", + }, + remoteName: "origin", + remoteUrl: "https://enterprise.example.test/org/repo.git", + }, + auth: processResult( + JSON.stringify({ + hosts: { + "enterprise.example.test": [ + { + state: "error", + active: true, + host: "enterprise.example.test", + login: "enterprise-user", + error: "Token expired", + }, + ], + }, + }), + ), + }); + + assert.strictEqual(provider, null); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 966782e4f0..af6ff4bc32 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -18,6 +18,7 @@ import { providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { @@ -98,6 +99,23 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } +function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { + const host = input.context.provider.name.toLowerCase(); + const authenticated = parseGitHubAuthStatus(combinedAuthOutput(input.auth)).accounts.some( + (entry) => entry.authenticated && entry.host === host, + ); + + if (!authenticated) { + return null; + } + + return { + kind: "github", + name: "GitHub Enterprise", + baseUrl: input.context.provider.baseUrl, + } as const; +} + export const discovery = { type: "cli", kind: "github", @@ -106,6 +124,7 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, + refineUnknownRemote: refineUnknownGitHubRemote, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8f3aaeeb70..ad086fe540 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -260,7 +260,8 @@ import { } from "lucide-react"; import { cn, randomHex, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; +import { fallbackPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; +import { usePullRequestProviders } from "~/state/pullRequests"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -2659,20 +2660,33 @@ export default function ChatView(props: ChatViewProps) { }); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; - // Same fallback the load-failure view offers: a pull request stays readable on - // GitHub even when this environment's server is too old to browse it here. - const pullRequestsUnavailableGitHubUrl = - activeRightPanelSurface?.kind === "pull-request" - ? gitHubPullRequestBrowserUrl( - allProjects.find( - (project) => - project.id === activeRightPanelSurface.projectId && - project.environmentId === activeThread?.environmentId, - )?.repositoryIdentity, - activeRightPanelSurface.repository, - activeRightPanelSurface.number, - ) - : null; + // Same fallback the load-failure view offers: a pull request stays readable in + // browser even when this environment's server is too old to browse it here. + const observedPullRequestProviders = usePullRequestProviders(); + const pullRequestsUnavailableFallbackLink = useMemo(() => { + if (activeRightPanelSurface?.kind !== "pull-request") return null; + const identity = allProjects.find( + (project) => + project.id === activeRightPanelSurface.projectId && + project.environmentId === activeThread?.environmentId, + )?.repositoryIdentity; + const host = activeRightPanelSurface.host ?? identity?.canonicalKey?.split("/")[0]; + const providerKind = host + ? observedPullRequestProviders.get(host.toLowerCase())?.kind + : undefined; + return fallbackPullRequestBrowserUrl( + identity, + activeRightPanelSurface.repository, + activeRightPanelSurface.number, + providerKind, + ); + }, [ + activeRightPanelSurface, + activeThread?.environmentId, + allProjects, + observedPullRequestProviders, + ]); + const pullRequestsUnavailableGitHubUrl = pullRequestsUnavailableFallbackLink?.url ?? null; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsQuestionAttachments = @@ -10177,9 +10191,10 @@ export default function ChatView(props: ChatViewProps) { { + const observedProviders = usePullRequestProviders(); + const unavailableFallbackLink = useMemo(() => { const identity = projects.find( (project) => project.id === reference.projectId && project.environmentId === environmentId, )?.repositoryIdentity; - return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); - }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); + const host = reference.host ?? identity?.canonicalKey?.split("/")[0]; + const providerKind = host ? observedProviders.get(host.toLowerCase())?.kind : undefined; + return fallbackPullRequestBrowserUrl( + identity, + reference.repository, + reference.number, + providerKind, + ); + }, [ + environmentId, + observedProviders, + projects, + reference.host, + reference.number, + reference.projectId, + reference.repository, + ]); + const unavailableGitHubUrl = unavailableFallbackLink?.url ?? null; // Project settings stored the override under the sidebar group's key, which a duplicate row // borrows from its siblings, so the project alone does not always name the same key. const legacyProjectDefaultMergeMethod = useMemo(() => { @@ -2741,6 +2763,7 @@ export function PullRequestDetailPanel({ error={detailQuery.error} refreshing={detailQuery.isPending} onRetry={refreshDetail} + externalLink={unavailableFallbackLink} {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} /> ) : detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index f2f509944c..d15eabba26 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -12,19 +12,28 @@ import { } from "../ui/empty"; import { PullRequestGlyph } from "./pullRequestIcons"; +export interface PullRequestExternalLink { + readonly url: string; + readonly label?: string; +} + export function PullRequestsUnavailableState({ title = "Could not load pull requests", error, onRetry, refreshing = false, gitHubUrl, + externalLink, }: { title?: string; error: string; onRetry?: () => void; refreshing?: boolean; gitHubUrl?: string; + externalLink?: PullRequestExternalLink | null; }) { + const link = externalLink ?? (gitHubUrl ? { url: gitHubUrl, label: "Open on GitHub" } : null); + return ( @@ -36,7 +45,7 @@ export function PullRequestsUnavailableState({ shows its message rather than trying to infer one from the failure text. */} {error} - {onRetry || gitHubUrl ? ( + {onRetry || link ? ( {onRetry ? ( ) : null} diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 220f65fda2..92af3fc759 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -7,14 +7,15 @@ import { resolveDisplayedPullRequestDetail, resolvePullRequestReferenceHost, } from "../components/pullRequest/pullRequestDetail.logic"; -import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; +import { fallbackPullRequestBrowserUrl } from "../lib/openPullRequestLink"; import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; import { useProject } from "../state/entities"; -import { pullRequestEnvironment } from "../state/pullRequests"; +import { pullRequestEnvironment, usePullRequestProviders } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { + const observedProviders = usePullRequestProviders(); const surface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, threadRef), ); @@ -72,10 +73,13 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { reference, })?.url ?? requestedReference?.url ?? - gitHubPullRequestBrowserUrl( + fallbackPullRequestBrowserUrl( project?.repositoryIdentity, reference.repository, reference.number, - )) + "host" in reference && typeof reference.host === "string" && reference.host.length > 0 + ? observedProviders.get(reference.host.toLowerCase())?.kind + : undefined, + )?.url) : undefined; } diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index ce9c2193a5..1e9dacf37f 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -4,6 +4,7 @@ import { changeRequestRepositoryUrl, findProjectForChangeRequest, findProjectOnChangeRequestHost, + fallbackPullRequestBrowserUrl, gitHubPullRequestBrowserUrl, matchesLinkedPullRequestUrl, parseChangeRequestUrl, @@ -24,6 +25,40 @@ function repositoryIdentity( }; } +describe("fallbackPullRequestBrowserUrl", () => { + it("builds an Open on GitHub fallback for an unrefined self-hosted identity when providerKind is passed", () => { + const identity = repositoryIdentity( + "unknown", + "github.acme.test/team/default", + "https://github.acme.test/team/default.git", + ); + expect(fallbackPullRequestBrowserUrl(identity, "platform/api", 7, "github")).toEqual({ + url: "https://github.acme.test/platform/api/pull/7", + label: "Open on GitHub", + }); + }); + + it("returns null when provider is unknown and not refined", () => { + const identity = repositoryIdentity( + "unknown", + "github.acme.test/team/default", + "https://github.acme.test/team/default.git", + ); + expect(fallbackPullRequestBrowserUrl(identity, "platform/api", 7)).toBeNull(); + }); + + it("builds an Open on GitLab fallback for GitLab identities", () => { + const identity = repositoryIdentity( + "gitlab", + "gitlab.acme.test/team/default", + "https://gitlab.acme.test/team/default.git", + ); + expect(fallbackPullRequestBrowserUrl(identity, "team/default", 12)).toEqual({ + url: "https://gitlab.acme.test/team/default/-/merge_requests/12", + label: "Open on GitLab", + }); + }); +}); describe("gitHubPullRequestBrowserUrl", () => { it("uses the requested GitHub repository instead of the project's default repository", () => { const identity = repositoryIdentity( diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 2dc5b62cc9..4aea96b7d3 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -23,6 +23,8 @@ export { parseChangeRequestUrl, type ChangeRequestLink, gitHubPullRequestBrowserUrl, + fallbackPullRequestBrowserUrl, + type PullRequestBrowserUrlTarget, pullRequestCandidateUrlFromReferenceAutolink, matchesLinkedPullRequestUrl, changeRequestRepositoryUrl, diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 365c6812d0..d2ad32fda3 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -9,6 +9,7 @@ import type { PullRequestListInput, PullRequestListStatsInput, PullRequestListEntry, + PullRequestProviderSummary, PullRequestRef, PullRequestSummary, } from "@t3tools/contracts"; @@ -173,6 +174,32 @@ export function useSharedPullRequestSummary( )?.summary ?? current ); } + +export const observedPullRequestProvidersAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.withLabel("web-pull-requests:observed-providers")); + +export function observePullRequestProviders( + providers: ReadonlyArray, +): void { + appAtomRegistry.modify(observedPullRequestProvidersAtom, (previous) => { + let changed = false; + const next = new Map(previous); + for (const provider of providers) { + const key = provider.host.toLowerCase(); + if (!next.has(key) || next.get(key) !== provider) { + next.set(key, provider); + changed = true; + } + } + return changed ? [true, next] : [false, previous]; + }); +} + +export function usePullRequestProviders(): ReadonlyMap { + return useAtomValue(observedPullRequestProvidersAtom); +} + export const pullRequestStackAtom = createPullRequestStackAtomFamily( connectionAtomRuntime, pullRequestEnvironment.refreshes, @@ -294,6 +321,7 @@ export function usePullRequestList( const query = usePullRequestListsQuery(targets); useLayoutEffect(() => { for (const [environmentId, answer, observedAt] of query.observations) { + observePullRequestProviders(answer.providers); for (const entry of answer.entries) { observePullRequestSummary( environmentId, diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 2af718e453..51c54ba01f 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { changeRequestUrlFor, + fallbackPullRequestBrowserUrl, + gitHubPullRequestBrowserUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, siblingPullRequestUrl, @@ -140,3 +142,106 @@ describe("changeRequestUrlFor", () => { }); }); }); + +describe("fallbackPullRequestBrowserUrl", () => { + const selfHostedIdentity = { + canonicalKey: "git.enterprise.corp/org/repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@git.enterprise.corp:org/repo.git", + }, + rootPath: "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/work/repo", + provider: "unknown", + }; + + it("returns null when provider is unknown and not overridden", () => { + expect(fallbackPullRequestBrowserUrl(selfHostedIdentity, "org/repo", 42)).toBeNull(); + }); + + it("builds a GitHub URL with label when refined to github", () => { + expect(fallbackPullRequestBrowserUrl(selfHostedIdentity, "org/repo", 42, "github")).toEqual({ + url: "https://git.enterprise.corp/org/repo/pull/42", + label: "Open on GitHub", + }); + }); + + it("builds a GitHub URL from an identity whose provider is github", () => { + expect( + fallbackPullRequestBrowserUrl({ ...selfHostedIdentity, provider: "github" }, "org/repo", 42), + ).toEqual({ + url: "https://git.enterprise.corp/org/repo/pull/42", + label: "Open on GitHub", + }); + }); + + it("respects HTTP protocol and custom ports from locator remote URL", () => { + expect( + fallbackPullRequestBrowserUrl( + { + ...selfHostedIdentity, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "http://git.enterprise.corp:8080/org/repo.git", + }, + }, + "org/repo", + 42, + "github", + ), + ).toEqual({ + url: "http://git.enterprise.corp:8080/org/repo/pull/42", + label: "Open on GitHub", + }); + }); + + it("builds a GitLab URL with label when refined to gitlab", () => { + expect(fallbackPullRequestBrowserUrl(selfHostedIdentity, "org/repo", 42, "gitlab")).toEqual({ + url: "https://git.enterprise.corp/org/repo/-/merge_requests/42", + label: "Open on GitLab", + }); + }); + + it("returns null for invalid numbers or paths", () => { + expect(fallbackPullRequestBrowserUrl(selfHostedIdentity, "org/repo", 0, "github")).toBeNull(); + expect( + fallbackPullRequestBrowserUrl(selfHostedIdentity, "org/repo/nested", 42, "github"), + ).toBeNull(); + }); +}); + +describe("gitHubPullRequestBrowserUrl", () => { + const identity = { + canonicalKey: "github.com/org/repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:org/repo.git", + }, + rootPath: "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/work/repo", + provider: "github", + }; + + it("builds a browser URL for standard GitHub identity", () => { + expect(gitHubPullRequestBrowserUrl(identity, "org/repo", 10)).toBe( + "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/org/repo/pull/10", + ); + }); + + it("supports providerKind override for self-hosted GitHub", () => { + const unknownIdentity = { + canonicalKey: "git.enterprise.corp/org/repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@git.enterprise.corp:org/repo.git", + }, + rootPath: "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/work/repo", + provider: "unknown", + }; + expect(gitHubPullRequestBrowserUrl(unknownIdentity, "org/repo", 10, "github")).toBe( + "https://git.enterprise.corp/org/repo/pull/10", + ); + }); +}); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 0687afd38e..1a359f4d43 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -1,4 +1,8 @@ -import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import type { + RepositoryIdentity, + SourceControlProviderKind, + ThreadLinkedPullRequest, +} from "@t3tools/contracts"; import { canonicalRepositoryKey } from "./sourceControl.ts"; /** @@ -98,42 +102,95 @@ export function changeRequestUrlFor( } } -/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ -export function gitHubPullRequestBrowserUrl( +export interface PullRequestBrowserUrlTarget { + readonly url: string; + readonly label: string; +} + +/** Builds a provider-appropriate URL and label that remains available when the pull request API cannot be read. */ +export function fallbackPullRequestBrowserUrl( 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; - } + providerKind?: SourceControlProviderKind | string | null, +): PullRequestBrowserUrlTarget | null { + const kind = (providerKind ?? identity?.provider) as SourceControlProviderKind | undefined; + if (!kind || kind === "unknown" || !Number.isSafeInteger(number) || number < 1) return null; let origin: string | null = null; try { - const remoteUrl = new URL(identity.locator.remoteUrl.trim()); + const remoteUrl = new URL(identity?.locator?.remoteUrl?.trim() ?? ""); if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { origin = remoteUrl.origin; } } catch { // SCP-style remotes are read from their normalized identity below. } - const hostname = identity.canonicalKey.split("/")[0]; + 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; + const baseOrigin = origin ?? `https://${hostname}`; + + switch (kind) { + case "github": { + const repositoryPath = repository.split("/"); + if ( + repositoryPath.length !== 2 || + repositoryPath.some( + (segment) => segment.length === 0 || segment === "." || segment === "..", + ) + ) { + return null; + } + try { + const url = new URL(baseOrigin); + url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; + return { url: url.toString(), label: "Open on GitHub" }; + } catch { + return null; + } + } + case "gitlab": { + try { + const url = new URL(baseOrigin); + url.pathname = `/${repository}/-/merge_requests/${number}`; + return { url: url.toString(), label: "Open on GitLab" }; + } catch { + return null; + } + } + case "azure-devops": { + const host = hostname ?? new URL(baseOrigin).host; + const url = changeRequestUrlFor("azure-devops", host, repository, number); + return url ? { url, label: "Open on Azure DevOps" } : null; + } + case "bitbucket": { + try { + const url = new URL(baseOrigin); + url.pathname = `/${repository}/pull-requests/${number}`; + return { url: url.toString(), label: "Open on Bitbucket" }; + } catch { + return null; + } + } + default: + return null; } } +/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ +export function gitHubPullRequestBrowserUrl( + identity: RepositoryIdentity | null | undefined, + repository: string, + number: number, + providerKind?: SourceControlProviderKind | string | null, +): string | null { + const kind = providerKind ?? identity?.provider; + if (kind !== "github") return null; + const target = fallbackPullRequestBrowserUrl(identity, repository, number, "github"); + return target?.url ?? 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 From 49615a249794a8986f04bf3b6c854e1f92b8af36 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Mon, 21 Sep 2026 17:10:48 -0600 Subject: [PATCH 2/2] fix(web): internalize observed pull request provider atom to satisfy knip --- apps/web/src/state/pullRequests.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index d2ad32fda3..58dafc12ce 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -175,13 +175,11 @@ export function useSharedPullRequestSummary( ); } -export const observedPullRequestProvidersAtom = Atom.make< - ReadonlyMap ->(new Map()).pipe(Atom.withLabel("web-pull-requests:observed-providers")); +const observedPullRequestProvidersAtom = Atom.make>( + new Map(), +).pipe(Atom.withLabel("web-pull-requests:observed-providers")); -export function observePullRequestProviders( - providers: ReadonlyArray, -): void { +function observePullRequestProviders(providers: ReadonlyArray): void { appAtomRegistry.modify(observedPullRequestProvidersAtom, (previous) => { let changed = false; const next = new Map(previous);