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};2728export type NewIssue = {29 repo: string;30 title: string;31 body: string;32 type: IssueType;33 version: string;34 reporterName: string;35 contactEmail: string;36 ipHash: string;37 filedAt: number;38};394041const PUBLIC_COLUMNS =42 "id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in, token_hash";4344export function hashToken(token: string): string {45 return createHash("sha256").update(token).digest("hex");46}474849export function fileIssue(db: Database, issue: NewIssue): { number: number; token: string } {50 const token = randomBytes(32).toString("hex");51 const number = db.transaction(() => {52 const row = db53 .query<{ next: number }, [string]>(54 `SELECT COALESCE(MAX(number), 0) + 1 AS next55FROM issues56WHERE repo = ?`,57 )58 .get(issue.repo) as { next: number };59 db.query(60 `INSERT INTO issues (61repo, number, title, body, type, version, reporter_name, contact_email,62filed_at, ip_hash, token_hash63) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,64 ).run(65 issue.repo,66 row.next,67 issue.title,68 issue.body,69 issue.type,70 issue.version,71 issue.reporterName,72 issue.contactEmail,73 issue.filedAt,74 issue.ipHash,75 hashToken(token),76 );77 return row.next;78 })();79 return { number, token };80}818283export function filingsSince(db: Database, ipHash: string, since: number): number {84 const row = db85 .query<{ n: number }, [string, number]>(86 "SELECT COUNT(*) AS n FROM issues WHERE ip_hash = ? AND filed_at > ?",87 )88 .get(ipHash, since);89 return row?.n ?? 0;90}919293export function listIssues(db: Database, repo: string, status: "open" | "closed"): Issue[] {94 return db95 .query<Issue, [string, string]>(96 `SELECT ${PUBLIC_COLUMNS}97FROM issues98WHERE repo = ? AND status = ? AND hidden = 099ORDER BY number DESC`,100 )101 .all(repo, status);102}103104export function issueCounts(db: Database, repo: string): { open: number; closed: number } {105 const rows = db106 .query<{ status: "open" | "closed"; n: number }, [string]>(107 `SELECT status, COUNT(*) AS n108FROM issues109WHERE repo = ? AND hidden = 0110GROUP BY status`,111 )112 .all(repo);113 const count = (status: string) => rows.find((row) => row.status === status)?.n ?? 0;114 return { open: count("open"), closed: count("closed") };115}116117118export function findIssue(db: Database, repo: string, number: number): Issue | null {119 return db120 .query<Issue, [string, number]>(121 `SELECT ${PUBLIC_COLUMNS}122FROM issues123WHERE repo = ? AND number = ? AND hidden = 0`,124 )125 .get(repo, number);126}127128export function threadEntries(db: Database, issueId: number): Entry[] {129 return db130 .query<Entry, [number]>(131 "SELECT id, author, body, created_at FROM issue_entries WHERE issue_id = ? ORDER BY id",132 )133 .all(issueId);134}135136export function addEntry(137 db: Database,138 issueId: number,139 author: Entry["author"],140 body: string,141 at: number,142): number {143 return Number(144 db145 .query("INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, ?, ?, ?)")146 .run(issueId, author, body, at).lastInsertRowid,147 );148}149150151export function publicIssueCount(db: Database): number {152 return (153 db154 .query<{ n: number }, []>(155 `SELECT COUNT(*) AS n156FROM issues i157JOIN repos r ON r.name = i.repo158WHERE r.public = 1 AND i.hidden = 0`,159 )160 .get()?.n ?? 0161 );162}163164165export type OperatorIssue = Issue & { repo: string; hidden: number; contact_email: string };166export type OperatorFilter = "open" | "closed" | "hidden";167168const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email`;169170171export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {172 const where = filter === "hidden" ? "hidden = 1" : "hidden = 0 AND status = ?";173 const query = db.query<OperatorIssue, string[]>(174 `SELECT ${OPERATOR_COLUMNS}175FROM issues176WHERE ${where}177ORDER BY filed_at DESC, id DESC`,178 );179 return filter === "hidden" ? query.all() : query.all(filter);180}181182export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {183 return db184 .query<OperatorIssue, [string, number]>(185 `SELECT ${OPERATOR_COLUMNS}186FROM issues187WHERE repo = ? AND number = ?`,188 )189 .get(repo, number);190}191192193export function setClosed(db: Database, id: number, closedAt: number | null, fixedIn = ""): void {194 db.query("UPDATE issues SET status = ?, closed_at = ?, fixed_in = ? WHERE id = ?").run(195 closedAt === null ? "open" : "closed",196 closedAt,197 fixedIn,198 id,199 );200}201202export function setHidden(db: Database, id: number, hidden: boolean): void {203 db.query("UPDATE issues SET hidden = ? WHERE id = ?").run(hidden ? 1 : 0, id);204}205206export function setType(db: Database, id: number, type: IssueType): void {207 db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);208}