From 4795cb50459154e10746690e3a0299fe6982e190 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Sep 2025 07:33:42 +0000 Subject: [PATCH] feat: Add label filtering to PR list This commit introduces label filtering to the PR list view, allowing users to filter pull requests by labels, including options for AND, OR, NOT, and ONLY logic, as well as filtering for PRs without labels. Co-authored-by: meta.alex.r --- src/renderer/services/github.ts | 25 ++ src/renderer/stores/uiStore.ts | 9 + src/renderer/views/PRListView.tsx | 382 +++++++++++++++++++++++++++++- 3 files changed, 403 insertions(+), 13 deletions(-) diff --git a/src/renderer/services/github.ts b/src/renderer/services/github.ts index 80297d10..820c5bbf 100644 --- a/src/renderer/services/github.ts +++ b/src/renderer/services/github.ts @@ -194,6 +194,31 @@ export class GitHubAPI { }); } + async getRepositoryLabels( + owner: string, + repo: string, + ): Promise> { + const allLabels: Array<{ name: string; color: string }> = []; + let page = 1; + const perPage = 100; + while (true) { + const { data } = await this.octokit.issues.listLabelsForRepo({ + owner, + repo, + per_page: perPage, + page, + }); + if (!Array.isArray(data) || data.length === 0) break; + for (const l of data) { + if (!l) continue; + allLabels.push({ name: l.name, color: (l as any).color || "000000" }); + } + if (data.length < perPage) break; + page++; + } + return allLabels; + } + async getRepositories(page = 1, perPage = 100): Promise { const { data } = await this.octokit.repos.listForAuthenticatedUser({ page, diff --git a/src/renderer/stores/uiStore.ts b/src/renderer/stores/uiStore.ts index a8880fbb..ffdbc5cb 100644 --- a/src/renderer/stores/uiStore.ts +++ b/src/renderer/stores/uiStore.ts @@ -30,6 +30,9 @@ interface UIState { sortBy: SortByType; selectedAuthors: string[]; selectedStatuses: PRStatusFilter[]; + selectedLabels: string[]; + labelMode: "OR" | "AND" | "NOT" | "ONLY"; + includeNoLabel: boolean; }; toggleSidebar: () => void; @@ -73,6 +76,9 @@ export const useUIStore = create()( sortBy: "updated", selectedAuthors: [], selectedStatuses: ["open", "draft"], + selectedLabels: [], + labelMode: "OR", + includeNoLabel: false, }, toggleSidebar: () => @@ -131,6 +137,9 @@ export const useUIStore = create()( sortBy: "updated", selectedAuthors: [], selectedStatuses: ["open", "draft"], + selectedLabels: [], + labelMode: "OR", + includeNoLabel: false, }, }), }), diff --git a/src/renderer/views/PRListView.tsx b/src/renderer/views/PRListView.tsx index 965f1166..97d4d79c 100644 --- a/src/renderer/views/PRListView.tsx +++ b/src/renderer/views/PRListView.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useEffect, useRef } from "react"; -import { useNavigate } from "react-router-dom"; -import { GitPullRequest } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { GitPullRequest, Tag, X, Search } from "lucide-react"; import { usePRStore } from "../stores/prStore"; import { useUIStore } from "../stores/uiStore"; import { useAuthStore } from "../stores/authStore"; @@ -13,6 +13,7 @@ import WelcomeView from "./WelcomeView"; import { GitHubAPI, PullRequest } from "../services/github"; import { PRTreeView } from "../components/PRTreeView"; import type { SortByType, PRWithMetadata } from "../types/prList"; +import { getLabelColors } from "../utils/labelColors"; type StatusType = PRStatusType; // Use the centralized type @@ -30,6 +31,7 @@ const statusOptions = [ export default function PRListView() { const navigate = useNavigate(); + const location = useLocation(); const { pullRequests, loading, @@ -55,6 +57,10 @@ export default function PRListView() { const [showStatusDropdown, setShowStatusDropdown] = useState(false); const statusDropdownRef = useRef(null); const [isClosing, setIsClosing] = useState(false); + const [showLabelDropdown, setShowLabelDropdown] = useState(false); + const labelDropdownRef = useRef(null); + const [labelSearch, setLabelSearch] = useState(""); + const [repoLabels, setRepoLabels] = useState>([]); const sortBy = prListFilters.sortBy; const selectedAuthors = useMemo( @@ -65,6 +71,12 @@ export default function PRListView() { () => new Set(prListFilters.selectedStatuses), [prListFilters.selectedStatuses], ); + const selectedLabels = useMemo( + () => new Set(prListFilters.selectedLabels), + [prListFilters.selectedLabels], + ); + const labelMode = prListFilters.labelMode; + const includeNoLabel = prListFilters.includeNoLabel; // Close dropdown when clicking outside useEffect(() => { @@ -75,16 +87,19 @@ export default function PRListView() { if (statusDropdownRef.current && !statusDropdownRef.current.contains(event.target as Node)) { setShowStatusDropdown(false); } + if (labelDropdownRef.current && !labelDropdownRef.current.contains(event.target as Node)) { + setShowLabelDropdown(false); + } }; - if (showAuthorDropdown || showStatusDropdown) { + if (showAuthorDropdown || showStatusDropdown || showLabelDropdown) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [showAuthorDropdown, showStatusDropdown]); + }, [showAuthorDropdown, showStatusDropdown, showLabelDropdown]); // Removed automatic stats fetching - was causing performance issues @@ -113,6 +128,45 @@ export default function PRListView() { const showRefreshingIndicator = loading && dataMatchesSelectedRepo && pendingRepoKey === selectedRepoKey; + // Fetch repository labels + useEffect(() => { + const fetchLabels = async () => { + if (!selectedRepo) { + setRepoLabels([]); + return; + } + try { + let authToken = token; + if (!authToken && typeof window !== "undefined" && window.electron) { + try { + authToken = await window.electron.auth.getToken(); + } catch {} + } + if (!authToken) return; + if (authToken === "dev-token") { + const labelMap = new Map(); + Array.from(pullRequests.values()).forEach((pr) => { + const baseOwner = pr.base?.repo?.owner?.login; + const baseName = pr.base?.repo?.name; + if (baseOwner === selectedRepo.owner && baseName === selectedRepo.name) { + (pr.labels || []).forEach((l: any) => { + if (l?.name && !labelMap.has(l.name)) labelMap.set(l.name, l.color || "999999"); + }); + } + }); + setRepoLabels(Array.from(labelMap.entries()).map(([name, color]) => ({ name, color }))); + } else { + const api = new GitHubAPI(authToken); + const labels = await api.getRepositoryLabels(selectedRepo.owner, selectedRepo.name); + setRepoLabels(labels); + } + } catch (e) { + console.error("Failed to fetch repository labels:", e); + } + }; + fetchLabels(); + }, [selectedRepo, token, pullRequests]); + // Function to fetch detailed PR data in the background const fetchDetailedPRsInBackground = useCallback( (prs: PullRequest[]) => { @@ -265,8 +319,67 @@ export default function PRListView() { [setPRListFilters], ); + // Label filter handlers + const toggleLabel = useCallback((labelName: string) => { + setPRListFilters(prev => { + const current = new Set(prev.selectedLabels); + if (current.has(labelName)) current.delete(labelName); else current.add(labelName); + return { ...prev, selectedLabels: Array.from(current) }; + }); + }, [setPRListFilters]); + + const clearAllLabels = useCallback(() => { + setPRListFilters(prev => ({ ...prev, selectedLabels: [], includeNoLabel: false })); + }, [setPRListFilters]); + + const setLabelModeValue = useCallback((mode: "OR" | "AND" | "NOT" | "ONLY") => { + setPRListFilters(prev => ({ ...prev, labelMode: mode })); + }, [setPRListFilters]); + + const toggleIncludeNoLabel = useCallback(() => { + setPRListFilters(prev => ({ ...prev, includeNoLabel: !prev.includeNoLabel })); + }, [setPRListFilters]); + + // URL sync: read initial params + useEffect(() => { + const params = new URLSearchParams(location.search); + const labelsParam = params.get("labels"); + const modeParam = params.get("labelMode"); + const noLabelParam = params.get("noLabel"); + if (labelsParam || modeParam || noLabelParam) { + setPRListFilters(prev => ({ + ...prev, + selectedLabels: labelsParam ? labelsParam.split(",").map(decodeURIComponent).filter(Boolean) : prev.selectedLabels, + labelMode: (modeParam as any) || prev.labelMode, + includeNoLabel: noLabelParam === "1", + })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // URL sync: write params when filters change + useEffect(() => { + const params = new URLSearchParams(location.search); + if (prListFilters.selectedLabels.length > 0) { + params.set("labels", prListFilters.selectedLabels.map(encodeURIComponent).join(",")); + } else { + params.delete("labels"); + } + if (prListFilters.labelMode && prListFilters.labelMode !== "OR") { + params.set("labelMode", prListFilters.labelMode); + } else { + params.delete("labelMode"); + } + if (prListFilters.includeNoLabel) { + params.set("noLabel", "1"); + } else { + params.delete("noLabel"); + } + navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); + }, [prListFilters.selectedLabels, prListFilters.labelMode, prListFilters.includeNoLabel, navigate, location.pathname, location.search]); - // Simplified filtering logic - cleaner and more maintainable + + // Filtering logic with author, status, then labels const getFilteredPRs = useMemo(() => { if (!selectedRepo) { return []; @@ -279,24 +392,48 @@ export default function PRListView() { return baseOwner === selectedRepo.owner && baseName === selectedRepo.name; }); - // Step 2: Apply filters - const filteredPRs = repoFilteredPRs.filter((pr) => { - // Author filter + // Step 2: Apply author and status filters + const authorStatusFilteredPRs = repoFilteredPRs.filter((pr) => { const authorMatches = selectedAuthors.size === 0 || selectedAuthors.has("all") || selectedAuthors.has(pr.user.login); - // Status filter const prStatus = getPRStatus(pr); const statusMatches = selectedStatuses.size === 0 || selectedStatuses.has(prStatus); - // Both filters must pass return authorMatches && statusMatches; }); - // Step 3: Sort PRs - const sortedPRs = [...filteredPRs].sort((a, b) => { + // Step 3: Apply label filters + const labelFilteredPRs = authorStatusFilteredPRs.filter((pr) => { + const prLabelNames = new Set((pr.labels || []).map((l: any) => l?.name).filter(Boolean)); + const hasNoLabels = prLabelNames.size === 0; + + if (selectedLabels.size === 0 && !includeNoLabel) return true; + + const hasAnySelected = Array.from(selectedLabels).some((name) => prLabelNames.has(name)); + const hasAllSelected = Array.from(selectedLabels).every((name) => prLabelNames.has(name)); + const isExactMatch = prLabelNames.size === selectedLabels.size && hasAllSelected; + + switch (labelMode) { + case "OR": + return (selectedLabels.size > 0 ? hasAnySelected : false) || (includeNoLabel && hasNoLabels); + case "AND": + return (selectedLabels.size > 0 ? hasAllSelected : true) || (includeNoLabel && hasNoLabels); + case "NOT": + return !hasAnySelected && (!includeNoLabel ? true : hasNoLabels || true); + case "ONLY": + if (selectedLabels.size === 0 && includeNoLabel) return hasNoLabels; + if (selectedLabels.size === 0) return false; + return isExactMatch || (includeNoLabel && hasNoLabels); + default: + return true; + } + }); + + // Step 4: Sort PRs + const sortedPRs = [...labelFilteredPRs].sort((a, b) => { const aDate = sortBy === "updated" ? new Date(a.updated_at).getTime() : new Date(a.created_at).getTime(); @@ -312,11 +449,40 @@ export default function PRListView() { pullRequests, selectedAuthors, selectedStatuses, + selectedLabels, + labelMode, + includeNoLabel, sortBy, selectedRepo, getPRStatus, ]); + // Compute label counts from author+status filtered pool (before label filter) + const labelCounts = useMemo(() => { + const counts = new Map(); + if (!selectedRepo) return counts; + const repoFilteredPRs = Array.from(pullRequests.values()).filter((pr) => { + const baseOwner = pr.base?.repo?.owner?.login; + const baseName = pr.base?.repo?.name; + return baseOwner === selectedRepo.owner && baseName === selectedRepo.name; + }); + const pool = repoFilteredPRs.filter((pr) => { + const authorMatches = selectedAuthors.size === 0 || selectedAuthors.has("all") || selectedAuthors.has(pr.user.login); + const prStatus = getPRStatus(pr); + const statusMatches = selectedStatuses.size === 0 || selectedStatuses.has(prStatus); + return authorMatches && statusMatches; + }); + pool.forEach((pr) => { + const seen = new Set(); + (pr.labels || []).forEach((l: any) => { + if (!l?.name || seen.has(l.name)) return; + seen.add(l.name); + counts.set(l.name, (counts.get(l.name) || 0) + 1); + }); + }); + return counts; + }, [pullRequests, selectedRepo, selectedAuthors, selectedStatuses, getPRStatus]); + // Pre-compute PR metadata for grouping const prsWithMetadata = useMemo(() => { return getFilteredPRs.map((pr) => ({ @@ -880,9 +1046,199 @@ export default function PRListView() { )} + + {/* Label filter dropdown */} +
+ + + {showLabelDropdown && ( +
+
+ {/* Search */} +
+ + setLabelSearch(e.target.value)} + placeholder="Search labels..." + className={cn("flex-1 text-xs outline-none bg-transparent", + theme === "dark" ? "text-gray-200 placeholder-gray-400" : "text-gray-800 placeholder-gray-400" + )} + /> +
+ + {/* Quick Filters */} +
+
Quick Filters
+ {[{name:"bug", icon:"🐛"},{name:"feature", icon:"✨"},{name:"documentation", icon:"📝"},{name:"critical", icon:"🚨"},{name:"needs-review", icon:"👀"}].map((q) => { + const found = repoLabels.find(l => l.name.toLowerCase() === q.name.toLowerCase()); + const color = found?.color || "6b7280"; + const labelColors = getLabelColors(color, theme); + return ( + + ); + })} +
+ +
+ + {/* All Labels */} +
+
+
All Labels ({repoLabels.length}) +
+ +
+
+ {repoLabels + .filter(l => l.name.toLowerCase().includes(labelSearch.toLowerCase())) + .sort((a, b) => { + const ca = labelCounts.get(a.name) || 0; + const cb = labelCounts.get(b.name) || 0; + if (cb !== ca) return cb - ca; + return a.name.localeCompare(b.name); + }) + .map((label) => { + const labelColors = getLabelColors(label.color, theme); + const count = labelCounts.get(label.name) || 0; + return ( + + ); + })} +
+
+ +
+ + {/* No Label option */} + + + {/* Filter Logic */} +
+
Filter logic
+ {(["OR","AND","NOT","ONLY"] as const).map(m => ( + + ))} +
+
+
+ )} +
)}
+ + {/* Selected label chips */} + {(selectedLabels.size > 0 || includeNoLabel) && ( +
+ {Array.from(selectedLabels).map((name) => { + const repoLabel = repoLabels.find(l => l.name === name); + const color = repoLabel?.color || "6b7280"; + const colors = getLabelColors(color, theme); + return ( + + {name} + + + ); + })} + {includeNoLabel && ( + + No Label + + + )} + +
+ )} {/* PR List */} @@ -916,7 +1272,7 @@ export default function PRListView() { ) : (