import { run } from "../db"; import { repoDb, stmt } from "../scoped-db"; import { registerLocator } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { repoActivityStatement } from "../issue-scope"; import { captureServerEvent } from "../analytics"; import { accountOrGhost, accountsById } from "../people"; import { forbidden } from "../errors"; import { assertNotArchived } from "../repos"; import { allocateIssueNumber, indexIssue, issueUrl, setIssueState } from "../issues"; import type { IssueRow } from "../issues"; import { emitNotification, emitWebhook } from "../events/emit"; import type { RepoRow, Viewer } from "~/lib/types"; /** Mirrors the web issue form (src/fns/issues.ts createIssue) for an already-authorized caller. */ export async function insertIssue(repo: RepoRow, viewer: Viewer, title: string, body: string): Promise { assertNotArchived(repo); if (repo.has_issues !== 1) throw forbidden("Issues are disabled for this repository"); const number = await allocateIssueNumber(repo.id); const id = scopedId(repo.id); const now = Date.now(); await repoDb(repo.id).batch([ stmt( `INSERT INTO issues (id, repo_id, number, author_id, title, body, state, is_pull, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'open', 0, ?, ?)`, id, repo.id, number, viewer.id, title, body, now, now, ), repoActivityStatement(viewer.id, repo.id, "issue_opened", { number, title, }), ]); await registerLocator("issue", id, repo.id); await run("UPDATE repos SET open_issues_count = open_issues_count + 1 WHERE id = ?", repo.id); await indexIssue({ id, repo_id: repo.id, title, body }); captureServerEvent( "issue_created", viewer.id, { source: "api", private_repo: repo.visibility !== "public" }, { ownerAccountId: repo.owner_id }, ); const url = issueUrl(repo, { number, is_pull: 0 }); await emitWebhook(repo.id, "issues", { action: "opened", issue: { number, title, body, html_url: url }, sender: { login: viewer.name, id: viewer.id }, }); await emitNotification({ repoId: repo.id, issueId: id, actorId: viewer.id, reason: "comment", title: `${repo.owner_name}/${repo.name}#${number}: ${title}`, url, mentionText: body, directRecipients: [], }); return number; } /** Mirrors src/fns/issues.ts addComment; works for issues and pull requests alike. */ export async function insertComment(repo: RepoRow, issue: IssueRow, viewer: Viewer, body: string): Promise { assertNotArchived(repo); if (issue.locked === 1) throw forbidden("This conversation is locked"); const id = scopedId(repo.id); const now = Date.now(); await repoDb(repo.id).batch([ stmt( "INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", id, issue.id, viewer.id, body, now, now, ), stmt("UPDATE issues SET comments_count = comments_count + 1, updated_at = ? WHERE id = ?", now, issue.id), repoActivityStatement(viewer.id, repo.id, "comment", { number: issue.number, title: issue.title, isPull: issue.is_pull === 1, }), ]); await registerLocator("comment", id, repo.id); captureServerEvent( "comment_created", viewer.id, { source: "api", on_pull: issue.is_pull === 1, private_repo: repo.visibility !== "public" }, { ownerAccountId: repo.owner_id }, ); const url = issueUrl(repo, issue); await emitWebhook(repo.id, "issue_comment", { action: "created", issue: { number: issue.number, title: issue.title, html_url: url }, comment: { body, user: { login: viewer.name } }, sender: { login: viewer.name, id: viewer.id }, }); await emitNotification({ repoId: repo.id, issueId: issue.id, actorId: viewer.id, reason: "comment", title: `${repo.owner_name}/${repo.name}#${issue.number}: ${issue.title}`, url, mentionText: body, directRecipients: [], }); return id; } export async function changeIssueState(repo: RepoRow, issue: IssueRow, viewer: Viewer, state: "open" | "closed") { if (issue.is_pull === 1 && state === "open") { const merged = await repoDb(repo.id).first( "SELECT 1 AS ok FROM pull_requests WHERE issue_id = ? AND merged_at IS NOT NULL", issue.id, ); if (merged) throw forbidden("Merged pull requests cannot be reopened"); } await setIssueState(issue, state, viewer.id); await emitWebhook(repo.id, issue.is_pull === 1 ? "pull_request" : "issues", { action: state === "closed" ? "closed" : "reopened", [issue.is_pull === 1 ? "pull_request" : "issue"]: { number: issue.number, title: issue.title, html_url: issueUrl(repo, issue), }, sender: { login: viewer.name, id: viewer.id }, }); } /** Visible comments of a thread, oldest first, with each author's login (the shape the API and MCP tools return). */ export async function commentsWithAuthors( repoId: string, issueId: string, limit: number, ): Promise> { const rows = await repoDb(repoId).all<{ id: string; body: string; author_id: string; created_at: number; }>( "SELECT id, body, author_id, created_at FROM comments WHERE issue_id = ? AND hidden_at IS NULL ORDER BY created_at LIMIT ?", issueId, limit, ); const authors = await accountsById(rows.map((row) => row.author_id)); return rows.map((row) => ({ id: row.id, body: row.body, author: accountOrGhost(authors, row.author_id).name, created_at: row.created_at, })); }