import { z } from "zod"; import { all, first, run } from "../db"; import { forbidden, notFoundError } from "../errors"; import { applyLabels, indexIssue, issueUrl, loadIssue } from "../issues"; import type { IssueRow } from "../issues"; import { addIssueEvent } from "../pulls"; import { limitWrite } from "../limits"; import { assertNotArchived, hasPermission } from "../repos"; import { changeIssueState, insertComment, insertIssue } from "../mcp/issue-writes"; import { markdownBody, title } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; import { readJson, requireScopedRepo, unprocessable } from "./http"; import type { ApiContext, ScopedRepo } from "./http"; const LIST_LIMIT = 100; const MAX_COMMENTS = 100; /** Opening issues and commenting need only read access (as on the web), but a fine-grained token must opt in to issue writes. */ function requireIssueWriteGrant(ctx: ApiContext): void { const grant = ctx.credential.grant; if (grant?.kind === "fine" && grant.permissions.issues !== "write") { throw forbidden("This token cannot write issues"); } if (grant?.kind === "deploy") throw forbidden("Deploy keys cannot write issues"); } function canMaintain(scoped: ScopedRepo): boolean { return hasPermission(scoped.permission, "write"); } async function loadPlainIssue(repo: RepoRow, number: number): Promise { const issue = await loadIssue(repo.id, number); if (!issue || issue.is_pull === 1) throw notFoundError("Issue not found"); return issue; } async function issueJson(repo: RepoRow, issue: IssueRow) { const [author, labels] = await Promise.all([ first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", issue.author_id), all<{ name: string }>( "SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ? ORDER BY l.name", issue.id, ), ]); return { number: issue.number, title: issue.title, body: issue.body, state: issue.state, locked: issue.locked === 1, author: author?.name ?? "ghost", comments: issue.comments_count, labels: labels.map((label) => label.name), html_url: issueUrl(repo, issue), }; } export async function listIssues(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "issues", "read"); const state = ctx.url.searchParams.get("state") ?? "open"; if (state !== "open" && state !== "closed" && state !== "all") throw unprocessable("Invalid state"); const rows = await all( `SELECT * FROM issues WHERE repo_id = ? AND is_pull = 0 AND (? = 'all' OR state = ?) ORDER BY number DESC LIMIT ${LIST_LIMIT}`, repo.id, state, state, ); return Response.json(await Promise.all(rows.map((row) => issueJson(repo, row)))); } export async function getIssue(ctx: ApiContext, owner: string, name: string, number: number): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "issues", "read"); return Response.json(await issueJson(repo, await loadPlainIssue(repo, number))); } const createInput = z.object({ title, body: markdownBody.default(""), labels: z.array(z.string()).max(50).default([]), }); export async function createIssue(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, createInput); requireIssueWriteGrant(ctx); await limitWrite(ctx.viewer); const scoped = await requireScopedRepo(ctx, owner, name, "issues", "read"); const { repo } = scoped; const number = await insertIssue(repo, ctx.viewer, data.title, data.body); const issue = await loadPlainIssue(repo, number); if (data.labels.length > 0 && canMaintain(scoped)) await applyLabels(repo.id, issue.id, [...new Set(data.labels)]); return Response.json(await issueJson(repo, issue), { status: 201 }); } const updateInput = z.object({ title: title.optional(), body: markdownBody.optional(), state: z.enum(["open", "closed"]).optional(), labels: z.array(z.string()).max(50).optional(), }); export async function updateIssue(ctx: ApiContext, owner: string, name: string, number: number): Promise { const data = await readJson(ctx.request, updateInput); requireIssueWriteGrant(ctx); await limitWrite(ctx.viewer); const scoped = await requireScopedRepo(ctx, owner, name, "issues", "read"); const { repo } = scoped; assertNotArchived(repo); const issue = await loadPlainIssue(repo, number); const maintainer = canMaintain(scoped); if (issue.author_id !== ctx.viewer.id && !maintainer) throw forbidden(); if (data.labels !== undefined && !maintainer) throw forbidden("Only maintainers can set labels"); if (data.title !== undefined || data.body !== undefined) { const nextTitle = data.title ?? issue.title; const nextBody = data.body ?? issue.body; await run( "UPDATE issues SET title = ?, body = ?, updated_at = ? WHERE id = ?", nextTitle, nextBody, Date.now(), issue.id, ); if (nextTitle !== issue.title) await addIssueEvent(issue.id, ctx.viewer.id, "renamed", { from: issue.title, to: nextTitle }); await indexIssue({ id: issue.id, repo_id: issue.repo_id, title: nextTitle, body: nextBody }); } if (data.labels !== undefined) { await applyLabels(repo.id, issue.id, data.labels); await addIssueEvent(issue.id, ctx.viewer.id, "labeled", { labels: data.labels }); } if (data.state !== undefined) await changeIssueState(repo, issue, ctx.viewer, data.state); return Response.json(await issueJson(repo, await loadPlainIssue(repo, number))); } export async function listComments(ctx: ApiContext, owner: string, name: string, number: number): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "issues", "read"); const issue = await loadPlainIssue(repo, number); const rows = await all<{ id: string; body: string; author: string; created_at: number }>( `SELECT c.id, c.body, a.name AS author, c.created_at FROM comments c JOIN accounts a ON a.id = c.author_id WHERE c.issue_id = ? ORDER BY c.created_at LIMIT ${MAX_COMMENTS}`, issue.id, ); return Response.json(rows); } const commentInput = z.object({ body: markdownBody.min(1) }); export async function createComment(ctx: ApiContext, owner: string, name: string, number: number): Promise { const data = await readJson(ctx.request, commentInput); requireIssueWriteGrant(ctx); await limitWrite(ctx.viewer); const scoped = await requireScopedRepo(ctx, owner, name, "issues", "read"); const issue = await loadPlainIssue(scoped.repo, number); if (issue.locked === 1 && !canMaintain(scoped)) throw forbidden("This conversation is locked"); const id = await insertComment(scoped.repo, { ...issue, locked: 0 }, ctx.viewer, data.body); return Response.json({ id, body: data.body, author: ctx.viewer.name }, { status: 201 }); }