import { adjustOpenCounter } from "./issue-counters"; import { waitUntil } from "cloudflare:workers"; import { first } from "./db"; import { repoDb, stmt } from "./scoped-db"; import { issueRepoId } from "./issue-scope"; import { addIssueEvent } from "./pulls"; import { dropPullRefsForIssue } from "./git/pull-bridge"; import { loadRepoById } from "./repos"; import { emitWebhook, publicUrl } from "./events/emit"; import { placeholders } from "./db"; import type { RepoRow } from "~/lib/types"; import { issuesChanged } from "./repo-state"; const MAX_CLOSING_REFERENCES = 10; const CLOSING_KEYWORDS = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi; export type IssueRow = { id: string; repo_id: string; number: number; author_id: string; title: string; body: string; state: "open" | "closed"; is_pull: number; comments_count: number; locked: number; closed_at: number | null; closed_by: string | null; milestone_id: string | null; type_id: string | null; parent_id: string | null; created_at: number; updated_at: number; hidden_at?: number | null; }; /** * Hidden issues are left out unless `includeHidden`, which only read paths for site admins pass; every write path * keeps the default so nothing hidden becomes writable. */ export async function loadIssue( repoId: string, number: number, { includeHidden = false }: { includeHidden?: boolean } = {}, ): Promise { return repoDb(repoId).first( `SELECT * FROM issues WHERE repo_id = ? AND number = ?${includeHidden ? "" : " AND hidden_at IS NULL"}`, repoId, number, ); } export async function allocateIssueNumber(repoId: string): Promise { const row = await first<{ number: number }>( "UPDATE repos SET next_number = next_number + 1 WHERE id = ? RETURNING next_number - 1 AS number", repoId, ); if (!row) throw new Error("Repository not found while allocating an issue number"); return row.number; } export async function indexIssue(issue: Pick): Promise { await repoDb(issue.repo_id).batch([ stmt("DELETE FROM issues_fts WHERE issue_id = ?", issue.id), stmt( "INSERT INTO issues_fts (issue_id, repo_id, title, body) VALUES (?, ?, ?, ?)", issue.id, issue.repo_id, issue.title, issue.body, ), ]); // Every create and retitle passes through here, so it doubles as the involvement-index hook for both. issuesChanged(issue.repo_id, [issue.id]); } export async function setIssueState( issue: IssueRow, state: "open" | "closed", actorId: string | null, reason: Record = {}, ): Promise { if (issue.state === state) return; const now = Date.now(); await repoDb(issue.repo_id).run( "UPDATE issues SET state = ?, closed_at = ?, closed_by = ?, updated_at = ? WHERE id = ?", state, state === "closed" ? now : null, state === "closed" ? actorId : null, now, issue.id, ); await adjustOpenCounter(issue, state === "closed" ? -1 : 1); await addIssueEvent(issue, actorId, state === "closed" ? "closed" : "reopened", reason); issuesChanged(issue.repo_id, [issue.id]); if (issue.is_pull === 1 && state === "closed") waitUntil(dropPullRefsForIssue(issue.repo_id, issue.id)); } /** Closes issues referenced by closing keywords ("fixes #12") in commits landing on the default branch. */ export async function closeIssuesFromCommits( repoId: string, actorId: string | null, commits: Array<{ sha: string; message: string }>, ): Promise { const repo = await loadRepoById(repoId); if (!repo) return 0; let closed = 0; for (const commit of commits) { for (const match of [...commit.message.matchAll(CLOSING_KEYWORDS)].slice(0, MAX_CLOSING_REFERENCES)) { const issue = await loadIssue(repoId, Number(match[1])); if (!issue || issue.state !== "open" || issue.is_pull === 1) continue; await setIssueState(issue, "closed", actorId, { commit: commit.sha }); await emitWebhook(repoId, "issues", { action: "closed", issue: { number: issue.number, title: issue.title, html_url: publicUrl(`/${repo.owner_name}/${repo.name}/issues/${issue.number}`), }, }); closed++; } } return closed; } export async function issueParticipants(issueId: string): Promise { const repoId = await issueRepoId(issueId); if (!repoId) return []; const rows = await repoDb(repoId).all<{ user_id: string }>( `SELECT author_id AS user_id FROM issues WHERE id = ?1 UNION SELECT author_id FROM comments WHERE issue_id = ?1 UNION SELECT author_id FROM reviews WHERE issue_id = ?1 UNION SELECT user_id FROM issue_assignees WHERE issue_id = ?1 UNION SELECT user_id FROM review_requests WHERE issue_id = ?1`, issueId, ); return rows.map((row) => row.user_id); } export async function touchIssue(issueId: string): Promise { const repoId = await issueRepoId(issueId); if (!repoId) return; await repoDb(repoId).run("UPDATE issues SET updated_at = ? WHERE id = ?", Date.now(), issueId); } export function issueUrl(repo: RepoRow, issue: Pick): string { return publicUrl(`/${repo.owner_name}/${repo.name}/${issue.is_pull ? "pull" : "issues"}/${issue.number}`); } export async function applyLabels(repoId: string, issueId: string, names: string[]): Promise { const labels = names.length ? await repoDb(repoId).all<{ id: string }>( `SELECT id FROM labels WHERE repo_id = ? AND name IN (${placeholders(names.length)})`, repoId, ...names, ) : []; await repoDb(repoId).batch([ stmt("DELETE FROM issue_labels WHERE issue_id = ?", issueId), ...labels.map((label) => stmt("INSERT INTO issue_labels (issue_id, label_id) VALUES (?, ?)", issueId, label.id)), ]); }