diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8190894..6cbd6e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import type { FilterOptionCounts, FilterState } from "./components/FilterPanel"; import { FilterPanel } from "./components/FilterPanel"; import { HowItWorksPage } from "./components/HowItWorksPage"; import { IssuesList } from "./components/IssuesList"; +import { IssuesPage } from "./components/IssuesPage"; import { ProgressPanel } from "./components/ProgressPanel"; import { RecentEvaluationsPage } from "./components/RecentEvaluationsPage"; import { SelectionSummaryBar } from "./components/SelectionSummaryBar"; @@ -1872,6 +1873,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> {assessmentEnabled && ( } /> diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index 39f760c..dd65253 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -6,6 +6,7 @@ interface AppHeaderProps { currentPage?: | "home" | "recent" + | "issues" | "evaluators" | "how-it-works" | "assessment"; @@ -201,6 +202,27 @@ export function AppHeader({ )} + + + + + Issues + {}; + +export function IssuesPage() { + const { history } = useEvaluationHistory(); + const { + issues, + pagination, + availableFilters, + isLoading, + error, + filters, + setFilters, + setPage, + } = useAggregatedIssues(); + + const updateFilter = useCallback( + (key: keyof IssuesPageFilters, value: string) => { + setFilters({ ...filters, [key]: value }); + }, + [filters, setFilters], + ); + + const clearFilters = useCallback(() => { + setFilters({ + evaluator: "", + severity: "", + repository: "", + issueType: "", + search: "", + }); + }, [setFilters]); + + const hasActiveFilters = + filters.evaluator || + filters.severity || + filters.repository || + filters.issueType || + filters.search; + + return ( +
+ + +
+
+ {/* Header */} +
+
+ + + +

All Issues

+
+ + {pagination.totalItems} total issue + {pagination.totalItems !== 1 ? "s" : ""} + +
+ +
+ {/* Filters sidebar */} +
+
+

Filters

+ {hasActiveFilters && ( + + )} +
+ + {/* Search */} +
+ + updateFilter("search", e.target.value)} + placeholder="Search issues..." + className="input-field w-full" + /> +
+ + {/* Repository */} +
+ + +
+ + {/* Evaluator */} +
+ + +
+ + {/* Severity */} +
+ + +
+ + {/* Issue Type */} +
+ + +
+
+ + {/* Issue list */} +
+ {isLoading && issues.length === 0 && ( +
+ Loading issues... +
+ )} + + {error && ( +
+ Failed to load issues: {error} +
+ )} + + {!isLoading && !error && issues.length === 0 && ( +
+

+ {hasActiveFilters + ? "No issues match the selected filters." + : "No issues found. Run an evaluation to see results here."} +

+
+ )} + + {issues.map((aggregatedIssue, idx) => ( +
+ {/* Evaluation context */} +
+ + {extractRepoName(aggregatedIssue.repositoryUrl)} + + | + + {formatRelativeDate(aggregatedIssue.evaluationDate)} + + | + {aggregatedIssue.evaluatorName} +
+ +
+ ))} + + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/hooks/useAggregatedIssues.ts b/frontend/src/hooks/useAggregatedIssues.ts new file mode 100644 index 0000000..2441310 --- /dev/null +++ b/frontend/src/hooks/useAggregatedIssues.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + IAggregatedIssue, + IAggregatedIssuesResponse, +} from "../types/evaluation"; + +export interface IssuesPageFilters { + evaluator: string; // "" = all + severity: string; // "" = all + repository: string; // "" = all + issueType: string; // "" = all + search: string; +} + +const DEFAULT_FILTERS: IssuesPageFilters = { + evaluator: "", + severity: "", + repository: "", + issueType: "", + search: "", +}; + +const DEFAULT_PAGINATION = { + page: 1, + pageSize: 25, + totalItems: 0, + totalPages: 0, +}; + +const DEFAULT_AVAILABLE_FILTERS = { + evaluators: [] as string[], + repositories: [] as string[], +}; + +export function useAggregatedIssues() { + const [issues, setIssues] = useState([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [availableFilters, setAvailableFilters] = useState( + DEFAULT_AVAILABLE_FILTERS, + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFiltersInternal] = + useState(DEFAULT_FILTERS); + const [page, setPageInternal] = useState(1); + + // Debounce timer for search + const searchTimerRef = useRef | null>(null); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // Debounce search input + useEffect(() => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(filters.search); + }, 300); + return () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + }; + }, [filters.search]); + + const fetchIssues = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("pageSize", "25"); + if (filters.evaluator) params.set("evaluator", filters.evaluator); + if (filters.severity) params.set("severity", filters.severity); + if (filters.repository) params.set("repository", filters.repository); + if (filters.issueType) params.set("issueType", filters.issueType); + if (debouncedSearch) params.set("search", debouncedSearch); + + const response = await fetch(`/api/issues?${params.toString()}`); + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`); + } + + const data = (await response.json()) as IAggregatedIssuesResponse; + setIssues(data.issues); + setPagination(data.pagination); + setAvailableFilters(data.availableFilters); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch issues"); + } finally { + setIsLoading(false); + } + }, [ + page, + filters.evaluator, + filters.severity, + filters.repository, + filters.issueType, + debouncedSearch, + ]); + + // Reset page to 1 when filters change + const setFilters = useCallback((newFilters: IssuesPageFilters) => { + setFiltersInternal(newFilters); + setPageInternal(1); + }, []); + + const setPage = useCallback((newPage: number) => { + setPageInternal(newPage); + }, []); + + // Fetch on mount and when page/filters change + useEffect(() => { + fetchIssues(); + }, [fetchIssues]); + + return { + issues, + pagination, + availableFilters, + isLoading, + error, + filters, + setFilters, + setPage, + refresh: fetchIssues, + }; +} diff --git a/frontend/src/types/evaluation.ts b/frontend/src/types/evaluation.ts index 4996088..fc4cbf1 100644 --- a/frontend/src/types/evaluation.ts +++ b/frontend/src/types/evaluation.ts @@ -634,3 +634,26 @@ export function getMaxCategoryGroupSeverity(groups: CategoryGroup[]): number { if (groups.length === 0) return 0; return Math.max(...groups.map((g) => g.maxSeverity)); } + +// Aggregated issues types (cross-evaluation) +export interface IAggregatedIssue { + issue: Issue; + evaluationId: string; + repositoryUrl: string; + evaluationDate: string; + evaluatorName: string; +} + +export interface IAggregatedIssuesResponse { + issues: IAggregatedIssue[]; + pagination: { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; + availableFilters: { + evaluators: string[]; + repositories: string[]; + }; +} diff --git a/src/api/db/evaluation-repository.ts b/src/api/db/evaluation-repository.ts index d6ac3a4..4f18b73 100644 --- a/src/api/db/evaluation-repository.ts +++ b/src/api/db/evaluation-repository.ts @@ -268,6 +268,49 @@ export class EvaluationRepository { return deletedCount; } + /** + * Get all completed evaluations with their full result JSON. + * Used by the aggregated issues endpoint to extract issues across evaluations. + */ + getAllCompletedEvaluationsWithResults(): Array<{ + id: string; + repositoryUrl: string; + completedAt: string; + result: EvaluationOutput; + }> { + const db = getDatabase(); + + const rows = db + .prepare< + Pick< + EvaluationRow, + "id" | "repository_url" | "completed_at" | "result_json" + >, + [] + >( + `SELECT id, repository_url, completed_at, result_json + FROM evaluations + WHERE status = 'completed' AND result_json IS NOT NULL + ORDER BY completed_at DESC`, + ) + .all(); + + return rows + .map((row) => { + try { + return { + id: row.id, + repositoryUrl: row.repository_url, + completedAt: row.completed_at, + result: JSON.parse(row.result_json!) as EvaluationOutput, + }; + } catch { + return null; + } + }) + .filter((r): r is NonNullable => r !== null); + } + /** * Get evaluation count */ diff --git a/src/api/index.ts b/src/api/index.ts index e904e90..a77fe26 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,6 +14,7 @@ import { ConfigRoutes } from "./routes/config"; import { EvaluationRoutes } from "./routes/evaluation"; import { FeedbackRoutes } from "./routes/feedback"; import { HealthRoutes } from "./routes/health"; +import { IssuesRoutes } from "./routes/issues"; import { ProviderRoutes } from "./routes/providers"; import { SSEProgressHandler } from "./sse/progress-handler"; import { serveStaticFile } from "./static/static-file-server"; @@ -64,6 +65,7 @@ export class APIServer { private feedbackRoutes: FeedbackRoutes; private bookmarkRoutes: BookmarkRoutes; private healthRoutes: HealthRoutes; + private issuesRoutes: IssuesRoutes; private configRoutes: ConfigRoutes; private providerRoutes: ProviderRoutes; private rateLimiter: DailyRateLimiter; @@ -84,6 +86,7 @@ export class APIServer { ); this.feedbackRoutes = new FeedbackRoutes(); this.bookmarkRoutes = new BookmarkRoutes(); + this.issuesRoutes = new IssuesRoutes(); this.healthRoutes = new HealthRoutes(this.jobManager); this.configRoutes = new ConfigRoutes( config.enableAssessmentFeatures ?? false, @@ -237,6 +240,11 @@ export class APIServer { return this.feedbackRoutes.getForEvaluation(req, evaluationId); } + // Aggregated issues routes + if (path === "/api/issues" && req.method === "GET") { + return this.issuesRoutes.list(req); + } + // Bookmark routes if (path === "/api/bookmarks" && req.method === "POST") { return this.bookmarkRoutes.post(req); diff --git a/src/api/routes/issues.test.ts b/src/api/routes/issues.test.ts new file mode 100644 index 0000000..2f2dc4c --- /dev/null +++ b/src/api/routes/issues.test.ts @@ -0,0 +1,510 @@ +import { Database } from "bun:sqlite"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { IssuesRoutes } from "./issues"; + +// Mock the database +let testDb: Database; + +mock.module("../db/database", () => ({ + getDatabase: () => testDb, +})); + +// Helper to create a unified evaluation result_json +function createUnifiedResultJson( + issues: Array<{ + issueType: string; + severity?: number; + impactLevel?: string; + category: string; + description: string; + evaluator: string; + }>, +) { + const evaluatorGroups: Record< + string, + Array<{ + issueType: string; + severity?: number; + impactLevel?: string; + category: string; + description: string; + location: { start: number; end: number }; + }> + > = {}; + + for (const issue of issues) { + if (!evaluatorGroups[issue.evaluator]) { + evaluatorGroups[issue.evaluator] = []; + } + const { evaluator: _e, ...issueData } = issue; + evaluatorGroups[issue.evaluator]!.push({ + ...issueData, + location: { start: 1, end: 5 }, + }); + } + + return JSON.stringify({ + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 1, + }, + results: Object.entries(evaluatorGroups).map(([evaluator, evalIssues]) => ({ + evaluator, + output: { + type: "text", + subtype: "", + is_error: false, + duration_ms: 100, + num_turns: 1, + result: JSON.stringify(evalIssues), + session_id: "s1", + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50 }, + }, + })), + crossFileIssues: [], + }); +} + +// Helper to insert an evaluation +function insertEvaluation( + id: string, + repositoryUrl: string, + resultJson: string, + completedAt = "2026-01-15T10:00:00Z", +) { + testDb.run( + `INSERT INTO evaluations ( + id, repository_url, evaluation_mode, evaluators_count, status, + total_files, total_issues, critical_count, high_count, medium_count, + total_cost_usd, total_duration_ms, total_input_tokens, total_output_tokens, + curated_count, result_json, created_at, completed_at + ) VALUES (?, ?, 'unified', 5, 'completed', 1, 3, 0, 1, 1, 0.05, 5000, 500, 200, 0, ?, ?, ?)`, + [id, repositoryUrl, resultJson, completedAt, completedAt], + ); +} + +describe("IssuesRoutes", () => { + let routes: IssuesRoutes; + + beforeAll(() => { + testDb = new Database(":memory:"); + testDb.run(` + CREATE TABLE IF NOT EXISTS evaluations ( + id TEXT PRIMARY KEY, + repository_url TEXT NOT NULL, + evaluation_mode TEXT, + evaluators_count INTEGER NOT NULL, + status TEXT NOT NULL, + total_files INTEGER DEFAULT 0, + total_issues INTEGER DEFAULT 0, + critical_count INTEGER DEFAULT 0, + high_count INTEGER DEFAULT 0, + medium_count INTEGER DEFAULT 0, + total_cost_usd REAL DEFAULT 0, + total_duration_ms INTEGER DEFAULT 0, + total_input_tokens INTEGER DEFAULT 0, + total_output_tokens INTEGER DEFAULT 0, + curated_count INTEGER DEFAULT 0, + context_score REAL, + context_grade TEXT, + failed_evaluator_count INTEGER DEFAULT 0, + result_json TEXT, + final_prompts_json TEXT, + error_message TEXT, + error_code TEXT, + created_at TEXT NOT NULL, + completed_at TEXT NOT NULL + ); + `); + routes = new IssuesRoutes(); + }); + + beforeEach(() => { + testDb.run("DELETE FROM evaluations"); + }); + + afterAll(() => { + testDb.close(); + }); + + test("returns empty results when no evaluations exist", async () => { + const req = new Request("http://localhost/api/issues"); + const res = await routes.list(req); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.issues).toHaveLength(0); + expect(data.pagination.totalItems).toBe(0); + expect(data.pagination.totalPages).toBe(0); + expect(data.pagination.page).toBe(1); + }); + + test("returns issues from a single evaluation", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "High issue", + evaluator: "content-quality", + }, + { + issueType: "error", + severity: 6, + category: "Commands", + description: "Medium issue", + evaluator: "command-completeness", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + const req = new Request("http://localhost/api/issues"); + const res = await routes.list(req); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.issues).toHaveLength(2); + expect(data.pagination.totalItems).toBe(2); + expect(data.issues[0].evaluationId).toBe("eval-1"); + expect(data.issues[0].repositoryUrl).toBe("https://github.com/owner/repo"); + expect(data.availableFilters.evaluators).toContain("content-quality"); + expect(data.availableFilters.evaluators).toContain("command-completeness"); + expect(data.availableFilters.repositories).toContain( + "https://github.com/owner/repo", + ); + }); + + test("aggregates issues from multiple evaluations", async () => { + const result1 = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Issue from eval 1", + evaluator: "content-quality", + }, + ]); + const result2 = createUnifiedResultJson([ + { + issueType: "suggestion", + impactLevel: "High", + category: "Context", + description: "Issue from eval 2", + evaluator: "context-gaps", + }, + ]); + + insertEvaluation( + "eval-1", + "https://github.com/owner/repo1", + result1, + "2026-01-15T10:00:00Z", + ); + insertEvaluation( + "eval-2", + "https://github.com/owner/repo2", + result2, + "2026-01-16T10:00:00Z", + ); + + const req = new Request("http://localhost/api/issues"); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(2); + expect(data.availableFilters.repositories).toHaveLength(2); + }); + + test("filters by evaluator", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Quality issue", + evaluator: "content-quality", + }, + { + issueType: "error", + severity: 7, + category: "Security", + description: "Security issue", + evaluator: "security", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + const req = new Request( + "http://localhost/api/issues?evaluator=content-quality", + ); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(1); + expect(data.issues[0].evaluatorName).toBe("content-quality"); + // Available filters should still show all evaluators (from unfiltered data) + expect(data.availableFilters.evaluators).toContain("security"); + }); + + test("filters by severity", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 9, + category: "Quality", + description: "High severity", + evaluator: "content-quality", + }, + { + issueType: "error", + severity: 6, + category: "Commands", + description: "Medium severity", + evaluator: "command-completeness", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + const req = new Request("http://localhost/api/issues?severity=high"); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(1); + expect(data.issues[0].issue.description).toBe("High severity"); + }); + + test("filters by repository", async () => { + const result1 = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Repo1 issue", + evaluator: "content-quality", + }, + ]); + const result2 = createUnifiedResultJson([ + { + issueType: "error", + severity: 7, + category: "Quality", + description: "Repo2 issue", + evaluator: "content-quality", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo1", result1); + insertEvaluation("eval-2", "https://github.com/owner/repo2", result2); + + const req = new Request( + "http://localhost/api/issues?repository=https://github.com/owner/repo1", + ); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(1); + expect(data.issues[0].repositoryUrl).toBe("https://github.com/owner/repo1"); + }); + + test("filters by issue type", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Error issue", + evaluator: "content-quality", + }, + { + issueType: "suggestion", + impactLevel: "High", + category: "Context", + description: "Suggestion issue", + evaluator: "context-gaps", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + const req = new Request("http://localhost/api/issues?issueType=suggestion"); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(1); + expect(data.issues[0].issue.issueType).toBe("suggestion"); + }); + + test("filters by search text", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Missing documentation for API", + evaluator: "content-quality", + }, + { + issueType: "error", + severity: 7, + category: "Security", + description: "Exposed credentials in config", + evaluator: "security", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + const req = new Request("http://localhost/api/issues?search=documentation"); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(1); + expect(data.issues[0].issue.description).toBe( + "Missing documentation for API", + ); + }); + + test("paginates results", async () => { + // Create 30 issues across evaluations + const issues = []; + for (let i = 0; i < 30; i++) { + issues.push({ + issueType: "error" as const, + severity: 8, + category: "Quality", + description: `Issue ${i}`, + evaluator: "content-quality", + }); + } + const resultJson = createUnifiedResultJson(issues); + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + // Page 1 + const req1 = new Request("http://localhost/api/issues?page=1&pageSize=10"); + const res1 = await routes.list(req1); + const data1 = await res1.json(); + + expect(data1.issues).toHaveLength(10); + expect(data1.pagination.page).toBe(1); + expect(data1.pagination.pageSize).toBe(10); + expect(data1.pagination.totalItems).toBe(30); + expect(data1.pagination.totalPages).toBe(3); + + // Page 3 (last page) + const req3 = new Request("http://localhost/api/issues?page=3&pageSize=10"); + const res3 = await routes.list(req3); + const data3 = await res3.json(); + + expect(data3.issues).toHaveLength(10); + expect(data3.pagination.page).toBe(3); + }); + + test("clamps page and pageSize to valid ranges", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Issue", + evaluator: "content-quality", + }, + ]); + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + // Page 0 should become 1 + const req = new Request("http://localhost/api/issues?page=0&pageSize=200"); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.pagination.page).toBe(1); + expect(data.pagination.pageSize).toBe(100); // Clamped to max 100 + }); + + test("skips failed evaluations", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 8, + category: "Quality", + description: "Good issue", + evaluator: "content-quality", + }, + ]); + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + // Insert a failed evaluation + testDb.run( + `INSERT INTO evaluations ( + id, repository_url, evaluation_mode, evaluators_count, status, + total_files, total_issues, critical_count, high_count, medium_count, + total_cost_usd, total_duration_ms, total_input_tokens, total_output_tokens, + curated_count, result_json, error_message, error_code, created_at, completed_at + ) VALUES (?, ?, 'unified', 5, 'failed', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, NULL, 'Error', 'ERR', ?, ?)`, + [ + "eval-failed", + "https://github.com/owner/repo2", + "2026-01-15T10:00:00Z", + "2026-01-15T10:00:00Z", + ], + ); + + const req = new Request("http://localhost/api/issues"); + const res = await routes.list(req); + const data = await res.json(); + + // Should only see issues from the completed evaluation + expect(data.issues).toHaveLength(1); + expect(data.issues[0].evaluationId).toBe("eval-1"); + }); + + test("combines multiple filters", async () => { + const resultJson = createUnifiedResultJson([ + { + issueType: "error", + severity: 9, + category: "Quality", + description: "High quality error", + evaluator: "content-quality", + }, + { + issueType: "error", + severity: 6, + category: "Quality", + description: "Medium quality error", + evaluator: "content-quality", + }, + { + issueType: "suggestion", + impactLevel: "High", + category: "Context", + description: "High context suggestion", + evaluator: "context-gaps", + }, + ]); + + insertEvaluation("eval-1", "https://github.com/owner/repo", resultJson); + + const req = new Request( + "http://localhost/api/issues?evaluator=content-quality&severity=high", + ); + const res = await routes.list(req); + const data = await res.json(); + + expect(data.issues).toHaveLength(1); + expect(data.issues[0].issue.description).toBe("High quality error"); + }); +}); diff --git a/src/api/routes/issues.ts b/src/api/routes/issues.ts new file mode 100644 index 0000000..a54291e --- /dev/null +++ b/src/api/routes/issues.ts @@ -0,0 +1,136 @@ +/** + * Issues routes - Aggregated issues across all evaluations + */ + +import type { + IAggregatedIssue, + IAggregatedIssuesResponse, +} from "@shared/types/api"; +import { getIssueSeverity } from "@shared/types/evaluation"; +import { getSeverityLevel } from "@shared/types/issues"; +import { evaluationRepository } from "../db/evaluation-repository"; +import { extractIssuesFromEvaluation } from "../utils/issue-extractor"; +import { internalErrorResponse, okResponse } from "../utils/response-builder"; + +export class IssuesRoutes { + /** + * GET /api/issues - Paginated, filtered list of all issues across evaluations + */ + async list(req: Request): Promise { + try { + const url = new URL(req.url); + + // Parse query parameters + const page = Math.max( + 1, + parseInt(url.searchParams.get("page") || "1", 10), + ); + const pageSize = Math.min( + 100, + Math.max(1, parseInt(url.searchParams.get("pageSize") || "25", 10)), + ); + const evaluatorFilter = url.searchParams.get("evaluator") || undefined; + const severityFilter = url.searchParams.get("severity") || undefined; + const repositoryFilter = url.searchParams.get("repository") || undefined; + const issueTypeFilter = url.searchParams.get("issueType") || undefined; + const searchFilter = url.searchParams.get("search") || undefined; + + // Load all completed evaluations with results + const evaluations = + evaluationRepository.getAllCompletedEvaluationsWithResults(); + + // Extract and enrich all issues + const allAggregatedIssues: IAggregatedIssue[] = []; + const evaluatorSet = new Set(); + const repositorySet = new Set(); + + for (const evaluation of evaluations) { + const issues = extractIssuesFromEvaluation(evaluation.result); + repositorySet.add(evaluation.repositoryUrl); + + for (const issue of issues) { + const evaluatorName = issue.evaluatorName || "unknown"; + evaluatorSet.add(evaluatorName); + + allAggregatedIssues.push({ + issue, + evaluationId: evaluation.id, + repositoryUrl: evaluation.repositoryUrl, + evaluationDate: evaluation.completedAt, + evaluatorName, + }); + } + } + + // Apply filters + let filtered = allAggregatedIssues; + + if (evaluatorFilter) { + filtered = filtered.filter( + (ai) => ai.evaluatorName === evaluatorFilter, + ); + } + + if (severityFilter) { + filtered = filtered.filter((ai) => { + const numericSeverity = getIssueSeverity(ai.issue); + const level = getSeverityLevel(numericSeverity); + return level === severityFilter; + }); + } + + if (repositoryFilter) { + filtered = filtered.filter( + (ai) => ai.repositoryUrl === repositoryFilter, + ); + } + + if (issueTypeFilter) { + filtered = filtered.filter( + (ai) => ai.issue.issueType === issueTypeFilter, + ); + } + + if (searchFilter) { + const searchLower = searchFilter.toLowerCase(); + filtered = filtered.filter((ai) => { + const searchableText = [ + ai.issue.description, + ai.issue.problem, + ai.issue.title, + ai.issue.category, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return searchableText.includes(searchLower); + }); + } + + // Paginate + const totalItems = filtered.length; + const totalPages = Math.ceil(totalItems / pageSize); + const startIndex = (page - 1) * pageSize; + const paginatedIssues = filtered.slice(startIndex, startIndex + pageSize); + + const response: IAggregatedIssuesResponse = { + issues: paginatedIssues, + pagination: { + page, + pageSize, + totalItems, + totalPages, + }, + availableFilters: { + evaluators: Array.from(evaluatorSet).sort(), + repositories: Array.from(repositorySet).sort(), + }, + }; + + return okResponse(response); + } catch (err: unknown) { + console.error("[IssuesRoutes] Error in GET /api/issues:", err); + return internalErrorResponse("Failed to fetch aggregated issues"); + } + } +} diff --git a/src/api/utils/issue-extractor.test.ts b/src/api/utils/issue-extractor.test.ts new file mode 100644 index 0000000..55d8e97 --- /dev/null +++ b/src/api/utils/issue-extractor.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from "bun:test"; +import type { EvaluationOutput } from "@shared/types/evaluation"; +import { extractIssuesFromEvaluation } from "./issue-extractor"; + +describe("extractIssuesFromEvaluation", () => { + test("extracts issues from unified format", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 1, + }, + results: [ + { + evaluator: "content-quality", + output: { + type: "text", + subtype: "", + is_error: false, + duration_ms: 100, + num_turns: 1, + result: JSON.stringify([ + { + issueType: "error", + severity: 8, + category: "Content Quality", + description: "Test issue", + location: { start: 1, end: 5 }, + }, + ]), + session_id: "s1", + total_cost_usd: 0.01, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + uuid: "test-uuid-1", + }, + }, + { + evaluator: "security", + output: { + type: "text", + subtype: "", + is_error: false, + duration_ms: 50, + num_turns: 1, + result: JSON.stringify([ + { + issueType: "error", + severity: 9, + category: "Security", + description: "Security issue", + location: { start: 10, end: 15 }, + }, + ]), + session_id: "s2", + total_cost_usd: 0.01, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + uuid: "test-uuid-2", + }, + }, + ], + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(2); + expect(issues[0]!.evaluatorName).toBe("content-quality"); + expect(issues[0]!.description).toBe("Test issue"); + expect(issues[1]!.evaluatorName).toBe("security"); + }); + + test("extracts cross-file issues from unified format", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 1, + }, + results: [], + crossFileIssues: [ + { + issueType: "error", + severity: 7, + category: "Cross File", + description: "Cross-file issue", + location: { start: 1, end: 2 }, + }, + ], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(1); + expect(issues[0]!.evaluatorName).toBe("cross-file"); + expect(issues[0]!.description).toBe("Cross-file issue"); + }); + + test("extracts issues from independent format with issues array", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "independent", + totalFiles: 1, + }, + files: { + "AGENTS.md": { + evaluations: [ + { + evaluator: "command-completeness", + issues: [ + { + issueType: "error", + severity: 6, + category: "Commands", + description: "Missing command", + location: { start: 5, end: 8 }, + }, + ], + }, + ], + }, + }, + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(1); + expect(issues[0]!.evaluatorName).toBe("command-completeness"); + }); + + test("extracts issues from independent format with output.result string", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "independent", + totalFiles: 1, + }, + files: { + "AGENTS.md": { + evaluations: [ + { + evaluator: "code-style", + output: { + result: JSON.stringify([ + { + issueType: "error", + severity: 7, + category: "Code Style", + description: "Style issue", + location: { start: 1, end: 3 }, + }, + ]), + }, + }, + ], + }, + }, + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(1); + expect(issues[0]!.evaluatorName).toBe("code-style"); + }); + + test("handles object format result string (perFileIssues + crossFileIssues)", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 1, + }, + results: [ + { + evaluator: "test-evaluator", + output: { + type: "text", + subtype: "", + is_error: false, + duration_ms: 100, + num_turns: 1, + result: JSON.stringify({ + perFileIssues: { + "file.md": [ + { + issueType: "error", + severity: 8, + category: "Test", + description: "Per-file issue", + location: { start: 1, end: 2 }, + }, + ], + }, + crossFileIssues: [ + { + issueType: "suggestion", + impactLevel: "High", + category: "Cross", + description: "Cross issue in result", + location: { start: 3, end: 4 }, + }, + ], + }), + session_id: "s1", + total_cost_usd: 0.01, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + uuid: "test-uuid-3", + }, + }, + ], + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(2); + expect(issues[0]!.description).toBe("Per-file issue"); + expect(issues[1]!.description).toBe("Cross issue in result"); + }); + + test("handles empty evaluation data", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 0, + }, + results: [], + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(0); + }); + + test("handles malformed result string gracefully", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 1, + }, + results: [ + { + evaluator: "broken", + output: { + type: "text", + subtype: "", + is_error: false, + duration_ms: 100, + num_turns: 1, + result: "This is not valid JSON at all", + session_id: "s1", + total_cost_usd: 0.01, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + uuid: "test-uuid-4", + }, + }, + ], + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(0); + }); + + test("skips evaluators with no output", () => { + const data: EvaluationOutput = { + metadata: { + generatedAt: "2026-01-01", + agent: "claude", + evaluationMode: "unified", + totalFiles: 1, + }, + results: [ + { + evaluator: "skipped", + skipped: true, + skipReason: "No file", + }, + ], + crossFileIssues: [], + }; + + const issues = extractIssuesFromEvaluation(data); + expect(issues).toHaveLength(0); + }); +}); diff --git a/src/api/utils/issue-extractor.ts b/src/api/utils/issue-extractor.ts new file mode 100644 index 0000000..e45b9ec --- /dev/null +++ b/src/api/utils/issue-extractor.ts @@ -0,0 +1,122 @@ +/** + * Server-side issue extraction from EvaluationOutput. + * Mirrors the logic in frontend/src/lib/issue-processing.ts parseAllIssues() + */ + +import type { EvaluationOutput, Issue } from "@shared/types/evaluation"; +import { isIndependentFormat, isUnifiedFormat } from "@shared/types/evaluation"; + +/** + * Parse evaluator result string to extract issues. + * More robust version that handles both object format ({perFileIssues, crossFileIssues}) + * and array format, mirroring frontend/src/types/evaluation.ts parseEvaluatorResult(). + */ +function parseEvaluatorResultRobust(resultString: string): Issue[] { + try { + const parsed = JSON.parse(resultString); + + // Handle unified format: {perFileIssues: {...}, crossFileIssues: [...]} + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const allIssues: Issue[] = []; + if (parsed.perFileIssues && typeof parsed.perFileIssues === "object") { + for (const fileIssues of Object.values(parsed.perFileIssues)) { + if (Array.isArray(fileIssues)) { + allIssues.push(...(fileIssues as Issue[])); + } + } + } + if (Array.isArray(parsed.crossFileIssues)) { + allIssues.push(...parsed.crossFileIssues); + } + return allIssues; + } + + // Handle array format directly + if (Array.isArray(parsed)) { + return parsed as Issue[]; + } + + return []; + } catch { + // If JSON parse fails, try to find JSON array in the result + try { + const jsonMatch = resultString.match(/\[[\s\S]*\]/); + if (!jsonMatch) { + return []; + } + const issues = JSON.parse(jsonMatch[0]) as Issue[]; + return Array.isArray(issues) ? issues : []; + } catch { + return []; + } + } +} + +/** + * Extract all issues from an EvaluationOutput, tagging each with its evaluator name. + */ +export function extractIssuesFromEvaluation( + data: EvaluationOutput, +): Array { + const issues: Array = []; + + if (isUnifiedFormat(data)) { + for (const result of data.results) { + if (result.output?.result) { + const parsedIssues = parseEvaluatorResultRobust(result.output.result); + for (const issue of parsedIssues) { + issues.push({ ...issue, evaluatorName: result.evaluator }); + } + } + } + if (data.crossFileIssues) { + issues.push( + ...data.crossFileIssues.map((issue) => ({ + ...issue, + evaluatorName: "cross-file", + })), + ); + } + } else if (isIndependentFormat(data)) { + for (const fileResult of Object.values(data.files)) { + const fr = fileResult as { + evaluations?: Array<{ + evaluator: string; + issues?: Issue[]; + output?: { result: string }; + }>; + }; + if (!fr.evaluations) continue; + for (const evaluation of fr.evaluations) { + if ("issues" in evaluation && Array.isArray(evaluation.issues)) { + for (const issue of evaluation.issues) { + issues.push({ + ...issue, + evaluatorName: evaluation.evaluator, + }); + } + } else if (evaluation.output?.result) { + const parsedIssues = parseEvaluatorResultRobust( + evaluation.output.result, + ); + for (const issue of parsedIssues) { + issues.push({ + ...issue, + evaluatorName: evaluation.evaluator, + }); + } + } + } + } + if (data.crossFileIssues) { + issues.push( + ...data.crossFileIssues.map((issue) => ({ + ...issue, + evaluatorName: "cross-file", + })), + ); + } + } + + return issues; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9e2296a..d8a6866 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -1,5 +1,5 @@ // API request and response types -import type { EvaluationOutput, IEvaluationOptions } from "./evaluation"; +import type { EvaluationOutput, IEvaluationOptions, Issue } from "./evaluation"; // Job status types export type JobStatus = "queued" | "running" | "completed" | "failed"; @@ -173,3 +173,26 @@ export interface IJob { failedAt?: Date; updatedAt: Date; } + +// Aggregated issues types (cross-evaluation) +export interface IAggregatedIssue { + issue: Issue; + evaluationId: string; + repositoryUrl: string; + evaluationDate: string; + evaluatorName: string; +} + +export interface IAggregatedIssuesResponse { + issues: IAggregatedIssue[]; + pagination: { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; + availableFilters: { + evaluators: string[]; + repositories: string[]; + }; +}