/** * PR-Issue Linking Library * Enhanced linking between PRs and Issues */ import { getDatabase, schema } from "@/db"; import { issues } from "@/db/schema/issues"; import { pullRequests } from "@/db/schema/pull-requests"; import { and, eq, inArray, or } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { logger } from "./logger"; /** * Explicit PR-Issue links */ export const prIssueLinks = pgTable("pr_issue_links", { id: text("id").primaryKey(), pullRequestId: text("pull_request_id") .notNull() .references(() => pullRequests.id, { onDelete: "cascade" }), issueId: text("issue_id") .notNull() .references(() => issues.id, { onDelete: "cascade" }), linkType: text("link_type").notNull(), // closes, fixes, relates, blocks, duplicates createdById: text("created_by_id").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), }); export type PRIssueLink = typeof prIssueLinks.$inferSelect; // Keywords that indicate PR closes an issue const CLOSING_KEYWORDS = [ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", ]; /** * Parse issue references from text */ export function parseIssueReferences(text: string): { number: number; type: "closes" | "fixes" | "relates"; }[] { const referenceTypeByNumber = new Map< number, "closes" | "fixes" | "relates" >(); const setReference = ( number: number, type: "closes" | "fixes" | "relates", ) => { const existing = referenceTypeByNumber.get(number); if (!existing) { referenceTypeByNumber.set(number, type); return; } // Preserve stronger semantic link types over generic "relates". if (existing === "relates" && (type === "closes" || type === "fixes")) { referenceTypeByNumber.set(number, type); } }; // Pattern: (keyword) #number or (keyword) repo#number const scopedIssueRef = "(?:[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+)?#(\\d+)"; for (const keyword of CLOSING_KEYWORDS) { const regex = new RegExp(`\\b${keyword}\\b\\s+${scopedIssueRef}\\b`, "gi"); let match; while ((match = regex.exec(text)) !== null) { const type = keyword.startsWith("fix") ? "fixes" : "closes"; const number = Number.parseInt(match[1], 10); if (Number.isInteger(number)) { setReference(number, type); } } } // Pattern: just #number or owner/repo#number (relates) const relatesRegex = new RegExp(`${scopedIssueRef}\\b`, "g"); let match; while ((match = relatesRegex.exec(text)) !== null) { const number = Number.parseInt(match[1], 10); if (Number.isInteger(number)) { setReference(number, "relates"); } } return Array.from(referenceTypeByNumber.entries()).map(([number, type]) => ({ number, type, })); } /** * Link PR to Issue */ export async function linkPRToIssue(options: { pullRequestId: string; issueId: string; linkType: "closes" | "fixes" | "relates" | "blocks" | "duplicates"; createdById: string; }): Promise { const db = getDatabase(); // Check for existing link const existing = await db.query.prIssueLinks?.findFirst({ where: and( eq(schema.prIssueLinks.pullRequestId, options.pullRequestId), eq(schema.prIssueLinks.issueId, options.issueId), ), }); if (existing) { // Update link type await (db as NodePgDatabase) .update(schema.prIssueLinks) .set({ linkType: options.linkType }) .where(eq(schema.prIssueLinks.id, existing.id)); return { ...existing, linkType: options.linkType }; } const link = { id: crypto.randomUUID(), pullRequestId: options.pullRequestId, issueId: options.issueId, linkType: options.linkType, createdById: options.createdById, createdAt: new Date(), }; // @ts-expect-error - Drizzle multi-db union type issue await db.insert(schema.prIssueLinks).values(link); logger.info( { prId: options.pullRequestId, issueId: options.issueId, type: options.linkType, }, "PR-Issue linked", ); return link as PRIssueLink; } /** * Auto-link PR to issues based on description/title */ export async function autoLinkPR( pullRequestId: string, createdById: string, ): Promise { const db = getDatabase(); const pr = await db.query.pullRequests.findFirst({ where: eq(schema.pullRequests.id, pullRequestId), }); if (!pr) return []; const textToSearch = `${pr.title} ${pr.body || ""}`; const references = parseIssueReferences(textToSearch); const links: PRIssueLink[] = []; for (const ref of references) { // Find issue by number in same repo const issue = await db.query.issues.findFirst({ where: and( eq(schema.issues.repositoryId, pr.repositoryId), eq(schema.issues.number, ref.number), ), }); if (issue) { const link = await linkPRToIssue({ pullRequestId, issueId: issue.id, linkType: ref.type, createdById, }); links.push(link); } } return links; } /** * Get issues linked to a PR */ export async function getLinkedIssues(pullRequestId: string): Promise< { link: PRIssueLink; issue: typeof schema.issues.$inferSelect; }[] > { const db = getDatabase(); try { const links = (await db.query.prIssueLinks?.findMany({ where: eq(schema.prIssueLinks.pullRequestId, pullRequestId), })) || []; if (links.length === 0) return []; // Batch fetch all linked issues in one query const issueIds = links.map((l) => l.issueId); const issues = await db.query.issues.findMany({ where: inArray(schema.issues.id, issueIds), }); const issueMap = new Map(issues.map((i) => [i.id, i])); return links .filter((link) => issueMap.has(link.issueId)) .map((link) => ({ link, issue: issueMap.get(link.issueId)! })); } catch { return []; } } /** * Get PRs linked to an issue */ export async function getLinkedPRs(issueId: string): Promise< { link: PRIssueLink; pr: typeof schema.pullRequests.$inferSelect; }[] > { const db = getDatabase(); try { const links = (await db.query.prIssueLinks?.findMany({ where: eq(schema.prIssueLinks.issueId, issueId), })) || []; if (links.length === 0) return []; // Batch fetch all linked PRs in one query const prIds = links.map((l) => l.pullRequestId); const prs = await db.query.pullRequests.findMany({ where: inArray(schema.pullRequests.id, prIds), }); const prMap = new Map(prs.map((p) => [p.id, p])); return links .filter((link) => prMap.has(link.pullRequestId)) .map((link) => ({ link, pr: prMap.get(link.pullRequestId)! })); } catch { return []; } } /** * Close linked issues when PR is merged */ export async function closeLinkedIssuesOnMerge( pullRequestId: string, userId: string, ): Promise { const db = getDatabase(); const closedIssueIds: string[] = []; try { const links = (await db.query.prIssueLinks?.findMany({ where: and( eq(schema.prIssueLinks.pullRequestId, pullRequestId), or( eq(schema.prIssueLinks.linkType, "closes"), eq(schema.prIssueLinks.linkType, "fixes"), ), ), })) || []; for (const link of links) { await (db as NodePgDatabase) .update(schema.issues) .set({ state: "closed", closedAt: new Date(), closedById: userId, updatedAt: new Date(), }) .where(eq(schema.issues.id, link.issueId)); closedIssueIds.push(link.issueId); } if (closedIssueIds.length > 0) { logger.info( { prId: pullRequestId, closedCount: closedIssueIds.length }, "Issues closed on PR merge", ); } } catch (error) { logger.error({ pullRequestId, error }, "Failed to close linked issues"); } return closedIssueIds; } /** * Unlink PR from Issue */ export async function unlinkPRFromIssue(linkId: string): Promise { const db = getDatabase(); try { await (db as NodePgDatabase) .delete(schema.prIssueLinks) .where(eq(schema.prIssueLinks.id, linkId)); return true; } catch { return false; } }