Merge pull request #522 from AutoMaker-Org/feature/v0.12.0rc-1768590871767-bl1c

feat: add filters to github issues view
This commit is contained in:
Shirone
2026-01-16 20:08:05 +00:00
committed by GitHub
7 changed files with 704 additions and 26 deletions

View File

@@ -1,20 +1,26 @@
// @ts-nocheck
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw } from 'lucide-react';
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { useModelOverride } from '@/components/shared';
import type { ValidateIssueOptions } from './github-issues-view/types';
import type {
ValidateIssueOptions,
IssuesFilterState,
IssuesStateFilter,
} from './github-issues-view/types';
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
const logger = createLogger('GitHubIssuesView');
@@ -26,6 +32,9 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
// Filter state
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
// Model override for validation
@@ -44,6 +53,41 @@ export function GitHubIssuesView() {
onShowValidationDialogChange: setShowValidationDialog,
});
// Combine all issues for filtering
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
// Apply filter to issues - now returns matched issues directly for better performance
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
// Separate filtered issues by state - this is O(n) but now only done once
// since filterResult.matchedIssues already contains the filtered issues
const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => {
const open: typeof openIssues = [];
const closed: typeof closedIssues = [];
for (const issue of filterResult.matchedIssues) {
if (issue.state.toLowerCase() === 'open') {
open.push(issue);
} else {
closed.push(issue);
}
}
return { filteredOpenIssues: open, filteredClosedIssues: closed };
}, [filterResult.matchedIssues]);
// Filter state change handlers
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
setFilterState((prev) => ({ ...prev, stateFilter }));
}, []);
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
setFilterState((prev) => ({ ...prev, selectedLabels }));
}, []);
// Clear all filters to default state
const handleClearFilters = useCallback(() => {
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
}, []);
// Get current branch from selected worktree
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
@@ -130,7 +174,10 @@ export function GitHubIssuesView() {
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
}
const totalIssues = openIssues.length + closedIssues.length;
const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
const isFilteredEmpty =
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
return (
<div className="flex-1 flex overflow-hidden">
@@ -143,10 +190,21 @@ export function GitHubIssuesView() {
>
{/* Header */}
<IssuesListHeader
openCount={openIssues.length}
closedCount={closedIssues.length}
openCount={filteredOpenIssues.length}
closedCount={filteredClosedIssues.length}
totalOpenCount={openIssues.length}
totalClosedCount={closedIssues.length}
hasActiveFilter={filterResult.hasActiveFilter}
refreshing={refreshing}
onRefresh={refresh}
compact={!!selectedIssue}
filterProps={{
stateFilter: filterState.stateFilter,
selectedLabels: filterState.selectedLabels,
availableLabels: filterResult.availableLabels,
onStateFilterChange: handleStateFilterChange,
onLabelsChange: handleLabelsChange,
}}
/>
{/* Issues List */}
@@ -154,15 +212,35 @@ export function GitHubIssuesView() {
{totalIssues === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<CircleDot className="h-8 w-8 text-muted-foreground" />
{isFilteredEmpty ? (
<SearchX className="h-8 w-8 text-muted-foreground" />
) : (
<CircleDot className="h-8 w-8 text-muted-foreground" />
)}
</div>
<h2 className="text-base font-medium mb-2">No Issues</h2>
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
<h2 className="text-base font-medium mb-2">
{isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{isFilteredEmpty
? 'No issues match your current filters.'
: 'This repository has no issues yet.'}
</p>
{isFilteredEmpty && (
<Button
variant="outline"
size="sm"
onClick={handleClearFilters}
className="text-xs"
>
Clear Filters
</Button>
)}
</div>
) : (
<div className="divide-y divide-border">
{/* Open Issues */}
{openIssues.map((issue) => (
{filteredOpenIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}
@@ -176,12 +254,12 @@ export function GitHubIssuesView() {
))}
{/* Closed Issues Section */}
{closedIssues.length > 0 && (
{filteredClosedIssues.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Closed Issues ({closedIssues.length})
Closed Issues ({filteredClosedIssues.length})
</div>
{closedIssues.map((issue) => (
{filteredClosedIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}

View File

@@ -1,4 +1,5 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';
export { IssuesFilterControls } from './issues-filter-controls';
export { CommentItem } from './comment-item';

View File

@@ -0,0 +1,191 @@
import { ChevronDown, Tag, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
/** Maximum number of labels to display before showing "+N more" in normal layout */
const VISIBLE_LABELS_LIMIT = 3;
/** Maximum number of labels to display before showing "+N more" in compact layout */
const VISIBLE_LABELS_LIMIT_COMPACT = 2;
interface IssuesFilterControlsProps {
/** Current state filter value */
stateFilter: IssuesStateFilter;
/** Currently selected labels */
selectedLabels: string[];
/** Available labels to choose from (typically from useIssuesFilter result) */
availableLabels: string[];
/** Callback when state filter changes */
onStateFilterChange: (filter: IssuesStateFilter) => void;
/** Callback when labels selection changes */
onLabelsChange: (labels: string[]) => void;
/** Whether the controls are disabled (e.g., during loading) */
disabled?: boolean;
/** Whether to use compact layout (stacked vertically) */
compact?: boolean;
/** Additional class name for the container */
className?: string;
}
/** Human-readable labels for state filter options */
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
open: 'Open',
closed: 'Closed',
all: 'All',
};
export function IssuesFilterControls({
stateFilter,
selectedLabels,
availableLabels,
onStateFilterChange,
onLabelsChange,
disabled = false,
compact = false,
className,
}: IssuesFilterControlsProps) {
/**
* Handles toggling a label in the selection.
* If the label is already selected, it removes it; otherwise, it adds it.
*/
const handleLabelToggle = (label: string) => {
const isSelected = selectedLabels.includes(label);
if (isSelected) {
onLabelsChange(selectedLabels.filter((l) => l !== label));
} else {
onLabelsChange([...selectedLabels, label]);
}
};
/**
* Clears all selected labels.
*/
const handleClearLabels = () => {
onLabelsChange([]);
};
const hasSelectedLabels = selectedLabels.length > 0;
const hasAvailableLabels = availableLabels.length > 0;
return (
<div className={cn('flex flex-col gap-2', className)}>
{/* Filter Controls Row */}
<div className="flex items-center gap-2">
{/* State Filter Select */}
<Select
value={stateFilter}
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
disabled={disabled}
>
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{STATE_FILTER_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Labels Filter Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
<Button
variant="outline"
size="sm"
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
disabled={disabled || !hasAvailableLabels}
>
<Tag className="h-3.5 w-3.5" />
<span>Labels</span>
{hasSelectedLabels && (
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
{selectedLabels.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Filter by label</span>
{hasSelectedLabels && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={handleClearLabels}
>
<X className="h-3 w-3 mr-0.5" />
Clear
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableLabels.map((label) => (
<DropdownMenuCheckboxItem
key={label}
checked={selectedLabels.includes(label)}
onCheckedChange={() => handleLabelToggle(label)}
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
>
{label}
</DropdownMenuCheckboxItem>
))}
{!hasAvailableLabels && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Selected Labels Display - shown on separate row */}
{hasSelectedLabels && (
<div className="flex items-center gap-1 flex-wrap">
{selectedLabels
.slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)
.map((label) => (
<Badge
key={label}
variant="outline"
size="sm"
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
onClick={() => handleLabelToggle(label)}
>
{label}
<X className="h-2.5 w-2.5" />
</Badge>
))}
{selectedLabels.length >
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && (
<Badge variant="muted" size="sm">
+
{selectedLabels.length -
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '}
more
</Badge>
)}
</div>
)}
</div>
);
}

View File

@@ -1,38 +1,100 @@
import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { IssuesFilterControls } from './issues-filter-controls';
interface IssuesListHeaderProps {
openCount: number;
closedCount: number;
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
totalOpenCount?: number;
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
totalClosedCount?: number;
/** Whether any filter is currently active */
hasActiveFilter?: boolean;
refreshing: boolean;
onRefresh: () => void;
/** Whether the list is in compact mode (e.g., when detail panel is open) */
compact?: boolean;
/** Optional filter state and handlers - when provided, filter controls are rendered */
filterProps?: {
stateFilter: IssuesStateFilter;
selectedLabels: string[];
availableLabels: string[];
onStateFilterChange: (filter: IssuesStateFilter) => void;
onLabelsChange: (labels: string[]) => void;
};
}
export function IssuesListHeader({
openCount,
closedCount,
totalOpenCount,
totalClosedCount,
hasActiveFilter = false,
refreshing,
onRefresh,
compact = false,
filterProps,
}: IssuesListHeaderProps) {
const totalIssues = openCount + closedCount;
// Format the counts subtitle based on filter state
const getCountsSubtitle = () => {
if (totalIssues === 0) {
return hasActiveFilter ? 'No matching issues' : 'No issues found';
}
// When filters are active and we have total counts, show "X of Y" format
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
const openText =
openCount === totalOpenCount
? `${openCount} open`
: `${openCount} of ${totalOpenCount} open`;
const closedText =
closedCount === totalClosedCount
? `${closedCount} closed`
: `${closedCount} of ${totalClosedCount} closed`;
return `${openText}, ${closedText}`;
}
// Default format when no filters active
return `${openCount} open, ${closedCount} closed`;
};
return (
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
</p>
<div className="border-b border-border">
{/* Top row: Title and refresh button */}
<div className="flex items-center justify-between p-4 pb-2">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
{/* Filter controls row (optional) */}
{filterProps && (
<div className="px-4 pb-3 pt-1">
<IssuesFilterControls
stateFilter={filterProps.stateFilter}
selectedLabels={filterProps.selectedLabels}
availableLabels={filterProps.availableLabels}
onStateFilterChange={filterProps.onStateFilterChange}
onLabelsChange={filterProps.onLabelsChange}
disabled={refreshing}
compact={compact}
/>
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation';
export { useIssueComments } from './use-issue-comments';
export { useIssuesFilter } from './use-issues-filter';

View File

@@ -0,0 +1,240 @@
import { useMemo } from 'react';
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
import { isValidationStale } from '../utils';
/**
* Determines the validation status of an issue based on its cached validation.
*/
function getValidationStatus(
issueNumber: number,
cachedValidations: Map<number, StoredValidation>
): IssuesValidationStatus | null {
const validation = cachedValidations.get(issueNumber);
if (!validation) {
return 'not_validated';
}
if (isValidationStale(validation.validatedAt)) {
return 'stale';
}
return 'validated';
}
/**
* Checks if a search query matches an issue's searchable content.
* Searches through title and body (case-insensitive).
*/
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
if (!normalizedQuery) return true;
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
return titleMatch || bodyMatch;
}
/**
* Checks if an issue matches the state filter (open/closed/all).
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
*/
function matchesStateFilter(
issue: GitHubIssue,
stateFilter: IssuesFilterState['stateFilter']
): boolean {
if (stateFilter === 'all') return true;
return issue.state.toLowerCase() === stateFilter;
}
/**
* Checks if an issue matches any of the selected labels.
* Returns true if no labels are selected (no filter) or if any selected label matches.
*/
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
if (selectedLabels.length === 0) return true;
const issueLabels = issue.labels.map((l) => l.name);
return selectedLabels.some((label) => issueLabels.includes(label));
}
/**
* Checks if an issue matches any of the selected assignees.
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
*/
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
if (selectedAssignees.length === 0) return true;
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
}
/**
* Checks if an issue matches any of the selected milestones.
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
*/
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
if (selectedMilestones.length === 0) return true;
// GitHub issues in the current schema don't have milestone field
// This is a placeholder for future milestone support
// For now, issues with no milestone won't match if a milestone filter is active
return false;
}
/**
* Checks if an issue matches the validation status filter.
*/
function matchesValidationStatus(
issue: GitHubIssue,
validationStatusFilter: IssuesValidationStatus | null,
cachedValidations: Map<number, StoredValidation>
): boolean {
if (!validationStatusFilter) return true;
const status = getValidationStatus(issue.number, cachedValidations);
return status === validationStatusFilter;
}
/**
* Extracts all unique labels from a list of issues.
*/
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
const labelsSet = new Set<string>();
for (const issue of issues) {
for (const label of issue.labels) {
labelsSet.add(label.name);
}
}
return Array.from(labelsSet).sort();
}
/**
* Extracts all unique assignees from a list of issues.
*/
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
const assigneesSet = new Set<string>();
for (const issue of issues) {
for (const assignee of issue.assignees ?? []) {
assigneesSet.add(assignee.login);
}
}
return Array.from(assigneesSet).sort();
}
/**
* Extracts all unique milestones from a list of issues.
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
*/
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
// GitHub issues in the current schema don't have milestone field
// This is a placeholder for future milestone support
return [];
}
/**
* Determines if any filter is currently active.
*/
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
const {
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
} = filterState;
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
// Only 'closed' or 'all' are considered active filters
const hasStateFilter = stateFilter !== 'open';
const hasSearchQuery = searchQuery.trim().length > 0;
const hasLabelFilter = selectedLabels.length > 0;
const hasAssigneeFilter = selectedAssignees.length > 0;
const hasMilestoneFilter = selectedMilestones.length > 0;
const hasValidationFilter = validationStatusFilter !== null;
return (
hasSearchQuery ||
hasStateFilter ||
hasLabelFilter ||
hasAssigneeFilter ||
hasMilestoneFilter ||
hasValidationFilter
);
}
/**
* Hook to filter GitHub issues based on the current filter state.
*
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
* It computes matched issues and extracts available filter options from all issues.
*
* @param issues - Combined array of all issues (open + closed) to filter
* @param filterState - Current filter state including search, labels, assignees, etc.
* @param cachedValidations - Map of issue numbers to their cached validation results
* @returns Filter result containing matched issue numbers and available filter options
*/
export function useIssuesFilter(
issues: GitHubIssue[],
filterState: IssuesFilterState,
cachedValidations: Map<number, StoredValidation> = new Map()
): IssuesFilterResult {
const {
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
} = filterState;
return useMemo(() => {
// Extract available options from all issues (for filter dropdown population)
const availableLabels = extractAvailableLabels(issues);
const availableAssignees = extractAvailableAssignees(issues);
const availableMilestones = extractAvailableMilestones(issues);
// Check if any filter is active
const hasActiveFilter = hasActiveFilterCheck(filterState);
// Normalize search query for case-insensitive matching
const normalizedQuery = searchQuery.toLowerCase().trim();
// Filter issues based on all criteria - return matched issues directly
// This eliminates the redundant O(n) filtering operation in the consuming component
const matchedIssues: GitHubIssue[] = [];
for (const issue of issues) {
// All conditions must be true for a match
const matchesAllFilters =
matchesSearchQuery(issue, normalizedQuery) &&
matchesStateFilter(issue, stateFilter) &&
matchesLabels(issue, selectedLabels) &&
matchesAssignees(issue, selectedAssignees) &&
matchesMilestones(issue, selectedMilestones) &&
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
if (matchesAllFilters) {
matchedIssues.push(issue);
}
}
return {
matchedIssues,
availableLabels,
availableAssignees,
availableMilestones,
hasActiveFilter,
matchedCount: matchedIssues.length,
};
}, [
issues,
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
cachedValidations,
]);
}

View File

@@ -1,6 +1,111 @@
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
// ============================================================================
// Issues Filter State Types
// ============================================================================
/**
* Available sort columns for issues list
*/
export const ISSUES_SORT_COLUMNS = [
'title',
'created_at',
'updated_at',
'comments',
'number',
] as const;
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
/**
* Sort direction options
*/
export type IssuesSortDirection = 'asc' | 'desc';
/**
* Available issue state filter values
*/
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
/**
* Validation status filter values for filtering issues by validation state
*/
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
/**
* Sort configuration for issues list
*/
export interface IssuesSortConfig {
column: IssuesSortColumn;
direction: IssuesSortDirection;
}
/**
* Main filter state interface for the GitHub Issues view
*
* This interface defines all filterable/sortable state for the issues list.
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
*/
export interface IssuesFilterState {
/** Search query for filtering by issue title or body */
searchQuery: string;
/** Filter by issue state (open/closed/all) */
stateFilter: IssuesStateFilter;
/** Filter by selected labels (matches any) */
selectedLabels: string[];
/** Filter by selected assignees (matches any) */
selectedAssignees: string[];
/** Filter by selected milestones (matches any) */
selectedMilestones: string[];
/** Filter by validation status */
validationStatusFilter: IssuesValidationStatus | null;
/** Current sort configuration */
sortConfig: IssuesSortConfig;
}
/**
* Result of applying filters to the issues list
*/
export interface IssuesFilterResult {
/** Array of GitHubIssue objects that match the current filters */
matchedIssues: GitHubIssue[];
/** Available labels from all issues (for filter dropdown population) */
availableLabels: string[];
/** Available assignees from all issues (for filter dropdown population) */
availableAssignees: string[];
/** Available milestones from all issues (for filter dropdown population) */
availableMilestones: string[];
/** Whether any filter is currently active */
hasActiveFilter: boolean;
/** Total count of matched issues */
matchedCount: number;
}
/**
* Default values for IssuesFilterState
*/
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
searchQuery: '',
stateFilter: 'open',
selectedLabels: [],
selectedAssignees: [],
selectedMilestones: [],
validationStatusFilter: null,
sortConfig: {
column: 'updated_at',
direction: 'desc',
},
};
// ============================================================================
// Component Props Types
// ============================================================================
export interface IssueRowProps {
issue: GitHubIssue;
isSelected: boolean;