diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 35c00a20..aebed98b 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -22,6 +22,13 @@ export class ClaudeUsageService { private timeout = 30000; // 30 second timeout private isWindows = os.platform() === 'win32'; private isLinux = os.platform() === 'linux'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + // When in Electron, always use winpty to avoid ConPTY's AttachConsole errors + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows /** * Kill a PTY process with platform-specific handling. @@ -197,30 +204,87 @@ export class ClaudeUsageService { ? ['/c', 'claude', '--add-dir', workingDirectory] : ['-c', `claude --add-dir "${workingDirectory}"`]; + // Using 'any' for ptyProcess because node-pty types don't include 'killed' property + // eslint-disable-next-line @typescript-eslint/no-explicit-any let ptyProcess: any = null; + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + '[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)' + ); + } + try { - ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + ptyProcess = pty.spawn(shell, args, ptyOptions); } catch (spawnError) { const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); - logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); - // Return a user-friendly error instead of crashing - reject( - new Error( - `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` - ) - ); - return; + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn( + '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback' + ); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, args, ptyOptions); + logger.info( + '[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback' + ); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error( + '[executeClaudeUsageCommandPty] Winpty fallback also failed:', + fallbackMessage + ); + reject( + new Error( + `Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage); + reject( + new Error( + `Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } } const timeoutId = setTimeout(() => { @@ -260,12 +324,19 @@ export class ClaudeUsageService { const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); // Check for specific authentication/permission errors - if ( - cleanOutput.includes('OAuth token does not meet scope requirement') || - cleanOutput.includes('permission_error') || - cleanOutput.includes('token_expired') || - cleanOutput.includes('authentication_error') - ) { + // Must be very specific to avoid false positives from garbled terminal encoding + // Removed permission_error check as it was causing false positives with winpty encoding + const authChecks = { + oauth: cleanOutput.includes('OAuth token does not meet scope requirement'), + tokenExpired: cleanOutput.includes('token_expired'), + // Only match if it looks like a JSON API error response + authError: + cleanOutput.includes('"type":"authentication_error"') || + cleanOutput.includes('"type": "authentication_error"'), + }; + const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError; + + if (hasAuthError) { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { @@ -281,11 +352,16 @@ export class ClaudeUsageService { } // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) - if ( - !hasSeenUsageData && - (cleanOutput.includes('Current session') || - (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) - ) { + // Also check for percentage patterns that appear in usage output + const hasUsageIndicators = + cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || + // Additional patterns for winpty - look for percentage patterns + /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || + cleanOutput.includes('Resets in') || + cleanOutput.includes('Current week'); + + if (!hasSeenUsageData && hasUsageIndicators) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -324,10 +400,18 @@ export class ClaudeUsageService { } // Detect REPL prompt and send /usage command - if ( - !hasSentCommand && - (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) - ) { + // On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators + const isReplReady = + cleanOutput.includes('❯') || + cleanOutput.includes('? for shortcuts') || + // Fallback for winpty garbled encoding - detect CLI welcome screen elements + (cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) || + (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || + // Detect model indicator which appears when REPL is ready + (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || + (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); + + if (!hasSentCommand && isReplReady) { hasSentCommand = true; // Wait for REPL to fully settle setTimeout(() => { @@ -364,11 +448,9 @@ export class ClaudeUsageService { if (settled) return; settled = true; - if ( - output.includes('token_expired') || - output.includes('authentication_error') || - output.includes('permission_error') - ) { + // Check for auth errors - must be specific to avoid false positives + // Removed permission_error check as it was causing false positives with winpty encoding + if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index bd4481a8..f83aaede 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -71,6 +71,12 @@ export class TerminalService extends EventEmitter { private dataCallbacks: Set = new Set(); private exitCallbacks: Set = new Set(); private isWindows = os.platform() === 'win32'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows /** * Kill a PTY process with platform-specific handling. @@ -339,13 +345,60 @@ export class TerminalService extends EventEmitter { logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); - const ptyProcess = pty.spawn(shell, shellArgs, { + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { name: 'xterm-256color', cols: options.cols || 80, rows: options.rows || 24, cwd, env, - }); + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + `[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)` + ); + } + + let ptyProcess: pty.IPty; + try { + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + } catch (spawnError) { + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed:`, errorMessage); + return null; + } + } const session: TerminalSession = { id, @@ -409,7 +462,11 @@ export class TerminalService extends EventEmitter { // Handle exit ptyProcess.onExit(({ exitCode }) => { - logger.info(`Session ${id} exited with code ${exitCode}`); + const exitMessage = + exitCode === undefined || exitCode === null + ? 'Session terminated' + : `Session exited with code ${exitCode}`; + logger.info(`${exitMessage} (${id})`); this.sessions.delete(id); this.exitCallbacks.forEach((cb) => cb(id, exitCode)); this.emit('exit', id, exitCode); diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index e1e09cad..03275b02 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -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(null); + // Filter state + const [filterState, setFilterState] = useState(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 ; } - 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 (
@@ -143,10 +190,21 @@ export function GitHubIssuesView() { > {/* Header */} {/* Issues List */} @@ -154,15 +212,35 @@ export function GitHubIssuesView() { {totalIssues === 0 ? (
- + {isFilteredEmpty ? ( + + ) : ( + + )}
-

No Issues

-

This repository has no issues yet.

+

+ {isFilteredEmpty ? 'No Matching Issues' : 'No Issues'} +

+

+ {isFilteredEmpty + ? 'No issues match your current filters.' + : 'This repository has no issues yet.'} +

+ {isFilteredEmpty && ( + + )}
) : (
{/* Open Issues */} - {openIssues.map((issue) => ( + {filteredOpenIssues.map((issue) => ( 0 && ( + {filteredClosedIssues.length > 0 && ( <>
- Closed Issues ({closedIssues.length}) + Closed Issues ({filteredClosedIssues.length})
- {closedIssues.map((issue) => ( + {filteredClosedIssues.map((issue) => ( 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 = { + 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 ( +
+ {/* Filter Controls Row */} +
+ {/* State Filter Select */} + + + {/* Labels Filter Dropdown */} + + + + + + + Filter by label + {hasSelectedLabels && ( + + )} + + + {availableLabels.map((label) => ( + handleLabelToggle(label)} + onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing + > + {label} + + ))} + {!hasAvailableLabels && ( +
No labels available
+ )} +
+
+
+ + {/* Selected Labels Display - shown on separate row */} + {hasSelectedLabels && ( +
+ {selectedLabels + .slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) + .map((label) => ( + handleLabelToggle(label)} + > + {label} + + + ))} + {selectedLabels.length > + (compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && ( + + + + {selectedLabels.length - + (compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '} + more + + )} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 5529b30c..1c58bbe4 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -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 ( -
-
-
- -
-
-

Issues

-

- {totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} -

+
+ {/* Top row: Title and refresh button */} +
+
+
+ +
+
+

Issues

+

{getCountsSubtitle()}

+
+
- + + {/* Filter controls row (optional) */} + {filterProps && ( +
+ +
+ )}
); } diff --git a/apps/ui/src/components/views/github-issues-view/hooks/index.ts b/apps/ui/src/components/views/github-issues-view/hooks/index.ts index 57b78868..a03332ad 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/index.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts new file mode 100644 index 00000000..987e890a --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts @@ -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 +): 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 +): 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(); + 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(); + 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 = 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, + ]); +} diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index 7ea799d9..a66e3a96 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -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;