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 53 const number = db54 .transaction(() => {55 const row = db56 .query<{ next: number }, [string]>(57 `SELECT COALESCE(MAX(number), 0) + 1 AS next58FROM issues59WHERE repo = ?`,60 )61 .get(issue.repo) as { next: number };62 db.query(63 `INSERT INTO issues (64repo, number, title, body, type, version, reporter_name, contact_email,65filed_at, ip_hash, token_hash66) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,67 ).run(68 issue.repo,69 row.next,70 issue.title,71 issue.body,72 issue.type,73 issue.version,74 issue.reporterName,75 issue.contactEmail,76 issue.filedAt,77 issue.ipHash,78 hashToken(token),79 );80 return row.next;81 })82 .immediate();83 return { number, token };84}858687export function filingsSince(db: Database, ipHash: string, since: number): number {88 const row = db89 .query<{ n: number }, [string, number]>(90 "SELECT COUNT(*) AS n FROM issues WHERE ip_hash = ? AND filed_at > ?",91 )92 .get(ipHash, since);93 return row?.n ?? 0;94}959697export function listIssues(db: Database, repo: string, status: "open" | "closed"): Issue[] {98 return db99 .query<Issue, [string, string]>(100 `SELECT ${PUBLIC_COLUMNS}101FROM issues102WHERE repo = ? AND status = ? AND hidden = 0103ORDER BY number DESC`,104 )105 .all(repo, status);106}107108export function issueCounts(db: Database, repo: string): { open: number; closed: number } {109 const rows = db110 .query<{ status: "open" | "closed"; n: number }, [string]>(111 `SELECT status, COUNT(*) AS n112FROM issues113WHERE repo = ? AND hidden = 0114GROUP BY status`,115 )116 .all(repo);117 const count = (status: string) => rows.find((row) => row.status === status)?.n ?? 0;118 return { open: count("open"), closed: count("closed") };119}120121122export function findIssue(db: Database, repo: string, number: number): Issue | null {123 return db124 .query<Issue, [string, number]>(125 `SELECT ${PUBLIC_COLUMNS}126FROM issues127WHERE repo = ? AND number = ? AND hidden = 0`,128 )129 .get(repo, number);130}131132133export function threadEntries(db: Database, issueId: number): Entry[] {134 return operatorEntries(db, issueId).filter((entry) => !entry.hidden);135}136137138export function operatorEntries(db: Database, issueId: number): Entry[] {139 return db140 .query<Entry, [number]>(141 `SELECT id, author, body, created_at, hidden142FROM issue_entries143WHERE issue_id = ?144ORDER BY id`,145 )146 .all(issueId);147}148149150export function setEntryHidden(151 db: Database,152 issueId: number,153 entryId: number,154 hidden: boolean,155): boolean {156 return (157 db158 .query("UPDATE issue_entries SET hidden = ? WHERE id = ? AND issue_id = ?")159 .run(hidden ? 1 : 0, entryId, issueId).changes === 1160 );161}162163export function addEntry(164 db: Database,165 issueId: number,166 author: Entry["author"],167 body: string,168 at: number,169): number {170 return Number(171 db172 .query("INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, ?, ?, ?)")173 .run(issueId, author, body, at).lastInsertRowid,174 );175}176177178export function publicIssueCount(db: Database): number {179 return (180 db181 .query<{ n: number }, []>(182 `SELECT COUNT(*) AS n183FROM issues i184JOIN repos r ON r.name = i.repo185WHERE r.public = 1 AND i.hidden = 0`,186 )187 .get()?.n ?? 0188 );189}190191192export type OperatorIssue = Issue & {193 repo: string;194 hidden: number;195 contact_email: string;196 197 awaiting: number;198};199export type OperatorFilter = "open" | "awaiting" | "closed" | "hidden";200201const REPORTER_SPOKE_LAST = `COALESCE((202 SELECT author FROM issue_entries e203 WHERE e.issue_id = issues.id AND e.hidden = 0204 ORDER BY e.id DESC205 LIMIT 1206), 'reporter') = 'reporter'`;207const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email,208${REPORTER_SPOKE_LAST} AS awaiting`;209const OPERATOR_WHERE: Record<OperatorFilter, string> = {210 open: "hidden = 0 AND status = 'open'",211 awaiting: `hidden = 0 AND status = 'open' AND ${REPORTER_SPOKE_LAST}`,212 closed: "hidden = 0 AND status = 'closed'",213 hidden: "hidden = 1",214};215216217export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {218 return db219 .query<OperatorIssue, []>(220 `SELECT ${OPERATOR_COLUMNS}221FROM issues222WHERE ${OPERATOR_WHERE[filter]}223ORDER BY filed_at DESC, id DESC`,224 )225 .all();226}227228export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {229 return db230 .query<OperatorIssue, [string, number]>(231 `SELECT ${OPERATOR_COLUMNS}232FROM issues233WHERE repo = ? AND number = ?`,234 )235 .get(repo, number);236}237238239export function setClosed(db: Database, id: number, closedAt: number | null, fixedIn = ""): void {240 db.query("UPDATE issues SET status = ?, closed_at = ?, fixed_in = ? WHERE id = ?").run(241 closedAt === null ? "open" : "closed",242 closedAt,243 fixedIn,244 id,245 );246}247248export function setHidden(db: Database, id: number, hidden: boolean): void {249 db.query("UPDATE issues SET hidden = ? WHERE id = ?").run(hidden ? 1 : 0, id);250}251252export function setType(db: Database, id: number, type: IssueType): void {253 db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);254}255256257const IDS = "id IN (SELECT value FROM json_each(?))";258259260export function countIssues(db: Database, ids: number[]): number {261 return (262 db263 .query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM issues WHERE ${IDS}`)264 .get(JSON.stringify(ids))?.n ?? 0265 );266}267268269270271272export function setManyClosed(db: Database, ids: number[], closedAt: number | null): void {273 db.query(274 `UPDATE issues275 SET status = ?1, closed_at = ?2, fixed_in = ''276 WHERE id IN (SELECT value FROM json_each(?3)) AND status != ?1`,277 ).run(closedAt === null ? "open" : "closed", closedAt, JSON.stringify(ids));278}279280export function setManyHidden(db: Database, ids: number[], hidden: boolean): void {281 db.query(`UPDATE issues SET hidden = ? WHERE ${IDS}`).run(hidden ? 1 : 0, JSON.stringify(ids));282}283284export type BlockedReporter = { id: number; repo: string; number: number; blocked_at: number };285286287288289290export function blockReporter(db: Database, issueId: number, now: number): number | null {291 return (292 db293 .query<{ id: number }, [number, number]>(294 `INSERT INTO blocked_reporters (ip_hash, repo, number, blocked_at)295 SELECT ip_hash, repo, number, ?296 FROM issues297 WHERE id = ? AND ip_hash != ''298 ON CONFLICT (ip_hash) DO UPDATE SET ip_hash = excluded.ip_hash299 RETURNING id`,300 )301 .get(now, issueId)?.id ?? null302 );303}304305306export function unblockReporter(db: Database, id: number): boolean {307 return db.query("DELETE FROM blocked_reporters WHERE id = ?").run(id).changes > 0;308}309310export function isBlocked(db: Database, ipHash: string): boolean {311 return db.query("SELECT 1 FROM blocked_reporters WHERE ip_hash = ?").get(ipHash) !== null;312}313314315export function reporterBlocked(db: Database, issueId: number): boolean {316 return (317 db318 .query(319 `SELECT 1320 FROM blocked_reporters b321 JOIN issues i ON i.ip_hash = b.ip_hash322 WHERE i.id = ?`,323 )324 .get(issueId) !== null325 );326}327328export function blockedReporters(db: Database): BlockedReporter[] {329 return db330 .query<BlockedReporter, []>(331 "SELECT id, repo, number, blocked_at FROM blocked_reporters ORDER BY blocked_at DESC, id DESC",332 )333 .all();334}