diff --git a/apps/admin/package.json b/apps/admin/package.json index bdb7fe1bf36..425f256b0f2 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -18,7 +18,7 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.90.10", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c1416c34abc..8ce43955c96 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -103,8 +103,10 @@ "@tanstack/electric-db-collection": "0.3.3", "@tanstack/electron-db-sqlite-persistence": "0.1.9", "@tanstack/node-db-sqlite-persistence": "0.1.9", + "@tanstack/query-async-storage-persister": "^5.100.9", "@tanstack/react-db": "0.1.83", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-persist-client": "^5.100.9", "@tanstack/react-router": "^1.147.3", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx index 91295eaa98f..28217e59e28 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx @@ -3,31 +3,32 @@ import { LuImageOff } from "react-icons/lu"; /** * Check if an image source is safe to load. * - * Uses strict ALLOWLIST approach - only data: URLs are safe. - * * ALLOWED: * - data: URLs (embedded base64 images) + * - https:// URLs (GitHub user-attachments, avatars, etc.) * * BLOCKED (everything else): - * - http://, https:// (tracking pixels, privacy leak) + * - http:// (cleartext / mixed-content) * - file:// URLs (arbitrary local file access) * - Absolute paths /... or \... (become file:// in Electron) * - Relative paths with .. (can escape repo boundary) * - UNC paths //server/share (Windows NTLM credential leak) * - Empty or malformed sources * - * Security context: In Electron production, renderer loads via file:// - * protocol. Any non-data: image src could access local filesystem or - * trigger network requests to attacker-controlled servers. + * Trade-off: https sources can phone home (tracking pixels). Acceptable + * here because the markdown comes from trusted sources (GitHub PR/issue + * bodies, user-authored task descriptions) where image embedding is part + * of the expected UX. */ function isSafeImageSrc(src: string | undefined): boolean { if (!src) return false; const trimmed = src.trim(); if (trimmed.length === 0) return false; + const lower = trimmed.toLowerCase(); - // Only allow data: URLs (embedded images) - // These are self-contained and can't access external resources - return trimmed.toLowerCase().startsWith("data:"); + if (lower.startsWith("data:")) return true; + if (lower.startsWith("https://")) return true; + return false; } interface SafeImageProps { @@ -37,15 +38,11 @@ interface SafeImageProps { } /** - * Safe image component for untrusted markdown content. - * - * Only renders embedded data: URLs. All other sources are blocked - * to prevent local file access, network requests, and path traversal - * attacks from malicious repository content. + * Safe image component for markdown content. * - * Future: Could add opt-in support for repo-relative images via a - * secure loader that validates paths through secureFs and serves - * as blob: URLs. + * Renders data: and http(s):// images. file://, absolute paths, UNC paths, + * and traversal sources are blocked to prevent local-filesystem access from + * malicious markdown content. */ export function SafeImage({ src, alt, className }: SafeImageProps) { if (!isSafeImageSrc(src)) { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx index 5173f7e4557..488537d63aa 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx @@ -12,7 +12,6 @@ export const tufteConfig: MarkdownStyleConfig = { {children} ), - // Block external images for privacy (tracking pixels, etc.) img: ({ src, alt }) => , }, }; diff --git a/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx b/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx index 58cc75aa1fa..a1917de4889 100644 --- a/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx +++ b/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx @@ -1,7 +1,16 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; +import { + defaultShouldDehydrateQuery, + QueryClient, +} from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import { del, get, set } from "idb-keyval"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronReactClient } from "../../lib/trpc-client"; +// Bump when query response shapes change — invalidates the persisted cache. +const PERSIST_BUSTER = "v1"; + // Shared QueryClient for tRPC hooks and router loaders const queryClient = new QueryClient({ defaultOptions: { @@ -16,10 +25,30 @@ const queryClient = new QueryClient({ }, }); -/** - * Provider for Electron IPC tRPC client. - * QueryClient is shared with router context for loader prefetching. - */ +// IndexedDB-backed persister. localStorage is too small (~5MB) for the +// volume of PR/issue rows we cache. idb-keyval uses a single object store +// keyed by the persister's `key` below. +const persister = createAsyncStoragePersister({ + storage: { + getItem: async (key) => (await get(key)) ?? null, + setItem: async (key, value) => { + await set(key, value); + }, + removeItem: async (key) => { + await del(key); + }, + }, + key: "superset-rq-cache", +}); + +// Whitelist of queryKey prefixes worth persisting — anything else (auth +// tokens, ephemeral host state, transient mutations) is left in memory only. +const PERSIST_KEY_PREFIXES = new Set([ + "tasks", // PR/issue list infinite queries + "pull-request-detail", + "issue-detail", +]); + export function ElectronTRPCProvider({ children, }: { @@ -30,7 +59,23 @@ export function ElectronTRPCProvider({ client={electronReactClient} queryClient={queryClient} > - {children} + { + if (!defaultShouldDehydrateQuery(query)) return false; + const head = query.queryKey[0]; + return typeof head === "string" && PERSIST_KEY_PREFIXES.has(head); + }, + }, + }} + > + {children} + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx index 9611eef13ef..f13dada54d7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx @@ -4,7 +4,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useTasksFilterStore } from "../../stores/tasks-filter-state"; import { BoardContent } from "./components/BoardContent"; +import { GitHubIssuesContent } from "./components/GitHubIssuesContent"; import { LinearCTA } from "./components/LinearCTA"; +import { PullRequestsContent } from "./components/PullRequestsContent"; import { TableContent } from "./components/TableContent"; import { type TabValue, TasksTopBar } from "./components/TasksTopBar"; import type { TaskWithStatus } from "./hooks/useTasksData"; @@ -13,41 +15,77 @@ interface TasksViewProps { initialTab?: "all" | "active" | "backlog"; initialAssignee?: string; initialSearch?: string; + initialType?: "tasks" | "prs" | "issues"; + initialProject?: string; } export function TasksView({ initialTab, initialAssignee, initialSearch, + initialType, + initialProject, }: TasksViewProps) { const navigate = useNavigate(); const collections = useCollections(); const currentTab: TabValue = initialTab ?? "all"; const [searchQuery, setSearchQuery] = useState(initialSearch ?? ""); const assigneeFilter = initialAssignee ?? null; + const typeTab = initialType ?? "tasks"; + const projectFilter = initialProject ?? null; const { setTab: storeSetTab, setAssignee: storeSetAssignee, setSearch: storeSetSearch, + setTypeTab: storeSetTypeTab, + setProjectFilter: storeSetProjectFilter, viewMode, setViewMode, } = useTasksFilterStore(); const debounceRef = useRef>(null); + const buildSearch = useCallback( + (overrides: { + tab?: TabValue; + assignee?: string | null; + search?: string; + type?: "tasks" | "prs" | "issues"; + project?: string | null; + }) => { + const tab = overrides.tab ?? currentTab; + const assignee = + overrides.assignee !== undefined ? overrides.assignee : assigneeFilter; + const query = + overrides.search !== undefined ? overrides.search : searchQuery; + const type = overrides.type ?? typeTab; + const project = + overrides.project !== undefined ? overrides.project : projectFilter; + + const search: Record = {}; + if (tab !== "all") search.tab = tab; + if (assignee) search.assignee = assignee; + if (query) search.search = query; + if (type !== "tasks") search.type = type; + if (project) search.project = project; + return search; + }, + [currentTab, assigneeFilter, searchQuery, typeTab, projectFilter], + ); + const syncSearchToUrl = useCallback( (query: string) => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - const search: Record = {}; - if (currentTab !== "all") search.tab = currentTab; - if (assigneeFilter) search.assignee = assigneeFilter; - if (query) search.search = query; - navigate({ to: "/tasks", search, replace: true }); + navigate({ + to: "/tasks", + search: buildSearch({ search: query }), + replace: true, + }); }, 300); }, - [navigate, currentTab, assigneeFilter], + [navigate, buildSearch], ); useEffect(() => { @@ -77,6 +115,14 @@ export function TasksView({ storeSetSearch(searchQuery); }, [searchQuery, storeSetSearch]); + useEffect(() => { + storeSetTypeTab(typeTab); + }, [typeTab, storeSetTypeTab]); + + useEffect(() => { + storeSetProjectFilter(projectFilter); + }, [projectFilter, storeSetProjectFilter]); + const { data: integrations } = useLiveQuery( (q) => q @@ -91,19 +137,23 @@ export function TasksView({ integrations?.some((i) => i.provider === "linear") ?? false; const handleTabChange = (tab: TabValue) => { - const search: Record = {}; - if (tab !== "all") search.tab = tab; - if (assigneeFilter) search.assignee = assigneeFilter; - if (searchQuery) search.search = searchQuery; - navigate({ to: "/tasks", search, replace: true }); + navigate({ to: "/tasks", search: buildSearch({ tab }), replace: true }); }; const handleAssigneeFilterChange = (assignee: string | null) => { - const search: Record = {}; - if (currentTab !== "all") search.tab = currentTab; - if (assignee) search.assignee = assignee; - if (searchQuery) search.search = searchQuery; - navigate({ to: "/tasks", search, replace: true }); + navigate({ + to: "/tasks", + search: buildSearch({ assignee }), + replace: true, + }); + }; + + const handleTypeTabChange = (type: "tasks" | "prs" | "issues") => { + navigate({ to: "/tasks", search: buildSearch({ type }), replace: true }); + }; + + const handleProjectFilterChange = (project: string | null) => { + navigate({ to: "/tasks", search: buildSearch({ project }), replace: true }); }; const [selectedTasks, setSelectedTasks] = useState([]); @@ -122,18 +172,19 @@ export function TasksView({ }, []); const handleTaskClick = (task: TaskWithStatus) => { - const search: Record = {}; - if (currentTab !== "all") search.tab = currentTab; - if (assigneeFilter) search.assignee = assigneeFilter; - if (searchQuery) search.search = searchQuery; navigate({ to: "/tasks/$taskId", params: { taskId: task.id }, - search, + search: buildSearch({}), }); }; - const showLinearCTA = integrations !== undefined && !isLinearConnected; + const showLinearCTA = + integrations !== undefined && !isLinearConnected && typeTab === "tasks"; + + const showTasks = typeTab === "tasks"; + const showPRs = typeTab === "prs"; + const showIssues = typeTab === "issues"; return (
@@ -149,26 +200,47 @@ export function TasksView({ onClearSelection={handleClearSelection} viewMode={viewMode} onViewModeChange={setViewMode} + typeTab={typeTab} + onTypeTabChange={handleTypeTabChange} + projectFilter={projectFilter} + onProjectFilterChange={handleProjectFilterChange} /> )} {showLinearCTA ? ( - ) : viewMode === "board" ? ( - ) : ( - +
+ {showTasks && + (viewMode === "board" ? ( + + ) : ( + + ))} + {showPRs && ( + + )} + {showIssues && ( + + )} +
)}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx new file mode 100644 index 00000000000..ab01b985342 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx @@ -0,0 +1,312 @@ +import { Button } from "@superset/ui/button"; +import { Checkbox } from "@superset/ui/checkbox"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { GoIssueClosed, GoIssueOpened } from "react-icons/go"; +import { HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; +import { LuMinus, LuPlus, LuRefreshCw } from "react-icons/lu"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + type LinkedIssue, + useNewWorkspaceDraftStore, +} from "renderer/stores/new-workspace-draft"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; + +interface GitHubIssuesContentProps { + projectFilter: string | null; + searchQuery: string; + onCollapse?: () => void; +} + +const PAGE_SIZE = 30; + +export function GitHubIssuesContent({ + projectFilter, + searchQuery, + onCollapse, +}: GitHubIssuesContentProps) { + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const hostUrl = useHostUrl(null); + const navigate = useNavigate(); + const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); + const openModal = useOpenNewWorkspaceModal(); + + const { + data, + isFetching, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + error, + refetch, + } = useInfiniteQuery({ + queryKey: [ + "tasks", + "searchGitHubIssues", + projectFilter, + hostUrl, + debouncedQuery.trim(), + showClosed, + ], + queryFn: async ({ pageParam }) => { + if (!hostUrl || !projectFilter) { + return { + issues: [], + totalCount: 0, + hasNextPage: false, + page: pageParam, + }; + } + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchGitHubIssues.query({ + projectId: projectFilter, + query: debouncedQuery.trim() || undefined, + limit: PAGE_SIZE, + includeClosed: showClosed, + page: pageParam, + }); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasNextPage ? lastPage.page + 1 : undefined, + staleTime: 30_000, + gcTime: 10 * 60_000, + placeholderData: keepPreviousData, + enabled: !!projectFilter && !!hostUrl, + retry: false, + }); + + const issues = useMemo( + () => data?.pages.flatMap((p) => p.issues) ?? [], + [data], + ); + const totalCount = data?.pages[0]?.totalCount ?? 0; + const repoMismatch = useMemo(() => { + const first = data?.pages[0]; + return first && "repoMismatch" in first ? first.repoMismatch : null; + }, [data]); + + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + useEffect(() => { + const el = sentinelRef.current; + const root = scrollRef.current; + if (!el || !root || !hasNextPage || isFetchingNextPage) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) fetchNextPage(); + }, + { root, rootMargin: "200px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleAddToWorkspace = (issue: (typeof issues)[number]) => { + if (!projectFilter) return; + const linkedIssue: LinkedIssue = { + slug: `gh-${issue.issueNumber}`, + title: issue.title, + source: "github", + url: issue.url, + number: issue.issueNumber, + state: issue.state.toLowerCase() === "closed" ? "closed" : "open", + }; + resetDraft(); + updateDraft({ + selectedProjectId: projectFilter, + linkedIssues: [linkedIssue], + }); + openModal(projectFilter); + }; + + const handleOpenUrl = (url: string) => { + window.open(url, "_blank", "noopener,noreferrer"); + }; + + const handleOpenPreview = (issueNumber: number) => { + if (!projectFilter) return; + navigate({ + to: "/tasks/issue/$issueNumber", + params: { issueNumber: String(issueNumber) }, + search: { project: projectFilter }, + }); + }; + + if (!projectFilter) { + return ( +
+
+ + Select a project to see issues. +
+
+ ); + } + + const isInitialLoad = isFetching && issues.length === 0; + const countLabel = isInitialLoad + ? "Loading…" + : totalCount === 0 + ? "0" + : `${issues.length} of ${totalCount}`; + + return ( +
+
+ + + GitHub issues + + + {countLabel} + + + {onCollapse && ( + + )} +
+ +
+ setShowClosed(checked === true)} + /> + + {isFetching && !isInitialLoad && ( + Loading… + )} +
+ +
+ {error instanceof Error && ( +
+ {error.message} +
+ )} + + {repoMismatch && ( +
+ Issue URL must match {repoMismatch}. +
+ )} + + {totalCount === 0 && !isFetching && !error ? ( +
+ + {showClosed ? "No issues found." : "No open issues."} + +
+ ) : ( +
+ {issues.map((issue) => { + const isClosed = issue.state.toLowerCase() === "closed"; + const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened; + return ( + // biome-ignore lint/a11y/useSemanticElements: row contains nested action buttons, so the outer element is a div with role/tabIndex +
handleOpenPreview(issue.issueNumber)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleOpenPreview(issue.issueNumber); + } + }} + role="button" + tabIndex={0} + > + + + #{issue.issueNumber} + + + {issue.title} + + {issue.authorLogin && ( + + {issue.authorLogin} + + )} +
+ + +
+
+ ); + })} + {hasNextPage && ( +
+ {isFetchingNextPage ? "Loading more…" : ""} +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/index.ts new file mode 100644 index 00000000000..7aec84f6c85 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/index.ts @@ -0,0 +1 @@ +export { GitHubIssuesContent } from "./GitHubIssuesContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/PullRequestsContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/PullRequestsContent.tsx new file mode 100644 index 00000000000..3205d653b45 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/PullRequestsContent.tsx @@ -0,0 +1,308 @@ +import { Button } from "@superset/ui/button"; +import { Checkbox } from "@superset/ui/checkbox"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { GoGitPullRequest } from "react-icons/go"; +import { HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; +import { LuMinus, LuPlus, LuRefreshCw } from "react-icons/lu"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + normalizePRState, + PRIcon, +} from "renderer/screens/main/components/PRIcon"; +import { + type LinkedPR, + useNewWorkspaceDraftStore, +} from "renderer/stores/new-workspace-draft"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; + +interface PullRequestsContentProps { + projectFilter: string | null; + searchQuery: string; + onCollapse?: () => void; +} + +const PAGE_SIZE = 30; + +export function PullRequestsContent({ + projectFilter, + searchQuery, + onCollapse, +}: PullRequestsContentProps) { + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const hostUrl = useHostUrl(null); + const navigate = useNavigate(); + const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); + const openModal = useOpenNewWorkspaceModal(); + + const { + data, + isFetching, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + error, + refetch, + } = useInfiniteQuery({ + queryKey: [ + "tasks", + "searchPullRequests", + projectFilter, + hostUrl, + debouncedQuery.trim(), + showClosed, + ], + queryFn: async ({ pageParam }) => { + if (!hostUrl || !projectFilter) { + return { + pullRequests: [], + totalCount: 0, + hasNextPage: false, + page: pageParam, + }; + } + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchPullRequests.query({ + projectId: projectFilter, + query: debouncedQuery.trim() || undefined, + limit: PAGE_SIZE, + includeClosed: showClosed, + page: pageParam, + }); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasNextPage ? lastPage.page + 1 : undefined, + staleTime: 30_000, + gcTime: 10 * 60_000, + placeholderData: keepPreviousData, + enabled: !!projectFilter && !!hostUrl, + retry: false, + }); + + const pullRequests = useMemo( + () => data?.pages.flatMap((p) => p.pullRequests) ?? [], + [data], + ); + const totalCount = data?.pages[0]?.totalCount ?? 0; + const repoMismatch = useMemo(() => { + const first = data?.pages[0]; + return first && "repoMismatch" in first ? first.repoMismatch : null; + }, [data]); + + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + useEffect(() => { + const el = sentinelRef.current; + const root = scrollRef.current; + if (!el || !root || !hasNextPage || isFetchingNextPage) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) fetchNextPage(); + }, + { root, rootMargin: "200px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleAddToWorkspace = (pr: (typeof pullRequests)[number]) => { + if (!projectFilter) return; + const linkedPR: LinkedPR = { + prNumber: pr.prNumber, + title: pr.title, + url: pr.url, + state: normalizePRState(pr.state, pr.isDraft), + }; + resetDraft(); + updateDraft({ selectedProjectId: projectFilter, linkedPR }); + openModal(projectFilter); + }; + + const handleOpenUrl = (url: string) => { + window.open(url, "_blank", "noopener,noreferrer"); + }; + + const handleOpenPreview = (prNumber: number) => { + if (!projectFilter) return; + navigate({ + to: "/tasks/pr/$prNumber", + params: { prNumber: String(prNumber) }, + search: { project: projectFilter }, + }); + }; + + if (!projectFilter) { + return ( +
+
+ + + Select a project to see pull requests. + +
+
+ ); + } + + const isInitialLoad = isFetching && pullRequests.length === 0; + const countLabel = isInitialLoad + ? "Loading…" + : totalCount === 0 + ? "0" + : `${pullRequests.length} of ${totalCount}`; + + return ( +
+
+ + + Pull requests + + + {countLabel} + + + {onCollapse && ( + + )} +
+ +
+ setShowClosed(checked === true)} + /> + + {isFetching && !isInitialLoad && ( + Loading… + )} +
+ +
+ {error instanceof Error && ( +
+ {error.message} +
+ )} + + {repoMismatch && ( +
+ PR URL must match {repoMismatch}. +
+ )} + + {totalCount === 0 && !isFetching && !error ? ( +
+ + {showClosed + ? "No pull requests found." + : "No open pull requests."} + +
+ ) : ( +
+ {pullRequests.map((pr) => { + const state = normalizePRState(pr.state, pr.isDraft); + return ( + // biome-ignore lint/a11y/useSemanticElements: row contains nested action buttons, so the outer element is a div with role/tabIndex +
handleOpenPreview(pr.prNumber)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleOpenPreview(pr.prNumber); + } + }} + role="button" + tabIndex={0} + > + + + #{pr.prNumber} + + + {pr.title} + + {pr.authorLogin && ( + + {pr.authorLogin} + + )} +
+ + +
+
+ ); + })} + {hasNextPage && ( +
+ {isFetchingNextPage ? "Loading more…" : ""} +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/index.ts new file mode 100644 index 00000000000..f6b236e1379 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/index.ts @@ -0,0 +1 @@ +export { PullRequestsContent } from "./PullRequestsContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx index 02a44301a8b..792dce8edeb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx @@ -3,6 +3,7 @@ import { Input } from "@superset/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { cn } from "@superset/ui/utils"; import { useRef, useState } from "react"; +import { GoGitPullRequest, GoIssueOpened } from "react-icons/go"; import { HiOutlineMagnifyingGlass, HiOutlinePencilSquare, @@ -12,15 +13,15 @@ import { } from "react-icons/hi2"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useHotkey } from "renderer/hotkeys"; -import type { ViewMode } from "../../../../stores/tasks-filter-state"; +import type { TypeTab, ViewMode } from "../../../../stores/tasks-filter-state"; import type { TaskWithStatus } from "../../hooks/useTasksData"; import { ActiveIcon } from "../shared/icons/ActiveIcon"; -import { AllIssuesIcon } from "../shared/icons/AllIssuesIcon"; -import { BacklogIcon } from "../shared/icons/BacklogIcon"; import { AssigneeFilter } from "./components/AssigneeFilter"; import { CreateTaskDialog } from "./components/CreateTaskDialog"; +import { ProjectFilter } from "./components/ProjectFilter"; import { RunInWorkspacePopover } from "./components/RunInWorkspacePopover"; import { RunInWorkspacePopoverV2 } from "./components/RunInWorkspacePopoverV2"; +import { StatusFilter } from "./components/StatusFilter"; export type TabValue = "all" | "active" | "backlog"; @@ -35,24 +36,16 @@ interface TasksTopBarProps { onClearSelection?: () => void; viewMode: ViewMode; onViewModeChange: (mode: ViewMode) => void; + typeTab: TypeTab; + onTypeTabChange: (typeTab: TypeTab) => void; + projectFilter: string | null; + onProjectFilterChange: (projectId: string | null) => void; } -const TABS = [ - { - value: "all" as const, - label: "All issues", - Icon: AllIssuesIcon, - }, - { - value: "active" as const, - label: "Active", - Icon: ActiveIcon, - }, - { - value: "backlog" as const, - label: "Backlog", - Icon: BacklogIcon, - }, +const TYPE_TABS = [ + { value: "tasks" as const, label: "Tasks", Icon: ActiveIcon }, + { value: "prs" as const, label: "PRs", Icon: GoGitPullRequest }, + { value: "issues" as const, label: "Issues", Icon: GoIssueOpened }, ] as const; export function TasksTopBar({ @@ -66,7 +59,12 @@ export function TasksTopBar({ onClearSelection, viewMode, onViewModeChange, + typeTab, + onTypeTabChange, + projectFilter, + onProjectFilterChange, }: TasksTopBarProps) { + const showTaskOnlyControls = typeTab === "tasks"; const selectedCount = selectedTasks.length; const searchInputRef = useRef(null); const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); @@ -85,7 +83,7 @@ export function TasksTopBar({ return ( <> -
+
{/* Left side: tabs/filters or selection actions */}
{hasSelection ? ( @@ -116,84 +114,112 @@ export function TasksTopBar({ ) : ( <> + + +
+ onTabChange(value as TabValue)} + value={typeTab} + onValueChange={(value) => onTypeTabChange(value as TypeTab)} > - - {TABS.map((tab) => { + + {TYPE_TABS.map((tab) => { const Icon = tab.Icon; return ( - {tab.label} + + {tab.label} + ); })} -
+ {showTaskOnlyControls && ( + <> +
- + + +
+ + + + )} )}
{/* Right side: create + view toggle + search */}
- - -
- - -
+ {showTaskOnlyControls && ( + <> + + +
+ + +
+ + )} -
+
onSearchChange(e.target.value)} onKeyDown={(e) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/AssigneeFilter/AssigneeFilter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/AssigneeFilter/AssigneeFilter.tsx index 3e55f64c7f3..aaa7b05eb35 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/AssigneeFilter/AssigneeFilter.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/AssigneeFilter/AssigneeFilter.tsx @@ -130,6 +130,8 @@ export function AssigneeFilter({ value, onChange }: AssigneeFilterProps) { + + + + + + + handleSelect(null)}> + All projects + {value === null && } + + + {filtered.length === 0 && search && ( + No projects found. + )} + {filtered.length > 0 && ( + + {filtered.map((project) => ( + handleSelect(project.id)} + > + + {project.name} + {project.id === value && ( + + )} + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/index.ts new file mode 100644 index 00000000000..5354e98bd5d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/index.ts @@ -0,0 +1 @@ +export { ProjectFilter } from "./ProjectFilter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/StatusFilter/StatusFilter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/StatusFilter/StatusFilter.tsx new file mode 100644 index 00000000000..4d27561ca29 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/StatusFilter/StatusFilter.tsx @@ -0,0 +1,82 @@ +import { Button } from "@superset/ui/button"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useState } from "react"; +import { HiCheck, HiChevronDown } from "react-icons/hi2"; +import { ActiveIcon } from "../../../shared/icons/ActiveIcon"; +import { AllIssuesIcon } from "../../../shared/icons/AllIssuesIcon"; +import { BacklogIcon } from "../../../shared/icons/BacklogIcon"; + +type TabValue = "all" | "active" | "backlog"; + +interface StatusFilterProps { + value: TabValue; + onChange: (value: TabValue) => void; +} + +const OPTIONS: ReadonlyArray<{ + value: TabValue; + label: string; + Icon: typeof AllIssuesIcon; +}> = [ + { value: "all", label: "All issues", Icon: AllIssuesIcon }, + { value: "active", label: "Active", Icon: ActiveIcon }, + { value: "backlog", label: "Backlog", Icon: BacklogIcon }, +]; + +export function StatusFilter({ value, onChange }: StatusFilterProps) { + const [open, setOpen] = useState(false); + const selected = OPTIONS.find((o) => o.value === value) ?? OPTIONS[0]; + const SelectedIcon = selected.Icon; + + const handleSelect = (next: TabValue) => { + onChange(next); + setOpen(false); + }; + + return ( + + + + + + + + + {OPTIONS.map((option) => { + const Icon = option.Icon; + return ( + handleSelect(option.value)} + > + + {option.label} + {option.value === value && ( + + )} + + ); + })} + + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/StatusFilter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/StatusFilter/index.ts new file mode 100644 index 00000000000..a7db761dcaf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/StatusFilter/index.ts @@ -0,0 +1 @@ +export { StatusFilter } from "./StatusFilter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx new file mode 100644 index 00000000000..49bf730e11e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx @@ -0,0 +1,229 @@ +import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { GoIssueClosed, GoIssueOpened } from "react-icons/go"; +import { HiArrowLeft } from "react-icons/hi2"; +import { LuExternalLink, LuPlus } from "react-icons/lu"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + type LinkedIssue, + useNewWorkspaceDraftStore, +} from "renderer/stores/new-workspace-draft"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { Route as TasksLayoutRoute } from "../../layout"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/tasks/issue/$issueNumber/", +)({ + component: IssueDetailPage, +}); + +function IssueDetailPage() { + const { issueNumber: issueNumberRaw } = Route.useParams(); + const issueNumber = Number.parseInt(issueNumberRaw, 10); + const search = TasksLayoutRoute.useSearch(); + const navigate = useNavigate(); + const hostUrl = useHostUrl(null); + const projectId = search.project ?? null; + const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); + const openModal = useOpenNewWorkspaceModal(); + + const backSearch = useMemo(() => { + const s: Record = {}; + if (search.tab) s.tab = search.tab; + if (search.assignee) s.assignee = search.assignee; + if (search.search) s.search = search.search; + if (search.type) s.type = search.type; + if (search.project) s.project = search.project; + return s; + }, [search]); + + const { data, isLoading, error } = useQuery({ + queryKey: ["issue-detail", projectId, hostUrl, issueNumber], + queryFn: async () => { + if (!hostUrl || !projectId) return null; + const client = getHostServiceClientByUrl(hostUrl); + return client.issues.getContent.query({ + projectId, + issueNumber, + }); + }, + enabled: !!hostUrl && !!projectId && Number.isFinite(issueNumber), + retry: false, + staleTime: 30_000, + gcTime: 10 * 60_000, + }); + + const handleBack = () => { + navigate({ to: "/tasks", search: backSearch }); + }; + + const handleAddToWorkspace = () => { + if (!projectId || !data) return; + const linkedIssue: LinkedIssue = { + slug: `gh-${data.number}`, + title: data.title, + source: "github", + url: data.url, + number: data.number, + state: data.state.toLowerCase() === "closed" ? "closed" : "open", + }; + resetDraft(); + updateDraft({ + selectedProjectId: projectId, + linkedIssues: [linkedIssue], + }); + openModal(projectId); + }; + + if (!projectId) { + return ( +
+ No project specified. +
+ ); + } + + if (isLoading) { + return ( +
+ Loading issue… +
+ ); + } + + if (error instanceof Error || !data) { + return ( +
+
+
+ {error instanceof Error ? error.message : "Issue not found."} +
+
+ ); + } + + const isClosed = data.state.toLowerCase() === "closed"; + const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened; + + return ( +
+
+ + +
+
+ +

+ {data.title} +

+
+ +
+ {data.state} + {data.author && ( + <> + · + by {data.author} + + )} +
+ + {data.body.trim() ? ( + + ) : ( +

+ No description provided. +

+ )} +
+
+
+ ); +} + +interface HeaderProps { + issueNumber: number; + url: string | null; + isClosed: boolean; + onBack: () => void; + onAddToWorkspace: (() => void) | null; +} + +function Header({ + issueNumber, + url, + isClosed, + onBack, + onAddToWorkspace, +}: HeaderProps) { + const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened; + return ( +
+ + + + #{issueNumber} + +
+ {url && ( + + + + )} + {onAddToWorkspace && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx index fae700db89b..db1f2925728 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx @@ -4,6 +4,8 @@ export type TasksSearch = { tab?: "all" | "active" | "backlog"; assignee?: string; search?: string; + type?: "tasks" | "prs" | "issues"; + project?: string; }; export const Route = createFileRoute("/_authenticated/_dashboard/tasks")({ @@ -14,6 +16,10 @@ export const Route = createFileRoute("/_authenticated/_dashboard/tasks")({ : undefined, assignee: typeof search.assignee === "string" ? search.assignee : undefined, search: typeof search.search === "string" ? search.search : undefined, + type: ["tasks", "prs", "issues"].includes(search.type as string) + ? (search.type as TasksSearch["type"]) + : undefined, + project: typeof search.project === "string" ? search.project : undefined, }), }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx index 4a6e1b33268..7b4253235fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx @@ -7,12 +7,14 @@ export const Route = createFileRoute("/_authenticated/_dashboard/tasks/")({ }); function TasksPage() { - const { tab, assignee, search } = TasksLayoutRoute.useSearch(); + const { tab, assignee, search, type, project } = TasksLayoutRoute.useSearch(); return ( ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx new file mode 100644 index 00000000000..832bc5c5a2c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx @@ -0,0 +1,226 @@ +import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { HiArrowLeft } from "react-icons/hi2"; +import { LuExternalLink, LuPlus } from "react-icons/lu"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + normalizePRState, + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon"; +import { + type LinkedPR, + useNewWorkspaceDraftStore, +} from "renderer/stores/new-workspace-draft"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { Route as TasksLayoutRoute } from "../../layout"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/tasks/pr/$prNumber/", +)({ + component: PullRequestDetailPage, +}); + +function PullRequestDetailPage() { + const { prNumber: prNumberRaw } = Route.useParams(); + const prNumber = Number.parseInt(prNumberRaw, 10); + const search = TasksLayoutRoute.useSearch(); + const navigate = useNavigate(); + const hostUrl = useHostUrl(null); + const projectId = search.project ?? null; + const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); + const openModal = useOpenNewWorkspaceModal(); + + const backSearch = useMemo(() => { + const s: Record = {}; + if (search.tab) s.tab = search.tab; + if (search.assignee) s.assignee = search.assignee; + if (search.search) s.search = search.search; + if (search.type) s.type = search.type; + if (search.project) s.project = search.project; + return s; + }, [search]); + + const { data, isLoading, error } = useQuery({ + queryKey: ["pull-request-detail", projectId, hostUrl, prNumber], + queryFn: async () => { + if (!hostUrl || !projectId) return null; + const client = getHostServiceClientByUrl(hostUrl); + return client.pullRequests.getContent.query({ + projectId, + prNumber, + }); + }, + enabled: !!hostUrl && !!projectId && Number.isFinite(prNumber), + retry: false, + staleTime: 30_000, + gcTime: 10 * 60_000, + }); + + const handleBack = () => { + navigate({ to: "/tasks", search: backSearch }); + }; + + const handleAddToWorkspace = () => { + if (!projectId || !data) return; + const linkedPR: LinkedPR = { + prNumber: data.number, + title: data.title, + url: data.url, + state: normalizePRState(data.state, data.isDraft), + }; + resetDraft(); + updateDraft({ selectedProjectId: projectId, linkedPR }); + openModal(projectId); + }; + + if (!projectId) { + return ( +
+ No project specified. +
+ ); + } + + if (isLoading) { + return ( +
+ Loading pull request… +
+ ); + } + + if (error instanceof Error || !data) { + return ( +
+
+
+ {error instanceof Error ? error.message : "Pull request not found."} +
+
+ ); + } + + const state = normalizePRState(data.state, data.isDraft); + const stateLabel = data.isDraft ? "Draft" : data.state; + const branchSummary = data.branch + ? `${data.headRepositoryOwner && data.isCrossRepository ? `${data.headRepositoryOwner}:${data.branch}` : data.branch} → ${data.baseBranch}` + : null; + + return ( +
+
+ + +
+
+ +

+ {data.title} +

+
+ +
+ {stateLabel} + {data.author && ( + <> + · + by {data.author} + + )} + {branchSummary && ( + <> + · + {branchSummary} + + )} +
+ + {data.body.trim() ? ( + + ) : ( +

+ No description provided. +

+ )} +
+
+
+ ); +} + +interface HeaderProps { + prNumber: number; + url: string | null; + state: PRState; + onBack: () => void; + onAddToWorkspace: (() => void) | null; +} + +function Header({ + prNumber, + url, + state, + onBack, + onAddToWorkspace, +}: HeaderProps) { + return ( +
+ + + + #{prNumber} + +
+ {url && ( + + + + )} + {onAddToWorkspace && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts index 1077f94244b..6fdfa8c9573 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts @@ -1,16 +1,21 @@ import { create } from "zustand"; export type ViewMode = "table" | "board"; +export type TypeTab = "tasks" | "prs" | "issues"; interface TasksFilterState { tab: "all" | "active" | "backlog"; assignee: string | null; search: string; viewMode: ViewMode; + typeTab: TypeTab; + projectFilter: string | null; setTab: (tab: "all" | "active" | "backlog") => void; setAssignee: (assignee: string | null) => void; setSearch: (search: string) => void; setViewMode: (viewMode: ViewMode) => void; + setTypeTab: (typeTab: TypeTab) => void; + setProjectFilter: (projectFilter: string | null) => void; } export const useTasksFilterStore = create()((set) => ({ @@ -18,8 +23,12 @@ export const useTasksFilterStore = create()((set) => ({ assignee: null, search: "", viewMode: "table", + typeTab: "tasks", + projectFilter: null, setTab: (tab) => set({ tab }), setAssignee: (assignee) => set({ assignee }), setSearch: (search) => set({ search }), setViewMode: (viewMode) => set({ viewMode }), + setTypeTab: (typeTab) => set({ typeTab }), + setProjectFilter: (projectFilter) => set({ projectFilter }), })); diff --git a/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts b/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts index 3c6b8a06935..2266d83df24 100644 --- a/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts @@ -1 +1,2 @@ +export { normalizePRState } from "./normalizePRState"; export { PRIcon, type PRState } from "./PRIcon"; diff --git a/apps/desktop/src/renderer/screens/main/components/PRIcon/normalizePRState.ts b/apps/desktop/src/renderer/screens/main/components/PRIcon/normalizePRState.ts new file mode 100644 index 00000000000..d7b780f6a41 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/PRIcon/normalizePRState.ts @@ -0,0 +1,13 @@ +import type { PRState } from "./PRIcon"; + +/** + * Map a raw GitHub PR state string + draft flag to the canonical PRState + * used by PRIcon and the rest of the renderer. Draft trumps merged/closed/open. + */ +export function normalizePRState(state: string, isDraft: boolean): PRState { + if (isDraft) return "draft"; + const lower = state.toLowerCase(); + if (lower === "merged") return "merged"; + if (lower === "closed") return "closed"; + return "open"; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c0b7292345a..ab01977e564 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -48,7 +48,7 @@ "@tanstack/db": "0.6.5", "@tanstack/electric-db-collection": "0.3.3", "@tanstack/react-db": "0.1.83", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "better-auth": "1.6.5", diff --git a/apps/web/package.json b/apps/web/package.json index cc98c941918..2b9c7159b12 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,7 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.90.10", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", diff --git a/bun.lock b/bun.lock index 7b66c80e765..e01f67e7b8d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.90.10", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", @@ -180,8 +180,10 @@ "@tanstack/electric-db-collection": "0.3.3", "@tanstack/electron-db-sqlite-persistence": "0.1.9", "@tanstack/node-db-sqlite-persistence": "0.1.9", + "@tanstack/query-async-storage-persister": "^5.100.9", "@tanstack/react-db": "0.1.83", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-persist-client": "^5.100.9", "@tanstack/react-router": "^1.147.3", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", @@ -493,7 +495,7 @@ "@tanstack/db": "0.6.5", "@tanstack/electric-db-collection": "0.3.3", "@tanstack/react-db": "0.1.83", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "better-auth": "1.6.5", @@ -583,7 +585,7 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.90.10", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", @@ -1066,7 +1068,7 @@ "dependencies": { "@superset/host-service": "workspace:*", "@superset/workspace-fs": "workspace:*", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "superjson": "^2.2.5", @@ -2737,16 +2739,22 @@ "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.2.1", "", {}, "sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ=="], - "@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="], + "@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9", "@tanstack/query-persist-client-core": "5.100.9" } }, "sha512-KTWqpXIAwuR1bSIxfOnoRr7hOMxfrtLk9kxSuDfOu4sSBEFqHp9+aDp8Ig6YdHxCclNI86SizPnmKmSqNxcJxg=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.95.2", "", {}, "sha512-QfaoqBn9uAZ+ICkA8brd1EHj+qBF6glCFgt94U8XP5BT6ppSsDBI8IJ00BU+cAGjQzp6wcKJL2EmRYvxy0TWIg=="], + "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" } }, "sha512-sCPZZp3D9sOeqcA4SDxjUIm4wVq8PwHebH4ouFZetwjT4xvGjT/cLBQ4Sst+BFcFuk745pCPkf3T4MFliLHECQ=="], + "@tanstack/react-db": ["@tanstack/react-db@0.1.83", "", { "dependencies": { "@tanstack/db": "0.6.5", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-LNV0C7OARazooT2hLTr5anXo6tbEyX2rHZQ0j9HZ/iNBI+Tx/y19o5Nd3ooyAYz5LEHJJxb8iM8ZTVB/diGnXw=="], - "@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.95.2", "", { "dependencies": { "@tanstack/query-devtools": "5.95.2" }, "peerDependencies": { "@tanstack/react-query": "^5.95.2", "react": "^18 || ^19" } }, "sha512-AFQFmbznVkbtfpx8VJ2DylW17wWagQel/qLstVLkYmNRo2CmJt3SNej5hvl6EnEeljJIdC3BTB+W7HZtpsH+3g=="], + "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.100.9", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.9" }, "peerDependencies": { "@tanstack/react-query": "^5.100.9", "react": "^18 || ^19" } }, "sha512-qO7j+3VUZm4YH4T3dWszDqgvO+5+6NB1kSY+QmF7JD6+IONfeNmGyOzyobjmrX+6CYLPQtQ+sjM9vFYaSOAv6A=="], + "@tanstack/react-router": ["@tanstack/react-router@1.168.8", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.7", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw=="], "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts index 044a8ac963a..0831d9a66f3 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts @@ -16,7 +16,15 @@ interface IssueResult { authorLogin: string | null; } -const ghIssueSchema = z.object({ +interface IssuesPage { + issues: IssueResult[]; + totalCount: number; + hasNextPage: boolean; + page: number; + repoMismatch?: string; +} + +const ghIssueViewSchema = z.object({ number: z.number(), title: z.string(), url: z.string(), @@ -24,17 +32,7 @@ const ghIssueSchema = z.object({ author: z.object({ login: z.string() }).nullable().optional(), }); -const ISSUE_JSON_FIELDS = "number,title,url,state,author"; - -function fromGhIssue(issue: z.infer): IssueResult { - return { - issueNumber: issue.number, - title: issue.title, - url: issue.url, - state: issue.state.toLowerCase(), - authorLogin: issue.author?.login ?? null, - }; -} +const ISSUE_VIEW_FIELDS = "number,title,url,state,author"; async function ghDirectLookup( execGh: ExecGh, @@ -49,45 +47,86 @@ async function ghDirectLookup( "--repo", `${repo.owner}/${repo.name}`, "--json", - ISSUE_JSON_FIELDS, + ISSUE_VIEW_FIELDS, ], { cwd: repo.repoPath ?? undefined }, ); - return fromGhIssue(ghIssueSchema.parse(raw)); + const issue = ghIssueViewSchema.parse(raw); + return { + issueNumber: issue.number, + title: issue.title, + url: issue.url, + state: issue.state.toLowerCase(), + authorLogin: issue.author?.login ?? null, + }; } -async function ghSearch( +const searchIssuesItemSchema = z.object({ + number: z.number(), + title: z.string(), + html_url: z.string(), + state: z.string(), + user: z.object({ login: z.string() }).nullable().optional(), + pull_request: z.unknown().optional(), +}); + +const searchIssuesResponseSchema = z.object({ + total_count: z.number(), + items: z.array(searchIssuesItemSchema), +}); + +async function ghApiSearchIssues( execGh: ExecGh, repo: ResolvedGithubRepo, query: string, includeClosed: boolean, - limit: number, -): Promise { + page: number, + perPage: number, +): Promise<{ + items: IssueResult[]; + totalCount: number; + hasNextPage: boolean; +}> { + const stateFilter = includeClosed ? "" : " is:open"; + const q = + `repo:${repo.owner}/${repo.name} is:issue${stateFilter}${query ? ` ${query}` : ""}`.trim(); const args = [ - "issue", - "list", - "--repo", - `${repo.owner}/${repo.name}`, - "--state", - includeClosed ? "all" : "open", - "--limit", - String(limit), - "--json", - ISSUE_JSON_FIELDS, + "api", + "-X", + "GET", + "search/issues", + "-f", + `q=${q}`, + "-F", + `per_page=${perPage}`, + "-F", + `page=${page}`, + "-f", + "sort=updated", + "-f", + "order=desc", ]; - if (query) { - args.push("--search", query); - } const raw = await execGh(args, { cwd: repo.repoPath ?? undefined }); - const arr = z.array(ghIssueSchema).parse(raw); - return arr.map(fromGhIssue); + const parsed = searchIssuesResponseSchema.parse(raw); + const items: IssueResult[] = parsed.items + .filter((item) => !item.pull_request) + .map((item) => ({ + issueNumber: item.number, + title: item.title, + url: item.html_url, + state: item.state.toLowerCase(), + authorLogin: item.user?.login ?? null, + })); + const hasNextPage = page * perPage < parsed.total_count; + return { items, totalCount: parsed.total_count, hasNextPage }; } export const searchGitHubIssues = protectedProcedure .input(githubSearchInputSchema) - .query(async ({ ctx, input }) => { + .query(async ({ ctx, input }): Promise => { const repo = await resolveGithubRepo(ctx, input.projectId); const limit = input.limit ?? 30; + const page = input.page ?? 1; const raw = input.query?.trim() ?? ""; const normalized = normalizeGitHubQuery(raw, repo, "issue"); @@ -95,6 +134,9 @@ export const searchGitHubIssues = protectedProcedure if (normalized.repoMismatch) { return { issues: [], + totalCount: 0, + hasNextPage: false, + page, repoMismatch: `${repo.owner}/${repo.name}`, }; } @@ -110,18 +152,34 @@ export const searchGitHubIssues = protectedProcedure // Octokit's path filters via `issue.pull_request`; we don't // have that field over `gh`, so detect via the canonical URL. if (issue.url.includes("/pull/")) { - return { issues: [] }; + return { + issues: [], + totalCount: 0, + hasNextPage: false, + page, + }; } - return { issues: [issue] }; + return { + issues: [issue], + totalCount: 1, + hasNextPage: false, + page, + }; } - const issues = await ghSearch( + const result = await ghApiSearchIssues( ctx.execGh, repo, effectiveQuery, input.includeClosed ?? false, + page, limit, ); - return { issues }; + return { + issues: result.items, + totalCount: result.totalCount, + hasNextPage: result.hasNextPage, + page, + }; } catch (ghErr) { console.warn( "[workspaceCreation.searchGitHubIssues] gh path failed; falling back to Octokit", @@ -140,7 +198,12 @@ export const searchGitHubIssues = protectedProcedure issue_number: issueNumber, }); if (issue.pull_request) { - return { issues: [] }; + return { + issues: [], + totalCount: 0, + hasNextPage: false, + page, + }; } return { issues: [ @@ -152,6 +215,9 @@ export const searchGitHubIssues = protectedProcedure authorLogin: issue.user?.login ?? null, }, ], + totalCount: 1, + hasNextPage: false, + page, }; } @@ -161,19 +227,25 @@ export const searchGitHubIssues = protectedProcedure const { data } = await octokit.search.issuesAndPullRequests({ q: query, per_page: limit, + page, sort: "updated", order: "desc", }); + const 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, + })); + const hasNextPage = page * limit < data.total_count; 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, + totalCount: data.total_count, + hasNextPage, + page, }; } catch (err) { console.warn( diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts index 936571aa77d..a6c537decd1 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts @@ -17,6 +17,14 @@ interface PullRequestResult { authorLogin: string | null; } +interface PullRequestsPage { + pullRequests: PullRequestResult[]; + totalCount: number; + hasNextPage: boolean; + page: number; + repoMismatch?: string; +} + function normalizePullRequestState( state: string, mergedAt: string | null | undefined, @@ -25,7 +33,7 @@ function normalizePullRequestState( return state.toLowerCase() === "closed" ? "closed" : "open"; } -const ghPrSchema = z.object({ +const ghPrViewSchema = z.object({ number: z.number(), title: z.string(), url: z.string(), @@ -35,18 +43,7 @@ const ghPrSchema = z.object({ mergedAt: z.string().nullable().optional(), }); -function fromGhPr(pr: z.infer): PullRequestResult { - return { - prNumber: pr.number, - title: pr.title, - url: pr.url, - state: normalizePullRequestState(pr.state, pr.mergedAt), - isDraft: pr.isDraft ?? false, - authorLogin: pr.author?.login ?? null, - }; -} - -const PR_JSON_FIELDS = "number,title,url,state,isDraft,author,mergedAt"; +const PR_VIEW_FIELDS = "number,title,url,state,isDraft,author,mergedAt"; async function ghDirectLookup( execGh: ExecGh, @@ -61,45 +58,96 @@ async function ghDirectLookup( "--repo", `${repo.owner}/${repo.name}`, "--json", - PR_JSON_FIELDS, + PR_VIEW_FIELDS, ], { cwd: repo.repoPath ?? undefined }, ); - return fromGhPr(ghPrSchema.parse(raw)); + const pr = ghPrViewSchema.parse(raw); + return { + prNumber: pr.number, + title: pr.title, + url: pr.url, + state: normalizePullRequestState(pr.state, pr.mergedAt), + isDraft: pr.isDraft ?? false, + authorLogin: pr.author?.login ?? null, + }; } -async function ghSearch( +const searchIssuesItemSchema = z.object({ + number: z.number(), + title: z.string(), + html_url: z.string(), + state: z.string(), + draft: z.boolean().optional(), + user: z.object({ login: z.string() }).nullable().optional(), + pull_request: z + .object({ + merged_at: z.string().nullable().optional(), + }) + .optional(), +}); + +const searchIssuesResponseSchema = z.object({ + total_count: z.number(), + items: z.array(searchIssuesItemSchema), +}); + +async function ghApiSearchPullRequests( execGh: ExecGh, repo: ResolvedGithubRepo, query: string, includeClosed: boolean, - limit: number, -): Promise { + page: number, + perPage: number, +): Promise<{ + items: PullRequestResult[]; + totalCount: number; + hasNextPage: boolean; +}> { + const stateFilter = includeClosed ? "" : " is:open"; + const q = + `repo:${repo.owner}/${repo.name} is:pr${stateFilter}${query ? ` ${query}` : ""}`.trim(); const args = [ - "pr", - "list", - "--repo", - `${repo.owner}/${repo.name}`, - "--state", - includeClosed ? "all" : "open", - "--limit", - String(limit), - "--json", - PR_JSON_FIELDS, + "api", + "-X", + "GET", + "search/issues", + "-f", + `q=${q}`, + "-F", + `per_page=${perPage}`, + "-F", + `page=${page}`, + "-f", + "sort=updated", + "-f", + "order=desc", ]; - if (query) { - args.push("--search", query); - } const raw = await execGh(args, { cwd: repo.repoPath ?? undefined }); - const arr = z.array(ghPrSchema).parse(raw); - return arr.map(fromGhPr); + const parsed = searchIssuesResponseSchema.parse(raw); + const items: PullRequestResult[] = parsed.items + .filter((item) => !!item.pull_request) + .map((item) => ({ + prNumber: item.number, + title: item.title, + url: item.html_url, + state: normalizePullRequestState( + item.state, + item.pull_request?.merged_at, + ), + isDraft: item.draft ?? false, + authorLogin: item.user?.login ?? null, + })); + const hasNextPage = page * perPage < parsed.total_count; + return { items, totalCount: parsed.total_count, hasNextPage }; } export const searchPullRequests = protectedProcedure .input(githubSearchInputSchema) - .query(async ({ ctx, input }) => { + .query(async ({ ctx, input }): Promise => { const repo = await resolveGithubRepo(ctx, input.projectId); const limit = input.limit ?? 30; + const page = input.page ?? 1; const raw = input.query?.trim() ?? ""; const normalized = normalizeGitHubQuery(raw, repo, "pull"); @@ -107,6 +155,9 @@ export const searchPullRequests = protectedProcedure if (normalized.repoMismatch) { return { pullRequests: [], + totalCount: 0, + hasNextPage: false, + page, repoMismatch: `${repo.owner}/${repo.name}`, }; } @@ -119,16 +170,27 @@ export const searchPullRequests = protectedProcedure if (normalized.isDirectLookup) { const prNumber = Number.parseInt(effectiveQuery, 10); const pr = await ghDirectLookup(ctx.execGh, repo, prNumber); - return { pullRequests: [pr] }; + return { + pullRequests: [pr], + totalCount: 1, + hasNextPage: false, + page, + }; } - const pullRequests = await ghSearch( + const result = await ghApiSearchPullRequests( ctx.execGh, repo, effectiveQuery, input.includeClosed ?? false, + page, limit, ); - return { pullRequests }; + return { + pullRequests: result.items, + totalCount: result.totalCount, + hasNextPage: result.hasNextPage, + page, + }; } catch (ghErr) { console.warn( "[workspaceCreation.searchPullRequests] gh path failed; falling back to Octokit", @@ -158,6 +220,9 @@ export const searchPullRequests = protectedProcedure authorLogin: pr.user?.login ?? null, }, ], + totalCount: 1, + hasNextPage: false, + page, }; } @@ -167,26 +232,32 @@ export const searchPullRequests = protectedProcedure const { data } = await octokit.search.issuesAndPullRequests({ q: query, per_page: limit, + page, sort: "updated", order: "desc", }); + const pullRequests = data.items + .filter((item) => item.pull_request) + .map((item) => { + const state = normalizePullRequestState( + item.state, + item.pull_request?.merged_at, + ); + return { + prNumber: item.number, + title: item.title, + url: item.html_url, + state, + isDraft: item.draft ?? false, + authorLogin: item.user?.login ?? null, + }; + }); + const hasNextPage = page * limit < data.total_count; return { - pullRequests: data.items - .filter((item) => item.pull_request) - .map((item) => { - const state = normalizePullRequestState( - item.state, - item.pull_request?.merged_at, - ); - return { - prNumber: item.number, - title: item.title, - url: item.html_url, - state, - isDraft: item.draft ?? false, - authorLogin: item.user?.login ?? null, - }; - }), + pullRequests, + totalCount: data.total_count, + hasNextPage, + page, }; } catch (err) { // Both gh and Octokit failed — rethrow so the renderer's toast diff --git a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts index ba6919b9833..cfb39cc7cac 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts @@ -28,4 +28,5 @@ export const githubSearchInputSchema = z.object({ query: z.string().optional(), limit: z.number().min(1).max(100).optional(), includeClosed: z.boolean().optional(), + page: z.number().int().min(1).optional(), }); diff --git a/packages/host-service/test/integration/workspace-creation-github.integration.test.ts b/packages/host-service/test/integration/workspace-creation-github.integration.test.ts index fb911d8b05f..9b629f3e126 100644 --- a/packages/host-service/test/integration/workspace-creation-github.integration.test.ts +++ b/packages/host-service/test/integration/workspace-creation-github.integration.test.ts @@ -81,6 +81,7 @@ describe("workspaceCreation github procedures with mocked Octokit", () => { calls.push({ method: "search.issuesAndPullRequests", args }); return { data: { + total_count: 1, items: [ { number: 7, @@ -252,7 +253,9 @@ describe("resolveGithubRepo prefers the user-configured remoteName", () => { }, issues: { get: async () => ({ data: {} }) }, search: { - issuesAndPullRequests: async () => ({ data: { items: [] } }), + issuesAndPullRequests: async () => ({ + data: { total_count: 0, items: [] }, + }), }, }; @@ -417,8 +420,7 @@ describe("gh CLI is first-class when execGh succeeds", () => { options?: { cwd?: string }, ): Promise => { ghCalls.push({ args, cwd: options?.cwd }); - const verb = args[1]; - if (verb === "view" && args[0] === "pr") { + if (args[0] === "pr" && args[1] === "view") { return { number: Number(args[2]), title: "PR via gh", @@ -429,29 +431,37 @@ describe("gh CLI is first-class when execGh succeeds", () => { mergedAt: null, }; } - if (verb === "list" && args[0] === "pr") { - return [ - { - number: 101, - title: "search result", - url: "https://github.com/octocat/hello/pull/101", - state: "OPEN", - isDraft: false, - author: { login: "carol" }, - mergedAt: null, - }, - ]; - } - if (verb === "list" && args[0] === "issue") { - return [ - { - number: 7, - title: "issue search result", - url: "https://github.com/octocat/hello/issues/7", - state: "OPEN", - author: { login: "dave" }, - }, - ]; + if (args[0] === "api" && args.includes("search/issues")) { + const qIndex = args.indexOf("-f"); + const q = args[qIndex + 1] ?? ""; + const isPr = q.includes("is:pr"); + if (isPr) { + return { + total_count: 1, + items: [ + { + number: 101, + title: "search result", + html_url: "https://github.com/octocat/hello/pull/101", + state: "open", + user: { login: "carol" }, + pull_request: { merged_at: null }, + }, + ], + }; + } + return { + total_count: 1, + items: [ + { + number: 7, + title: "issue search result", + html_url: "https://github.com/octocat/hello/issues/7", + state: "open", + user: { login: "dave" }, + }, + ], + }; } return {}; }; @@ -494,21 +504,24 @@ describe("gh CLI is first-class when execGh succeeds", () => { expect(ghCalls[0].cwd).toBe(realpathSync(repoDir)); }); - test("searchPullRequests free-text invokes `gh pr list --search`", async () => { + test("searchPullRequests free-text invokes `gh api search/issues` with is:pr filter", async () => { const result = await host.trpc.workspaceCreation.searchPullRequests.query({ projectId, query: "find me", }); expect(result.pullRequests).toHaveLength(1); expect(result.pullRequests[0].prNumber).toBe(101); + expect(result.totalCount).toBe(1); + expect(result.hasNextPage).toBe(false); expect(ghCalls).toHaveLength(1); const args = ghCalls[0].args; - expect(args[0]).toBe("pr"); - expect(args[1]).toBe("list"); - expect(args).toContain("--repo"); - expect(args).toContain("octocat/hello"); - expect(args).toContain("--search"); - expect(args).toContain("find me"); + expect(args[0]).toBe("api"); + expect(args).toContain("search/issues"); + const qArg = args[args.indexOf("-f") + 1] ?? ""; + expect(qArg).toContain("repo:octocat/hello"); + expect(qArg).toContain("is:pr"); + expect(qArg).toContain("is:open"); + expect(qArg).toContain("find me"); }); test("searchGitHubIssues #N filters out PRs leaked by `gh issue view`", async () => { @@ -544,15 +557,22 @@ describe("gh CLI is first-class when execGh succeeds", () => { rmSync(localRepo, { recursive: true, force: true }); }); - test("searchGitHubIssues free-text invokes `gh issue list --search`", async () => { + test("searchGitHubIssues free-text invokes `gh api search/issues` with is:issue filter", async () => { const result = await host.trpc.workspaceCreation.searchGitHubIssues.query({ projectId, query: "bug", }); expect(result.issues).toHaveLength(1); expect(result.issues[0].issueNumber).toBe(7); + expect(result.totalCount).toBe(1); + expect(result.hasNextPage).toBe(false); expect(ghCalls).toHaveLength(1); - expect(ghCalls[0].args[0]).toBe("issue"); - expect(ghCalls[0].args[1]).toBe("list"); + const args = ghCalls[0].args; + expect(args[0]).toBe("api"); + expect(args).toContain("search/issues"); + const qArg = args[args.indexOf("-f") + 1] ?? ""; + expect(qArg).toContain("repo:octocat/hello"); + expect(qArg).toContain("is:issue"); + expect(qArg).toContain("bug"); }); }); diff --git a/packages/workspace-client/package.json b/packages/workspace-client/package.json index c028a4e3fe9..46331bb6734 100644 --- a/packages/workspace-client/package.json +++ b/packages/workspace-client/package.json @@ -16,7 +16,7 @@ "dependencies": { "@superset/host-service": "workspace:*", "@superset/workspace-fs": "workspace:*", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.100.9", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "superjson": "^2.2.5"