| author | |
| committer | |
| log | a4c9494b0e3606775cc5fc929fc138399288bd43 |
| tree | 609c13c28aad07e1a0bb9972367025e632273ddd |
| parent | 5e2225671f705fcd33f1ea5591b79ba612fb6974 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 75 | 75 | ||
| 76 | /** | 76 | /** |
| 77 | * Which pull request an input names, or why it cannot. A URL carries its own host and | 77 | * 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 | 78 | * 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 | */ |
| 81 | export function resolveLinkPullRequestInput(input: { | 80 | export 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 host | 206 | 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { canLinkChangeRequest } from "./usePullRequestLinking"; | ||
| 4 | import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; | ||
| 5 | |||
| 6 | describe("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"; |
| 9 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | ||
| 9 | import { | 10 | import { |
| 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"; |
| 21 | import { | 22 | import { |
| 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"; |
| 27 | import { useProjects, useServerConfigs } from "~/state/entities"; | 28 | import { useProjects, useServerConfigs } from "~/state/entities"; |
| 28 | import { threadEnvironment } from "~/state/threads"; | 29 | import { threadEnvironment } from "~/state/threads"; |
| 29 | import { useAtomCommand } from "~/state/use-atom-command"; | 30 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 30 | 31 | ||
| 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 | */ | ||
| 40 | export 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. */ |
| 32 | export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) { | 51 | export 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: { |