diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 05fe4b444a6ef..0d5b77f061837 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -15,6 +15,7 @@ import { import { videoMimeType } from "@t3tools/shared/video"; import { createContext, useContext, useMemo, useState } from "react"; import { + changeRequestLinkResolver, changeRequestReferenceAutolinker, resolveChangeRequestMediaUrl, } from "@t3tools/client-runtime/pull-request-markdown"; @@ -22,21 +23,28 @@ import { sourceControlClients, type ReferenceAutolink, } from "@t3tools/client-runtime/source-control-clients"; +import { assetUrlStateFromResult } from "@t3tools/client-runtime/state/assets"; import { linkReferenceAutolinks } from "./pullRequestAutolinks"; +import { assetEnvironment } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { markdownImageSources } from "../../lib/markdownImageSources"; import { FilePreviewGallery, useFilePreviewGallery } from "../../components/ui/FilePreviewGallery"; -import { pullRequestImageSource } from "./pullRequestMediaSource"; +import { pullRequestImageSource, pullRequestMediaSource } from "./pullRequestMediaSource"; import { FilePreviewModal, type FilePreviewSource, type FilePreviewItem, } from "../../components/ui/FilePreviewModal"; import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; -import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; +import type { + MarkdownImageRenderer, + MarkdownImageSourceResolver, +} from "../../native/SelectableMarkdownText"; import type { SelectableMarkdownTextProps } from "../../native/SelectableMarkdownText.types"; const AutolinksContext = createContext<((text: string) => ReadonlyArray) | null>( @@ -44,6 +52,7 @@ const AutolinksContext = createContext<((text: string) => ReadonlyArray(null); +const LinkResolverContext = createContext<((url: string) => string) | null>(null); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -58,6 +67,7 @@ export function PullRequestMarkdownRepository(props: { ? null : changeRequestReferenceAutolinker({ provider, url }); const lineBreaks = sourceControlClients.find(provider ?? "")?.hardLineBreaks ?? false; + const linkResolver = changeRequestLinkResolver(props.changeRequest); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -71,13 +81,63 @@ export function PullRequestMarkdownRepository(props: { - {props.children} + + {props.children} + ); } +/** A body's destinations, as its host resolves them; a guide's stay relative to the checkout. */ +function useResolveLink() { + const linkResolver = useContext(LinkResolverContext); + return (href: string) => linkResolver?.(href) ?? href; +} + +function useResolveMediaUrl() { + const resolveLink = useResolveLink(); + const changeRequestUrl = useContext(ChangeRequestUrlContext); + return (href: string) => { + const url = resolveLink(href); + return changeRequestUrl ? resolveChangeRequestMediaUrl(url, changeRequestUrl) : url; + }; +} + +/** + * Enriched draws an image in a table, list or link itself, from this source, not `renderImage`. + * It keeps a resolved source for the life of the view, so the view is keyed to everything an + * answer depends on: the change request it resolves against, the media location and the origin. + */ +function usePullRequestImageSource(resolve: (url: string) => string): { + readonly resolveImageSource: MarkdownImageSourceResolver; + readonly sourceKey: string; +} { + const location = useContext(PullRequestMediaContext); + const connection = usePreparedConnection(location?.environmentId ?? null); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); + const resolveImageSource: MarkdownImageSourceResolver = async (image) => { + const url = resolve(image.href); + const source = pullRequestMediaSource(url, location); + if ("uri" in source) return /^https?:\/\//i.test(url) ? { uri: url } : null; + const state = assetUrlStateFromResult( + await createUrl({ + environmentId: source.environmentId, + input: { resource: source.resource }, + }), + httpBaseUrl, + ); + if (state._tag !== "Success") throw new Error("Pull request image is unavailable"); + return { uri: state.url }; + }; + return { + resolveImageSource, + sourceKey: `${location?.environmentId ?? ""}\n${location?.cwd ?? ""}\n${httpBaseUrl ?? ""}`, + }; +} + function MarkdownImage(props: { readonly href: string; readonly alt: string | null; @@ -120,13 +180,13 @@ export function PullRequestMarkdown(props: { }) => boolean; }) { const mediaContext = useContext(PullRequestMediaContext); - const changeRequestUrl = useContext(ChangeRequestUrlContext); + const resolveMediaUrl = useResolveMediaUrl(); const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]); const [undoneTicks, setUndoneTicks] = useState(0); const gallery: FilePreviewItem[] = markdownImageSources( props.imageGroupMarkdown ?? props.markdown, ) - .map((href) => (changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href)) + .map(resolveMediaUrl) .filter( (uri) => /^https?:\/\//i.test(uri) && @@ -149,6 +209,15 @@ export function PullRequestMarkdown(props: { ); } +/** The agent writes the guide about the checkout, so its relative links name workspace files. */ +export function PullRequestGuideMarkdown(props: { readonly markdown: string }) { + return ( + + + + ); +} + function PullRequestBodySegmentView(props: { readonly segment: PullRequestBodySegment; readonly gallery: FilePreviewItem[]; @@ -221,26 +290,30 @@ function PullRequestMarkdownContent(props: { const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); const changeRequestUrl = useContext(ChangeRequestUrlContext); - const resolveMediaUrl = (href: string) => - changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href; + const resolveLink = useResolveLink(); + const resolveMediaUrl = useResolveMediaUrl(); const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); const openLink = (href: string) => { - void openPullRequest(href); + void openPullRequest(resolveLink(href)); }; const [preview, setPreview] = useState(null); const renderImage: MarkdownImageRenderer = (image) => ( ); + const { resolveImageSource, sourceKey } = usePullRequestImageSource(resolveMediaUrl); + const nativeKey = `${changeRequestUrl ?? ""}\n${sourceKey}`; const styles = useMarkdownPreviewStyles(); return ( setPreview(pullRequestImageSource(resolveMediaUrl(href), mediaContext)) } diff --git a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx index 9fd845eff921e..4301a84be72c2 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx @@ -4,7 +4,7 @@ import { stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../../components/ui/AppText"; import { SymbolView } from "../../../components/ui/AppSymbol"; -import { PullRequestMarkdown } from "../PullRequestMarkdown"; +import { PullRequestGuideMarkdown } from "../PullRequestMarkdown"; import { Menu } from "../../../components/ui/Menu"; import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; @@ -59,7 +59,7 @@ export function GuideOutline({ {guide.stops[0]?.summary ? ( - + ) : null} {guide.stops.map((stop, position) => { diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index 542700dc69185..66ff23dffb829 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -17,7 +17,7 @@ import { AppText as Text } from "../../../components/ui/AppText"; import { ScreenScrollView } from "../../../components/ui/ScreenScrollView"; import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls"; -import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; +import { PullRequestGuideMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; import { reviewGuideState } from "./reviewGuideState"; @@ -163,7 +163,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {stop.title} - + {stop.attention ? ( @@ -172,7 +172,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps - + ) : null} {stale ? ( @@ -212,7 +212,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {note.path.split("/").at(-1)}:{note.line} - + ))} @@ -241,7 +241,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps Questions to consider {stop.questions.map((question) => ( - + ))} ) : null} diff --git a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx index c280c6ea46525..4700a15520b3a 100644 --- a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx +++ b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx @@ -11,7 +11,7 @@ import { buildReviewParsedDiff } from "../../review/reviewModel"; import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge"; import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface"; import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; -import { PullRequestMarkdown } from "../PullRequestMarkdown"; +import { PullRequestGuideMarkdown } from "../PullRequestMarkdown"; const EMPTY_IDS: readonly string[] = []; @@ -55,9 +55,7 @@ export function ReviewGuideExcerpt({ const [NativeDiff] = useState(() => resolveNativeReviewDiffView()); return ( - {excerpt.caption.trim() ? ( - - ) : null} + {excerpt.caption.trim() ? : null} { import ChatMarkdown from "./ChatMarkdown"; import { AudioPreview } from "./files/AudioPreview"; +import { pullRequestRehypePlugins } from "./pullRequest/pullRequestMarkdown.logic"; it("keeps compact audio playback loaded across signed URL renewals", async () => { const original = @@ -275,6 +276,39 @@ it("reuses the parse of a remounted message that has stable extra plugins", asyn expect(parsed).toHaveBeenCalledTimes(1); }); +it("resolves destinations in parsed html and reference links, but not code", async () => { + setup(); + const extraRehypePlugins = pullRequestRehypePlugins({ + provider: "github", + url: "https://github.com/team/repo/pull/8", + }); + await using screen = await renderText( + [ + 'before > after', + "", + `b`, + "", + 'docs [ref][r] `![c](../blob/abc/c.png)`', + "", + "[r]: ../blob/abc/ref.md", + ].join("\n"), + { extraRehypePlugins }, + ); + await expect + .element(screen.getByAltText("before > after")) + .toHaveAttribute("src", "https://github.com/team/repo/blob/abc/a.png"); + await expect + .element(screen.getByAltText("b")) + .toHaveAttribute("src", "https://github.com/team/repo/blob/abc/b.png"); + await expect + .element(screen.getByRole("link", { name: "docs" })) + .toHaveAttribute("href", "https://github.com/team/repo/blob/abc/docs.md"); + await expect + .element(screen.getByRole("link", { name: "ref" })) + .toHaveAttribute("href", "https://github.com/team/repo/blob/abc/ref.md"); + await expect.element(screen.getByText("![c](../blob/abc/c.png)", { exact: true })).toBeVisible(); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0b0116be549cb..d445e34271b7e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -251,6 +251,8 @@ interface ChatMarkdownProps { /** The change request's repository, when it can differ from the checkout at `cwd`. */ sourceControlMediaRepository?: string | undefined; headingLevelOffset?: number | undefined; + /** Run after raw HTML is parsed and sanitized; keep the list stable to share the parse cache. */ + extraRehypePlugins?: PluggableList; } export interface ChatMarkdownContextReference { @@ -261,6 +263,7 @@ export interface ChatMarkdownContextReference { const EMPTY_MARKDOWN_SKILLS: ReadonlyArray = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; +const EMPTY_REHYPE_PLUGINS: PluggableList = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, @@ -1746,6 +1749,7 @@ function ChatMarkdown({ lineBreaks = false, parseRawHtml = true, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, + extraRehypePlugins = EMPTY_REHYPE_PLUGINS, ...props }: ChatMarkdownProps) { const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } = @@ -1803,11 +1807,12 @@ function ChatMarkdown({ key={index} text={source} remarkPlugins={remarkPlugins} - rehypePlugins={ + rehypePlugins={withExtraPlugins( parseRawHtml ? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS - : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS - } + : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS, + extraRehypePlugins, + )} shared={!uncachedPlugins} // Every block before the last is finished, so its parse can be reused. cache={!props.isStreaming || index < blocks.length - 1} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 81dea810bb0d0..09f0b187292b5 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -112,7 +112,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel } from "./pullRequestLinkContextMenu"; import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; -import { pullRequestMarkdownPlugins } from "./pullRequestMarkdown.logic"; +import { pullRequestMarkdownPlugins, pullRequestRehypePlugins } from "./pullRequestMarkdown.logic"; import { PullRequestComposer } from "./PullRequestComposer"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; @@ -244,6 +244,7 @@ function pullRequestMarkdownContext( ) { return { remarkPlugins: pullRequestMarkdownPlugins(detail), + rehypePlugins: pullRequestRehypePlugins(detail), lineBreaks: sourceControlClients.find(detail?.provider ?? "")?.hardLineBreaks ?? false, threadRef, repository: detail?.repository ?? null, diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx index fae5270d27123..7dee77bcf2752 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx @@ -25,6 +25,7 @@ async function renderBody( const { repository = null, text = "first line\nsecond line", ...props } = options; const context = { remarkPlugins: [], + rehypePlugins: [], lineBreaks: sourceControlClients.get(host).hardLineBreaks ?? false, threadRef: null, repository, diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 449e901d0f0d3..a19c2895692ab 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -21,6 +21,8 @@ import { splitPullRequestBody, type PullRequestBodySegment } from "./pullRequest export const PullRequestMarkdownContext = createContext<{ /** Stable per repository, so comments share one renderer and its parse cache. */ remarkPlugins: PluggableList; + /** Stable per change request, whose page its host resolves relative destinations against. */ + rehypePlugins: PluggableList; lineBreaks: boolean; threadRef: ScopedThreadRef | null; /** The change request's `owner/name`, which can differ from the checkout the body renders in. */ @@ -121,7 +123,9 @@ function PullRequestBodySegmentView({ threadRef={threadRef} pullRequestPanelRef={threadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} - {...(context ? { extraRemarkPlugins: context.remarkPlugins } : {})} + {...(context + ? { extraRemarkPlugins: context.remarkPlugins, extraRehypePlugins: context.rehypePlugins } + : {})} lineBreaks={lineBreaks} sourceControlMedia sourceControlMediaRepository={repository} diff --git a/apps/web/src/components/pullRequest/guide/GuideProse.tsx b/apps/web/src/components/pullRequest/guide/GuideProse.tsx index adcdb7cd0a868..2e80b91333018 100644 --- a/apps/web/src/components/pullRequest/guide/GuideProse.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideProse.tsx @@ -3,7 +3,7 @@ import { createContext, useContext } from "react"; import { cn } from "~/lib/utils"; -import { PullRequestMarkdown } from "../PullRequestMarkdown"; +import { PullRequestMarkdown, PullRequestMarkdownContext } from "../PullRequestMarkdown"; export const GuideProseContext = createContext<{ readonly environmentId: EnvironmentId; @@ -20,14 +20,20 @@ export function GuideProse({ className?: string; }) { const context = useContext(GuideProseContext); + const markdownContext = useContext(PullRequestMarkdownContext); if (plain || context === null) return text; return ( - + // The agent writes the guide about the checkout, so its relative links name workspace files. + + + ); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index adf90dbec4e58..1cf07e12ba71f 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,4 +1,5 @@ import { + changeRequestLinkResolver, changeRequestReferenceAutolinker, resolveChangeRequestMediaUrl, } from "@t3tools/client-runtime/pull-request-markdown"; @@ -95,3 +96,44 @@ export function pullRequestMarkdownPlugins( } return plugins; } + +type HastNode = { + type: string; + properties?: Record; + children?: HastNode[]; +}; + +// `dataDarkSrc` and `dataLightSrc` carry a ``'s theme sources. +const URL_PROPERTIES = ["href", "src", "poster", "dataDarkSrc", "dataLightSrc"] as const; + +// After raw HTML is parsed, so `` and `` resolve like markdown's own; code is text. +function rehypeChangeRequestUrls(options: { readonly resolve: (url: string) => string }) { + const visit = (node: HastNode): void => { + const properties = node.properties; + if (node.type === "element" && properties) { + for (const key of URL_PROPERTIES) { + const value = properties[key]; + if (typeof value === "string") properties[key] = options.resolve(value); + } + } + node.children?.forEach(visit); + }; + return (tree: HastNode) => visit(tree); +} + +const changeRequestRehypePlugins = new Map(); + +/** One list per change request, since its host can resolve destinations against its page. */ +export function pullRequestRehypePlugins( + changeRequest: { readonly provider: string; readonly url: string } | null, +): PluggableList { + const resolve = changeRequestLinkResolver(changeRequest); + if (changeRequest === null || resolve === null) return NO_PLUGINS; + const key = `${changeRequest.provider}\u0000${changeRequest.url}`; + let plugins = changeRequestRehypePlugins.get(key); + if (plugins === undefined) { + plugins = [[rehypeChangeRequestUrls, { resolve }]]; + changeRequestRehypePlugins.set(key, plugins); + } + return plugins; +} diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 2ca59143ad296..b90c03c4ac241 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -271,6 +271,18 @@ function splitBodyAt(body: string, baseOffset: number): ReadonlyArray string) | null { + if (!changeRequest) return null; + const resolve = sourceControlClients.get(changeRequest.provider).resolveRelativeUrl; + return resolve === undefined ? null : (url) => resolve(url, changeRequest.url) ?? url; +} + /** * Finds the references the change request's host links in plain text, resolved against the change * request's repository. Null when the host links none. diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 8f71e1cc73468..60036ea9a13b1 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -290,6 +290,12 @@ export interface SourceControlDefinition { text: string, repositoryUrl: string, ) => ReadonlyArray; + /** + * The absolute URL for a link destination this host's bodies write relative to the change + * request's page at `changeRequestUrl`, or null for any other destination. Absent for a host + * whose bodies link absolutely. + */ + readonly resolveRelativeUrl?: (url: string, changeRequestUrl: string) => string | null; /** True when the host renders a single newline in change request text as a line break. */ readonly hardLineBreaks?: boolean; /** Where the host lists a change request's checks. Absent means the change request's own page. */ diff --git a/packages/source-control-github/src/shared/definition.test.ts b/packages/source-control-github/src/shared/definition.test.ts index 924ab31716853..1a587b4acf2c0 100644 --- a/packages/source-control-github/src/shared/definition.test.ts +++ b/packages/source-control-github/src/shared/definition.test.ts @@ -178,3 +178,29 @@ describe("GitHub media URLs", () => { } }); }); + +describe("GitHub relative URLs", () => { + const resolve = (url: string) => + definition.resolveRelativeUrl?.(url, "https://github.com/o/r/pull/5"); + + it("resolves a pull request body's relative destinations against the pull request page", () => { + expect(resolve("../blob/dd60e3cd/docs/shot.png?raw=true")).toBe( + "https://github.com/o/r/blob/dd60e3cd/docs/shot.png?raw=true", + ); + expect(resolve("/other/r/blob/801d91ff/docs/shot.webp?raw=true")).toBe( + "https://github.com/other/r/blob/801d91ff/docs/shot.webp?raw=true", + ); + for (const url of [ + "https://example.com/shot.png", + "#x", + "README.md", + "apps/web/src/x.ts", + "docs/shot.png", + "mailto:a@example.com", + "//evil.example/shot.png", + "\\\\evil.example\\shot.png", + ]) { + expect(resolve(url), url).toBeNull(); + } + }); +}); diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 3c33cfb3a3f64..69d148f943598 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -44,6 +44,22 @@ function isRepositoryPath(repository: string): boolean { ); } +// The forms GitHub writes against the PR page. Bare paths stay workspace-relative: they 404 there. +const PAGE_RELATIVE = /^(?:\.\.\/|\/(?![/\\]))/u; + +/** As github.com's browser reads a body's destinations: relative to the pull request's page. */ +function resolveRelativeUrl(url: string, changeRequestUrl: string): string | null { + if (!PAGE_RELATIVE.test(url)) return null; + try { + const page = new URL(changeRequestUrl); + const resolved = new URL(url, page); + // `//host/x`, and the `\\host/x` a browser reads the same way, leave the page's origin. + return resolved.origin === page.origin ? resolved.href : null; + } catch { + return null; + } +} + /** Signing in through the T3 Code for GitHub app lifts the per-user API rate limit to the app's. */ export const APP_SIGN_IN: SourceControlAppSignIn = { appName: "T3 Code for GitHub", @@ -127,6 +143,7 @@ export const definition = defineSourceControl({ ? null : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), referenceAutolinks: gitHubReferenceAutolinks, + resolveRelativeUrl, changeRequestChecksUrl: (changeRequestUrl) => `${changeRequestUrl.replace(/\/+$/u, "")}/checks`, // Before servers reported their actions, GitHub was the only host they acted on from a list. changeRequestActionsWhenUnreported: new Set(["close", "merge", "ready", "reopen"] as const),