diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index b7826d4f9a52..577660d4276a 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -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, @@ -8,6 +11,7 @@ import type { PullRequestRef, PullRequestState, ScopedThreadRef, + ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -26,6 +30,7 @@ import { GitPullRequestIcon, HammerIcon, LayersIcon, + Link2Icon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -35,6 +40,7 @@ import { RefreshCwIcon, ServerIcon, TriangleAlertIcon, + UnlinkIcon, } from "lucide-react"; import { lazy, @@ -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"; @@ -109,6 +120,7 @@ import { handoffReviewComments, latestPullRequestReviewOutcomes, isStackedPullRequestBase, + isThreadLinkedToPullRequest, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, pullRequestComposerTarget, @@ -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) => { + 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); @@ -1356,6 +1447,36 @@ export function PullRequestDetailPanel({ {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} + {/* 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" ? ( + + + {pullRequestLinkPending === "unlink" + ? "Unlinking..." + : "Unlink PR from this thread"} + + ) : ( + + + {pullRequestLinkPending === "link" + ? "Linking..." + : "Link PR to this thread"} + + ) + ) : null} {pickableEnvironments.length > 0 ? ( { } }); }); + +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); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index a616a8395239..9865f6577b42 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -937,3 +937,26 @@ const ACTION_NEEDS_HOST_REFRESH: Record = { 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) + ); +} diff --git a/apps/web/src/hooks/useThreadPullRequestLink.ts b/apps/web/src/hooks/useThreadPullRequestLink.ts new file mode 100644 index 000000000000..9ed02b0c6e78 --- /dev/null +++ b/apps/web/src/hooks/useThreadPullRequestLink.ts @@ -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 }; +}