Files
paseo/packages/app/src/composer/github/auto-attach.ts
T
Mohamed BoudraandGitHub 2cb1b041dd Start new workspaces from pasted pull requests (#2290)
* fix(workspaces): start pasted pull requests from their branch

Recognized pull request links now select their branch directly instead of
waiting for a separate confirmation. Ensure single-branch clones also record
the fetched branch so the new worktree can track it.

* fix(workspaces): keep pasted PR checkout target-safe

* fix(workspaces): make pasted PR resolution deterministic

* fix(workspaces): preserve explicit checkout selection

A recognized pull request selects itself only when its attachment is added. Once the user chooses a branch, later composer edits must not derive checkout state from existing attachments.

* fix(composer): ignore stale pull request lookups

A lookup may finish after the workspace target changes. Only apply its attachment and selection event when the host, working directory, client, and remote still match the target that started it.

* fix(workspaces): make checkout choices authoritative

Arm automatic pull request selection only when a new PR link is detected. An explicit picker choice cancels that pending selection, existing draft attachments never infer checkout state, and target changes suppress carried-over lookups.

* fix(workspaces): stabilize pull request auto-selection

Keep the first PR selected when one edit contains several links, and accept a completed lookup when only the transport client changed for the same host and checkout.

* fix(composer): release removed pull request lookups

Release each auto-attach invocation when its composer state is abandoned, while continuing to discard any stale response.
2026-07-21 15:20:05 +02:00

367 lines
9.9 KiB
TypeScript

import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: ForgeSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void;
onPullRequestAdded?: (item: ForgeSearchItem) => void;
}
interface ComposerGithubAutoAttachResult {
isResolving: boolean;
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
}
export function useComposerGithubAutoAttach(
params: ComposerGithubAutoAttachInput,
): ComposerGithubAutoAttachResult {
const queryClient = useQueryClient();
const latestRef = useRef(params);
const removedRefKeysRef = useRef(new Set<string>());
const pendingRefKeysRef = useRef(new Set<string>());
const presentPullRequestKeysRef = useRef(new Set<string>());
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => new Map(),
);
latestRef.current = params;
useEffect(() => {
suppressRefsCarriedAcrossTargets({
params: latestRef.current,
previousTargetRef,
removedRefKeys: removedRefKeysRef.current,
});
notifyNewPullRequestRefs({
params: latestRef.current,
presentPullRequestKeysRef,
});
const refs = refsReadyForLookup({
params: latestRef.current,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
if (refs.length === 0) {
return;
}
const refKeys = refs.map(githubRefKey);
setResolvingRefCounts((current) => addKeys(current, refKeys));
let resolvingReleased = false;
const releaseResolving = () => {
if (resolvingReleased) return;
resolvingReleased = true;
clearResolvingKeys(setResolvingRefCounts, refKeys);
};
const timerId = setTimeout(() => {
void attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
}).finally(releaseResolving);
}, AUTO_ATTACH_DEBOUNCE_MS);
return () => {
clearTimeout(timerId);
releaseResolving();
};
}, [
params.text,
params.remoteUrl,
params.attachments,
params.client,
params.isConnected,
params.serverId,
params.cwd,
params.supportsForgeSearch,
queryClient,
]);
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
const key = attachmentKey(attachment);
if (key) {
removedRefKeysRef.current.add(key);
}
}, []);
return useMemo(
() => ({
isResolving: resolvingRefCounts.size > 0,
markGithubAttachmentRemoved,
}),
[markGithubAttachmentRemoved, resolvingRefCounts.size],
);
}
function suppressRefsCarriedAcrossTargets({
params,
previousTargetRef,
removedRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
removedRefKeys: Set<string>;
}): void {
const previous = previousTargetRef.current;
const targetChanged =
previous.cwd.trim().length > 0 &&
params.cwd.trim().length > 0 &&
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
if (!targetChanged) return;
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
removedRefKeys.add(githubRefKey(ref));
}
}
function notifyNewPullRequestRefs({
params,
presentPullRequestKeysRef,
}: {
params: ComposerGithubAutoAttachInput;
presentPullRequestKeysRef: RefObject<Set<string>>;
}): void {
const currentKeys = new Set(
extractGithubRefs(params.text, params.remoteUrl)
.filter((ref) => ref.kind === "pull")
.map(githubRefKey),
);
for (const key of currentKeys) {
if (!presentPullRequestKeysRef.current.has(key)) {
params.onPullRequestDetected?.();
}
}
presentPullRequestKeysRef.current = currentKeys;
}
function addKeys(
current: ReadonlyMap<string, number>,
keys: readonly string[],
): ReadonlyMap<string, number> {
const nextCounts = new Map(current);
for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1);
return nextCounts;
}
function removeKeys(
current: ReadonlyMap<string, number>,
keys: readonly string[],
): ReadonlyMap<string, number> {
const next = new Map(current);
for (const key of keys) {
const count = next.get(key) ?? 0;
if (count <= 1) next.delete(key);
else next.set(key, count - 1);
}
return next;
}
function clearResolvingKeys(
setResolvingRefCounts: Dispatch<SetStateAction<ReadonlyMap<string, number>>>,
keys: readonly string[],
): void {
setResolvingRefCounts((current) => removeKeys(current, keys));
}
async function attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys,
pendingRefKeys,
}: {
refs: GithubRef[];
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): Promise<void> {
for (const ref of refs) {
const key = githubRefKey(ref);
if (pendingRefKeys.has(key)) {
continue;
}
pendingRefKeys.add(key);
try {
await attachRef({ ref, key, queryClient, latestRef, removedRefKeys });
} finally {
pendingRefKeys.delete(key);
}
}
}
async function attachRef({
ref,
key,
queryClient,
latestRef,
removedRefKeys,
}: {
ref: GithubRef;
key: string;
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
}): Promise<void> {
const snapshot = latestRef.current;
if (!snapshot.client || !snapshot.isConnected || !isRefStillPresent(ref, snapshot)) {
return;
}
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
if (!search) {
return;
}
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
const current = latestRef.current;
if (
!item ||
removedRefKeys.has(key) ||
!isSameLookupTarget(snapshot, current) ||
!isRefStillPresent(ref, current)
) {
return;
}
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
return;
}
current.setAttachments((attachments) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) {
return attachments;
}
return toggleGithubAttachment(attachments, item);
});
if (item.kind === "change_request") {
current.onPullRequestAdded?.(item);
}
}
function refsReadyForLookup({
params,
removedRefKeys,
pendingRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): GithubRef[] {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
return [];
}
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
const key = githubRefKey(ref);
return (
!removedRefKeys.has(key) &&
!pendingRefKeys.has(key) &&
!hasGithubAttachment(params.attachments, ref)
);
});
}
async function fetchGithubRefSearch({
ref,
snapshot,
queryClient,
}: {
ref: GithubRef;
snapshot: ComposerGithubAutoAttachInput;
queryClient: QueryClient;
}) {
if (!snapshot.client) {
return null;
}
try {
return await queryClient.fetchQuery(
buildForgeSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true,
}),
);
} catch {
return null;
}
}
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
return extractGithubRefs(params.text, params.remoteUrl).some(
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
);
}
function isSameLookupTarget(
initial: ComposerGithubAutoAttachInput,
current: ComposerGithubAutoAttachInput,
): boolean {
return (
initial.serverId === current.serverId &&
initial.cwd === current.cwd &&
initial.remoteUrl === current.remoteUrl
);
}
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] {
return ref.kind === "pull" ? "change_request" : "issue";
}
function githubRefKey(ref: GithubRef): string {
return `${githubItemKind(ref)}:${ref.number}`;
}
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "forge_change_request" &&
attachment.kind !== "forge_issue" &&
attachment.kind !== "github_pr" &&
attachment.kind !== "github_issue")
) {
return null;
}
return `${attachment.item.kind}:${attachment.item.number}`;
}