import { repoDb, stmt } from "./scoped-db"; import { registerLocator } from "./scoped-db/locators"; import { scopedId } from "./scoped-db/ids"; import { registerRepoRows } from "./issue-scope"; import { randomId } from "./crypto"; import { allocateIssueNumber } from "./issues"; import type { IssueRow } from "./issues"; import { addIssueEvent } from "./pulls"; import { repoReadOnlyReason, requireRepoAccess } from "./repos"; import type { RepoAccess } from "./repos"; import { forbidden, notFoundError } from "./errors"; import type { Permission, Viewer } from "~/lib/types"; const MAX_CONVERTED_COMMENTS = 500; export type DiscussionRow = { id: string; repo_id: string; number: number; category_id: string; author_id: string; title: string; body: string; answer_id: string | null; upvotes: number; comments_count: number; locked: number; created_at: number; updated_at: number; }; export type CategoryRow = { id: string; repo_id: string; name: string; emoji: string; description: string; answerable: number; }; const DEFAULT_CATEGORIES = [ { name: "General", emoji: "💬", description: "Chat about anything", answerable: 0, }, { name: "Q&A", emoji: "🙏", description: "Ask the community for help", answerable: 1, }, { name: "Ideas", emoji: "💡", description: "Share ideas for new features", answerable: 0, }, { name: "Announcements", emoji: "📣", description: "Updates from maintainers", answerable: 0, }, ]; /** Lists categories, seeding the defaults the first time a repository uses discussions. */ export async function repoCategories(repoId: string): Promise { const db = repoDb(repoId); const existing = await db.all( "SELECT * FROM discussion_categories WHERE repo_id = ? ORDER BY name", repoId, ); if (existing.length > 0) return existing; await db.batch( DEFAULT_CATEGORIES.map((category) => stmt( "INSERT OR IGNORE INTO discussion_categories (id, repo_id, name, emoji, description, answerable) VALUES (?, ?, ?, ?, ?, ?)", randomId(), repoId, category.name, category.emoji, category.description, category.answerable, ), ), ); return db.all("SELECT * FROM discussion_categories WHERE repo_id = ? ORDER BY name", repoId); } export async function loadDiscussion(repoId: string, number: number): Promise { return repoDb(repoId).first( "SELECT * FROM discussions WHERE repo_id = ? AND number = ?", repoId, number, ); } export async function createDiscussion(input: { repoId: string; categoryId: string; authorId: string; title: string; body: string; }): Promise<{ id: string; number: number }> { const number = await allocateIssueNumber(input.repoId); const id = scopedId(input.repoId); const now = Date.now(); await repoDb(input.repoId).run( `INSERT INTO discussions (id, repo_id, number, category_id, author_id, title, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.repoId, number, input.categoryId, input.authorId, input.title, input.body, now, now, ); await registerLocator("discussion", id, input.repoId); return { id, number }; } /** Copies an issue and its latest comments into a new discussion, then closes and locks the issue. */ export async function convertIssue(issue: IssueRow, categoryId: string, actorId: string): Promise { const discussion = await createDiscussion({ repoId: issue.repo_id, categoryId, authorId: issue.author_id, title: issue.title, body: issue.body, }); const db = repoDb(issue.repo_id); const comments = await db.all<{ author_id: string; body: string; created_at: number; updated_at: number; }>( "SELECT author_id, body, created_at, updated_at FROM comments WHERE issue_id = ? AND hidden_at IS NULL ORDER BY created_at LIMIT ?", issue.id, MAX_CONVERTED_COMMENTS, ); const ids = comments.map(() => scopedId(issue.repo_id)); await db.batch([ ...comments.map((comment, index) => stmt( `INSERT INTO discussion_comments (id, discussion_id, parent_id, author_id, body, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?)`, ids[index], discussion.id, comment.author_id, comment.body, comment.created_at, comment.updated_at, ), ), stmt("UPDATE discussions SET comments_count = ? WHERE id = ?", comments.length, discussion.id), stmt("UPDATE issues SET locked = 1 WHERE id = ?", issue.id), ]); await registerRepoRows("discussion_comment", ids, issue.repo_id); await addIssueEvent(issue, actorId, "converted_to_discussion", { number: discussion.number, }); return discussion.number; } /** Repo access for discussion endpoints: discussions follow the issues toggle, and archived repos are read-only. */ export async function requireDiscussionAccess( ref: { owner: string; repo: string }, viewer: Viewer | null, needed: Permission, writing: boolean, ): Promise { const access = await requireRepoAccess(ref.owner, ref.repo, viewer, needed); if (access.repo.has_issues !== 1) throw notFoundError("Discussions are disabled for this repository"); const readOnly = writing ? repoReadOnlyReason(access.repo) : null; if (readOnly) throw forbidden(readOnly); return access; }