diff --git a/src/lib/components/ReviewHistory.svelte b/src/lib/components/ReviewHistory.svelte new file mode 100644 index 0000000..50e53d6 --- /dev/null +++ b/src/lib/components/ReviewHistory.svelte @@ -0,0 +1,341 @@ + + +
+

+ Lists GitHub Code Scanning alerts (Cora reviews upload SARIF via cora upload-sarif). + The token needs the security_events scope and is kept in memory for this session only — it is never written to storage. +

+ +
+ + + +
+ + {#if errorMsg} +
{errorMsg}
+ {/if} + + {#if loading} +
Loading alerts…
+ {:else if loaded && alerts.length === 0} + + {:else if loaded} +
+ new alerts / week + {#each trend as t (t.label)} + + {/each} +
+ +
+ + + + + {breakdown.none} unranked +
+ +
+ + + {visible.length} shown +
+ +
+ {#each visible as a (a.number)} +
+ {a.severity} +
+
+ {a.rule} + {a.state} +
+

{a.message || a.description}

+
+ {#if a.file}{a.file}{a.line ? `:${a.line}` : ''}{/if} + {fmtDate(a.created_at)} + #{a.number} +
+
+
+ {:else} +

No alerts match the current filters.

+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/ToolsView.svelte b/src/lib/components/ToolsView.svelte index 6dc1a05..3c01fe8 100644 --- a/src/lib/components/ToolsView.svelte +++ b/src/lib/components/ToolsView.svelte @@ -1,5 +1,6 @@ @@ -337,6 +339,10 @@ {/if} + + {:else if activeTab === 'reviews'} + + {:else if activeTab === 'context'}
diff --git a/src/lib/utils/__tests__/reviews.test.ts b/src/lib/utils/__tests__/reviews.test.ts new file mode 100644 index 0000000..1c7bc27 --- /dev/null +++ b/src/lib/utils/__tests__/reviews.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeAlerts, + alertTrend, + severityBreakdown, + filterAlerts, +} from '../reviews'; + +const fixture = [ + { + number: 1, + rule: { + id: 'js/sql-injection', + description: 'SQL built from user input', + security_severity_level: 'high', + }, + most_recent_instance: { + message: { text: 'Unsanitized input used in query' }, + location: { path: 'src/db.ts', start_line: 42 }, + }, + state: 'open', + created_at: '2026-08-01T10:00:00Z', + tool: { name: 'CodeCora' }, + }, + { + number: 2, + rule: { + id: 'js/hardcoded-secret', + description: 'Hardcoded credential', + security_severity_level: 'critical', + }, + most_recent_instance: { + message: { text: 'Secret literal in source' }, + location: { path: 'src/config.ts', start_line: 7 }, + }, + state: 'fixed', + created_at: '2026-08-20T10:00:00Z', + }, + { + number: 3, + rule: { + id: 'js/xss', + description: 'XSS via innerHTML', + security_severity_level: 'medium', + }, + most_recent_instance: { + message: { text: 'DOM write with tainted data' }, + location: { path: 'src/ui.ts', start_line: 12 }, + }, + state: 'open', + created_at: '2026-08-22T10:00:00Z', + }, +]; + +describe('normalizeAlerts', () => { + it('maps the fields the viewer needs', () => { + const alerts = normalizeAlerts(fixture); + expect(alerts).toHaveLength(3); + expect(alerts[0]).toMatchObject({ + number: 1, + rule: 'js/sql-injection', + file: 'src/db.ts', + line: 42, + severity: 'high', + state: 'open', + }); + }); + + it('falls back to none for unknown severity and defaults missing fields', () => { + const alerts = normalizeAlerts([{}]); + expect(alerts[0].severity).toBe('none'); + expect(alerts[0].rule).toBe('unknown'); + expect(alerts[0].line).toBe(0); + }); +}); + +describe('severityBreakdown', () => { + it('counts per severity', () => { + const b = severityBreakdown(normalizeAlerts(fixture)); + expect(b).toEqual({ critical: 1, high: 1, medium: 1, low: 0, none: 0 }); + }); +}); + +describe('filterAlerts', () => { + const alerts = normalizeAlerts(fixture); + + it('filters by severity', () => { + expect(filterAlerts(alerts, { severity: 'high', state: 'all', query: '' })).toHaveLength(1); + }); + + it('filters by state', () => { + expect(filterAlerts(alerts, { severity: 'all', state: 'fixed', query: '' })).toHaveLength(1); + }); + + it('matches rule, file, or message text', () => { + expect(filterAlerts(alerts, { severity: 'all', state: 'all', query: 'config.ts' })).toHaveLength(1); + expect(filterAlerts(alerts, { severity: 'all', state: 'all', query: 'xss' })).toHaveLength(1); + expect(filterAlerts(alerts, { severity: 'all', state: 'all', query: 'tidak-ada' })).toHaveLength(0); + }); +}); + +describe('alertTrend', () => { + it('produces the requested number of weekly buckets, oldest first', () => { + const trend = alertTrend(normalizeAlerts(fixture), 4); + expect(trend).toHaveLength(4); + expect(trend[3].label <= trend[2].label).toBe(false); // ascending + }); + + it('counts alerts into their week bucket', () => { + const trend = alertTrend(normalizeAlerts(fixture), 12); + const total = trend.reduce((sum, t) => sum + t.count, 0); + expect(total).toBe(3); + }); +}); diff --git a/src/lib/utils/reviews.ts b/src/lib/utils/reviews.ts new file mode 100644 index 0000000..3202608 --- /dev/null +++ b/src/lib/utils/reviews.ts @@ -0,0 +1,155 @@ +/** + * GitHub Code Scanning helpers for the Cora review history viewer (#20). + * Cora uploads findings as SARIF via `cora upload-sarif`; GitHub surfaces + * them through the code-scanning REST API, which this module wraps as pure, + * unit-testable functions. + */ + +export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'none'; + +export interface ScanAlert { + number: number; + rule: string; + description: string; + message: string; + file: string; + line: number; + severity: Severity; + state: 'open' | 'dismissed' | 'fixed'; + created_at: string; + tool: string; +} + +/** Raw GitHub code-scanning alert (only the fields we consume). */ +interface RawAlert { + number?: number; + rule?: { + id?: string; + description?: string; + security_severity_level?: string; + }; + most_recent_instance?: { + message?: { text?: string }; + location?: { path?: string; start_line?: number }; + }; + state?: string; + created_at?: string; + tool?: { name?: string }; +} + +function toSeverity(raw: RawAlert): Severity { + const s = (raw.rule?.security_severity_level ?? '').toLowerCase(); + if (s === 'critical' || s === 'high' || s === 'medium' || s === 'low') return s; + return 'none'; +} + +/** Normalize one page of GitHub alerts; tolerant of missing fields. */ +export function normalizeAlerts(rows: RawAlert[]): ScanAlert[] { + return rows.map((r) => ({ + number: r.number ?? 0, + rule: r.rule?.id ?? 'unknown', + description: r.rule?.description ?? '', + message: r.most_recent_instance?.message?.text ?? '', + file: r.most_recent_instance?.location?.path ?? '', + line: r.most_recent_instance?.location?.start_line ?? 0, + severity: toSeverity(r), + state: (r.state as ScanAlert['state']) ?? 'open', + created_at: r.created_at ?? '', + tool: r.tool?.name ?? 'CodeCora', + })); +} + +/** New alerts per ISO-week bucket, oldest first — the trend series. */ +export function alertTrend(alerts: ScanAlert[], weeks = 12): { label: string; count: number }[] { + const buckets = new Map(); + for (const a of alerts) { + if (!a.created_at) continue; + const d = new Date(a.created_at); + if (Number.isNaN(d.getTime())) continue; + // ISO week key: YYYY-Www (Monday-based) + const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const day = (target.getUTCDay() + 6) % 7; + target.setUTCDate(target.getUTCDate() - day); + const key = target.toISOString().slice(0, 10); + buckets.set(key, (buckets.get(key) ?? 0) + 1); + } + const out: { label: string; count: number }[] = []; + const cursor = new Date(); + cursor.setUTCHours(0, 0, 0, 0); + cursor.setUTCDate(cursor.getUTCDate() - ((cursor.getUTCDay() + 6) % 7)); // this Monday + for (let w = weeks - 1; w >= 0; w--) { + const d = new Date(cursor); + d.setUTCDate(cursor.getUTCDate() - w * 7); + const key = d.toISOString().slice(0, 10); + out.push({ label: key, count: buckets.get(key) ?? 0 }); + } + return out; +} + +export interface SeverityBreakdown { + critical: number; + high: number; + medium: number; + low: number; + none: number; +} + +export function severityBreakdown(alerts: ScanAlert[]): SeverityBreakdown { + const b: SeverityBreakdown = { critical: 0, high: 0, medium: 0, low: 0, none: 0 }; + for (const a of alerts) b[a.severity]++; + return b; +} + +export interface AlertFilter { + severity: Severity | 'all'; + state: ScanAlert['state'] | 'all'; + query: string; +} + +export function filterAlerts(alerts: ScanAlert[], f: AlertFilter): ScanAlert[] { + const q = f.query.trim().toLowerCase(); + return alerts.filter((a) => { + if (f.severity !== 'all' && a.severity !== f.severity) return false; + if (f.state !== 'all' && a.state !== f.state) return false; + if (q && !`${a.rule} ${a.file} ${a.message} ${a.description}`.toLowerCase().includes(q)) return false; + return true; + }); +} + +/** Extract the next page URL from a GitHub `Link` response header. */ +function parseNextLink(link: string | null): string | null { + if (!link) return null; + const m = /<([^>]+)>;\s*rel="next"/.exec(link); + return m ? m[1] : null; +} + +/** Fetch ALL code-scanning alerts, following pagination (cap 10 pages). */ +export async function fetchAlerts( + repo: string, + token: string, + opts?: { state?: string; perPage?: number }, +): Promise { + const [owner, name] = repo.split('/').map((p) => p.trim()); + if (!owner || !name) throw new Error('Repo harus format owner/name'); + const first = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/code-scanning/alerts?state=${opts?.state ?? 'all'}&per_page=${opts?.perPage ?? 100}`; + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const raw: RawAlert[] = []; + let url: string | null = first; + let pages = 0; + while (url && pages < 10) { + const res = await fetch(url, { headers }); + if (res.status === 404) throw new Error('Repo tidak ditemukan atau token tanpa akses code scanning (butuh scope security_events).'); + if (res.status === 401) throw new Error('Token GitHub tidak valid (401).'); + if (res.status === 403) throw new Error('Akses ditolak (403) — cek scope token atau rate limit.'); + if (!res.ok) throw new Error(`GitHub API ${res.status}`); + raw.push(...((await res.json()) as RawAlert[])); + url = parseNextLink(res.headers.get('link')); + pages++; + } + return normalizeAlerts(raw); +}