Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 79 additions & 28 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ type OpenNewResult =
| { canceled: false; needsGitInit: true; selectedPath: string }
| OpenNewError;

/**
* Parses and transforms raw GitHub PR data from CLI output.
* Filters valid PR objects and maps them to our internal format.
*/
function isRawPullRequest(item: unknown): item is {
number: number;
title: string;
url: string;
state: string;
isDraft: boolean;
} {
if (typeof item !== "object" || item === null) return false;

const value = item as Record<string, unknown>;
return (
typeof value.number === "number" &&
typeof value.title === "string" &&
typeof value.url === "string" &&
typeof value.state === "string" &&
typeof value.isDraft === "boolean"
);
}

function parsePullRequests(raw: unknown) {
if (!Array.isArray(raw)) return [];

return raw.filter(isRawPullRequest).map((pr) => ({
prNumber: pr.number,
title: pr.title,
url: pr.url,
state: pr.isDraft
? "draft"
: pr.state === "OPEN"
? "open"
: pr.state.toLowerCase(),
}));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

type FolderOutcome =
| { status: "success"; project: Project }
| { status: "needsGitInit"; selectedPath: string }
Expand Down Expand Up @@ -326,40 +364,53 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
{ cwd: project.mainRepoPath },
);
const raw: unknown = JSON.parse(stdout.trim() || "[]");
if (!Array.isArray(raw)) return [];
return raw
.filter(
(
item: unknown,
): item is {
number: number;
title: string;
url: string;
state: string;
isDraft: boolean;
} =>
typeof item === "object" &&
item !== null &&
"number" in item &&
"title" in item &&
"url" in item,
)
.map((pr) => ({
prNumber: pr.number,
title: pr.title,
url: pr.url,
state: pr.isDraft
? "draft"
: pr.state === "OPEN"
? "open"
: pr.state.toLowerCase(),
}));
return parsePullRequests(raw);
} catch (err) {
console.warn("[listPullRequests] Failed to list PRs:", err);
return [];
}
}),

searchPullRequests: publicProcedure
.input(
z.object({
projectId: z.string(),
query: z.string(),
}),
)
.query(async ({ input }) => {
const project = localDb
.select()
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) return [];

try {
const { stdout } = await execWithShellEnv(
"gh",
[
"pr",
"list",
"--state",
"all",
"--search",
input.query,
"--limit",
"100",
"--json",
"number,title,url,state,isDraft",
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{ cwd: project.mainRepoPath, timeout: 10_000 },
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const raw: unknown = JSON.parse(stdout.trim() || "[]");
return parsePullRequests(raw);
} catch (err) {
console.warn("[searchPullRequests] Failed to search PRs:", err);
return [];
}
}),

listIssues: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,8 @@ ${sanitizeText(truncatedBody)}`;
onOpenChange={setPRLinkOpen}
onSelect={setLinkedPR}
projectId={projectId}
githubOwner={project?.githubOwner ?? null}
repoName={project?.mainRepoPath.split("/").pop() ?? null}
anchorRef={plusMenuRef}
/>
<PromptInputSubmit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,16 @@ import {
CommandList,
} from "@superset/ui/command";
import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover";
import Fuse from "fuse.js";
import type React from "react";
import type { RefObject } from "react";
import { useMemo, useState } from "react";
import { useDebouncedValue } from "renderer/hooks/useDebouncedValue";
import { electronTrpc } from "renderer/lib/electron-trpc";
import {
PRIcon,
type PRState,
} from "renderer/screens/main/components/PRIcon/PRIcon";

const MAX_RESULTS = 20;

export interface SelectedPR {
prNumber: number;
title: string;
Expand All @@ -31,64 +29,114 @@ interface PRLinkCommandProps {
onOpenChange: (open: boolean) => void;
onSelect: (pr: SelectedPR) => void;
projectId: string | null;
githubOwner: string | null;
repoName: string | null;
anchorRef: RefObject<HTMLElement | null>;
}

function parseGitHubPullRequestUrl(query: string): {
owner: string;
repo: string;
prNumber: string;
} | null {
const match = query.match(
/^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i,
);

if (!match) return null;

return {
owner: match[1],
repo: match[2],
prNumber: match[3],
};
}

export function PRLinkCommand({
open,
onOpenChange,
onSelect,
projectId,
githubOwner,
repoName,
anchorRef,
}: PRLinkCommandProps) {
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebouncedValue(searchQuery, 300);
const trimmedQuery = searchQuery.trim(); // Immediate trim for UI decisions
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const debouncedTrimmed = debouncedQuery.trim(); // Debounced trim for RPC calls

// Detect if we're in the pending debounce state
const isPendingDebounce = trimmedQuery !== debouncedTrimmed;

const parsedPullRequestUrl = useMemo(() => {
return parseGitHubPullRequestUrl(debouncedTrimmed);
}, [debouncedTrimmed]);

const selectedRepositoryLabel = useMemo(() => {
if (!githubOwner || !repoName) return null;
return `${githubOwner}/${repoName}`;
}, [githubOwner, repoName]);

const { data: pullRequests, isLoading } =
const pastedRepository = useMemo(() => {
if (!parsedPullRequestUrl) return null;
return `${parsedPullRequestUrl.owner}/${parsedPullRequestUrl.repo}`.toLowerCase();
}, [parsedPullRequestUrl]);

const isCrossRepositoryUrl = Boolean(
selectedRepositoryLabel &&
pastedRepository &&
pastedRepository !== selectedRepositoryLabel.toLowerCase(),
);

// Search by PR number when the pasted URL matches the selected repository.
const effectiveQuery = parsedPullRequestUrl
? isCrossRepositoryUrl
? ""
: parsedPullRequestUrl.prNumber
: debouncedTrimmed;

// Fetch recent PRs for browsing (only when no search query)
const { data: recentPRs, isLoading: isLoadingRecent } =
electronTrpc.projects.listPullRequests.useQuery(
{ projectId: projectId ?? "" },
{ enabled: !!projectId && open },
{ enabled: !!projectId && open && !debouncedTrimmed },
);

const prsWithSearchField = useMemo(
() =>
(pullRequests ?? []).map((pr) => ({
...pr,
prNumberStr: String(pr.prNumber),
})),
[pullRequests],
);
// Server-side search when user types (use debounced for RPC)
const { data: searchResults, isLoading: isSearching } =
electronTrpc.projects.searchPullRequests.useQuery(
{ projectId: projectId ?? "", query: effectiveQuery },
{
enabled:
!!projectId && open && !!effectiveQuery && !isCrossRepositoryUrl,
},
);

const prFuse = useMemo(
() =>
new Fuse(prsWithSearchField, {
keys: [
{ name: "prNumberStr", weight: 3 },
{ name: "title", weight: 2 },
],
threshold: 0.4,
ignoreLocation: true,
}),
[prsWithSearchField],
);
const pullRequests = useMemo(() => {
if (isCrossRepositoryUrl) {
return [];
}

const searchResults = useMemo(() => {
if (!prsWithSearchField.length) return [];
if (!searchQuery) {
return prsWithSearchField.slice(0, MAX_RESULTS);
// Use debounced value for mode decision to avoid empty gap
if (debouncedTrimmed) {
return searchResults ?? [];
}
const urlMatch = prsWithSearchField.find((pr) => pr.url === searchQuery);
if (urlMatch) return [urlMatch];
return prFuse
.search(searchQuery, { limit: MAX_RESULTS })
.map((r) => r.item);
}, [prsWithSearchField, searchQuery, prFuse]);
return recentPRs ?? [];
}, [debouncedTrimmed, isCrossRepositoryUrl, searchResults, recentPRs]);

const isLoading = isCrossRepositoryUrl
? false
: debouncedTrimmed
? isSearching || isPendingDebounce
: isLoadingRecent;

const handleClose = () => {
setSearchQuery("");
onOpenChange(false);
};

const handleSelect = (pr: (typeof searchResults)[number]) => {
const handleSelect = (pr: (typeof pullRequests)[number]) => {
onSelect({
prNumber: pr.prNumber,
title: pr.title,
Expand Down Expand Up @@ -117,18 +165,28 @@ export function PRLinkCommand({
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[280px]">
{searchResults.length === 0 && (
{pullRequests.length === 0 && (
<CommandEmpty>
{isLoading
? "Loading pull requests..."
: "No open pull requests found."}
? debouncedTrimmed
? "Searching..."
: "Loading pull requests..."
: isCrossRepositoryUrl
? `PR URL must match ${selectedRepositoryLabel}.`
: debouncedTrimmed
? "No pull requests found."
: "No open pull requests."}
</CommandEmpty>
)}
{searchResults.length > 0 && (
{pullRequests.length > 0 && (
<CommandGroup
heading={searchQuery ? "Results" : "Open pull requests"}
heading={
debouncedTrimmed
? `${pullRequests.length} result${pullRequests.length === 1 ? "" : "s"}`
: "Recent pull requests"
}
>
{searchResults.map((pr) => (
{pullRequests.map((pr) => (
<CommandItem
key={pr.prNumber}
value={`${pr.prNumber}-${pr.title}`}
Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading