Skip to content

Commit 14a3e75

Browse files
msoginclaude
andauthored
feat(report-runner): integrity tracker + threshold abort + IntegrityBadge (GLOOK-13) (#50)
* docs(glook-13): design spec for report-integrity hardening Add an IntegrityTracker + skip classifier + threshold evaluator inside runReport(). Persist a run_metadata JSON column with every SKIP and non-fatal error classified as expected / auto-flagged / unknown. Threshold logic counts only unknown SKIPs: auto-abort at >=5 AND >=10% of org, degraded badge at >=3 OR >=5%. Expand withRetry() to cover transient 5xx + network errors; 404 stays non-retryable so the auth- loss signal that caused 5/28 still surfaces. Out of scope: GitHub-side investigation, Splunk alerting, mid-run interrupt, 401-fatal handling, 404 retries. * docs(glook-13): implementation plan for report-integrity hardening * feat(db): reports.run_metadata + report_skip_allowlist (GLOOK-13) Adds the canonical schema + idempotent runtime ALTERs for both MySQL and SQLite. Seeds report_skip_allowlist with @oshpak (the persistent expected SKIP). Broadens the mysql-ready ordering regex to also accept INSERT IGNORE so the new seed call inside the migration IIFE still validates as a gated pre-SELECT statement. * feat(report-runner): integrity types (GLOOK-13) * feat(report-runner): IntegrityTracker (GLOOK-13) Accumulates SKIPs + non-fatal errors during a report run; dedupes skips by login (last-write-wins); truncates messages to 500 chars. * feat(report-runner): skip classifier + threshold evaluator (GLOOK-13) loadSkipClassifier() runs 2 SELECTs at the top of each report and returns a hot closure (allowlist ⊃ auto-flagged ⊃ unknown). evaluateIntegrity() is pure: counts only unknown SKIPs against the abort (AND) and degraded (OR) thresholds defined in DEFAULT_THRESHOLDS. * feat(github): retry transient 5xx + network errors (GLOOK-13) Extends withRetry() to cover 5xx HTTP + transient network errors (ECONNRESET, ETIMEDOUT, EAI_AGAIN, ENOTFOUND) with shallow backoff (3 attempts x 1s/2s/4s). 404 stays non-retryable - that's the signal the report-integrity threshold depends on. 403/429 rate-limit behavior is unchanged. * feat(report-runner): wire integrity tracker + threshold abort (GLOOK-13) L1/L2/L3 catches now record into IntegrityTracker. After the gather loop, evaluateIntegrity() decides ok/degraded/failed; on failed we write run_metadata + UPDATE reports.status='failed' and short-circuit the rest of the run. ok/degraded paths persist run_metadata alongside the existing 'completed' update. * feat(report): surface run_metadata on getReport (GLOOK-13) * feat(ui): IntegrityBadge component (GLOOK-13) Renders nothing for state='ok' (silent), an inline amber pill for 'degraded' (click-to-expand detail panel with skipped + errors), or a full red banner for 'failed'. * feat(report-page): wire IntegrityBadge into team + org pages (GLOOK-13) * feat(api): skip-allowlist CRUD (GLOOK-13) GET (entries + auto-flagged candidates), POST (add/upsert), DELETE. Admin-only via requireAdmin(). Auto-flagged candidates computed from the same last-5-runs query the report-runner classifier uses. * feat(settings): Skip Allowlist tab (GLOOK-13) Lists current allowlist entries, inline add/remove form, and an 'auto-flagged candidates' section listing logins that SKIPped on 4+ of the last 5 reports with a one-click 'Promote' action. * fix(report-runner): inline LIMIT in classifier (GLOOK-13) mysql2's prepared-statement binary protocol rejects bound LIMIT params with 'Incorrect arguments to mysqld_stmt_execute', breaking every report run after T6. AUTO_FLAG_RECENT_RUNS is a module constant, not user input — inlining is safe. * fix(glook-13): PR #50 review — critical correctness (GLOOK-13) - auth: read added_by from extractUser(headers).email (x-amzn-oidc-data JWT), not the non-existent x-amzn-oidc-identity header - sqlite: add report_skip_allowlist to translator's conflictCols map so ON DUPLICATE KEY UPDATE upserts correctly on the default DB - normalize github_login (.trim()) before insert; validate reason is non-empty after trim - gate all failed-report data surfaces (org page body, team Teams view, exports CSV/Sheet/PDF) — keep partial rows for forensics but never render them when state='failed' - drop the duplicate red banner — header badge now renders only for non-failed states; failed state surfaces a single banner in body - bound the retry total-attempt count at 12 across all error mixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(glook-13): instrument review-count + branch-compare catches (GLOOK-13) Two existing catches in report-runner silently dropped data without recording into IntegrityTracker — exactly the failure class this PR targets. Add recordError calls so they surface in run_metadata.errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish(glook-13): PR #50 review — quality + UX polish (GLOOK-13) - IntegrityBadge: drop dead 'developerCount' prop; surface 'N of M' using metadata.expectedCount in the detail panel - IntegrityTracker.snapshot(): drop redundant double cast - evaluateIntegrity accepts a snapshot directly (avoid double-take in the runner) + test adjustments - extract loadRecentSkipCounts() shared helper used by both the classifier and the settings GET endpoint (was duplicated) - Settings UI: format added_at via toLocaleString, surface mutation errors inline, align promote-reason wording with the actual rule Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: msogin <msogin@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d8f72b9 commit 14a3e75

22 files changed

Lines changed: 3217 additions & 34 deletions

docs/superpowers/plans/2026-06-01-glook-13-report-integrity.md

Lines changed: 1865 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# GLOOK-13 — Harden report-runner against silent GitHub-API failures
2+
3+
## Goal
4+
5+
The 2026-05-28 09:00 ET scheduled report silently dropped 41 of 102 engineers due to a spike in GitHub 404s. The runner caught each error in a `try/catch`, logged `SKIP @<user>`, and shipped a half-empty report with `status = 'completed'`. Consumers couldn't tell that the report was degraded.
6+
7+
Make report degradation visible and prevent half-empty reports from shipping. Specifically:
8+
9+
1. Classify every SKIP as `expected` / `auto-flagged` / `unknown` so persistent legitimate skips (e.g. `@oshpak`) don't alarm.
10+
2. Compute an integrity state at the end of each run from `unknown`-only counts.
11+
3. Auto-abort the report when SKIPs cross a hard threshold; show a degraded badge when they cross a softer one.
12+
4. Surface every SKIP + non-fatal error in a `run_metadata` JSON column so consumers can see exactly what happened.
13+
5. Tighten the GitHub retry layer to cover transient 5xx + network errors without retrying 404 (the deterministic signal we depend on).
14+
15+
## Non-goals
16+
17+
- **GitHub-side investigation** (#1 in the Jira proposal) — ops, not engineering.
18+
- **Splunk alerting** (#3 in the Jira proposal) — infra; out of scope for this PR.
19+
- **Mid-run interrupt.** Threshold evaluation happens once after the gather loop. Simpler control flow; the LLM cost on a failed run is acceptable.
20+
- **401 → immediate fatal abort.** Orthogonal hardening; can be a follow-up.
21+
- **Retrying 404s.** 404 is the *signal*; retrying it would mask the very condition we need to surface.
22+
- **Mid-PR refactor of `report-runner.ts`.** It's 600+ lines but the surgery here is additive (a tracker + classifier + threshold check); a structural cleanup is a separate concern.
23+
24+
## Architecture
25+
26+
```
27+
runReport(reportId, org, days)
28+
29+
├─► classifySkip = await loadSkipClassifier() ← 2 SELECTs: allowlist + last-5-runs history
30+
│ returns (login) => 'expected' | 'auto-flagged' | 'unknown'
31+
32+
├─► tracker = new IntegrityTracker({
33+
│ expectedCount: orgMembers.length,
34+
│ thresholds: { abortUnknownCount: 5, abortUnknownPct: 0.10,
35+
│ degradedUnknownCount: 3, degradedUnknownPct: 0.05 }
36+
│ })
37+
38+
├─► For each member m:
39+
│ ├── try: existing gather path (GitHub calls + LLM queueing)
40+
│ ├── catch L1: tracker.recordSkip(m.login, err.message, classifySkip(m.login))
41+
│ │ continue
42+
│ │
43+
│ ├── (within gather) L2 catches (openPRs, unmerged-commit-detail):
44+
│ │ tracker.recordError({ context, login: m.login, message })
45+
│ │ do NOT count toward SKIP threshold (member is kept with partial data)
46+
│ │
47+
│ └── (within github.ts) L3 catch in isShaInMergedPR:
48+
│ tracker.recordError({ context: 'sha-merge-check', sha, message })
49+
│ returns false as today
50+
51+
├─► state = evaluateIntegrity(tracker)
52+
53+
├─► persist run_metadata + update report status:
54+
│ state === 'failed': UPDATE reports SET status='failed', error=<abortReason>, run_metadata=<json>
55+
│ return (LLM cost paid, but consumer sees the failure)
56+
│ state === 'degraded': UPDATE reports SET run_metadata=<json> (status proceeds to 'completed')
57+
│ state === 'ok': UPDATE reports SET run_metadata=<json> (status proceeds to 'completed')
58+
59+
└─► (existing completion path)
60+
```
61+
62+
**Threshold rationale** (from the 2026-05-28 incident data):
63+
64+
| Scenario | Unknown SKIPs | Org members | Pct | State |
65+
|---|---|---|---|---|
66+
| 5/28 incident | 41 | 102 | 40.2% | `failed` (≥5 AND ≥10%) ✅ |
67+
| Recent baseline (only `@oshpak` SKIPped) | 0 | 102 | 0% | `ok`|
68+
| 4 unknown skips, 100 members | 4 | 100 | 4% | `degraded` (count ≥3) |
69+
| 1 unknown, 100 members | 1 | 100 | 1% | `ok` |
70+
| 7 unknown, 102 members | 7 | 102 | 6.9% | `degraded` (count ≥3; abort pct not met) |
71+
72+
The `AND` on abort prevents tiny orgs from auto-failing on a single SKIP; the `OR` on degraded keeps the warning sensitive.
73+
74+
## Data layer
75+
76+
### `report_skip_allowlist` (new table)
77+
78+
One row per persistently-expected SKIP.
79+
80+
```sql
81+
CREATE TABLE IF NOT EXISTS report_skip_allowlist (
82+
github_login VARCHAR(255) NOT NULL PRIMARY KEY,
83+
reason TEXT NOT NULL,
84+
added_by VARCHAR(255) NULL,
85+
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
86+
);
87+
88+
-- Seed
89+
INSERT IGNORE INTO report_skip_allowlist (github_login, reason, added_by) VALUES
90+
('oshpak', 'Private GitHub profile; not in org-visible members for non-mutual permissions', 'seed');
91+
```
92+
93+
Add to `schema.sql` and to both runtime DB modules (`src/lib/db/mysql.ts`, `src/lib/db/sqlite.ts`) following the existing idempotent-migration pattern.
94+
95+
### `reports.run_metadata` (new column)
96+
97+
```sql
98+
ALTER TABLE reports ADD COLUMN run_metadata JSON NULL;
99+
```
100+
101+
Nullable so legacy rows keep working; UI treats `null` as `state: 'ok'` (no badge).
102+
103+
JSON shape:
104+
105+
```ts
106+
interface RunMetadata {
107+
state: 'ok' | 'degraded' | 'failed';
108+
skipped: SkippedMember[];
109+
errors: IntegrityError[];
110+
expectedCount: number;
111+
thresholds: {
112+
abortUnknownCount: 5;
113+
abortUnknownPct: 0.10;
114+
degradedUnknownCount: 3;
115+
degradedUnknownPct: 0.05;
116+
};
117+
abortReason?: string;
118+
}
119+
120+
type SkipClassification = 'expected' | 'auto-flagged' | 'unknown';
121+
122+
interface SkippedMember {
123+
login: string;
124+
reason: string;
125+
classification: SkipClassification;
126+
}
127+
128+
interface IntegrityError {
129+
context: 'openPRs' | 'unmerged-commit-detail' | 'sha-merge-check' | 'other';
130+
login?: string;
131+
sha?: string;
132+
message: string;
133+
}
134+
```
135+
136+
### Classification
137+
138+
`loadSkipClassifier()` runs once at the top of each report, returns a closure used inline by `tracker.recordSkip()`. Two queries:
139+
140+
1. `SELECT github_login FROM report_skip_allowlist``allowlisted: Set<string>`
141+
2. `SELECT run_metadata FROM reports WHERE status='completed' AND run_metadata IS NOT NULL ORDER BY completed_at DESC LIMIT 5` — aggregate SKIPped logins; any login appearing in `≥4` of the last 5 reports → `autoFlagged: Set<string>`.
142+
143+
Classifier:
144+
145+
```ts
146+
classifySkip = (login: string): SkipClassification =>
147+
allowlisted.has(login) ? 'expected' :
148+
autoFlagged.has(login) ? 'auto-flagged' :
149+
'unknown';
150+
```
151+
152+
### Retry expansion in `withRetry()`
153+
154+
`src/lib/github.ts:98–126` today retries only on `403/429`. Expand to also retry on `5xx` and transient network errors. **404 is explicitly NOT retried.**
155+
156+
| Status / error | Today | Proposed |
157+
|---|---|---|
158+
| 403, 429 | retry w/ exp backoff (existing) | unchanged |
159+
| 5xx (500, 502, 503, 504) | propagates | retry, 3 attempts, backoff 1s → 2s → 4s |
160+
| Network (`ECONNRESET`, `ETIMEDOUT`, `EAI_AGAIN`, `ENOTFOUND`) | propagates | retry, same shallow backoff |
161+
| 404 | propagates | unchanged (retry would mask the signal) |
162+
| 401 | propagates | unchanged (future hardening) |
163+
164+
Shallow retry: 3 attempts × ≤4s ≈ 7s worst-case per call. Doesn't materially extend report runtime even on a degraded GitHub.
165+
166+
## API surface
167+
168+
| Endpoint | Verb | Auth | Shape change |
169+
|---|---|---|---|
170+
| `/api/report/[id]` | GET | unchanged | `report` adds `run_metadata: RunMetadata \| null` |
171+
| `/api/settings/skip-allowlist` | GET | admin | returns `{ entries: AllowlistEntry[], autoFlaggedCandidates: string[] }` |
172+
| `/api/settings/skip-allowlist` | POST | admin | body `{ github_login, reason }`; inserts |
173+
| `/api/settings/skip-allowlist/[login]` | DELETE | admin | removes entry |
174+
175+
All mutations gated on `AUTH_ADMIN_GROUP` (existing pattern).
176+
177+
## UI layer
178+
179+
### `<IntegrityBadge>` component
180+
181+
```tsx
182+
interface IntegrityBadgeProps {
183+
metadata: RunMetadata | null;
184+
}
185+
```
186+
187+
Behavior:
188+
189+
| `metadata.state` | Render |
190+
|---|---|
191+
| `null` (legacy) or `'ok'` | nothing (silent) |
192+
| `'degraded'` | amber pill: `⚠ N partial` next to "N developers"; clicking expands a panel listing skipped users + reasons + non-fatal errors |
193+
| `'failed'` | replaces the data view with a red banner: *"GitHub API degraded — N of M engineers couldn't be fetched. Likely upstream auth issue. Last successful report: {timestamp}. [Regenerate report]"* |
194+
195+
### Insertion points
196+
197+
- `src/app/report/[id]/team/page.tsx` — wrap the existing `{developers.length} developers` line; render error banner above the data section when `state === 'failed'`
198+
- `src/app/report/[id]/org/page.tsx` — same treatment
199+
200+
### Auto-promotion nudge
201+
202+
When `report.run_metadata.skipped` contains any `classification: 'auto-flagged'` entries, the report header surfaces:
203+
204+
> *"`@<login>` has SKIPped 4+ of last 5 runs — [promote to allowlist]"* (links to Settings)
205+
206+
Only renders for users with admin role.
207+
208+
### Skip Allowlist section on Settings
209+
210+
Lives in `src/app/settings/page.tsx`. Lists current allowlist rows (login, reason, added_by, added_at). Inline add form (login + reason text). Inline remove button per row. Below that, an **Auto-flagged candidates** sub-section showing logins currently classified `auto-flagged` with a one-click "Promote" button (POSTs to the allowlist endpoint with `reason: 'auto-promoted after N consecutive SKIPs'`).
211+
212+
## Edge cases
213+
214+
| Case | Behavior |
215+
|---|---|
216+
| Org with 0 members | `expectedCount = 0`; `unknownPct = 0` by convention; state = `'ok'` |
217+
| First report after deploy (no history rows) | Auto-flagged set is empty; classification falls back to allowlist + unknown |
218+
| Allowlist contains a login no longer in the org | Harmless; classifier only acts when a SKIP happens |
219+
| Same user SKIPped multiple times in one run | Recorded once (Set-based dedup in tracker); count = 1 |
220+
| LLM analysis fails post-gather but pre-persist | Same as today (existing error path); `run_metadata` may not be persisted — acceptable, separate failure mode |
221+
| Report aborted via threshold but `developer_stats` rows already partially inserted | UI source-of-truth is `reports.status='failed'` + the error banner; partial rows are ignored by the UI |
222+
| `auto-flagged` user added to allowlist mid-run | No-op for current run; effective from the next report |
223+
| Manual report run during a real GitHub outage | Same threshold logic; user sees `state='failed'` banner; can retry later |
224+
| Two reports running concurrently (unlikely but possible) | Each loads its own classifier snapshot; isolated trackers; no cross-contamination |
225+
226+
## Tests
227+
228+
| Layer | What | Where |
229+
|---|---|---|
230+
| Unit — classifier | allowlist hits → `expected`; ≥4-of-5 → `auto-flagged`; otherwise → `unknown`; empty history; missing `run_metadata` columns gracefully ignored | `src/lib/__tests__/unit/skip-classifier.test.ts` |
231+
| Unit — tracker | `recordSkip`/`recordError` accumulate; dedup logic; `.snapshot()` returns a frozen view; immutability | `src/lib/__tests__/unit/integrity-tracker.test.ts` |
232+
| Unit — threshold | 5/28 incident shape → `failed`; baseline → `ok`; degraded scenarios | same file as tracker tests |
233+
| Unit — retry | 5xx retried 3× then propagates; network error retried; 404 NOT retried; 403/429 existing behavior preserved | `src/lib/__tests__/unit/github-retry.test.ts` |
234+
| Unit — runner | (a) all members succeed → `state='ok'`, no badge; (b) 41/102 unknown SKIPs → `state='failed'`, report row updated, `error` populated; (c) only `@oshpak` SKIPped → `state='ok'`, `skipped[0].classification='expected'`; (d) auto-flagged classification flows through `run_metadata` | extend `src/lib/__tests__/unit/report-runner.test.ts` if present, else create |
235+
| Unit — settings API | allowlist GET/POST/DELETE; admin-only enforcement; auto-flagged candidates enumeration | `src/lib/__tests__/unit/skip-allowlist-api.test.ts` |
236+
| Mock provider | No new mock — existing GitHub mock suffices; tests inject targeted errors via `jest.spyOn` ||
237+
| Visual / smoke | Manual via podman: trigger a report with mocked GitHub returning 404s for half the members; verify banner + badge + Settings UI | n/a (no RTL harness) |
238+
239+
## Files touched
240+
241+
**DB / runtime migrations**
242+
- `schema.sql` — add `reports.run_metadata` column, add `report_skip_allowlist` table + seed
243+
- `src/lib/db/mysql.ts` — idempotent `ALTER TABLE reports ADD COLUMN run_metadata` + create `report_skip_allowlist`
244+
- `src/lib/db/sqlite.ts` — same
245+
246+
**Backend**
247+
- `src/lib/github.ts` — extend `withRetry()` to cover 5xx + network errors (3 attempts × shallow backoff)
248+
- `src/lib/report-runner/integrity-tracker.ts`**new**: `IntegrityTracker` class
249+
- `src/lib/report-runner/skip-classifier.ts`**new**: `loadSkipClassifier()` + `evaluateIntegrity()`
250+
- `src/lib/report-runner/types.ts`**new**: `RunMetadata`, `SkippedMember`, `IntegrityError`, `SkipClassification`
251+
- `src/lib/report-runner.ts` — wire tracker + classifier into the gather loop; threshold evaluation; persist `run_metadata`; conditionally mark report as `failed`
252+
- `src/lib/report/service.ts` — surface `run_metadata` on report fetch
253+
- `src/app/api/settings/skip-allowlist/route.ts`**new**: GET/POST handlers
254+
- `src/app/api/settings/skip-allowlist/[login]/route.ts`**new**: DELETE handler
255+
256+
**Frontend**
257+
- `src/components/IntegrityBadge.tsx`**new**
258+
- `src/app/report/[id]/team/page.tsx` — render badge + failed-state banner
259+
- `src/app/report/[id]/org/page.tsx` — same
260+
- `src/app/settings/page.tsx` — Skip Allowlist section + Auto-flagged candidates sub-section
261+
262+
**Tests** — files listed in the Tests table above.
263+
264+
## Open questions (none blocking)
265+
266+
- Should the auto-promotion nudge auto-dismiss after the user clicks "Promote"? — yes, the next render reads from the freshly-updated allowlist; nothing to design.
267+
- Should we add an integrity badge to the Reports list page? — defer to a follow-up if useful.

schema.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@ CREATE TABLE IF NOT EXISTS reports (
77
period_days INT NOT NULL,
88
status ENUM('pending','running','completed','failed','stopped') NOT NULL DEFAULT 'pending',
99
error TEXT NULL,
10+
run_metadata JSON NULL,
1011
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
1112
completed_at TIMESTAMP NULL
1213
);
1314

15+
CREATE TABLE IF NOT EXISTS report_skip_allowlist (
16+
github_login VARCHAR(255) NOT NULL PRIMARY KEY,
17+
reason TEXT NOT NULL,
18+
added_by VARCHAR(255) NULL,
19+
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
20+
);
21+
22+
INSERT IGNORE INTO report_skip_allowlist (github_login, reason, added_by) VALUES
23+
('oshpak', 'Private GitHub profile; not in org-visible members for non-mutual permissions', 'seed');
24+
1425
CREATE TABLE IF NOT EXISTS developer_stats (
1526
id INT AUTO_INCREMENT PRIMARY KEY,
1627
report_id VARCHAR(36) NOT NULL,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import db from '@/lib/db';
3+
import { requireAdmin } from '@/lib/auth';
4+
import { withRequestLog } from '@/lib/logger';
5+
6+
async function deleteHandler(
7+
req: NextRequest,
8+
{ params }: { params: Promise<{ login: string }> },
9+
) {
10+
const denied = await requireAdmin(req);
11+
if (denied) return denied;
12+
13+
const { login } = await params;
14+
await db.execute(
15+
`DELETE FROM report_skip_allowlist WHERE github_login = ?`,
16+
[login],
17+
);
18+
return NextResponse.json({ ok: true });
19+
}
20+
21+
export const DELETE = withRequestLog(deleteHandler);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import db from '@/lib/db';
3+
import { requireAdmin, extractUser } from '@/lib/auth';
4+
import { withRequestLog } from '@/lib/logger';
5+
import { loadRecentSkipCounts, AUTO_FLAG_THRESHOLD } from '@/lib/report-runner/skip-classifier';
6+
7+
async function getHandler(req: NextRequest) {
8+
const denied = await requireAdmin(req);
9+
if (denied) return denied;
10+
11+
const [rows] = await db.execute(
12+
`SELECT github_login, reason, added_by, added_at FROM report_skip_allowlist ORDER BY added_at ASC`,
13+
) as [any[], any];
14+
15+
// Compute auto-flagged candidates from the last 5 completed reports (shared helper).
16+
const counts = await loadRecentSkipCounts();
17+
const onAllowlist = new Set(rows.map((r: any) => r.github_login));
18+
const autoFlaggedCandidates = [...counts.entries()]
19+
.filter(([login, n]) => n >= AUTO_FLAG_THRESHOLD && !onAllowlist.has(login))
20+
.map(([login]) => login);
21+
22+
return NextResponse.json({ entries: rows, autoFlaggedCandidates });
23+
}
24+
25+
async function postHandler(req: NextRequest) {
26+
const denied = await requireAdmin(req);
27+
if (denied) return denied;
28+
29+
const { github_login, reason } = await req.json();
30+
const normalizedLogin = typeof github_login === 'string' ? github_login.trim() : '';
31+
if (!normalizedLogin) {
32+
return NextResponse.json({ error: 'github_login required' }, { status: 400 });
33+
}
34+
if (!reason || typeof reason !== 'string' || !reason.trim()) {
35+
return NextResponse.json({ error: 'reason required' }, { status: 400 });
36+
}
37+
38+
// Identity comes from the OIDC JWT in x-amzn-oidc-data, parsed by extractUser().
39+
const addedBy = extractUser(req.headers)?.email ?? null;
40+
41+
await db.execute(
42+
`INSERT INTO report_skip_allowlist (github_login, reason, added_by) VALUES (?, ?, ?)
43+
ON DUPLICATE KEY UPDATE reason = VALUES(reason), added_by = VALUES(added_by), added_at = CURRENT_TIMESTAMP`,
44+
[normalizedLogin, reason, addedBy],
45+
);
46+
47+
return NextResponse.json({ ok: true });
48+
}
49+
50+
export const GET = withRequestLog(getHandler);
51+
export const POST = withRequestLog(postHandler);

0 commit comments

Comments
 (0)