import { placeholders } from "./db"; import { repoDb } from "./scoped-db"; import { loadIssue, setIssueState } from "./issues"; import type { PullRow } from "./pulls"; import { emitWebhook, publicUrl } from "./events/emit"; import { assertBelowCap } from "./limits"; import type { RepoRow } from "~/lib/types"; const CLOSING_KEYWORDS = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi; const MAX_KEYWORD_LINKS = 10; export const MAX_LINKED_ISSUES = 20; export type LinkedIssue = { number: number; title: string; state: "open" | "closed"; source: "manual" | "keyword" }; type LinkPull = Pick; export async function linkIssue(pull: LinkPull, issueId: string, actorId: string): Promise { const db = repoDb(pull.repo_id); const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM pull_issue_links WHERE pull_issue_id = ?", pull.issue_id, ); assertBelowCap(count?.count ?? 0, MAX_LINKED_ISSUES, "linked issues per pull request"); await db.run( `INSERT INTO pull_issue_links (pull_issue_id, issue_id, source, created_by, created_at) VALUES (?, ?, 'manual', ?, ?) ON CONFLICT (pull_issue_id, issue_id) DO UPDATE SET source = 'manual', created_by = excluded.created_by`, pull.issue_id, issueId, actorId, Date.now(), ); } export async function unlinkIssue(pull: LinkPull, issueId: string): Promise { await repoDb(pull.repo_id).run( "DELETE FROM pull_issue_links WHERE pull_issue_id = ? AND issue_id = ?", pull.issue_id, issueId, ); } function keywordNumbers(body: string): number[] { return [...new Set([...body.matchAll(CLOSING_KEYWORDS)].map((match) => Number(match[1])))].slice( 0, MAX_KEYWORD_LINKS, ); } /** Manual links plus issues referenced by closing keywords ("fixes #12") in the pull request body. */ export async function listLinkedIssues(pull: { issue_id: string; repo_id: string; body: string; }): Promise { const db = repoDb(pull.repo_id); const manual = await db.all( `SELECT i.number, i.title, i.state, 'manual' AS source FROM pull_issue_links l JOIN issues i ON i.id = l.issue_id WHERE l.pull_issue_id = ? ORDER BY i.number LIMIT ?`, pull.issue_id, MAX_LINKED_ISSUES, ); const numbers = keywordNumbers(pull.body).filter((number) => !manual.some((issue) => issue.number === number)); const keyword = numbers.length ? await db.all( `SELECT number, title, state, 'keyword' AS source FROM issues WHERE repo_id = ? AND is_pull = 0 AND number IN (${placeholders(numbers.length)})`, pull.repo_id, ...numbers, ) : []; return [...manual, ...keyword].sort((left, right) => left.number - right.number); } /** Closes linked issues once a pull request lands on the default branch, mirroring GitHub. */ export async function closeLinkedIssues(repo: RepoRow, pull: PullRow, actorId: string | null, sha: string) { const rows = (await listLinkedIssues(pull)).filter((issue) => issue.state === "open"); for (const row of rows) { const issue = await loadIssue(repo.id, row.number); if (!issue || issue.state !== "open") continue; await setIssueState(issue, "closed", actorId, { commit: sha }); await emitWebhook(repo.id, "issues", { action: "closed", issue: { number: issue.number, title: issue.title, html_url: publicUrl(`/${repo.owner_name}/${repo.name}/issues/${issue.number}`), }, }); } }