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 {...@@ -75,8 +75,7 @@ interface ResolvedLink {
7575
76/**76/**
77 * Which pull request an input names, or why it cannot. A URL carries its own host and77 * 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; a78 * repository; a bare `#123` can only mean the thread's own repository.
79 * bare `#123` can only mean the thread's own repository.
80 */79 */
81export function resolveLinkPullRequestInput(input: {80export function resolveLinkPullRequestInput(input: {
82 readonly reference: string;81 readonly reference: string;
...@@ -204,8 +203,8 @@ function LinkPullRequestDialog({...@@ -204,8 +203,8 @@ function LinkPullRequestDialog({
204 <DialogHeader>203 <DialogHeader>
205 <DialogTitle>Link pull request</DialogTitle>204 <DialogTitle>Link pull request</DialogTitle>
206 <DialogDescription>205 <DialogDescription>
207 Attach a pull request to this thread. A full URL can point at any repository on a host206 Attach a pull request to this thread by its URL, or by its number for this thread's
208 this environment has a project for.207 repository.
209 </DialogDescription>208 </DialogDescription>
210 </DialogHeader>209 </DialogHeader>
211 <DialogPanel>210 <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 {...@@ -6,6 +6,7 @@ import type {
6 ThreadLinkedPullRequest,6 ThreadLinkedPullRequest,
7 ThreadPullRequestLink,7 ThreadPullRequestLink,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
9import {10import {
10 isAtomCommandInterrupted,11 isAtomCommandInterrupted,
11 squashAtomCommandFailure,12 squashAtomCommandFailure,
...@@ -20,14 +21,32 @@ import {...@@ -20,14 +21,32 @@ import {
20} from "@t3tools/shared/threadPullRequests";21} from "@t3tools/shared/threadPullRequests";
21import {22import {
22 findProjectForChangeRequest,23 findProjectForChangeRequest,
23 findProjectOnChangeRequestHost,
24 matchesLinkedPullRequestUrl,24 matchesLinkedPullRequestUrl,
25 parseChangeRequestUrl,25 parseChangeRequestUrl,
26 type ChangeRequestLink,
26} from "~/lib/openPullRequestLink";27} from "~/lib/openPullRequestLink";
27import { useProjects, useServerConfigs } from "~/state/entities";28import { useProjects, useServerConfigs } from "~/state/entities";
28import { threadEnvironment } from "~/state/threads";29import { threadEnvironment } from "~/state/threads";
29import { useAtomCommand } from "~/state/use-atom-command";30import { 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
31/** Routes link actions through the command advertised by this environment. */50/** Routes link actions through the command advertised by this environment. */
32export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {51export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
33 const configs = useServerConfigs();52 const configs = useServerConfigs();
...@@ -44,13 +63,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde...@@ -44,13 +63,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde
44 );63 );
45 const canLink = (url: string) => {64 const canLink = (url: string) => {
46 const parsed = parseChangeRequestUrl(url);65 const parsed = parseChangeRequestUrl(url);
47 if (parsed === null || mode === "unsupported") return false;66 return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed);
48 return (
49 (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)(
50 environmentProjects,
51 parsed,
52 ) !== undefined
53 );
54 };67 };
55 const isLinked = (68 const isLinked = (
56 thread: {69 thread: {