1import type { Database } from "bun:sqlite";23export const ISSUE_TYPES = ["bug", "idea", "question"] as const;4export type IssueType = (typeof ISSUE_TYPES)[number];56export type Issue = {7 id: number;8 number: number;9 title: string;10 body: string;11 type: IssueType;12 version: string;13 reporter_name: string;14 filed_at: number;15 status: "open" | "closed";16 fixed_in: string;17};181920export type ListedIssue = Issue & { replies: number };212223export type Entry = {24 id: number;25 author: "reporter" | "operator";26 name: string;27 body: string;28 created_at: number;29 hidden: number;30 reply_to: number | null;31};3233export type NewEntry = {34 author: Entry["author"];35 body: string;36 at: number;37 name?: string;38 ipHash?: string;39 replyTo?: number | null;40};4142export type NewIssue = {43 repo: string;44 title: string;45 body: string;46 type: IssueType;47 version: string;48 reporterName: string;49 contactEmail: string;50 ipHash: string;51 filedAt: number;52};535455const PUBLIC_COLUMNS =56 "id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in";575859export function fileIssue(db: Database, issue: NewIssue): number {60 61 return db62 .transaction(() => {63 const row = db64 .query<{ next: number }, [string]>(65 `SELECT COALESCE(MAX(number), 0) + 1 AS next66FROM issues67WHERE repo = ?`,68 )69 .get(issue.repo) as { next: number };70 db.query(71 `INSERT INTO issues (72repo, number, title, body, type, version, reporter_name, contact_email, filed_at, ip_hash73) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,74 ).run(75 issue.repo,76 row.next,77 issue.title,78 issue.body,79 issue.type,80 issue.version,81 issue.reporterName,82 issue.contactEmail,83 issue.filedAt,84 issue.ipHash,85 );86 return row.next;87 })88 .immediate();89}909192export function filingsSince(db: Database, ipHash: string, since: number): number {93 const row = db94 .query<{ n: number }, [string, number]>(95 "SELECT COUNT(*) AS n FROM issues WHERE ip_hash = ? AND filed_at > ?",96 )97 .get(ipHash, since);98 return row?.n ?? 0;99}100101102export function repliesSince(db: Database, ipHash: string, since: number): number {103 const row = db104 .query<{ n: number }, [string, number]>(105 "SELECT COUNT(*) AS n FROM issue_entries WHERE ip_hash = ? AND created_at > ?",106 )107 .get(ipHash, since);108 return row?.n ?? 0;109}110111112export function listIssues(db: Database, repo: string, status: "open" | "closed"): ListedIssue[] {113 return db114 .query<ListedIssue, [string, string]>(115 `SELECT ${PUBLIC_COLUMNS}, (116 SELECT COUNT(*) FROM issue_entries e WHERE e.issue_id = issues.id AND e.hidden = 0117) AS replies118FROM issues119WHERE repo = ? AND status = ? AND hidden = 0120ORDER BY number DESC`,121 )122 .all(repo, status);123}124125export function issueCounts(db: Database, repo: string): { open: number; closed: number } {126 const rows = db127 .query<{ status: "open" | "closed"; n: number }, [string]>(128 `SELECT status, COUNT(*) AS n129FROM issues130WHERE repo = ? AND hidden = 0131GROUP BY status`,132 )133 .all(repo);134 const count = (status: string) => rows.find((row) => row.status === status)?.n ?? 0;135 return { open: count("open"), closed: count("closed") };136}137138139export function findIssue(db: Database, repo: string, number: number): Issue | null {140 return db141 .query<Issue, [string, number]>(142 `SELECT ${PUBLIC_COLUMNS}143FROM issues144WHERE repo = ? AND number = ? AND hidden = 0`,145 )146 .get(repo, number);147}148149150export function threadEntries(db: Database, issueId: number): Entry[] {151 return operatorEntries(db, issueId).filter((entry) => !entry.hidden);152}153154155export function operatorEntries(db: Database, issueId: number): Entry[] {156 return db157 .query<Entry, [number]>(158 `SELECT id, author, name, body, created_at, hidden, reply_to159FROM issue_entries160WHERE issue_id = ?161ORDER BY id`,162 )163 .all(issueId);164}165166167export function setEntryHidden(168 db: Database,169 issueId: number,170 entryId: number,171 hidden: boolean,172): boolean {173 return (174 db175 .query("UPDATE issue_entries SET hidden = ? WHERE id = ? AND issue_id = ?")176 .run(hidden ? 1 : 0, entryId, issueId).changes === 1177 );178}179180export function addEntry(db: Database, issueId: number, entry: NewEntry): number {181 return Number(182 db183 .query(184 `INSERT INTO issue_entries (issue_id, author, name, body, created_at, ip_hash, reply_to)185VALUES (?, ?, ?, ?, ?, ?, ?)`,186 )187 .run(188 issueId,189 entry.author,190 entry.name ?? "",191 entry.body,192 entry.at,193 entry.ipHash ?? "",194 entry.replyTo ?? null,195 ).lastInsertRowid,196 );197}198199200export function publicIssueCount(db: Database): number {201 return (202 db203 .query<{ n: number }, []>(204 `SELECT COUNT(*) AS n205FROM issues i206JOIN repos r ON r.name = i.repo207WHERE r.public = 1 AND i.hidden = 0`,208 )209 .get()?.n ?? 0210 );211}212213214export type OperatorIssue = Issue & {215 repo: string;216 hidden: number;217 contact_email: string;218 219 awaiting: number;220};221export type OperatorFilter = "open" | "awaiting" | "closed" | "hidden";222223const REPORTER_SPOKE_LAST = `COALESCE((224 SELECT author FROM issue_entries e225 WHERE e.issue_id = issues.id AND e.hidden = 0226 ORDER BY e.id DESC227 LIMIT 1228), 'reporter') = 'reporter'`;229const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email,230${REPORTER_SPOKE_LAST} AS awaiting`;231const OPERATOR_WHERE: Record<OperatorFilter, string> = {232 open: "hidden = 0 AND status = 'open'",233 awaiting: `hidden = 0 AND status = 'open' AND ${REPORTER_SPOKE_LAST}`,234 closed: "hidden = 0 AND status = 'closed'",235 hidden: "hidden = 1",236};237238239export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {240 return db241 .query<OperatorIssue, []>(242 `SELECT ${OPERATOR_COLUMNS}243FROM issues244WHERE ${OPERATOR_WHERE[filter]}245ORDER BY filed_at DESC, id DESC`,246 )247 .all();248}249250export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {251 return db252 .query<OperatorIssue, [string, number]>(253 `SELECT ${OPERATOR_COLUMNS}254FROM issues255WHERE repo = ? AND number = ?`,256 )257 .get(repo, number);258}259260261export function setClosed(db: Database, id: number, closedAt: number | null, fixedIn = ""): void {262 db.query("UPDATE issues SET status = ?, closed_at = ?, fixed_in = ? WHERE id = ?").run(263 closedAt === null ? "open" : "closed",264 closedAt,265 fixedIn,266 id,267 );268}269270export function setHidden(db: Database, id: number, hidden: boolean): void {271 db.query("UPDATE issues SET hidden = ? WHERE id = ?").run(hidden ? 1 : 0, id);272}273274export function setType(db: Database, id: number, type: IssueType): void {275 db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);276}277278279const IDS = "id IN (SELECT value FROM json_each(?))";280281282export function countIssues(db: Database, ids: number[]): number {283 return (284 db285 .query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM issues WHERE ${IDS}`)286 .get(JSON.stringify(ids))?.n ?? 0287 );288}289290291292293294export function setManyClosed(db: Database, ids: number[], closedAt: number | null): void {295 db.query(296 `UPDATE issues297 SET status = ?1, closed_at = ?2, fixed_in = ''298 WHERE id IN (SELECT value FROM json_each(?3)) AND status != ?1`,299 ).run(closedAt === null ? "open" : "closed", closedAt, JSON.stringify(ids));300}301302export function setManyHidden(db: Database, ids: number[], hidden: boolean): void {303 db.query(`UPDATE issues SET hidden = ? WHERE ${IDS}`).run(hidden ? 1 : 0, JSON.stringify(ids));304}305306export type BlockedReporter = { id: number; repo: string; number: number; blocked_at: number };307308309310311312313export function blockReporter(314 db: Database,315 issueId: number,316 now: number,317 entryId: number | null = null,318): number | null {319 return (320 db321 .query<{ id: number }, [number | null, number, number | null, number]>(322 `INSERT INTO blocked_reporters (ip_hash, repo, number, blocked_at)323 SELECT hash, repo, number, blocked_at FROM (324 SELECT CASE WHEN ?1 IS NULL THEN i.ip_hash ELSE e.ip_hash END AS hash,325 i.repo, i.number, ?2 AS blocked_at326 FROM issues i327 LEFT JOIN issue_entries e ON e.id = ?3 AND e.issue_id = i.id328 WHERE i.id = ?4329 )330 WHERE hash != ''331 ON CONFLICT (ip_hash) DO UPDATE SET ip_hash = excluded.ip_hash332 RETURNING id`,333 )334 .get(entryId, now, entryId, issueId)?.id ?? null335 );336}337338339export function unblockReporter(db: Database, id: number): boolean {340 return db.query("DELETE FROM blocked_reporters WHERE id = ?").run(id).changes > 0;341}342343export function isBlocked(db: Database, ipHash: string): boolean {344 return db.query("SELECT 1 FROM blocked_reporters WHERE ip_hash = ?").get(ipHash) !== null;345}346347348export function reporterBlocked(db: Database, issueId: number): boolean {349 return (350 db351 .query(352 `SELECT 1353 FROM blocked_reporters b354 JOIN issues i ON i.ip_hash = b.ip_hash355 WHERE i.id = ?`,356 )357 .get(issueId) !== null358 );359}360361export function blockedReporters(db: Database): BlockedReporter[] {362 return db363 .query<BlockedReporter, []>(364 "SELECT id, repo, number, blocked_at FROM blocked_reporters ORDER BY blocked_at DESC, id DESC",365 )366 .all();367}