1import type { Database } from "bun:sqlite";2import { createHash, randomBytes } from "node:crypto";34export const ISSUE_TYPES = ["bug", "idea", "question"] as const;5export type IssueType = (typeof ISSUE_TYPES)[number];67export type Issue = {8 id: number;9 number: number;10 title: string;11 body: string;12 type: IssueType;13 version: string;14 reporter_name: string;15 filed_at: number;16 status: "open" | "closed";17 fixed_in: string;18 token_hash: string | null;19};2021export type Entry = {22 id: number;23 author: "reporter" | "operator";24 body: string;25 created_at: number;26 hidden: number;27};2829export type NewIssue = {30 repo: string;31 title: string;32 body: string;33 type: IssueType;34 version: string;35 reporterName: string;36 contactEmail: string;37 ipHash: string;38 filedAt: number;39};404142const PUBLIC_COLUMNS =43 "id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in, token_hash";4445export function hashToken(token: string): string {46 return createHash("sha256").update(token).digest("hex");47}484950export function fileIssue(db: Database, issue: NewIssue): { number: number; token: string } {51 const token = randomBytes(32).toString("hex");52 const number = db.transaction(() => {53 const row = db54 .query<{ next: number }, [string]>(55 `SELECT COALESCE(MAX(number), 0) + 1 AS next56FROM issues57WHERE repo = ?`,58 )59 .get(issue.repo) as { next: number };60 db.query(61 `INSERT INTO issues (62repo, number, title, body, type, version, reporter_name, contact_email,63filed_at, ip_hash, token_hash64) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,65 ).run(66 issue.repo,67 row.next,68 issue.title,69 issue.body,70 issue.type,71 issue.version,72 issue.reporterName,73 issue.contactEmail,74 issue.filedAt,75 issue.ipHash,76 hashToken(token),77 );78 return row.next;79 })();80 return { number, token };81}828384export function filingsSince(db: Database, ipHash: string, since: number): number {85 const row = db86 .query<{ n: number }, [string, number]>(87 "SELECT COUNT(*) AS n FROM issues WHERE ip_hash = ? AND filed_at > ?",88 )89 .get(ipHash, since);90 return row?.n ?? 0;91}929394export function listIssues(db: Database, repo: string, status: "open" | "closed"): Issue[] {95 return db96 .query<Issue, [string, string]>(97 `SELECT ${PUBLIC_COLUMNS}98FROM issues99WHERE repo = ? AND status = ? AND hidden = 0100ORDER BY number DESC`,101 )102 .all(repo, status);103}104105export function issueCounts(db: Database, repo: string): { open: number; closed: number } {106 const rows = db107 .query<{ status: "open" | "closed"; n: number }, [string]>(108 `SELECT status, COUNT(*) AS n109FROM issues110WHERE repo = ? AND hidden = 0111GROUP BY status`,112 )113 .all(repo);114 const count = (status: string) => rows.find((row) => row.status === status)?.n ?? 0;115 return { open: count("open"), closed: count("closed") };116}117118119export function findIssue(db: Database, repo: string, number: number): Issue | null {120 return db121 .query<Issue, [string, number]>(122 `SELECT ${PUBLIC_COLUMNS}123FROM issues124WHERE repo = ? AND number = ? AND hidden = 0`,125 )126 .get(repo, number);127}128129130export function threadEntries(db: Database, issueId: number): Entry[] {131 return operatorEntries(db, issueId).filter((entry) => !entry.hidden);132}133134135export function operatorEntries(db: Database, issueId: number): Entry[] {136 return db137 .query<Entry, [number]>(138 `SELECT id, author, body, created_at, hidden139FROM issue_entries140WHERE issue_id = ?141ORDER BY id`,142 )143 .all(issueId);144}145146147export function setEntryHidden(148 db: Database,149 issueId: number,150 entryId: number,151 hidden: boolean,152): boolean {153 return (154 db155 .query("UPDATE issue_entries SET hidden = ? WHERE id = ? AND issue_id = ?")156 .run(hidden ? 1 : 0, entryId, issueId).changes === 1157 );158}159160export function addEntry(161 db: Database,162 issueId: number,163 author: Entry["author"],164 body: string,165 at: number,166): number {167 return Number(168 db169 .query("INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, ?, ?, ?)")170 .run(issueId, author, body, at).lastInsertRowid,171 );172}173174175export function publicIssueCount(db: Database): number {176 return (177 db178 .query<{ n: number }, []>(179 `SELECT COUNT(*) AS n180FROM issues i181JOIN repos r ON r.name = i.repo182WHERE r.public = 1 AND i.hidden = 0`,183 )184 .get()?.n ?? 0185 );186}187188189export type OperatorIssue = Issue & {190 repo: string;191 hidden: number;192 contact_email: string;193 194 awaiting: number;195};196export type OperatorFilter = "open" | "awaiting" | "closed" | "hidden";197198const REPORTER_SPOKE_LAST = `COALESCE((199 SELECT author FROM issue_entries e200 WHERE e.issue_id = issues.id AND e.hidden = 0201 ORDER BY e.id DESC202 LIMIT 1203), 'reporter') = 'reporter'`;204const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email,205${REPORTER_SPOKE_LAST} AS awaiting`;206const OPERATOR_WHERE: Record<OperatorFilter, string> = {207 open: "hidden = 0 AND status = 'open'",208 awaiting: `hidden = 0 AND status = 'open' AND ${REPORTER_SPOKE_LAST}`,209 closed: "hidden = 0 AND status = 'closed'",210 hidden: "hidden = 1",211};212213214export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {215 return db216 .query<OperatorIssue, []>(217 `SELECT ${OPERATOR_COLUMNS}218FROM issues219WHERE ${OPERATOR_WHERE[filter]}220ORDER BY filed_at DESC, id DESC`,221 )222 .all();223}224225export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {226 return db227 .query<OperatorIssue, [string, number]>(228 `SELECT ${OPERATOR_COLUMNS}229FROM issues230WHERE repo = ? AND number = ?`,231 )232 .get(repo, number);233}234235236export function setClosed(db: Database, id: number, closedAt: number | null, fixedIn = ""): void {237 db.query("UPDATE issues SET status = ?, closed_at = ?, fixed_in = ? WHERE id = ?").run(238 closedAt === null ? "open" : "closed",239 closedAt,240 fixedIn,241 id,242 );243}244245export function setHidden(db: Database, id: number, hidden: boolean): void {246 db.query("UPDATE issues SET hidden = ? WHERE id = ?").run(hidden ? 1 : 0, id);247}248249export function setType(db: Database, id: number, type: IssueType): void {250 db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);251}252253254const IDS = "id IN (SELECT value FROM json_each(?))";255256257export function countIssues(db: Database, ids: number[]): number {258 return (259 db260 .query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM issues WHERE ${IDS}`)261 .get(JSON.stringify(ids))?.n ?? 0262 );263}264265266267268269export function setManyClosed(db: Database, ids: number[], closedAt: number | null): void {270 db.query(271 `UPDATE issues272 SET status = ?1, closed_at = ?2, fixed_in = ''273 WHERE id IN (SELECT value FROM json_each(?3)) AND status != ?1`,274 ).run(closedAt === null ? "open" : "closed", closedAt, JSON.stringify(ids));275}276277export function setManyHidden(db: Database, ids: number[], hidden: boolean): void {278 db.query(`UPDATE issues SET hidden = ? WHERE ${IDS}`).run(hidden ? 1 : 0, JSON.stringify(ids));279}280281export type BlockedReporter = { id: number; repo: string; number: number; blocked_at: number };282283284285286287export function blockReporter(db: Database, issueId: number, now: number): number | null {288 return (289 db290 .query<{ id: number }, [number, number]>(291 `INSERT INTO blocked_reporters (ip_hash, repo, number, blocked_at)292 SELECT ip_hash, repo, number, ?293 FROM issues294 WHERE id = ? AND ip_hash != ''295 ON CONFLICT (ip_hash) DO UPDATE SET ip_hash = excluded.ip_hash296 RETURNING id`,297 )298 .get(now, issueId)?.id ?? null299 );300}301302303export function unblockReporter(db: Database, id: number): boolean {304 return db.query("DELETE FROM blocked_reporters WHERE id = ?").run(id).changes > 0;305}306307export function isBlocked(db: Database, ipHash: string): boolean {308 return db.query("SELECT 1 FROM blocked_reporters WHERE ip_hash = ?").get(ipHash) !== null;309}310311312export function reporterBlocked(db: Database, issueId: number): boolean {313 return (314 db315 .query(316 `SELECT 1317 FROM blocked_reporters b318 JOIN issues i ON i.ip_hash = b.ip_hash319 WHERE i.id = ?`,320 )321 .get(issueId) !== null322 );323}324325export function blockedReporters(db: Database): BlockedReporter[] {326 return db327 .query<BlockedReporter, []>(328 "SELECT id, repo, number, blocked_at FROM blocked_reporters ORDER BY blocked_at DESC, id DESC",329 )330 .all();331}