Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ interface ResolvedLink {

/**
* Which pull request an input names, or why it cannot. A URL carries its own host and
* repository and may point at any repository on a host this environment has a project for; a
* bare `#123` can only mean the thread's own repository.
* repository; a bare `#123` can only mean the thread's own repository.
*/
export function resolveLinkPullRequestInput(input: {
readonly reference: string;
Expand Down Expand Up @@ -204,8 +203,8 @@ function LinkPullRequestDialog({
<DialogHeader>
<DialogTitle>Link pull request</DialogTitle>
<DialogDescription>
Attach a pull request to this thread. A full URL can point at any repository on a host
this environment has a project for.
Attach a pull request to this thread by its URL, or by its number for this thread's
repository.
</DialogDescription>
</DialogHeader>
<DialogPanel>
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/hooks/usePullRequestLinking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vite-plus/test";

import { canLinkChangeRequest } from "./usePullRequestLinking";
import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";

describe("canLinkChangeRequest (#9435 / #9440)", () => {
// A non-Git parent workspace (a folder of several child repositories that is not itself a
// checkout) registers as a project with no `repositoryIdentity` at all.
const nonGitProject = { id: "scratch-project" } as never;
const link = parseChangeRequestUrl("https://github.com/pingdotgg/t3code/pull/15111")!;

it("links a pasted pull request URL when the server tracks links on the thread, even with no matching project", () => {
// `multiple` mode (the `threadPullRequests` capability) stores a link's host, repository
// and number on the thread directly, so no project needs to match for the link itself.
expect(canLinkChangeRequest("multiple", [nonGitProject], link)).toBe(true);
expect(canLinkChangeRequest("multiple", [], link)).toBe(true);
});

it("still requires a matching project in single-link mode, where the link needs a projectId", () => {
expect(canLinkChangeRequest("single", [nonGitProject], link)).toBe(false);
expect(canLinkChangeRequest("single", [], link)).toBe(false);
});

it("never links when the environment does not support pull request linking", () => {
expect(canLinkChangeRequest("unsupported", [nonGitProject], link)).toBe(false);
});
});
29 changes: 21 additions & 8 deletions apps/web/src/hooks/usePullRequestLinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ThreadLinkedPullRequest,
ThreadPullRequestLink,
} from "@t3tools/contracts";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
Expand All @@ -20,14 +21,32 @@ import {
} from "@t3tools/shared/threadPullRequests";
import {
findProjectForChangeRequest,
findProjectOnChangeRequestHost,
matchesLinkedPullRequestUrl,
parseChangeRequestUrl,
type ChangeRequestLink,
} from "~/lib/openPullRequestLink";
import { useProjects, useServerConfigs } from "~/state/entities";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";

/**
* Whether a parsed change-request link can be linked to a thread. `multiple` mode (the
* `threadPullRequests` capability) stores a link's host, repository and number on the thread
* directly and needs no project at all; a freshly linked pull request already starts with a
* null snapshot until some project can sync it, so a host nothing here is checked out from is
* the same case, not a reason to refuse the link. `single` mode still needs a matching project,
* since that is where its required `projectId` comes from.
*/
export function canLinkChangeRequest(
mode: ReturnType<typeof threadPullRequestLinkMode>,
projects: ReadonlyArray<EnvironmentProject>,
parsed: ChangeRequestLink,
): boolean {
if (mode === "unsupported") return false;
if (mode === "multiple") return true;
return findProjectForChangeRequest(projects, parsed) !== undefined;
}

/** Routes link actions through the command advertised by this environment. */
export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
const configs = useServerConfigs();
Expand All @@ -44,13 +63,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde
);
const canLink = (url: string) => {
const parsed = parseChangeRequestUrl(url);
if (parsed === null || mode === "unsupported") return false;
return (
(mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)(
environmentProjects,
parsed,
) !== undefined
);
return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed);
};
const isLinked = (
thread: {
Expand Down
Loading