Skip to content
Closed
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
127 changes: 124 additions & 3 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type {
EnvironmentId,
PullRequestAction,
Expand All @@ -8,6 +11,7 @@ import type {
PullRequestRef,
PullRequestState,
ScopedThreadRef,
ThreadLinkedPullRequest,
} from "@t3tools/contracts";
import {
ArrowDownUpIcon,
Expand All @@ -26,6 +30,7 @@ import {
GitPullRequestIcon,
HammerIcon,
LayersIcon,
Link2Icon,
MessageCircleQuestionIcon,
MessageSquareIcon,
LinkIcon,
Expand All @@ -35,6 +40,7 @@ import {
RefreshCwIcon,
ServerIcon,
TriangleAlertIcon,
UnlinkIcon,
} from "lucide-react";
import {
lazy,
Expand All @@ -51,12 +57,17 @@ import {
import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
import { useThreadPullRequestLinkActions } from "~/hooks/useThreadPullRequestLink";
import {
changeRequestRepositoryUrl,
gitHubPullRequestBrowserUrl,
matchesLinkedPullRequestUrl,
} from "~/lib/openPullRequestLink";
import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import type { ReviewCommentContext } from "~/reviewCommentContext";
import { useProjects } from "~/state/entities";
import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities";
import { useEnvironments } from "~/state/environments";
import { useEnvironmentQuery } from "~/state/query";
import { useLiveRefresh } from "~/hooks/useLiveRefresh";
Expand Down Expand Up @@ -109,6 +120,7 @@ import {
handoffReviewComments,
latestPullRequestReviewOutcomes,
isStackedPullRequestBase,
isThreadLinkedToPullRequest,
pullRequestActionMenuHasGroup,
pullRequestActionNeedsHostRefresh,
pullRequestComposerTarget,
Expand Down Expand Up @@ -715,6 +727,85 @@ export function PullRequestDetailPanel({
const attachTarget = pullRequestComposerTarget(context, composerDraftTarget);
const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);

// Linking from the pull-request side needs a real thread to pin, and that is
// the open thread — not the handoff attach target. Gating on thread context
// would strand the reader: once they unlink, the thread stops matching this
// pull request by branch, the panel falls back to page context, and the way
// back would vanish with it.
const attachThreadRef =
composerDraftTarget !== undefined &&
composerDraftTarget !== null &&
typeof composerDraftTarget !== "string"
? composerDraftTarget
: null;
const attachThread = useThreadShell(attachThreadRef);
const { linkPullRequest, unlinkPullRequest } = useThreadPullRequestLinkActions();
const threadLinkedPullRequest = attachThread?.linkedPullRequest ?? null;
const isLinkedToThisPullRequest = isThreadLinkedToPullRequest({
linkedPullRequest: threadLinkedPullRequest,
detail,
matchesUrl: (linked, targetUrl) =>
matchesLinkedPullRequestUrl(linked as ThreadLinkedPullRequest, targetUrl),
});
// Capabilities land after connect, so this read has to be reactive: an older
// server drops linkedPullRequest from thread.meta.update and still resolves,
// which would leave a success toast for a link that was never persisted.
const serverConfigs = useServerConfigs();
const supportsPullRequestLink =
attachThreadRef !== null &&
serverConfigs.get(attachThreadRef.environmentId)?.environment.capabilities
.threadPullRequestLinking === true;

// thread.meta.update is last-write-wins, so two of these in flight at once
// can land in the reverse order and leave the thread on the choice the
// reader made first. One at a time, and the item says so while it runs.
// Which operation is running, not just that one is: the thread shell picks
// up the new link before the command settles, so a label read off the live
// state would flip to the opposite verb mid-flight.
const [pullRequestLinkPending, setPullRequestLinkPending] = useState<"link" | "unlink" | null>(
null,
);

const linkPullRequestToThread = () => {
if (detail === null || attachThreadRef === null || pullRequestLinkPending !== null) return;
const link: ThreadLinkedPullRequest = {
projectId: detail.projectId,
repository: reference.repository,
number: detail.number,
url: detail.url,
};
setPullRequestLinkPending("link");
void linkPullRequest(attachThreadRef, link).then((result) => {
setPullRequestLinkPending(null);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
toastManager.add({ type: "error", title: "Could not link the pull request" });
}
return;
}
toastManager.add({
type: "success",
title: "Linked to this thread",
description: `#${detail.number} now drives this thread's badge and settle-on-merge.`,
});
});
};

const unlinkPullRequestFromThread = () => {
if (attachThreadRef === null || pullRequestLinkPending !== null) return;
setPullRequestLinkPending("unlink");
void unlinkPullRequest(attachThreadRef).then((result) => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
setPullRequestLinkPending(null);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
toastManager.add({ type: "error", title: "Could not unlink the pull request" });
}
return;
}
toastManager.add({ type: "success", title: "Unlinked from this thread" });
});
};

const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {
const store = useComposerDraftStore.getState();
const draft = store.getComposerDraft(target);
Expand Down Expand Up @@ -1356,6 +1447,36 @@ export function PullRequestDetailPanel({
<HammerIcon className="size-3.5" />
{handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
</MenuItem>
{/* Pinning from the pull-request side: where the panel sits beside a thread,
that thread is one press away from following this pull request's state.
Unlink takes the item's place once this is the linked one. */}
{attachThreadRef !== null && detail !== null && supportsPullRequestLink ? (
// The running operation outranks the live state while it
// is in flight, so the item keeps the verb the reader
// pressed until the command settles.
(pullRequestLinkPending ?? (isLinkedToThisPullRequest ? "unlink" : "link")) ===
"unlink" ? (
<MenuItem
disabled={pullRequestLinkPending !== null}
onClick={unlinkPullRequestFromThread}
>
<UnlinkIcon className="size-3.5" />
{pullRequestLinkPending === "unlink"
? "Unlinking..."
: "Unlink PR from this thread"}
</MenuItem>
) : (
<MenuItem
disabled={pullRequestLinkPending !== null}
onClick={linkPullRequestToThread}
>
<Link2Icon className="size-3.5" />
{pullRequestLinkPending === "link"
? "Linking..."
: "Link PR to this thread"}
</MenuItem>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : null}
Comment thread
cursor[bot] marked this conversation as resolved.
{pickableEnvironments.length > 0 ? (
<ActOnEnvironmentPicker
environments={pickableEnvironments}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
handoffReviewComments,
isPullRequestVerdictStale,
isStackedPullRequestBase,
isThreadLinkedToPullRequest,
isThreadOwnPullRequest,
latestPullRequestReviewOutcomes,
newestPullRequestCommitAt,
Expand Down Expand Up @@ -1247,3 +1248,56 @@ describe("which actions need the host read again after they run", () => {
}
});
});

describe("isThreadLinkedToPullRequest", () => {
// The real matcher: host + repository + number, path-suffix tolerant.
const matchesUrl = (linked: { readonly url: string }, targetUrl: string) => {
const parse = (url: string) => /^(https:\/\/[^/]+\/[^/]+\/[^/]+)\/pull\/(\d+)/u.exec(url);
const left = parse(linked.url);
const right = parse(targetUrl);
return left !== null && right !== null && left[1] === right[1] && left[2] === right[2];
};
const detail = { number: 42, url: "https://github.com/acme/repo/pull/42" };

it("treats a link stored from a subpage as the same pull request", () => {
expect(
isThreadLinkedToPullRequest({
linkedPullRequest: { number: 42, url: "https://github.com/acme/repo/pull/42/files" },
detail,
matchesUrl,
}),
).toBe(true);
});

it("does not claim a different pull request in the same repository", () => {
expect(
isThreadLinkedToPullRequest({
linkedPullRequest: { number: 43, url: "https://github.com/acme/repo/pull/43" },
detail,
matchesUrl,
}),
).toBe(false);
});

it("still recognises its own link on a host the matcher cannot parse", () => {
// This menu stores the detail URL verbatim, so exact equality is the way
// back for a self-hosted forge the change-request parser does not know.
const selfHosted = { number: 7, url: "https://git.internal/team/repo/changes/7" };
expect(
isThreadLinkedToPullRequest({
linkedPullRequest: selfHosted,
detail: selfHosted,
matchesUrl,
}),
).toBe(true);
});

it("is false while either side is missing", () => {
expect(isThreadLinkedToPullRequest({ linkedPullRequest: null, detail, matchesUrl })).toBe(
false,
);
expect(
isThreadLinkedToPullRequest({ linkedPullRequest: detail, detail: null, matchesUrl }),
).toBe(false);
});
});
23 changes: 23 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,3 +937,26 @@ const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = {
export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean {
return ACTION_NEEDS_HOST_REFRESH[action];
}

/**
* Whether the thread beside this panel is already pinned to the pull request on
* screen — the decision behind offering Unlink rather than a second Link.
*
* The stored link keeps whatever URL it was created from, which for an
* agent-written href is often a subpage, so the shared change-request match is
* the primary test. The exact-URL fallback covers hosts that match cannot
* parse: this menu stores the detail URL verbatim, so without it an
* unrecognised host would link once and never offer the way back.
*/
export function isThreadLinkedToPullRequest(input: {
readonly linkedPullRequest: { readonly number: number; readonly url: string } | null;
readonly detail: { readonly number: number; readonly url: string } | null;
readonly matchesUrl: (linked: { readonly url: string }, targetUrl: string) => boolean;
}): boolean {
const { linkedPullRequest, detail } = input;
if (linkedPullRequest === null || detail === null) return false;
return (
input.matchesUrl(linkedPullRequest, detail.url) ||
(linkedPullRequest.number === detail.number && linkedPullRequest.url === detail.url)
);
}
33 changes: 33 additions & 0 deletions apps/web/src/hooks/useThreadPullRequestLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ScopedThreadRef, ThreadLinkedPullRequest } from "@t3tools/contracts";
import { useCallback } from "react";

import { threadEnvironment } from "../state/threads";
import { useAtomCommand } from "../state/use-atom-command";

/**
* Link and unlink, both through `thread.meta.update`. These are the only
* callers allowed to send `linkedPullRequest`: the command is multi-field, so
* any other caller spreading thread state would silently unlink.
*/
export function useThreadPullRequestLinkActions() {
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
const linkPullRequest = useCallback(
(threadRef: ScopedThreadRef, link: ThreadLinkedPullRequest) =>
updateThreadMetadata({
environmentId: threadRef.environmentId,
input: { threadId: threadRef.threadId, linkedPullRequest: link },
}),
[updateThreadMetadata],
);
const unlinkPullRequest = useCallback(
(threadRef: ScopedThreadRef) =>
updateThreadMetadata({
environmentId: threadRef.environmentId,
input: { threadId: threadRef.threadId, linkedPullRequest: null },
}),
[updateThreadMetadata],
);
return { linkPullRequest, unlinkPullRequest };
}
Loading