authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-08 16:01:54+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:01:54+05:30
loga4c9494b0e3606775cc5fc929fc138399288bd43
tree609c13c28aad07e1a0bb9972367025e632273ddd
parent5e2225671f705fcd33f1ea5591b79ba612fb6974
signature Signed by PGP key B5690EEEBB952194

fix(web): link pull requests to threads in folders that aren't Git repos (#15946)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 51 insertions(+), 12 deletions(-)

apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx+3-4
......@@ -75,8 +75,7 @@ interface ResolvedLink {
7575
7676/**
7777 * Which pull request an input names, or why it cannot. A URL carries its own host and
78 * repository and may point at any repository on a host this environment has a project for; a
79 * bare `#123` can only mean the thread's own repository.
78 * repository; a bare `#123` can only mean the thread's own repository.
8079 */
8180export function resolveLinkPullRequestInput(input: {
8281 readonly reference: string;
......@@ -204,8 +203,8 @@ function LinkPullRequestDialog({
204203 <DialogHeader>
205204 <DialogTitle>Link pull request</DialogTitle>
206205 <DialogDescription>
207 Attach a pull request to this thread. A full URL can point at any repository on a host
208 this environment has a project for.
206 Attach a pull request to this thread by its URL, or by its number for this thread's
207 repository.
209208 </DialogDescription>
210209 </DialogHeader>
211210 <DialogPanel>
apps/web/src/hooks/usePullRequestLinking.test.ts created+27
......@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { canLinkChangeRequest } from "./usePullRequestLinking";
4import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
5
6describe("canLinkChangeRequest (#9435 / #9440)", () => {
7 // A non-Git parent workspace (a folder of several child repositories that is not itself a
8 // checkout) registers as a project with no `repositoryIdentity` at all.
9 const nonGitProject = { id: "scratch-project" } as never;
10 const link = parseChangeRequestUrl("https://github.com/pingdotgg/t3code/pull/15111")!;
11
12 it("links a pasted pull request URL when the server tracks links on the thread, even with no matching project", () => {
13 // `multiple` mode (the `threadPullRequests` capability) stores a link's host, repository
14 // and number on the thread directly, so no project needs to match for the link itself.
15 expect(canLinkChangeRequest("multiple", [nonGitProject], link)).toBe(true);
16 expect(canLinkChangeRequest("multiple", [], link)).toBe(true);
17 });
18
19 it("still requires a matching project in single-link mode, where the link needs a projectId", () => {
20 expect(canLinkChangeRequest("single", [nonGitProject], link)).toBe(false);
21 expect(canLinkChangeRequest("single", [], link)).toBe(false);
22 });
23
24 it("never links when the environment does not support pull request linking", () => {
25 expect(canLinkChangeRequest("unsupported", [nonGitProject], link)).toBe(false);
26 });
27});
apps/web/src/hooks/usePullRequestLinking.ts+21-8
......@@ -6,6 +6,7 @@ import type {
66 ThreadLinkedPullRequest,
77 ThreadPullRequestLink,
88} from "@t3tools/contracts";
9import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
910import {
1011 isAtomCommandInterrupted,
1112 squashAtomCommandFailure,
......@@ -20,14 +21,32 @@ import {
2021} from "@t3tools/shared/threadPullRequests";
2122import {
2223 findProjectForChangeRequest,
23 findProjectOnChangeRequestHost,
2424 matchesLinkedPullRequestUrl,
2525 parseChangeRequestUrl,
26 type ChangeRequestLink,
2627} from "~/lib/openPullRequestLink";
2728import { useProjects, useServerConfigs } from "~/state/entities";
2829import { threadEnvironment } from "~/state/threads";
2930import { useAtomCommand } from "~/state/use-atom-command";
3031
32/**
33 * Whether a parsed change-request link can be linked to a thread. `multiple` mode (the
34 * `threadPullRequests` capability) stores a link's host, repository and number on the thread
35 * directly and needs no project at all; a freshly linked pull request already starts with a
36 * null snapshot until some project can sync it, so a host nothing here is checked out from is
37 * the same case, not a reason to refuse the link. `single` mode still needs a matching project,
38 * since that is where its required `projectId` comes from.
39 */
40export function canLinkChangeRequest(
41 mode: ReturnType<typeof threadPullRequestLinkMode>,
42 projects: ReadonlyArray<EnvironmentProject>,
43 parsed: ChangeRequestLink,
44): boolean {
45 if (mode === "unsupported") return false;
46 if (mode === "multiple") return true;
47 return findProjectForChangeRequest(projects, parsed) !== undefined;
48}
49
3150/** Routes link actions through the command advertised by this environment. */
3251export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
3352 const configs = useServerConfigs();
......@@ -44,13 +63,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde
4463 );
4564 const canLink = (url: string) => {
4665 const parsed = parseChangeRequestUrl(url);
47 if (parsed === null || mode === "unsupported") return false;
48 return (
49 (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)(
50 environmentProjects,
51 parsed,
52 ) !== undefined
53 );
66 return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed);
5467 };
5568 const isLinked = (
5669 thread: {