diff --git a/apps/desktop/V2_PR_LINK_COMMAND_DESIGN.md b/apps/desktop/V2_PR_LINK_COMMAND_DESIGN.md new file mode 100644 index 00000000000..89a7f19ff45 --- /dev/null +++ b/apps/desktop/V2_PR_LINK_COMMAND_DESIGN.md @@ -0,0 +1,143 @@ +# V2 PRLinkCommand — Design Doc + +> Porting V1's GitHub PR URL paste + cross-repo validation into the V2 workspace creation modal. + +## Context + +V2's `PRLinkCommand` uses the host-service `searchPullRequests` endpoint for text-based PR search. V1 additionally supports pasting full GitHub PR URLs and validates them against the selected project's repo. This is a frontend-only change — no backend work needed. + +### File References + +| | Path | +|---|---| +| **V1 PRLinkCommand** | `src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx` | +| **V2 PRLinkCommand** | `src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx` | +| **V2 PromptGroup** | `…/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx` | +| **V2 ProjectOption type** | `…/DashboardNewWorkspaceForm/PromptGroup/types.ts` | +| **V2 ModalContent** | `…/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx` | + +--- + +## Gap A: No GitHub PR URL Paste Support + +**V1**: User pastes `https://github.com/owner/repo/pull/123` into the search field. V1 parses it with `parseGitHubPullRequestUrl()`, extracts the PR number, and uses that as the search query. + +**V2**: Only does plain text search via `client.workspaceCreation.searchPullRequests`. Pasting a URL returns no results. + +## Gap B: No Cross-Repo Validation + +**V1**: Receives `githubOwner` and `repoName` as props. Compares the parsed URL's `owner/repo` against these values. If they don't match, it blocks selection and shows: _"PR URL must match owner/repo."_ + +**V2**: `PRLinkCommand` has no `githubOwner`/`repoName` props. However, this data **is already available** — `ProjectOption` in `types.ts` has `githubOwner` and `githubRepoName`, and `DashboardNewWorkspaceModalContent` resolves them from the `githubRepositories` collection. The data just isn't threaded through to `PRLinkCommand`. + +## Gap C: No Debounce Loading State + +**V1**: Tracks `isPendingDebounce` by comparing `trimmedQuery !== debouncedTrimmed`. Shows loading state during the debounce window instead of briefly flashing "No results" before the query fires. + +**V2**: No debounce gap handling. There's a brief flash of empty state between typing and the debounced query firing. + +--- + +## Research: V1 vs VS Code vs GitHub Desktop + +### V1 (Superset) + +A single regex that only matches full HTTPS GitHub.com PR URLs: + +``` +/^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i +``` + +**Handles:** `https://github.com/owner/repo/pull/123`, trailing slashes, query params, hash fragments, `www.` prefix. + +**Does not handle:** +- Shorthand like `#123` or bare `123` — always requires the full URL +- `owner/repo#123` cross-reference syntax +- GitHub Enterprise domains +- SSH-style URLs + +The parsed result extracts the PR number, which becomes the search query. If the URL's `owner/repo` doesn't match the selected project, selection is blocked with _"PR URL must match owner/repo."_ + +### VS Code + +VS Code does **not** parse PR URLs from user input in search fields at all. It solves a different problem: + +1. **Git remote URL parsing** (`parseRemoteUrl` in `gitService.ts`) — normalizes SSH shorthand, aliases, ports, HTTPS URLs into canonical form. Supports GitHub Enterprise via `ghe.com` host matching. + +2. **PR detection via branch** (`PullRequestDetectionService`) — discovers PRs by querying GitHub API with the current branch name, not by parsing URLs. Uses exponential backoff retry. + +Not applicable to our use case — VS Code never asks users to paste PR URLs. + +### GitHub Desktop + +GitHub Desktop also **does not** support pasting PR URLs in its search. Its approach: + +1. **PR list is pre-fetched** — all open PRs for the current repo are loaded from the GitHub API upfront. No server-side search endpoint. + +2. **Client-side fuzzy filtering** (`pull-request-list.tsx:358-366`) — uses `fuzzaldrin-plus` library for fuzzy matching. Each PR is indexed as: + ```typescript + text: [pr.title, `#${pr.pullRequestNumber} opened ${timeAgo} by ${author}`] + ``` + So typing `#123` or `123` or part of a title all work through fuzzy match — no URL parsing needed. + +3. **Git remote parsing** (`remote-parsing.ts`) — parses repository URLs via multiple regex patterns covering HTTPS, SSH (`git@`), SSH with GHE domains (`*.ghe.com`), `git:` protocol, and `ssh://` protocol. Also supports `owner/repo` shorthand via `parseRepositoryIdentifier()`. + +4. **Cross-repo validation** (`repository-matching.ts:74-119`) — `repositoryMatchesRemote()` compares a PR's GitHub repository against local git remotes by parsing both URLs and comparing hostname, owner, and name (case-insensitive). Uses the same `parseRemote()` for both sides. + +**Key takeaway:** GitHub Desktop sidesteps the URL paste problem entirely by pre-loading all PRs and doing client-side fuzzy search. The `#123` syntax works naturally because the subtitle string includes `#${prNumber}`. Their remote parsing is comprehensive (5 regex patterns, GHE support) but only used for git remotes, not browser URLs. + +### Other Superset Parsers + +The codebase has several git remote URL parsers, none for PR URLs: + +| Utility | Location | Purpose | +|---------|----------|---------| +| `parseGitHubRemote` | `packages/host-service` | SSH + HTTPS git remote → `{ owner, name }` | +| `normalizeGitHubRepoUrl` | `apps/desktop/.../changes/utils` | Git remote → normalized HTTPS URL | +| `normalizeGitHubUrl` | `apps/desktop/.../repo-context` | Git remote → `owner/repo` string | + +### Recommendation for V2 + +Neither VS Code nor GitHub Desktop solve this exact problem — both avoid PR URL parsing in search fields entirely (VS Code uses branch detection, GitHub Desktop uses pre-fetched fuzzy search). + +V1's regex is the right approach for our use case: parsing browser URLs pasted by users into a server-side search field. It covers all realistic browser-pasted URLs. + +V2 should improve on V1 in one way: **strip `#` from shorthand like `#123`**. GitHub Desktop gets this for free via fuzzy matching (the subtitle includes `#123`). Our backend does server-side search, so we should normalize `#123` → `123` before sending the query to ensure it reliably matches by number. V1 sends `#123` as-is and hopes the backend text search handles it. + +GitHub Enterprise isn't supported anywhere in the codebase, so adding it here would be inconsistent. The `owner/repo#123` cross-reference syntax adds complexity for a pattern nobody uses in a PR link popover. + +--- + +## Design (Final — server-side normalization) + +All URL parsing, `#` shorthand stripping, and cross-repo validation happen in the host service. The client sends raw user input and reacts to a `repoMismatch` field in the response. + +### 1. Host service: `normalizePullRequestQuery` helper + +Added to `packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts`. + +Handles three cases: +- **Full GitHub PR URL** → parse with regex, extract PR number, validate owner/repo against the project's linked repo. Return `{ repoMismatch: true }` if different. +- **`#123` shorthand** → strip the leading `#`, search by number. +- **Plain text** → pass through as-is. + +The `searchPullRequests` procedure calls this before querying GitHub. On repo mismatch it returns early with `{ pullRequests: [], repoMismatch: "owner/repo" }` — no GitHub API call made. + +### 2. Client: thin — send raw query, react to `repoMismatch` + +`PRLinkCommand` sends the raw `debouncedTrimmed` string to the host service. No URL parsing, no `githubOwner`/`repoName` props needed. + +On response, reads `data.repoMismatch` (a string like `"owner/repo"` or absent). Shows _"PR URL must match owner/repo."_ in the empty state when present. + +### 3. Client: debounce gap handling + +Tracks `isPendingDebounce` (`trimmedQuery !== debouncedTrimmed`) to show loading state during the debounce window instead of flashing "No results". + +--- + +## Files Modified + +| File | Change | +|------|--------| +| `packages/host-service/…/workspace-creation.ts` | Add `normalizePullRequestQuery` helper + wire into `searchPullRequests` procedure | +| `apps/desktop/…/PRLinkCommand/PRLinkCommand.tsx` (V2) | Add `isPendingDebounce`, read `repoMismatch` from response, update empty state messaging | diff --git a/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md b/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md new file mode 100644 index 00000000000..282aa33f170 --- /dev/null +++ b/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md @@ -0,0 +1,117 @@ +# V2 Workspace Creation Modal — Gap Analysis vs V1 + +> Generated 2026-04-11. Compares V2 (`DashboardNewWorkspaceModal`) against V1 (`NewWorkspaceModal`). + +## File References + +| | Path | +|---|---| +| **V1 PromptGroup** | `src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx` | +| **V2 PromptGroup** | `src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx` | +| **V2 Submit Hook** | `…/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts` | +| **V2 Draft Context** | `…/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx` | + +--- + +## Gaps + +### 1. Project Picker — "Open project" / "New project" actions + +**V1**: Project picker includes a separator and two extra items — "Open project" (`onImportRepo`) and "New project" (`onNewProject`). + +**V2**: Only lists existing projects with search. No way to import a repo or create a new project from within the modal. + +**V1 ref**: `PromptGroup.tsx:246-268` +**V2 ref**: `ProjectPickerPill.tsx` (entire file) + +--- + +### 2. Branch Picker — Worktree awareness and Open/Create actions + +**V1** has a fully worktree-aware branch picker (`CompareBaseBranchPickerInline`) with: +- All / Worktrees filter toggle (tabs with counts) +- Differentiated icons: local branch, remote branch, openable worktree, active workspace +- "external" badge for external worktrees +- Active workspace detection with `GoArrowUpRight` icon +- Hover actions: "Open" button to navigate to existing workspace/worktree, "Create" button to create alongside an existing one +- Keyboard hint labels (Enter / Cmd+Enter) + +**V2** has a simplified `CompareBaseBranchPicker` — flat list of branches with `GoGitBranch` icons, "default" and "workspace" badges. No open/create actions, no worktree filter toggle, no differentiated icons. + +**V1 ref**: `PromptGroup.tsx:275-530` (`CompareBaseBranchPickerInline`) +**V2 ref**: `CompareBaseBranchPicker/CompareBaseBranchPicker.tsx` + +--- + +### 3. AI Branch Name Generation + +**V1**: On submit, calls `electronTrpc.workspaces.generateBranchName.mutateAsync()` with a 30-second timeout. Falls back to random name on timeout/failure with toast feedback. Shows "generating-branch" pending status. + +**V2**: `resolveNames()` resolves names synchronously. No AI generation call. + +**V1 ref**: `PromptGroup.tsx:600-608, 759-809` +**V2 ref**: `useSubmitWorkspace.ts` → `resolveNames()` + +--- + +### 4. GitHub Issue Content Auto-Fetching + +**V1**: On submit, fetches full GitHub issue content via `projects.getIssueContent.query()`, sanitizes it (HTML entity escaping, URL validation, 50KB body limit), converts to markdown, and attaches as file attachments alongside user-uploaded files. + +**V2**: Only sends `githubIssueUrls` as string URLs in `linkedContext`. Does not fetch or attach issue content. + +**V1 ref**: `PromptGroup.tsx:832-943` +**V2 ref**: `useSubmitWorkspace.ts` → `mapLinkedContext()` + +--- + +### 5. Agent Launch Request Building + +**V1**: Builds a full `AgentLaunchRequest` via `buildPromptAgentLaunchRequest()` with agent config, prompt, converted files, and task slug. Passes this to `createWorkspace.mutateAsyncWithPendingSetup()`. + +**V2**: No `AgentLaunchRequest` is built. The prompt is sent as part of `composer` but agent config resolution, file bundling, and task slug mapping are missing. + +**V1 ref**: `PromptGroup.tsx:695-708, 945-959` +**V2 ref**: `useSubmitWorkspace.ts:96-110` + +--- + +### 6. Dedicated "Create from PR" Flow + +**V1**: When a PR is linked, submit uses a separate code path — `createFromPr.mutateAsyncWithSetup()` — that creates the workspace from the PR's branch and metadata. + +**V2**: Sends `linkedPrUrl` as part of `linkedContext`. The PR is treated as context rather than driving branch creation. No separate mutation. + +**V1 ref**: `PromptGroup.tsx:963-982` +**V2 ref**: `useSubmitWorkspace.ts:79` → `mapLinkedContext()` + +--- + +### 7. PR URL Parsing and Cross-Repo Validation + +**V1**: `PRLinkCommand` parses pasted GitHub PR URLs (`github.com/:owner/:repo/pull/:number`), detects cross-repository links, and shows an error ("PR URL must match {repo}") for mismatched repos. + +**V2**: `PRLinkCommand` uses host-service `searchPullRequests` endpoint only. No client-side URL parsing or cross-repo validation. + +**V1 ref**: `PRLinkCommand.tsx:37-53, 86-97` +**V2 ref**: `PRLinkCommand.tsx` (V2 version) + +--- + +## Not a Gap (V2 advantage) + +**Branch name preview**: V2 shows a live `branchPreview` in the branch name input placeholder using `slugifyForBranch(trimmedPrompt)`. V1 shows a static `"branch name"` placeholder. V2 is better here. + +--- + +## Priority Assessment + +| # | Gap | Impact | Effort | +|---|-----|--------|--------| +| 5 | Agent launch request building | High — agents won't receive full config/prompt/files | Medium | +| 3 | AI branch name generation | High — branch names won't be meaningful | Low | +| 4 | GitHub issue content fetching | Medium — issues linked as URLs only, not rich context | Medium | +| 6 | Dedicated "create from PR" flow | Medium — PR workspaces may not set up branches properly | Medium | +| 2 | Branch picker worktree awareness | Medium — can't discover/open existing worktrees | High | +| 1 | Project picker open/new actions | Low — can do this outside the modal | Low | +| 7 | PR URL parsing / cross-repo validation | Low — server search covers most cases | Low | diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx index 0820c0b943e..c470c3567ac 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -21,7 +21,7 @@ import { } from "renderer/screens/main/components/IssueIcon/IssueIcon"; import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; -const MAX_RESULTS = 20; +const MAX_RESULTS = 30; const normalizeIssueState = (state: string): IssueState => state.toLowerCase() === "closed" ? "closed" : "open"; @@ -54,25 +54,29 @@ export function GitHubIssueLinkCommand({ const debouncedQuery = useDebouncedValue(searchQuery, 300); const { activeHostUrl } = useLocalHostService(); + const trimmedQuery = searchQuery.trim(); + const debouncedTrimmed = debouncedQuery.trim(); + const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + const hostUrl = hostTarget.kind === "local" ? activeHostUrl : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; - const { data, isLoading } = useQuery({ + const { data, isFetching } = useQuery({ queryKey: [ "workspaceCreation", "searchGitHubIssues", projectId, hostUrl, - debouncedQuery, + debouncedTrimmed, ], queryFn: async () => { if (!hostUrl || !projectId) return { issues: [] }; const client = getHostServiceClientByUrl(hostUrl); return client.workspaceCreation.searchGitHubIssues.query({ projectId, - query: debouncedQuery.trim() || undefined, + query: debouncedTrimmed || undefined, limit: MAX_RESULTS, }); }, @@ -80,6 +84,13 @@ export function GitHubIssueLinkCommand({ }); const searchResults = data?.issues ?? []; + const repoMismatch = + data && "repoMismatch" in data ? data.repoMismatch : null; + + const isLoading = + debouncedTrimmed || trimmedQuery + ? isFetching || isPendingDebounce + : isFetching; const handleClose = () => { setSearchQuery(""); @@ -117,11 +128,25 @@ export function GitHubIssueLinkCommand({ {searchResults.length === 0 && ( - {isLoading ? "Loading issues..." : "No open issues found."} + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading..." + : repoMismatch + ? `Issue URL must match ${repoMismatch}.` + : debouncedTrimmed + ? "No issues found." + : "No issues found."} )} {searchResults.length > 0 && ( - + {searchResults.map((issue) => ( { if (!hostUrl || !projectId) return { pullRequests: [] }; const client = getHostServiceClientByUrl(hostUrl); return client.workspaceCreation.searchPullRequests.query({ projectId, - query: debouncedQuery.trim() || undefined, + query: debouncedTrimmed || undefined, limit: 30, }); }, @@ -81,7 +85,13 @@ export function PRLinkCommand({ }); const pullRequests = data?.pullRequests ?? []; - const debouncedTrimmed = debouncedQuery.trim(); + const repoMismatch = + data && "repoMismatch" in data ? data.repoMismatch : null; + + const isLoading = + debouncedTrimmed || trimmedQuery + ? isFetching || isPendingDebounce + : isFetching; const handleClose = () => { setSearchQuery(""); @@ -122,10 +132,12 @@ export function PRLinkCommand({ {isLoading ? debouncedTrimmed ? "Searching..." - : "Loading pull requests..." - : debouncedTrimmed - ? "No pull requests found." - : "No open pull requests."} + : "Loading..." + : repoMismatch + ? `PR URL must match ${repoMismatch}.` + : debouncedTrimmed + ? "No pull requests found." + : "No pull requests found."} )} {pullRequests.length > 0 && ( @@ -133,7 +145,7 @@ export function PRLinkCommand({ heading={ debouncedTrimmed ? `${pullRequests.length} result${pullRequests.length === 1 ? "" : "s"}` - : "Recent pull requests" + : "Recent PRs" } > {pullRequests.map((pr) => ( diff --git a/packages/host-service/src/trpc/router/workspace-creation/NORMALIZE_GITHUB_QUERY_PLAN.md b/packages/host-service/src/trpc/router/workspace-creation/NORMALIZE_GITHUB_QUERY_PLAN.md new file mode 100644 index 00000000000..0a7a04b0247 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/NORMALIZE_GITHUB_QUERY_PLAN.md @@ -0,0 +1,203 @@ +# Plan: Shared GitHub Query Normalization for PRs and Issues + +> Unify URL parsing, `#N` shorthand, bare number detection, and cross-repo validation into a shared normalizer used by both `searchPullRequests` and `searchGitHubIssues`. + +## Current State + +### PRs (`searchPullRequests`) +- **Done**: `normalizePullRequestQuery` in `normalize-pull-request-query.ts` handles URL paste, `#N`, bare numbers, cross-repo validation. Direct lookup via `octokit.pulls.get()`. Text search without `in:title`. 36 tests. + +### Issues (`searchGitHubIssues`) +- **Missing**: No URL parsing, no `#N` shorthand, no bare number direct lookup, no debounce handling on client, no cross-repo validation. +- Uses `in:title,body` for text search (fine, keep this). +- Lists open issues with `octokit.issues.listForRepo()` when no query (fine, keep this). + +## Shared vs Different + +The URL structure is the only real difference: +- PR: `github.com/:owner/:repo/pull/:number` +- Issue: `github.com/:owner/:repo/issues/:number` + +Everything else is identical: `#N` stripping, bare number detection, cross-repo owner/repo comparison, `NormalizedQuery` shape. + +## Plan + +### Step 1: Generalize normalizer → `normalize-github-query.ts` + +Rename `normalize-pull-request-query.ts` → `normalize-github-query.ts`. Make it handle both PR and issue URLs with a `kind` parameter. + +```typescript +type GitHubEntityKind = "pull" | "issue"; + +// Matches both /pull/123 and /issues/123 — the `kind` param controls which path to accept +const GITHUB_URL_RE = + /^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/(pull|issues)\/(\d+)(?:[/?#].*)?$/i; + +export function normalizeGitHubQuery( + raw: string, + repo: { owner: string; name: string }, + kind: GitHubEntityKind, +): NormalizedQuery { + if (!raw) return { query: "", repoMismatch: false, isDirectLookup: false }; + + // Full GitHub URL — accept both /pull/ and /issues/ URLs + const urlMatch = raw.match(GITHUB_URL_RE); + if (urlMatch) { + const urlOwner = urlMatch[1] as string; + const urlRepo = urlMatch[2] as string; + const urlKind = urlMatch[3] as string; // "pull" or "issues" + const number = urlMatch[4] as string; + + // Map URL path to kind: "pull" → "pull", "issues" → "issue" + const urlEntityKind = urlKind === "pull" ? "pull" : "issue"; + + // Wrong entity type (e.g. issue URL pasted in PR search) + if (urlEntityKind !== kind) { + return { query: raw, repoMismatch: false, isDirectLookup: false }; + } + + const isSameRepo = + urlOwner.toLowerCase() === repo.owner.toLowerCase() && + urlRepo.toLowerCase() === repo.name.toLowerCase(); + return { + query: isSameRepo ? number : "", + repoMismatch: !isSameRepo, + isDirectLookup: isSameRepo, + }; + } + + // `#123` shorthand + if (/^#\d+$/.test(raw)) { + return { query: raw.slice(1), repoMismatch: false, isDirectLookup: true }; + } + + // Bare number + if (/^\d+$/.test(raw)) { + return { query: raw, repoMismatch: false, isDirectLookup: true }; + } + + return { query: raw, repoMismatch: false, isDirectLookup: false }; +} +``` + +Key behavior: if you paste an issue URL into the PR search (or vice versa), it falls through to plain text search rather than blocking or extracting the number for the wrong entity type. + +### Step 2: Update `searchPullRequests` procedure + +Replace import: +```diff +-import { normalizePullRequestQuery } from "./normalize-pull-request-query"; ++import { normalizeGitHubQuery } from "./normalize-github-query"; +``` + +Replace call: +```diff +-const normalized = normalizePullRequestQuery(raw, repo); ++const normalized = normalizeGitHubQuery(raw, repo, "pull"); +``` + +No other changes to this procedure. + +### Step 3: Update `searchGitHubIssues` procedure + +Wire in the same normalizer + direct lookup: + +```typescript +const raw = input.query?.trim() ?? ""; +const normalized = normalizeGitHubQuery(raw, repo, "issue"); + +if (normalized.repoMismatch) { + return { issues: [], repoMismatch: `${repo.owner}/${repo.name}` }; +} + +const effectiveQuery = normalized.query; + +// Direct lookup by issue number +if (normalized.isDirectLookup) { + const issueNumber = Number.parseInt(effectiveQuery, 10); + const { data: issue } = await octokit.issues.get({ + owner: repo.owner, + repo: repo.name, + issue_number: issueNumber, + }); + // issues.get returns PRs too — filter them out + if (issue.pull_request) { + return { issues: [] }; + } + return { + issues: [{ + issueNumber: issue.number, + title: issue.title, + url: issue.html_url, + state: issue.state, + authorLogin: issue.user?.login ?? null, + }], + }; +} + +// Text search (keep existing `in:title,body`) +if (effectiveQuery) { + const q = `repo:${repo.owner}/${repo.name} is:issue is:open ${effectiveQuery}`; + // ... existing search logic +} + +// No query — list open issues (keep existing) +``` + +Note: `octokit.issues.get()` can return PRs (GitHub treats PRs as issues). Filter with `issue.pull_request` check. + +Also remove `in:title,body` from the text search query — same rationale as PRs. GitHub search without `in:` qualifiers searches title + body by default, so `in:title,body` is redundant. + +### Step 4: Update `GitHubIssueLinkCommand` client + +Same pattern as `PRLinkCommand`: +- Add `isPendingDebounce` handling +- Read `repoMismatch` from response +- Update empty state messages + +```diff ++const trimmedQuery = searchQuery.trim(); ++const debouncedTrimmed = debouncedQuery.trim(); ++const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + + // ... useQuery uses debouncedTrimmed + ++const repoMismatch = data && "repoMismatch" in data ? data.repoMismatch : null; ++const isLoading = debouncedTrimmed || trimmedQuery ++ ? isFetching || isPendingDebounce ++ : isFetching; + + // ... CommandEmpty: +-{isLoading ? "Loading issues..." : "No open issues found."} ++{isLoading ++ ? debouncedTrimmed ? "Searching..." : "Loading issues..." ++ : repoMismatch ++ ? `Issue URL must match ${repoMismatch}.` ++ : debouncedTrimmed ++ ? "No issues found." ++ : "No open issues found."} +``` + +### Step 5: Update tests + +Rename test file to `normalize-github-query.test.ts`. Expand to cover: +- All existing PR URL test cases, updated to pass `kind: "pull"` +- New issue URL test cases (`/issues/123`, `/issues/123?q=1`, cross-repo) +- Cross-entity: PR URL in issue search → plain text fallback +- Cross-entity: issue URL in PR search → plain text fallback +- `#N` and bare number cases for both kinds (same behavior) + +### Step 6: Delete old file + +Remove `normalize-pull-request-query.ts` and `normalize-pull-request-query.test.ts`. + +## Files + +| File | Action | +|------|--------| +| `normalize-github-query.ts` | **Create** — generalized normalizer with `kind` param | +| `normalize-github-query.test.ts` | **Create** — expanded tests for both PR and issue URLs | +| `workspace-creation.ts` | **Edit** — wire normalizer into both `searchPullRequests` + `searchGitHubIssues` | +| `normalize-pull-request-query.ts` | **Delete** | +| `normalize-pull-request-query.test.ts` | **Delete** | +| `GitHubIssueLinkCommand.tsx` (V2 client) | **Edit** — add debounce + repoMismatch handling | diff --git a/packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.test.ts b/packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.test.ts new file mode 100644 index 00000000000..6f960df8045 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.test.ts @@ -0,0 +1,511 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeGitHubQuery } from "./normalize-github-query"; + +const repo = { owner: "superset-sh", name: "superset" }; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared behaviors (same for both kinds) +// ───────────────────────────────────────────────────────────────────────────── + +describe("normalizeGitHubQuery — shared behaviors", () => { + describe("empty input", () => { + test("empty string (pull)", () => { + expect(normalizeGitHubQuery("", repo, "pull")).toEqual({ + query: "", + repoMismatch: false, + isDirectLookup: false, + }); + }); + + test("empty string (issue)", () => { + expect(normalizeGitHubQuery("", repo, "issue")).toEqual({ + query: "", + repoMismatch: false, + isDirectLookup: false, + }); + }); + }); + + describe("plain text search", () => { + test("regular text", () => { + const result = normalizeGitHubQuery("fix login bug", repo, "pull"); + expect(result.query).toBe("fix login bug"); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + + test("text with numbers", () => { + const result = normalizeGitHubQuery("v2 workspace", repo, "issue"); + expect(result.query).toBe("v2 workspace"); + expect(result.isDirectLookup).toBe(false); + }); + + test("text with special characters", () => { + const result = normalizeGitHubQuery("feat: add auth", repo, "pull"); + expect(result.query).toBe("feat: add auth"); + expect(result.isDirectLookup).toBe(false); + }); + }); + + describe("bare number (direct lookup)", () => { + test("single digit", () => { + const result = normalizeGitHubQuery("1", repo, "pull"); + expect(result.query).toBe("1"); + expect(result.isDirectLookup).toBe(true); + }); + + test("typical number", () => { + const result = normalizeGitHubQuery("3130", repo, "issue"); + expect(result.query).toBe("3130"); + expect(result.isDirectLookup).toBe(true); + }); + + test("large number", () => { + const result = normalizeGitHubQuery("99999", repo, "pull"); + expect(result.query).toBe("99999"); + expect(result.isDirectLookup).toBe(true); + }); + }); + + describe("#N shorthand (direct lookup)", () => { + test("#123 strips the hash", () => { + const result = normalizeGitHubQuery("#123", repo, "pull"); + expect(result.query).toBe("123"); + expect(result.isDirectLookup).toBe(true); + expect(result.repoMismatch).toBe(false); + }); + + test("#1 single digit", () => { + const result = normalizeGitHubQuery("#1", repo, "issue"); + expect(result.query).toBe("1"); + expect(result.isDirectLookup).toBe(true); + }); + + test("#3354 typical", () => { + const result = normalizeGitHubQuery("#3354", repo, "pull"); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("#abc is NOT shorthand", () => { + const result = normalizeGitHubQuery("#abc", repo, "pull"); + expect(result.query).toBe("#abc"); + expect(result.isDirectLookup).toBe(false); + }); + + test("#123abc is NOT shorthand", () => { + const result = normalizeGitHubQuery("#123abc", repo, "issue"); + expect(result.query).toBe("#123abc"); + expect(result.isDirectLookup).toBe(false); + }); + }); + + describe("non-GitHub URLs (plain text fallback)", () => { + test("GitHub repo URL (no entity path)", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset", + repo, + "pull", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + + test("GitHub compare URL", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/compare/main...feature", + repo, + "pull", + ); + expect(result.isDirectLookup).toBe(false); + }); + + test("non-GitHub URL", () => { + const result = normalizeGitHubQuery( + "https://gitlab.com/org/repo/merge_requests/123", + repo, + "pull", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + + test("SSH-style URL", () => { + const result = normalizeGitHubQuery( + "git@github.com:superset-sh/superset.git", + repo, + "pull", + ); + expect(result.isDirectLookup).toBe(false); + }); + + test("GitHub Enterprise URL (not supported)", () => { + const result = normalizeGitHubQuery( + "https://github.mycompany.com/org/repo/pull/123", + repo, + "pull", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PR URL tests (kind = "pull") +// ───────────────────────────────────────────────────────────────────────────── + +describe("normalizeGitHubQuery — PR URLs", () => { + describe("same repo", () => { + test("basic URL", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3130", + repo, + "pull", + ); + expect(result.query).toBe("3130"); + expect(result.isDirectLookup).toBe(true); + expect(result.repoMismatch).toBe(false); + }); + + test("/files tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/files", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("/changes tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/changes", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("/commits tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/commits", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("/checks tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/checks", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("trailing slash", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("query params", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354?diff=unified", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("query params on tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/files?diff=split&w=1", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("hash fragment", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354#discussion_r123", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("hash fragment on files tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354/files#diff-abc123", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("www prefix", () => { + const result = normalizeGitHubQuery( + "https://www.github.com/superset-sh/superset/pull/3354", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("http (not https)", () => { + const result = normalizeGitHubQuery( + "http://github.com/superset-sh/superset/pull/3354", + repo, + "pull", + ); + expect(result.query).toBe("3354"); + expect(result.isDirectLookup).toBe(true); + }); + + test("case-insensitive owner/repo", () => { + const result = normalizeGitHubQuery( + "https://github.com/Superset-SH/Superset/pull/100", + repo, + "pull", + ); + expect(result.query).toBe("100"); + expect(result.isDirectLookup).toBe(true); + expect(result.repoMismatch).toBe(false); + }); + + test("owner with dots and hyphens", () => { + const dotRepo = { owner: "my.org-name", name: "my.repo-name" }; + const result = normalizeGitHubQuery( + "https://github.com/my.org-name/my.repo-name/pull/42", + dotRepo, + "pull", + ); + expect(result.query).toBe("42"); + expect(result.isDirectLookup).toBe(true); + expect(result.repoMismatch).toBe(false); + }); + }); + + describe("cross-repo mismatch", () => { + test("different owner", () => { + const result = normalizeGitHubQuery( + "https://github.com/other-org/superset/pull/100", + repo, + "pull", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + expect(result.isDirectLookup).toBe(false); + }); + + test("different repo name", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/other-repo/pull/100", + repo, + "pull", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + }); + + test("completely different", () => { + const result = normalizeGitHubQuery( + "https://github.com/facebook/react/pull/28000", + repo, + "pull", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + }); + + test("cross-repo with /files tab", () => { + const result = normalizeGitHubQuery( + "https://github.com/other-org/other-repo/pull/50/files", + repo, + "pull", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Issue URL tests (kind = "issue") +// ───────────────────────────────────────────────────────────────────────────── + +describe("normalizeGitHubQuery — issue URLs", () => { + describe("same repo", () => { + test("basic URL", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/issues/100", + repo, + "issue", + ); + expect(result.query).toBe("100"); + expect(result.isDirectLookup).toBe(true); + expect(result.repoMismatch).toBe(false); + }); + + test("trailing slash", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/issues/100/", + repo, + "issue", + ); + expect(result.query).toBe("100"); + expect(result.isDirectLookup).toBe(true); + }); + + test("query params", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/issues/100?q=1", + repo, + "issue", + ); + expect(result.query).toBe("100"); + expect(result.isDirectLookup).toBe(true); + }); + + test("hash fragment (comment anchor)", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/issues/100#issuecomment-12345", + repo, + "issue", + ); + expect(result.query).toBe("100"); + expect(result.isDirectLookup).toBe(true); + }); + + test("www prefix", () => { + const result = normalizeGitHubQuery( + "https://www.github.com/superset-sh/superset/issues/200", + repo, + "issue", + ); + expect(result.query).toBe("200"); + expect(result.isDirectLookup).toBe(true); + }); + + test("http (not https)", () => { + const result = normalizeGitHubQuery( + "http://github.com/superset-sh/superset/issues/200", + repo, + "issue", + ); + expect(result.query).toBe("200"); + expect(result.isDirectLookup).toBe(true); + }); + + test("case-insensitive owner/repo", () => { + const result = normalizeGitHubQuery( + "https://github.com/Superset-SH/SUPERSET/issues/55", + repo, + "issue", + ); + expect(result.query).toBe("55"); + expect(result.repoMismatch).toBe(false); + expect(result.isDirectLookup).toBe(true); + }); + }); + + describe("cross-repo mismatch", () => { + test("different owner", () => { + const result = normalizeGitHubQuery( + "https://github.com/other-org/superset/issues/100", + repo, + "issue", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + }); + + test("different repo", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/other-repo/issues/100", + repo, + "issue", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + }); + + test("completely different with fragment", () => { + const result = normalizeGitHubQuery( + "https://github.com/facebook/react/issues/9999#issuecomment-1", + repo, + "issue", + ); + expect(result.query).toBe(""); + expect(result.repoMismatch).toBe(true); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Cross-entity tests (wrong URL kind pasted) +// ───────────────────────────────────────────────────────────────────────────── + +describe("normalizeGitHubQuery — cross-entity fallback", () => { + test("issue URL pasted into PR search → plain text", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/issues/100", + repo, + "pull", + ); + expect(result.query).toBe( + "https://github.com/superset-sh/superset/issues/100", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + + test("PR URL pasted into issue search → plain text", () => { + const result = normalizeGitHubQuery( + "https://github.com/superset-sh/superset/pull/3354", + repo, + "issue", + ); + expect(result.query).toBe( + "https://github.com/superset-sh/superset/pull/3354", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + + test("cross-repo issue URL pasted into PR search → plain text (no mismatch)", () => { + const result = normalizeGitHubQuery( + "https://github.com/facebook/react/issues/100", + repo, + "pull", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); + + test("cross-repo PR URL pasted into issue search → plain text (no mismatch)", () => { + const result = normalizeGitHubQuery( + "https://github.com/facebook/react/pull/28000", + repo, + "issue", + ); + expect(result.isDirectLookup).toBe(false); + expect(result.repoMismatch).toBe(false); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.ts b/packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.ts new file mode 100644 index 00000000000..b0406556a91 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.ts @@ -0,0 +1,66 @@ +export type GitHubEntityKind = "pull" | "issue"; + +export interface NormalizedQuery { + query: string; + repoMismatch: boolean; + /** When true, `query` is a number and should use direct lookup, not text search. */ + isDirectLookup: boolean; +} + +// Matches both /pull/123 and /issues/123 +const GITHUB_URL_RE = + /^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/(pull|issues)\/(\d+)(?:[/?#].*)?$/i; + +/** + * Normalize raw search input for GitHub PR or issue search endpoints. + * + * Handles: + * - Full GitHub URL → extract number, validate entity kind and repo + * - `#123` shorthand → strip `#`, direct lookup by number + * - Bare number `123` → direct lookup by number + * - Plain text → pass through for text search + */ +export function normalizeGitHubQuery( + raw: string, + repo: { owner: string; name: string }, + kind: GitHubEntityKind, +): NormalizedQuery { + if (!raw) return { query: "", repoMismatch: false, isDirectLookup: false }; + + // Full GitHub URL + const urlMatch = raw.match(GITHUB_URL_RE); + if (urlMatch) { + const urlOwner = urlMatch[1] as string; + const urlRepo = urlMatch[2] as string; + const urlPath = (urlMatch[3] as string).toLowerCase(); // "pull" or "issues" + const number = urlMatch[4] as string; + + // Wrong entity type (e.g. issue URL pasted in PR search) — fall through to text search + const urlEntityKind: GitHubEntityKind = + urlPath === "pull" ? "pull" : "issue"; + if (urlEntityKind !== kind) { + return { query: raw, repoMismatch: false, isDirectLookup: false }; + } + + const isSameRepo = + urlOwner.toLowerCase() === repo.owner.toLowerCase() && + urlRepo.toLowerCase() === repo.name.toLowerCase(); + return { + query: isSameRepo ? number : "", + repoMismatch: !isSameRepo, + isDirectLookup: isSameRepo, + }; + } + + // `#123` shorthand — strip the `#`, direct lookup by number + if (/^#\d+$/.test(raw)) { + return { query: raw.slice(1), repoMismatch: false, isDirectLookup: true }; + } + + // Bare number — direct lookup + if (/^\d+$/.test(raw)) { + return { query: raw, repoMismatch: false, isDirectLookup: true }; + } + + return { query: raw, repoMismatch: false, isDirectLookup: false }; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index c6785f407fe..33423319260 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -92,6 +92,8 @@ async function resolveGithubRepo( return { owner: repo.owner, name: repo.name }; } +import { normalizeGitHubQuery } from "./normalize-github-query"; + async function listBranchNames( ctx: HostServiceContext, repoPath: string, @@ -489,37 +491,58 @@ export const workspaceCreationRouter = router({ ) .query(async ({ ctx, input }) => { const repo = await resolveGithubRepo(ctx, input.projectId); - const octokit = await ctx.github(); const limit = input.limit ?? 30; + // Normalize the query: detect GitHub issue URLs, strip `#` shorthand + const raw = input.query?.trim() ?? ""; + const normalized = normalizeGitHubQuery(raw, repo, "issue"); + + if (normalized.repoMismatch) { + return { + issues: [], + repoMismatch: `${repo.owner}/${repo.name}`, + }; + } + + const effectiveQuery = normalized.query; + const octokit = await ctx.github(); + try { - if (input.query?.trim()) { - const q = `repo:${repo.owner}/${repo.name} is:issue is:open in:title,body ${input.query}`; - const { data } = await octokit.search.issuesAndPullRequests({ - q, - per_page: limit, + // Direct lookup by issue number (from URL paste or `#123` shorthand) + if (normalized.isDirectLookup) { + const issueNumber = Number.parseInt(effectiveQuery, 10); + const { data: issue } = await octokit.issues.get({ + owner: repo.owner, + repo: repo.name, + issue_number: issueNumber, }); + // issues.get returns PRs too — filter them out + if (issue.pull_request) { + return { issues: [] }; + } return { - issues: data.items - .filter((item) => !item.pull_request) - .map((item) => ({ - issueNumber: item.number, - title: item.title, - url: item.html_url, - state: item.state, - authorLogin: item.user?.login ?? null, - })), + issues: [ + { + issueNumber: issue.number, + title: issue.title, + url: issue.html_url, + state: issue.state, + authorLogin: issue.user?.login ?? null, + }, + ], }; } - const { data } = await octokit.issues.listForRepo({ - owner: repo.owner, - repo: repo.name, - state: "open", + const q = + `repo:${repo.owner}/${repo.name} is:issue ${effectiveQuery}`.trim(); + const { data } = await octokit.search.issuesAndPullRequests({ + q, per_page: limit, + sort: "updated", + order: "desc", }); return { - issues: data + issues: data.items .filter((item) => !item.pull_request) .map((item) => ({ issueNumber: item.number, @@ -545,47 +568,64 @@ export const workspaceCreationRouter = router({ ) .query(async ({ ctx, input }) => { const repo = await resolveGithubRepo(ctx, input.projectId); - const octokit = await ctx.github(); const limit = input.limit ?? 30; + // Normalize the query: detect GitHub PR URLs, strip `#` shorthand + const raw = input.query?.trim() ?? ""; + const normalized = normalizeGitHubQuery(raw, repo, "pull"); + + if (normalized.repoMismatch) { + return { + pullRequests: [], + repoMismatch: `${repo.owner}/${repo.name}`, + }; + } + + const effectiveQuery = normalized.query; + const octokit = await ctx.github(); + try { - if (input.query?.trim()) { - const q = `repo:${repo.owner}/${repo.name} is:pr in:title ${input.query}`; - const { data } = await octokit.search.issuesAndPullRequests({ - q, - per_page: limit, + // Direct lookup by PR number (from URL paste or `#123` shorthand) + if (normalized.isDirectLookup) { + const prNumber = Number.parseInt(effectiveQuery, 10); + const { data: pr } = await octokit.pulls.get({ + owner: repo.owner, + repo: repo.name, + pull_number: prNumber, }); return { - pullRequests: data.items - .filter((item) => item.pull_request) - .map((item) => ({ - prNumber: item.number, - title: item.title, - url: item.html_url, - state: item.state, - isDraft: item.draft ?? false, - authorLogin: item.user?.login ?? null, - })), + pullRequests: [ + { + prNumber: pr.number, + title: pr.title, + url: pr.html_url, + state: pr.state, + isDraft: pr.draft ?? false, + authorLogin: pr.user?.login ?? null, + }, + ], }; } - const { data } = await octokit.pulls.list({ - owner: repo.owner, - repo: repo.name, - state: "open", - sort: "updated", - direction: "desc", + const q = + `repo:${repo.owner}/${repo.name} is:pr ${effectiveQuery}`.trim(); + const { data } = await octokit.search.issuesAndPullRequests({ + q, per_page: limit, + sort: "updated", + order: "desc", }); return { - pullRequests: data.map((pr) => ({ - prNumber: pr.number, - title: pr.title, - url: pr.html_url, - state: pr.state, - isDraft: pr.draft ?? false, - authorLogin: pr.user?.login ?? null, - })), + pullRequests: data.items + .filter((item) => item.pull_request) + .map((item) => ({ + prNumber: item.number, + title: item.title, + url: item.html_url, + state: item.state, + isDraft: item.draft ?? false, + authorLogin: item.user?.login ?? null, + })), }; } catch (err) { console.warn("[workspaceCreation.searchPullRequests] failed", err);