From 2e8bced7072cbfb3ac6349cb13f36e020614406c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 14 May 2026 17:52:46 +0800 Subject: [PATCH] Auto-attach pasted GitHub PR/issue URLs in the composer (#1006) * Auto-attach pasted GitHub PR/issue URLs in the composer Paste a GitHub PR or issue link into any composer and it shows up as an attachment pill within a debounce tick when the URL matches the workspace's remote. On the new-workspace screen, an attached PR also surfaces an inline "Check out PR #N?" hint next to the picker; one click promotes it to the checkout target through the same syncPickerPrAttachment flow the dropdown uses. Unifies the new-workspace picker and composer attachment picker on one shared GitHub search hook and cache key, so both consumers (and the auto-attach lookup) share the React Query cache. * Fix oxfmt formatting in new-workspace-screen import --- .../src/components/composer-actions.test.ts | 33 ++- .../app/src/components/composer-actions.ts | 23 ++ packages/app/src/components/composer.tsx | 91 ++++--- .../use-composer-github-auto-attach.test.tsx | 213 ++++++++++++++++ .../use-composer-github-auto-attach.ts | 240 ++++++++++++++++++ .../src/git/use-github-search-query.test.ts | 42 +++ .../app/src/git/use-github-search-query.ts | 64 +++++ .../new-workspace-picker-state.test.ts | 74 ++++-- .../src/screens/new-workspace-picker-state.ts | 27 +- .../app/src/screens/new-workspace-screen.tsx | 223 ++++++++++++---- packages/app/src/utils/github-refs.test.ts | 130 ++++++++++ packages/app/src/utils/github-refs.ts | 117 +++++++++ 12 files changed, 1147 insertions(+), 130 deletions(-) create mode 100644 packages/app/src/components/use-composer-github-auto-attach.test.tsx create mode 100644 packages/app/src/components/use-composer-github-auto-attach.ts create mode 100644 packages/app/src/git/use-github-search-query.test.ts create mode 100644 packages/app/src/git/use-github-search-query.ts create mode 100644 packages/app/src/utils/github-refs.test.ts create mode 100644 packages/app/src/utils/github-refs.ts diff --git a/packages/app/src/components/composer-actions.test.ts b/packages/app/src/components/composer-actions.test.ts index d6fd2bea6..ac7749db5 100644 --- a/packages/app/src/components/composer-actions.test.ts +++ b/packages/app/src/components/composer-actions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages"; import type { AttachmentMetadata, @@ -19,6 +19,7 @@ import { removeComposerAttachmentAtIndex, sendQueuedComposerMessageNow, toggleGithubAttachment, + toggleGithubAttachmentFromPicker, type AgentStreamWriter, type AttachmentPersister, type ComposerCancelClient, @@ -696,6 +697,36 @@ describe("toggleGithubAttachment", () => { }); }); +describe("toggleGithubAttachmentFromPicker", () => { + it("marks an existing GitHub item as removed when picker toggle removes it", () => { + const markGithubAttachmentRemoved = vi.fn(); + const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem }; + + const next = toggleGithubAttachmentFromPicker({ + current: [attachment], + item: prItem, + markGithubAttachmentRemoved, + }); + + expect(next).toEqual([]); + expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1); + expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment); + }); + + it("does not mark a GitHub item removed when picker toggle adds it", () => { + const markGithubAttachmentRemoved = vi.fn(); + + const next = toggleGithubAttachmentFromPicker({ + current: [], + item: issueItem, + markGithubAttachmentRemoved, + }); + + expect(next).toEqual([{ kind: "github_issue", item: issueItem }]); + expect(markGithubAttachmentRemoved).not.toHaveBeenCalled(); + }); +}); + describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => { it("locates items via their composite kind:number id", () => { expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem); diff --git a/packages/app/src/components/composer-actions.ts b/packages/app/src/components/composer-actions.ts index 83cf606d0..3827b31bc 100644 --- a/packages/app/src/components/composer-actions.ts +++ b/packages/app/src/components/composer-actions.ts @@ -305,6 +305,29 @@ export function toggleGithubAttachment( return [...current, buildGithubAttachment(item)]; } +interface ToggleGithubAttachmentFromPickerInput { + current: UserComposerAttachment[]; + item: GitHubSearchItem; + markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void; +} + +export function toggleGithubAttachmentFromPicker({ + current, + item, + markGithubAttachmentRemoved, +}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] { + const existingAttachment = current.find( + (attachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number, + ); + if (existingAttachment) { + markGithubAttachmentRemoved(existingAttachment); + } + return toggleGithubAttachment(current, item); +} + export function findGithubItemByOption( items: readonly GitHubSearchItem[], optionId: string, diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 198837421..e381eec84 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -21,7 +21,6 @@ import { Paperclip, } from "lucide-react-native"; import Animated from "react-native-reanimated"; -import { useQuery } from "@tanstack/react-query"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { AgentStatusBar, @@ -53,7 +52,7 @@ import { queueComposerMessage, removeComposerAttachmentAtIndex, sendQueuedComposerMessageNow, - toggleGithubAttachment, + toggleGithubAttachmentFromPicker, type AgentStreamWriter, type QueueWriter, type QueuedComposerMessage, @@ -97,6 +96,9 @@ import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; +import { useGithubSearchQuery } from "@/git/use-github-search-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; +import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach"; type QueuedMessage = QueuedComposerMessage; @@ -137,6 +139,20 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string { return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; } +function resolveGithubSearchEnabled( + isGithubPickerOpen: boolean, + isConnected: boolean, + cwd: string, +): boolean { + return isGithubPickerOpen && isConnected && cwd.trim().length > 0; +} + +function resolveCheckoutRemoteUrl( + checkoutStatus: ReturnType["status"], +): string | null { + return checkoutStatus?.remoteUrl ?? null; +} + function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); @@ -164,31 +180,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) { }; } -interface BuildGithubSearchQueryOptionsArgs { - serverId: string; - cwd: string; - githubSearchQueryTrimmed: string; - isGithubPickerOpen: boolean; - isConnected: boolean; - client: ReturnType; -} - -function buildGithubSearchQueryOptions(args: BuildGithubSearchQueryOptionsArgs) { - const { serverId, cwd, githubSearchQueryTrimmed, isGithubPickerOpen, isConnected, client } = args; - const hasClient = Boolean(client); - const cwdIsSet = cwd.trim().length > 0; - const enabled = isGithubPickerOpen && isConnected && hasClient && cwdIsSet; - return { - queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed], - queryFn: async () => { - if (!client) throw new Error("Host is not connected"); - return client.searchGitHub({ cwd, query: githubSearchQueryTrimmed, limit: 20 }); - }, - enabled, - staleTime: 30_000, - }; -} - function renderContextWindowMeterSlot( contextWindowMaxTokens: number | null, contextWindowUsedTokens: number | null, @@ -890,6 +881,17 @@ export function Composer({ onOpenWorkspaceAttachment, }); const setSelectedAttachments = onChangeAttachments; + const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); + const githubAutoAttach = useComposerGithubAutoAttach({ + text: userInput, + remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), + attachments, + client, + isConnected, + serverId, + cwd, + setAttachments: setSelectedAttachments, + }); const [cursorIndex, setCursorIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); const [isCancellingAgent, setIsCancellingAgent] = useState(false); @@ -1135,6 +1137,7 @@ export function Composer({ const handleRemoveAttachment = useCallback( (index: number) => { + githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]); const didRemoveWorkspaceAttachment = removeAttachment({ selectedAttachments, index, @@ -1146,7 +1149,7 @@ export function Composer({ removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), ); }, - [removeAttachment, selectedAttachments, setSelectedAttachments], + [githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], ); const handleOpenAttachment = useCallback( @@ -1376,16 +1379,13 @@ export function Composer({ ); const githubSearchQueryTrimmed = githubSearchQuery.trim(); - const githubSearchResultsQuery = useQuery( - buildGithubSearchQueryOptions({ - serverId, - cwd, - githubSearchQueryTrimmed, - isGithubPickerOpen, - isConnected, - client, - }), - ); + const githubSearchResultsQuery = useGithubSearchQuery({ + client, + serverId, + cwd, + query: githubSearchQueryTrimmed, + enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), + }); const githubSearchItemsRaw = githubSearchResultsQuery.data?.items; const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]); @@ -1423,11 +1423,22 @@ export function Composer({ const handleToggleGithubItem = useCallback( (item: GitHubSearchItem) => { - setSelectedAttachments((current) => toggleGithubAttachment(current, item)); + const nextAttachments = toggleGithubAttachmentFromPicker({ + current: attachments, + item, + markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved, + }); + setSelectedAttachments(nextAttachments); setIsGithubPickerOpen(false); setGithubSearchQuery(""); }, - [setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], + [ + attachments, + githubAutoAttach, + setSelectedAttachments, + setGithubSearchQuery, + setIsGithubPickerOpen, + ], ); const leftContent = useMemo( diff --git a/packages/app/src/components/use-composer-github-auto-attach.test.tsx b/packages/app/src/components/use-composer-github-auto-attach.test.tsx new file mode 100644 index 000000000..773dfe273 --- /dev/null +++ b/packages/app/src/components/use-composer-github-auto-attach.test.tsx @@ -0,0 +1,213 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import React, { type ReactNode } from "react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { GitHubSearchClient } from "@/git/use-github-search-query"; +import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages"; +import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach"; + +type GitHubSearchPayload = GitHubSearchResponse["payload"]; + +const remoteUrl = "git@github.com:acme/paseo.git"; +const cwd = "/repo"; + +const pr101: GitHubSearchItem = { + kind: "pr", + number: 101, + title: "Attach PR", + url: "https://github.com/acme/paseo/pull/101", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature", +}; + +const issue202: GitHubSearchItem = { + kind: "issue", + number: 202, + title: "Attach issue", + url: "https://github.com/acme/paseo/issues/202", + state: "open", + body: null, + labels: [], + baseRefName: null, + headRefName: null, +}; + +interface SearchCall { + cwd: string; + query: string; + limit?: number; +} + +interface HarnessInput { + initialAttachments?: UserComposerAttachment[]; + initialText?: string; + remote?: string | null; +} + +function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload { + return { + items, + githubFeaturesEnabled: true, + error: null, + requestId, + }; +} + +function createSearchClient( + items: GitHubSearchItem[], +): GitHubSearchClient & { calls: SearchCall[] } { + const calls: SearchCall[] = []; + return { + calls, + async searchGitHub(options) { + calls.push(options); + return githubPayload(items, `search-${options.query}`); + }, + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) { + const [text, setText] = useState(input.initialText ?? ""); + const [attachments, setAttachments] = useState( + input.initialAttachments ?? [], + ); + const autoAttach = useComposerGithubAutoAttach({ + text, + remoteUrl: input.remote ?? remoteUrl, + attachments, + client, + isConnected: true, + serverId: "server-1", + cwd, + setAttachments, + }); + + return { + text, + setText, + attachments, + setAttachments, + markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved, + }; +} + +async function flushDebounce() { + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); + }); +} + +describe("useComposerGithubAutoAttach", () => { + it("adds a matching pasted GitHub PR URL as a composer attachment", async () => { + vi.useFakeTimers(); + const client = createSearchClient([pr101]); + const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + + act(() => { + result.current.setText("Please review https://github.com/acme/paseo/pull/101"); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]); + expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]); + vi.useRealTimers(); + }); + + it("ignores URLs that do not match the current remote", async () => { + vi.useFakeTimers(); + const client = createSearchClient([pr101]); + const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + + act(() => { + result.current.setText("Other repo https://github.com/other/paseo/pull/101"); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual([]); + expect(client.calls).toEqual([]); + vi.useRealTimers(); + }); + + it("does not add a second pill when the ref is already attached", async () => { + vi.useFakeTimers(); + const client = createSearchClient([pr101]); + const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }]; + const { result } = renderHook(() => useHarness(client, { initialAttachments }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setText("Already here https://github.com/acme/paseo/pull/101"); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual(initialAttachments); + expect(client.calls).toEqual([]); + vi.useRealTimers(); + }); + + it("does not re-add a GitHub ref removed earlier in the same composer session", async () => { + vi.useFakeTimers(); + const client = createSearchClient([pr101]); + const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }]; + const { result } = renderHook(() => useHarness(client, { initialAttachments }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.markGithubAttachmentRemoved(initialAttachments[0]); + result.current.setAttachments([]); + result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101"); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual([]); + expect(client.calls).toEqual([]); + vi.useRealTimers(); + }); + + it("handles multiple matching URLs from one paste", async () => { + vi.useFakeTimers(); + const client = createSearchClient([pr101, issue202]); + const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + + act(() => { + result.current.setText( + "Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202", + ); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual([ + { kind: "github_pr", item: pr101 }, + { kind: "github_issue", item: issue202 }, + ]); + expect(client.calls).toEqual([ + { cwd, query: "101", limit: 20 }, + { cwd, query: "202", limit: 20 }, + ]); + vi.useRealTimers(); + }); +}); diff --git a/packages/app/src/components/use-composer-github-auto-attach.ts b/packages/app/src/components/use-composer-github-auto-attach.ts new file mode 100644 index 000000000..77f5b18bc --- /dev/null +++ b/packages/app/src/components/use-composer-github-auto-attach.ts @@ -0,0 +1,240 @@ +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { + useCallback, + useEffect, + useMemo, + useRef, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react"; +import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; +import { + buildGithubSearchQueryOptions, + type GitHubSearchClient, +} from "@/git/use-github-search-query"; +import { extractGithubRefs, type GithubRef } from "@/utils/github-refs"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions"; + +const AUTO_ATTACH_DEBOUNCE_MS = 300; + +interface ComposerGithubAutoAttachInput { + text: string; + remoteUrl: string | null | undefined; + attachments: UserComposerAttachment[]; + client: GitHubSearchClient | null; + isConnected: boolean; + serverId: string; + cwd: string; + setAttachments: Dispatch>; +} + +interface ComposerGithubAutoAttachResult { + markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void; +} + +export function useComposerGithubAutoAttach( + params: ComposerGithubAutoAttachInput, +): ComposerGithubAutoAttachResult { + const queryClient = useQueryClient(); + const latestRef = useRef(params); + const removedRefKeysRef = useRef(new Set()); + const pendingRefKeysRef = useRef(new Set()); + + latestRef.current = params; + + useEffect(() => { + const refs = refsReadyForLookup({ + params: latestRef.current, + removedRefKeys: removedRefKeysRef.current, + pendingRefKeys: pendingRefKeysRef.current, + }); + if (refs.length === 0) { + return; + } + + const timerId = setTimeout(() => { + void attachRefs({ + refs, + queryClient, + latestRef, + removedRefKeys: removedRefKeysRef.current, + pendingRefKeys: pendingRefKeysRef.current, + }); + }, AUTO_ATTACH_DEBOUNCE_MS); + + return () => { + clearTimeout(timerId); + }; + }, [ + params.text, + params.remoteUrl, + params.attachments, + params.client, + params.isConnected, + params.serverId, + params.cwd, + queryClient, + ]); + + const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => { + const key = attachmentKey(attachment); + if (key) { + removedRefKeysRef.current.add(key); + } + }, []); + + return useMemo( + () => ({ + markGithubAttachmentRemoved, + }), + [markGithubAttachmentRemoved], + ); +} + +async function attachRefs({ + refs, + queryClient, + latestRef, + removedRefKeys, + pendingRefKeys, +}: { + refs: GithubRef[]; + queryClient: QueryClient; + latestRef: RefObject; + removedRefKeys: Set; + pendingRefKeys: Set; +}): Promise { + 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; + removedRefKeys: Set; +}): Promise { + 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)); + if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) { + return; + } + + latestRef.current.setAttachments((current) => { + if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) { + return current; + } + return toggleGithubAttachment(current, item); + }); +} + +function refsReadyForLookup({ + params, + removedRefKeys, + pendingRefKeys, +}: { + params: ComposerGithubAutoAttachInput; + removedRefKeys: Set; + pendingRefKeys: Set; +}): 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( + buildGithubSearchQueryOptions({ + client: snapshot.client, + serverId: snapshot.serverId, + cwd: snapshot.cwd, + query: String(ref.number), + 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 hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean { + return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref)); +} + +function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean { + return item.kind === githubItemKind(ref) && item.number === ref.number; +} + +function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] { + return ref.kind === "pull" ? "pr" : "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 !== "github_pr" && attachment.kind !== "github_issue") + ) { + return null; + } + return `${attachment.item.kind}:${attachment.item.number}`; +} diff --git a/packages/app/src/git/use-github-search-query.test.ts b/packages/app/src/git/use-github-search-query.test.ts new file mode 100644 index 000000000..6f342a8c5 --- /dev/null +++ b/packages/app/src/git/use-github-search-query.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query"; + +describe("githubSearchQueryKey", () => { + it("keeps the shared cache key shape for no-kinds searches", () => { + expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([ + "github-search", + "server-1", + "/repo", + "123", + ]); + }); + + it("adds a deterministic kinds key when kinds are specified", () => { + expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual( + ["github-search", "server-1", "/repo", "123", "github-issue,github-pr"], + ); + }); +}); + +describe("buildGithubSearchQueryOptions", () => { + it("forwards kinds to the GitHub search request when specified", async () => { + const requests: unknown[] = []; + const query = buildGithubSearchQueryOptions({ + client: { + async searchGitHub(options) { + requests.push(options); + return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" }; + }, + }, + serverId: "server-1", + cwd: "/repo", + query: " 123 ", + kinds: ["github-pr"], + enabled: true, + }); + + await query.queryFn(); + + expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]); + }); +}); diff --git a/packages/app/src/git/use-github-search-query.ts b/packages/app/src/git/use-github-search-query.ts new file mode 100644 index 000000000..d3db8d31c --- /dev/null +++ b/packages/app/src/git/use-github-search-query.ts @@ -0,0 +1,64 @@ +import { useQuery } from "@tanstack/react-query"; +import type { GitHubSearchRequest, GitHubSearchResponse } from "@server/shared/messages"; + +export const GITHUB_SEARCH_STALE_TIME = 30_000; + +export type GitHubSearchPayload = GitHubSearchResponse["payload"]; + +export interface GitHubSearchClient { + searchGitHub: ( + options: { + cwd: string; + query: string; + limit?: number; + kinds?: GitHubSearchRequest["kinds"]; + }, + requestId?: string, + ) => Promise; +} + +interface GitHubSearchQueryInput { + client: GitHubSearchClient | null; + serverId: string; + cwd: string; + query: string; + kinds?: GitHubSearchRequest["kinds"]; + enabled: boolean; +} + +export function githubSearchQueryKey( + serverId: string, + cwd: string, + query: string, + kinds?: GitHubSearchRequest["kinds"], +) { + const trimmedQuery = query.trim(); + if (!kinds) { + return ["github-search", serverId, cwd, trimmedQuery] as const; + } + return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const; +} + +export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) { + const query = input.query.trim(); + + return { + queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds), + queryFn: async (): Promise => { + if (!input.client) { + throw new Error("Host is not connected"); + } + const request = { cwd: input.cwd, query, limit: 20 }; + if (input.kinds) { + return input.client.searchGitHub({ ...request, kinds: input.kinds }); + } + return input.client.searchGitHub(request); + }, + enabled: input.enabled && Boolean(input.client), + staleTime: GITHUB_SEARCH_STALE_TIME, + }; +} + +export function useGithubSearchQuery(input: GitHubSearchQueryInput) { + return useQuery(buildGithubSearchQueryOptions(input)); +} diff --git a/packages/app/src/screens/new-workspace-picker-state.test.ts b/packages/app/src/screens/new-workspace-picker-state.test.ts index 82a938d81..b475f2c82 100644 --- a/packages/app/src/screens/new-workspace-picker-state.test.ts +++ b/packages/app/src/screens/new-workspace-picker-state.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; import type { GitHubSearchItem } from "@server/shared/messages"; -import { - deriveAutoPickerItemFromAttachments, - syncPickerPrAttachment, -} from "./new-workspace-picker-state"; +import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem { return { @@ -20,7 +17,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G }; } -function prAttachment(item: GitHubSearchItem): UserComposerAttachment { +function prAttachment( + item: GitHubSearchItem, +): Extract { return { kind: "github_pr", item }; } @@ -98,34 +97,53 @@ describe("syncPickerPrAttachment", () => { }); }); -describe("deriveAutoPickerItemFromAttachments", () => { - it("returns null when there are no attachments", () => { - expect(deriveAutoPickerItemFromAttachments([])).toBeNull(); +describe("findCheckoutHintPrAttachment", () => { + it("returns the first attached PR that is not selected or dismissed", () => { + const first = prAttachment(makePrItem(101, "A")); + const second = prAttachment(makePrItem(202, "B")); + + expect( + findCheckoutHintPrAttachment({ + attachments: [issueAttachment(44), first, second], + selectedItem: null, + dismissedPrNumbers: new Set(), + }), + ).toBe(first); }); - it("returns the PR when exactly one is attached", () => { - const pr = makePrItem(923, "Nix overridable npm deps hash"); - expect(deriveAutoPickerItemFromAttachments([prAttachment(pr)])).toEqual({ - kind: "github-pr", - item: pr, - }); + it("skips the selected PR and offers the next attached PR", () => { + const selected = prAttachment(makePrItem(101, "A")); + const next = prAttachment(makePrItem(202, "B")); + + expect( + findCheckoutHintPrAttachment({ + attachments: [selected, next], + selectedItem: { kind: "github-pr", item: selected.item }, + dismissedPrNumbers: new Set(), + }), + ).toBe(next); }); - it("returns null when multiple PRs are attached", () => { - const a = makePrItem(101, "A"); - const b = makePrItem(202, "B"); - expect(deriveAutoPickerItemFromAttachments([prAttachment(a), prAttachment(b)])).toBeNull(); + it("skips dismissed PRs and ignores issues", () => { + const dismissed = prAttachment(makePrItem(101, "A")); + const next = prAttachment(makePrItem(202, "B")); + + expect( + findCheckoutHintPrAttachment({ + attachments: [issueAttachment(44), dismissed, next], + selectedItem: null, + dismissedPrNumbers: new Set([101]), + }), + ).toBe(next); }); - it("ignores non-PR attachments", () => { - expect(deriveAutoPickerItemFromAttachments([issueAttachment(44)])).toBeNull(); - }); - - it("returns the lone PR even when other non-PR attachments are present", () => { - const pr = makePrItem(923, "Nix overridable npm deps hash"); - expect(deriveAutoPickerItemFromAttachments([issueAttachment(44), prAttachment(pr)])).toEqual({ - kind: "github-pr", - item: pr, - }); + it("returns null when only issues qualify", () => { + expect( + findCheckoutHintPrAttachment({ + attachments: [issueAttachment(44)], + selectedItem: null, + dismissedPrNumbers: new Set(), + }), + ).toBeNull(); }); }); diff --git a/packages/app/src/screens/new-workspace-picker-state.ts b/packages/app/src/screens/new-workspace-picker-state.ts index c9c33d2f3..c3a25452e 100644 --- a/packages/app/src/screens/new-workspace-picker-state.ts +++ b/packages/app/src/screens/new-workspace-picker-state.ts @@ -34,18 +34,21 @@ export function syncPickerPrAttachment(input: { return { attachments: nextAttachments, attachedPrNumber }; } -// Suggest a ref picker target derived from composer attachments alone. When the -// user attaches exactly one PR they almost always want the worktree based on -// that PR — surface it as the picker's default so the worktree isn't silently -// branched off main. The caller is responsible for letting manual picks win. -export function deriveAutoPickerItemFromAttachments( - attachments: ReadonlyArray, -): PickerItem | null { - let onlyPr: Extract | null = null; - for (const attachment of attachments) { +export function findCheckoutHintPrAttachment(input: { + attachments: ReadonlyArray; + selectedItem: PickerItem | null; + dismissedPrNumbers: ReadonlySet; +}): Extract | null { + const selectedPrNumber = + input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null; + + for (const attachment of input.attachments) { if (attachment.kind !== "github_pr") continue; - if (onlyPr) return null; - onlyPr = attachment; + const prNumber = attachment.item.number; + if (prNumber === selectedPrNumber) continue; + if (input.dismissedPrNumbers.has(prNumber)) continue; + return attachment; } - return onlyPr ? { kind: "github-pr", item: onlyPr.item } : null; + + return null; } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5c2f97fdf..5ebc0de8f 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated from "react-native-reanimated"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native"; +import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native"; import { Composer } from "@/components/composer"; import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { FileDropZone } from "@/components/file-drop-zone"; @@ -19,6 +19,7 @@ import { ScreenHeader } from "@/components/headers/screen-header"; import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; +import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; @@ -38,10 +39,7 @@ import { type PickerCheckoutRequest, type PickerItem, } from "./new-workspace-picker-item"; -import { - deriveAutoPickerItemFromAttachments, - syncPickerPrAttachment, -} from "./new-workspace-picker-state"; +import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; function resolveCheckoutRequest( selectedItem: PickerItem | null, @@ -72,17 +70,6 @@ interface PickerSelection { attachedPrNumber: number | null; } -// Manual picks always win; the auto-promoted item is a fallback so the user -// doesn't silently get "main" when they meant the PR they just attached. -function combinePickerSelection( - manual: PickerSelection | null, - autoItem: PickerItem | null, -): PickerSelection | null { - if (manual) return manual; - if (autoItem) return { item: autoItem, attachedPrNumber: null }; - return null; -} - const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; @@ -160,6 +147,46 @@ function RefPickerTrigger({ ); } +function CheckoutHintBadge({ + prNumber, + onAccept, + onDismiss, + iconColor, + iconSize, +}: { + prNumber: number; + onAccept: () => void; + onDismiss: () => void; + iconColor: string; + iconSize: number; +}) { + return ( + + + Check out PR #{prNumber}? + + + + + + + + + ); +} + function PickerOptionItem({ testID, label, @@ -262,6 +289,17 @@ function computePickerOptionData( return { options: timedOptions.map((t) => t.option), itemById: idMap }; } +function normalizeBranchDetails( + data: + | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } + | undefined, +): Array<{ name: string; committerDate: number }> { + const details = data?.branchDetails; + if (details && details.length > 0) return details; + const names = data?.branches ?? []; + return names.map((name) => ({ name, committerDate: 0 })); +} + interface SubmitDraftInput { serverId: string; draftKey: string; @@ -363,6 +401,47 @@ function computeWorkspaceTitle( ); } +function collectAttachedPrNumbers(attachments: ReadonlyArray): Set { + const numbers = new Set(); + for (const attachment of attachments) { + if (attachment.kind === "github_pr") { + numbers.add(attachment.item.number); + } + } + return numbers; +} + +function pruneDismissedCheckoutHintPrNumbers( + dismissed: ReadonlySet, + attached: ReadonlySet, +): ReadonlySet { + let changed = false; + const next = new Set(); + for (const prNumber of dismissed) { + if (attached.has(prNumber)) { + next.add(prNumber); + } else { + changed = true; + } + } + return changed ? next : dismissed; +} + +function useCheckoutHintDismissals(attachments: ReadonlyArray) { + const [dismissedPrNumbers, setDismissedPrNumbers] = useState>( + () => new Set(), + ); + const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]); + + useEffect(() => { + setDismissedPrNumbers((current) => + pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers), + ); + }, [attachedPrNumbers]); + + return [dismissedPrNumbers, setDismissedPrNumbers] as const; +} + function submitWorkspaceDraft(input: SubmitDraftInput): void { const { serverId, @@ -460,13 +539,10 @@ export function NewWorkspaceScreen({ }), }); const composerState = chatDraft.composerState; + const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] = + useCheckoutHintDismissals(chatDraft.attachments); - const autoPickerItem = useMemo( - () => deriveAutoPickerItemFromAttachments(chatDraft.attachments), - [chatDraft.attachments], - ); - const pickerSelection = combinePickerSelection(manualPickerSelection, autoPickerItem); - const selectedItem = pickerSelection?.item ?? null; + const selectedItem = manualPickerSelection?.item ?? null; const withConnectedClient = useCallback(() => { if (!client || !isConnected) { @@ -507,32 +583,23 @@ export function NewWorkspaceScreen({ staleTime: 15_000, }); - const githubPrSearchQuery = useQuery({ - queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery], - queryFn: async () => { - const connectedClient = withConnectedClient(); - return connectedClient.searchGitHub({ - cwd: sourceDirectory, - query: debouncedPickerSearchQuery, - limit: 20, - kinds: ["github-pr"], - }); - }, + const githubPrSearchQuery = useGithubSearchQuery({ + client, + serverId, + cwd: sourceDirectory, + query: debouncedPickerSearchQuery, + kinds: ["github-pr"], enabled: pickerQueryEnabled, - staleTime: 30_000, }); - const branchDetails = useMemo(() => { - const details = branchSuggestionsQuery.data?.branchDetails; - if (details && details.length > 0) return details; - const names = branchSuggestionsQuery.data?.branches ?? []; - return names.map((name) => ({ name, committerDate: 0 })); - }, [branchSuggestionsQuery.data?.branchDetails, branchSuggestionsQuery.data?.branches]); + const branchDetails = useMemo( + () => normalizeBranchDetails(branchSuggestionsQuery.data), + [branchSuggestionsQuery.data], + ); const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false; const prItems: GitHubSearchItem[] = useMemo(() => { if (!githubFeaturesEnabled) return []; - const items = githubPrSearchQuery.data?.items ?? []; - return items.filter((item): item is GitHubSearchItem => item.kind === "pr"); + return githubPrSearchQuery.data?.items ?? []; }, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]); const { options, itemById }: PickerOptionData = useMemo( @@ -552,14 +619,11 @@ export function NewWorkspaceScreen({ : prOptionId(selectedItem.item.number); }, [selectedItem]); - const handleSelectOption = useCallback( - (id: string) => { - const item = itemById.get(id); - if (!item) return; - + const selectPickerItem = useCallback( + (item: PickerItem) => { const next = syncPickerPrAttachment({ attachments: chatDraft.attachments, - previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null, + previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null, item, }); @@ -572,9 +636,44 @@ export function NewWorkspaceScreen({ } setPickerOpen(false); }, - [chatDraft, itemById, pickerSelection?.attachedPrNumber], + [chatDraft, manualPickerSelection?.attachedPrNumber], ); + const handleSelectOption = useCallback( + (id: string) => { + const item = itemById.get(id); + if (!item) return; + selectPickerItem(item); + }, + [itemById, selectPickerItem], + ); + + const checkoutHintPrAttachment = useMemo( + () => + findCheckoutHintPrAttachment({ + attachments: chatDraft.attachments, + selectedItem, + dismissedPrNumbers: dismissedCheckoutHintPrNumbers, + }), + [chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem], + ); + + const acceptCheckoutHint = useCallback(() => { + if (!checkoutHintPrAttachment) return; + selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item }); + }, [checkoutHintPrAttachment, selectPickerItem]); + + const dismissCheckoutHint = useCallback(() => { + if (!checkoutHintPrAttachment) return; + const prNumber = checkoutHintPrAttachment.item.number; + setDismissedCheckoutHintPrNumbers((current) => { + if (current.has(prNumber)) return current; + const next = new Set(current); + next.add(prNumber); + return next; + }); + }, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]); + const openPicker = useCallback(() => { setPickerOpen(true); }, []); @@ -831,6 +930,15 @@ export function NewWorkspaceScreen({ renderOption={renderPickerOption} /> + {checkoutHintPrAttachment ? ( + + ) : null} {errorMessage ? {errorMessage} : null} @@ -908,6 +1016,23 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius["2xl"], gap: theme.spacing[1], }, + checkoutHintBadge: { + flexDirection: "row", + alignItems: "center", + height: 28, + maxWidth: 240, + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + gap: theme.spacing[1], + backgroundColor: theme.colors.surface1, + }, + checkoutHintAction: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.full, + }, badgeHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/app/src/utils/github-refs.test.ts b/packages/app/src/utils/github-refs.test.ts new file mode 100644 index 000000000..b8c69d108 --- /dev/null +++ b/packages/app/src/utils/github-refs.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { extractGithubRefs, normalizeGithubRemote, parseGithubRef } from "./github-refs"; + +const httpsRemote = "https://github.com/getpaseo/paseo.git"; +const sshRemote = "git@github.com:getpaseo/paseo.git"; + +describe("normalizeGithubRemote", () => { + it.each([ + ["https://github.com/getpaseo/paseo", { owner: "getpaseo", repo: "paseo", host: "github.com" }], + [ + "https://github.com/getpaseo/paseo.git", + { owner: "getpaseo", repo: "paseo", host: "github.com" }, + ], + ["git@github.com:getpaseo/paseo.git", { owner: "getpaseo", repo: "paseo", host: "github.com" }], + [ + "ssh://git@github.com/getpaseo/paseo.git", + { owner: "getpaseo", repo: "paseo", host: "github.com" }, + ], + ])("extracts GitHub identity from %s", (remoteUrl, expected) => { + expect(normalizeGithubRemote(remoteUrl)).toEqual(expected); + }); + + it("returns null for non-GitHub remotes and empty input", () => { + expect(normalizeGithubRemote("git@gitlab.com:getpaseo/paseo.git")).toBeNull(); + expect(normalizeGithubRemote(null)).toBeNull(); + }); +}); + +describe("parseGithubRef", () => { + it.each([ + "https://github.com/getpaseo/paseo/pull/994", + "https://github.com/getpaseo/paseo/pull/994/", + "https://github.com/getpaseo/paseo/pull/994/files", + "https://github.com/getpaseo/paseo/pull/994?diff=split", + "https://github.com/getpaseo/paseo/pull/994#discussion_r123", + ])("parses a matching pull request URL: %s", (text) => { + expect(parseGithubRef(text, httpsRemote)).toEqual({ + kind: "pull", + number: 994, + owner: "getpaseo", + repo: "paseo", + url: "https://github.com/getpaseo/paseo/pull/994", + }); + }); + + it("parses a matching issue URL", () => { + expect(parseGithubRef("https://github.com/getpaseo/paseo/issues/456", httpsRemote)).toEqual({ + kind: "issues", + number: 456, + owner: "getpaseo", + repo: "paseo", + url: "https://github.com/getpaseo/paseo/issues/456", + }); + }); + + it("matches HTTPS pasted URLs against an SSH remote", () => { + expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", sshRemote)).toEqual({ + kind: "pull", + number: 994, + owner: "getpaseo", + repo: "paseo", + url: "https://github.com/getpaseo/paseo/pull/994", + }); + }); + + it("ignores URLs for another owner or repo", () => { + expect(parseGithubRef("https://github.com/other/paseo/pull/994", httpsRemote)).toBeNull(); + expect(parseGithubRef("https://github.com/getpaseo/other/pull/994", httpsRemote)).toBeNull(); + }); + + it("returns null for non-GitHub remotes and empty text", () => { + expect( + parseGithubRef( + "https://github.com/getpaseo/paseo/pull/994", + "git@gitlab.com:getpaseo/paseo.git", + ), + ).toBeNull(); + expect(parseGithubRef("", httpsRemote)).toBeNull(); + expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", null)).toBeNull(); + }); + + it("finds URLs embedded in text and markdown links", () => { + expect( + parseGithubRef( + "See:\n[the PR](https://github.com/getpaseo/paseo/pull/994/files).", + httpsRemote, + ), + ).toEqual({ + kind: "pull", + number: 994, + owner: "getpaseo", + repo: "paseo", + url: "https://github.com/getpaseo/paseo/pull/994", + }); + }); +}); + +describe("extractGithubRefs", () => { + it("returns every matching ref deduped by kind and number", () => { + const text = [ + "https://github.com/getpaseo/paseo/pull/994", + "https://github.com/getpaseo/paseo/issues/456#issuecomment-1", + "https://github.com/getpaseo/paseo/pull/994/files", + "https://github.com/other/paseo/issues/1", + ].join("\n"); + + expect(extractGithubRefs(text, httpsRemote)).toEqual([ + { + kind: "pull", + number: 994, + owner: "getpaseo", + repo: "paseo", + url: "https://github.com/getpaseo/paseo/pull/994", + }, + { + kind: "issues", + number: 456, + owner: "getpaseo", + repo: "paseo", + url: "https://github.com/getpaseo/paseo/issues/456", + }, + ]); + }); + + it("returns an empty array for empty text or null remote", () => { + expect(extractGithubRefs("", httpsRemote)).toEqual([]); + expect(extractGithubRefs("https://github.com/getpaseo/paseo/pull/994", null)).toEqual([]); + }); +}); diff --git a/packages/app/src/utils/github-refs.ts b/packages/app/src/utils/github-refs.ts new file mode 100644 index 000000000..1c31ad103 --- /dev/null +++ b/packages/app/src/utils/github-refs.ts @@ -0,0 +1,117 @@ +import { parseGitHubRemoteUrl } from "@server/shared/git-remote"; + +export type GithubRefKind = "pull" | "issues"; + +export interface GithubRemote { + owner: string; + repo: string; + host: "github.com"; +} + +export interface GithubRef { + kind: GithubRefKind; + number: number; + owner: string; + repo: string; + url: string; +} + +interface ParsedGithubUrl { + kind: GithubRefKind; + number: number; + owner: string; + repo: string; +} + +const GITHUB_REF_URL_PATTERN = + /https?:\/\/github\.com\/([^/\s<>)\]]+)\/([^/\s<>)\]]+)\/(pull|issues)\/(\d+)(?:[/?#][^\s<>)\]]*)?/giu; + +export function normalizeGithubRemote(remoteUrl: string | null | undefined): GithubRemote | null { + const trimmed = remoteUrl?.trim(); + if (!trimmed) { + return null; + } + + const identity = parseGitHubRemoteUrl(trimmed); + if (!identity) { + return null; + } + + return { + owner: identity.owner, + repo: identity.name, + host: "github.com", + }; +} + +export function parseGithubRef( + text: string | null | undefined, + remoteUrl: string | null | undefined, +): GithubRef | null { + return extractGithubRefs(text, remoteUrl)[0] ?? null; +} + +export function extractGithubRefs( + text: string | null | undefined, + remoteUrl: string | null | undefined, +): GithubRef[] { + const remote = normalizeGithubRemote(remoteUrl); + const body = text?.trim(); + if (!remote || !body) { + return []; + } + + const refs: GithubRef[] = []; + const seen = new Set(); + + for (const match of body.matchAll(GITHUB_REF_URL_PATTERN)) { + const parsed = parseGithubUrlMatch(match); + if (!parsed || !matchesRemote(parsed, remote)) { + continue; + } + + const dedupeKey = `${parsed.kind}:${parsed.number}`; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + + refs.push({ + kind: parsed.kind, + number: parsed.number, + owner: remote.owner, + repo: remote.repo, + url: `https://github.com/${remote.owner}/${remote.repo}/${parsed.kind}/${parsed.number}`, + }); + } + + return refs; +} + +function parseGithubUrlMatch(match: RegExpMatchArray): ParsedGithubUrl | null { + const owner = match[1]; + const repo = match[2]; + const kind = match[3]; + const numberText = match[4]; + if (!owner || !repo || !isGithubRefKind(kind) || !numberText) { + return null; + } + + const number = Number.parseInt(numberText, 10); + if (!Number.isSafeInteger(number) || number <= 0) { + return null; + } + + return { owner, repo, kind, number }; +} + +function isGithubRefKind(value: string): value is GithubRefKind { + return value === "pull" || value === "issues"; +} + +function matchesRemote(parsed: ParsedGithubUrl, remote: GithubRemote): boolean { + return ( + parsed.owner.toLowerCase() === remote.owner.toLowerCase() && + parsed.repo.toLowerCase() === remote.repo.toLowerCase() + ); +}