From 491e13fdb233a6d7058d1df7d3e1a605458f9087 Mon Sep 17 00:00:00 2001 From: abdout Date: Tue, 12 May 2026 10:47:23 +0300 Subject: [PATCH 1/2] feat(report): credibility scoring pipeline for report-an-issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare "POST to GitHub" report action with a strict scoring pipeline that filters nonsense + destructive submissions before they reach the auto-fix queue, while respecting wisdom-of-the-crowd corroboration (3 independent reports on the same URL force verified). Pipeline: Zod parse → resolve reporter → hard filters (HF1-HF10) → Turnstile → dedup search → Haiku triage → score R+Q+C+A+P → bucket → GitHub issue Strict thresholds (locked in plan): <30 silent-reject no issue created 30-54 low-confidence issue + label, agent skips, 14d auto-close 55-74 needs-human issue + label, human review ≥75 verified-report issue + label, agent auto-fixes Overrides: destructive classification → forced needs-human regardless of score 3 corroborations on same URL → upgrade existing to verified AI failure → cap at needs-human (never silent-rejects legit report) severityHint=critical + score≥60 → promote to verified Mkan-specifics: - Adapter uses @/lib/auth + existing assertRateLimit + Upstash KV - Adds 'report' + 'report-tenant' buckets to existing rateLimiters map - HOST and GUEST roles get reputation bases of 14 and 10 respectively - Reuses existing Upstash infrastructure (already in mkan's deps) Anti-abuse measures: - 60s client-side cooldown mirrors HF9 (fixes triple-click case) - Symmetric success toast denies feedback to spammers - Anonymous requires Turnstile, capped at base reputation 4 - Per-tenant rate limit catches coordinated abuse Plan: /Users/abdout/.claude/plans/read-report-an-issue-glistening-wave.md Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 + pnpm-lock.yaml | 46 +++ src/components/report-issue.tsx | 138 -------- src/components/report-issue/dialog.tsx | 401 ++++++++++++++++++++++ src/components/report-issue/dictionary.ts | 86 +++++ src/components/report-issue/index.tsx | 35 ++ src/lib/actions/report-issue.ts | 135 +++----- src/lib/rate-limit.ts | 18 + src/lib/report/README.md | 95 +++++ src/lib/report/adapter.ts | 155 +++++++++ src/lib/report/adapters/adapter.ts | 70 ++++ src/lib/report/corroboration.ts | 65 ++++ src/lib/report/dedup.ts | 81 +++++ src/lib/report/github.ts | 190 ++++++++++ src/lib/report/hard-filters.ts | 174 ++++++++++ src/lib/report/index.ts | 29 ++ src/lib/report/labels.ts | 97 ++++++ src/lib/report/pipeline.ts | 358 +++++++++++++++++++ src/lib/report/schema.ts | 49 +++ src/lib/report/score.ts | 255 ++++++++++++++ src/lib/report/triage.ts | 236 +++++++++++++ src/lib/report/turnstile.ts | 48 +++ src/lib/report/types.ts | 157 +++++++++ 23 files changed, 2699 insertions(+), 221 deletions(-) delete mode 100644 src/components/report-issue.tsx create mode 100644 src/components/report-issue/dialog.tsx create mode 100644 src/components/report-issue/dictionary.ts create mode 100644 src/components/report-issue/index.tsx create mode 100644 src/lib/report/README.md create mode 100644 src/lib/report/adapter.ts create mode 100644 src/lib/report/adapters/adapter.ts create mode 100644 src/lib/report/corroboration.ts create mode 100644 src/lib/report/dedup.ts create mode 100644 src/lib/report/github.ts create mode 100644 src/lib/report/hard-filters.ts create mode 100644 src/lib/report/index.ts create mode 100644 src/lib/report/labels.ts create mode 100644 src/lib/report/pipeline.ts create mode 100644 src/lib/report/schema.ts create mode 100644 src/lib/report/score.ts create mode 100644 src/lib/report/triage.ts create mode 100644 src/lib/report/turnstile.ts create mode 100644 src/lib/report/types.ts diff --git a/package.json b/package.json index 0bab49b..f0b4943 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,11 @@ "analyze": "ANALYZE=true next build --webpack" }, "dependencies": { + "@anthropic-ai/sdk": "^0.68.0", "@auth/prisma-adapter": "^2.11.2", "@formatjs/intl-localematcher": "^0.8.3", "@hookform/resolvers": "^5.2.2", + "@marsidev/react-turnstile": "^1.4.0", "@neondatabase/serverless": "^1.1.0", "@prisma/adapter-neon": "^7.8.0", "@prisma/adapter-pg": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9fe6d2..d434a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.68.0 + version: 0.68.0(zod@4.3.6) '@auth/prisma-adapter': specifier: ^2.11.2 version: 2.11.2(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)) @@ -17,6 +20,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.73.1(react@19.2.5)) + '@marsidev/react-turnstile': + specifier: ^1.4.0 + version: 1.5.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 @@ -268,6 +274,15 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@anthropic-ai/sdk@0.68.0': + resolution: {integrity: sha512-SMYAmbbiprG8k1EjEPMTwaTqssDT7Ae+jxcR5kWXiqTlbwMR2AthXtscEVWOHkRfyAV5+y3PFYTJRNa3OJWIEw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -972,6 +987,12 @@ packages: '@mapbox/vector-tile@2.0.4': resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + '@marsidev/react-turnstile@1.5.2': + resolution: {integrity: sha512-+3aBPxp86JzSC0ZmgyonoGoUEENcUkH3LGahXSpkV87ArvD2DzRCmPgh0FyQk6PQRmJwQJDAfwNavFsxUxMQWA==} + peerDependencies: + react: ^17.0.2 || ^18.0.0 || ^19.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + '@napi-rs/wasm-runtime@0.2.10': resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==} @@ -3850,6 +3871,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5011,6 +5036,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -5393,6 +5421,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@anthropic-ai/sdk@0.68.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -6045,6 +6079,11 @@ snapshots: '@types/geojson': 7946.0.16 pbf: 4.0.1 + '@marsidev/react-turnstile@1.5.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@napi-rs/wasm-runtime@0.2.10': dependencies: '@emnapi/core': 1.4.3 @@ -9198,6 +9237,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -10489,6 +10533,8 @@ snapshots: dependencies: punycode: 2.3.1 + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 diff --git a/src/components/report-issue.tsx b/src/components/report-issue.tsx deleted file mode 100644 index ab08cf5..0000000 --- a/src/components/report-issue.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client" - -import { useState } from "react" -import { usePathname } from "next/navigation" -import { Bug } from "lucide-react" - -import { reportIssue } from "@/lib/actions/report-issue" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" - -const translations = { - en: { - link: "Report an issue", - title: "Report an issue", - placeholder: "Describe the issue...", - submit: "Submit", - submitting: "Submitting...", - success: "Submitted. Thank you!", - error: "Something went wrong. Try again.", - }, - ar: { - link: "\u0627\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0645\u0634\u0643\u0644\u0629", - title: "\u0627\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0645\u0634\u0643\u0644\u0629", - placeholder: "\u0635\u0641 \u0627\u0644\u0645\u0634\u0643\u0644\u0629...", - submit: "\u0625\u0631\u0633\u0627\u0644", - submitting: "\u062c\u0627\u0631\u064a \u0627\u0644\u0625\u0631\u0633\u0627\u0644...", - success: "\u062a\u0645 \u0627\u0644\u0625\u0631\u0633\u0627\u0644. \u0634\u0643\u0631\u0627\u064b \u0644\u0643!", - error: "\u062d\u062f\u062b \u062e\u0637\u0623. \u062d\u0627\u0648\u0644 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.", - }, -} as const - -interface ReportIssueProps { - variant?: "text" | "icon" -} - -function parseBrowser(ua: string): string { - if (ua.includes("Firefox/")) return `Firefox / ${getOS(ua)}` - if (ua.includes("Edg/")) return `Edge / ${getOS(ua)}` - if (ua.includes("Chrome/")) return `Chrome / ${getOS(ua)}` - if (ua.includes("Safari/")) return `Safari / ${getOS(ua)}` - return ua.slice(0, 50) -} - -function getOS(ua: string): string { - if (ua.includes("Mac OS")) return "macOS" - if (ua.includes("Windows")) return "Windows" - if (ua.includes("Android")) return "Android" - if (ua.includes("iPhone") || ua.includes("iPad")) return "iOS" - if (ua.includes("Linux")) return "Linux" - return "Unknown" -} - -export function ReportIssue({ variant = "text" }: ReportIssueProps) { - const [open, setOpen] = useState(false) - const [description, setDescription] = useState("") - const [status, setStatus] = useState< - "idle" | "loading" | "success" | "error" - >("idle") - const pathname = usePathname() - const t = translations[pathname?.startsWith("/ar") ? "ar" : "en"] - - async function handleSubmit() { - if (!description.trim()) return - setStatus("loading") - try { - await reportIssue({ - description, - pageUrl: window.location.href, - viewport: `${window.innerWidth}x${window.innerHeight}`, - direction: document.documentElement.dir || "ltr", - browser: parseBrowser(navigator.userAgent), - }) - setStatus("success") - setDescription("") - setTimeout(() => { - setOpen(false) - setStatus("idle") - }, 1500) - } catch { - setStatus("error") - } - } - - return ( - { - setOpen(v) - if (!v) setStatus("idle") - }} - > - - {variant === "icon" ? ( - - ) : ( - - )} - - - - {t.title} - -