Skip to main content
Glama
pr.ts13.9 kB
import simpleGit from "simple-git"; import { z } from "zod"; import type { SQLiteDatabase } from "@/database/sqlite"; import type { EmbeddingClient } from "@/embeddings/provider"; import type { Memory, ReviewerSuggestion, SuggestReviewersResult, } from "@/types"; import type { VectorStore } from "@/vectors/interface"; import { suggestReviewers } from "../expertise/reviewers"; export const PRDescriptionInputSchema = z.object({ branch: z .string() .optional() .describe("Current branch name. Auto-detected if not provided."), base: z.string().default("main").describe("Base branch to compare against"), projectPath: z .string() .optional() .describe("Project path. Defaults to current directory."), }); export type PRDescriptionInput = z.infer<typeof PRDescriptionInputSchema>; export interface PRDescriptionResult { title: string; body: string; commits: string[]; filesChanged: string[]; additions: number; deletions: number; suggestedReviewers: ReviewerSuggestion[]; relatedDecisions: Array<{ id: string; title: string; type: string; relevance: number; }>; } interface RelatedMemory { memory: Memory; score: number; } export async function generatePRDescription( input: PRDescriptionInput, db: SQLiteDatabase, vectors: VectorStore, embeddings: EmbeddingClient, ): Promise<PRDescriptionResult> { const projectPath = input.projectPath ?? process.cwd(); const git = simpleGit(projectPath); // Get current branch if not provided const branch = input.branch ?? (await git.revparse(["--abbrev-ref", "HEAD"])); // Get commits between base and current branch const logResult = await git.log([`${input.base}..${branch}`]); const commits = logResult.all.map((c) => c.message); // Get diff stats const diffStat = await git.diffSummary([`${input.base}...${branch}`]); const filesChanged = diffStat.files.map((f) => f.file); // Get the actual diff for context search const diff = await git.diff([`${input.base}...${branch}`]); // Search for related memories const relatedMemories = await findRelatedMemories( filesChanged, commits, diff, db, vectors, embeddings, ); // Get suggested reviewers const reviewerResult = await suggestReviewers({ files: filesChanged, projectPath, excludeAuthors: [], limit: 3, }); // Combine required and optional reviewers for backwards compatibility const allReviewers = [...reviewerResult.required, ...reviewerResult.optional]; // Generate PR title from branch name or first commit const title = generateTitle(branch, commits); // Generate PR body with context const body = generateBody( commits, filesChanged, diffStat.insertions, diffStat.deletions, relatedMemories, reviewerResult, ); return { title, body, commits, filesChanged, additions: diffStat.insertions, deletions: diffStat.deletions, suggestedReviewers: allReviewers, relatedDecisions: relatedMemories.map((m) => ({ id: m.memory.id, title: m.memory.title, type: m.memory.type, relevance: m.score, })), }; } async function findRelatedMemories( files: string[], commits: string[], diff: string, db: SQLiteDatabase, vectors: VectorStore, embeddings: EmbeddingClient, ): Promise<RelatedMemory[]> { const results: RelatedMemory[] = []; const seenIds = new Set<string>(); // 1. Search by changed files if (files.length > 0) { const fileQuery = files.slice(0, 10).join(" "); const fileVector = await embeddings.embed(fileQuery); const fileResults = await vectors.search( fileVector, { relatedFiles: files }, 5, ); for (const result of fileResults) { if (!seenIds.has(result.memoryId)) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } } // 2. Search by commit messages (combined context) if (commits.length > 0) { const commitQuery = commits.slice(0, 5).join("\n"); const commitVector = await embeddings.embed(commitQuery); const commitResults = await vectors.search(commitVector, {}, 5); for (const result of commitResults) { if (!seenIds.has(result.memoryId) && result.score > 0.5) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } } // 3. Search for decisions specifically const branchContext = extractBranchContext(files, commits); if (branchContext) { const contextVector = await embeddings.embed(branchContext); const decisionResults = await vectors.search( contextVector, { type: "decision" }, 3, ); for (const result of decisionResults) { if (!seenIds.has(result.memoryId) && result.score > 0.5) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } } // 4. Search for patterns const patternResults = await vectors.search( await embeddings.embed(files.slice(0, 5).join(" ")), { type: "pattern" }, 2, ); for (const result of patternResults) { if (!seenIds.has(result.memoryId) && result.score > 0.6) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } // Sort by score and return top results return results.sort((a, b) => b.score - a.score).slice(0, 8); } function extractBranchContext(files: string[], commits: string[]): string { const parts: string[] = []; // Extract areas from file paths const areas = new Set<string>(); for (const file of files) { const pathParts = file.split("/"); if (pathParts.length > 1) { areas.add(pathParts[0]); if (pathParts.length > 2) { areas.add(pathParts.slice(0, 2).join("/")); } } } if (areas.size > 0) { parts.push(`Changes in: ${[...areas].slice(0, 5).join(", ")}`); } // Extract key terms from commits const keywords = new Set<string>(); for (const commit of commits) { // Extract scope from conventional commits const scopeMatch = commit.match(/^\w+\(([^)]+)\):/); if (scopeMatch) { keywords.add(scopeMatch[1]); } // Extract key terms const terms = commit .toLowerCase() .match(/\b(auth|api|db|database|ui|config|test|security|performance)\b/g); if (terms) { for (const term of terms) keywords.add(term); } } if (keywords.size > 0) { parts.push(`Topics: ${[...keywords].slice(0, 5).join(", ")}`); } return parts.join(". "); } function generateTitle(branch: string, commits: string[]): string { // Try to extract meaningful title from branch name // Common patterns: feature/xxx, fix/xxx, feat-xxx, etc. const branchMatch = branch.match( /^(?:feature|feat|fix|bugfix|hotfix|chore|docs)[-/](.+)$/i, ); if (branchMatch) { const rawTitle = branchMatch[1] .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); return rawTitle; } // Fall back to first commit message if (commits.length > 0) { const firstCommit = commits[commits.length - 1]; // Oldest commit // Remove conventional commit prefix for title if (firstCommit) { return firstCommit.replace( /^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?:\s*/i, "", ); } } // Default return `Changes from ${branch}`; } function generateBody( commits: string[], filesChanged: string[], additions: number, deletions: number, relatedMemories: RelatedMemory[], reviewers: SuggestReviewersResult, ): string { const sections: string[] = []; // Summary section sections.push("## Summary\n"); sections.push(generateSummary(commits, filesChanged)); // Why section - from related decisions const decisions = relatedMemories.filter((m) => m.memory.type === "decision"); if (decisions.length > 0) { sections.push("\n## Context\n"); sections.push( "This PR relates to the following architectural decisions:\n", ); for (const { memory, score } of decisions.slice(0, 3)) { const relevancePercent = Math.round(score * 100); sections.push(`- **${memory.title}** (${relevancePercent}% relevant)`); if (memory.summary) { sections.push(` ${memory.summary}`); } } } // Patterns followed const patterns = relatedMemories.filter((m) => m.memory.type === "pattern"); if (patterns.length > 0) { sections.push("\n## Patterns Applied\n"); for (const { memory } of patterns.slice(0, 2)) { sections.push(`- ${memory.title}`); } } // Changes section if (commits.length > 0) { sections.push("\n## Changes\n"); for (const commit of commits.slice(0, 10)) { sections.push(`- ${commit}`); } if (commits.length > 10) { sections.push(`- ... and ${commits.length - 10} more commits`); } } // Files section if (filesChanged.length > 0) { sections.push("\n## Files Changed\n"); sections.push( `**${filesChanged.length}** files changed, **${additions}** insertions(+), **${deletions}** deletions(-)\n`, ); // Group by directory const byDir = groupFilesByDirectory(filesChanged); const dirEntries = Object.entries(byDir).slice(0, 5); for (const [dir, files] of dirEntries) { sections.push(`\n### ${dir || "root"}`); for (const file of files.slice(0, 5)) { sections.push(`- \`${file}\``); } if (files.length > 5) { sections.push(`- ... and ${files.length - 5} more`); } } } // Related issues from memories const relatedIssues = extractRelatedIssues(relatedMemories); if (relatedIssues.length > 0) { sections.push("\n## Related Issues\n"); sections.push(relatedIssues.join(", ")); } // Testing section sections.push("\n## Testing\n"); sections.push("- [ ] Unit tests added/updated"); sections.push("- [ ] Manual testing completed"); // Suggested reviewers const hasReviewers = reviewers.required.length > 0 || reviewers.optional.length > 0; if (hasReviewers) { sections.push("\n## Suggested Reviewers\n"); // Required reviewers for (const reviewer of reviewers.required) { sections.push( `- **@${reviewer.name}** (${reviewer.reason}) — **required**`, ); } // Optional reviewers for (const reviewer of reviewers.optional) { sections.push(`- **@${reviewer.name}** (${reviewer.reason}) — optional`); } // Warning for files with no owner if (reviewers.noOwner.length > 0) { sections.push(""); if (reviewers.noOwner.length <= 3) { sections.push( `> ⚠️ No clear owner for: ${reviewers.noOwner.join(", ")}`, ); } else { sections.push( `> ⚠️ No clear owner for ${reviewers.noOwner.length} files`, ); } } } return sections.join("\n"); } function extractRelatedIssues(memories: RelatedMemory[]): string[] { const issues: string[] = []; const issuePattern = /#(\d+)|([A-Z]+-\d+)/g; for (const { memory } of memories) { // Check sourcePr field if (memory.sourcePr) { const prMatch = memory.sourcePr.match(/\d+/); if (prMatch) { const issue = `#${prMatch[0]}`; if (!issues.includes(issue)) { issues.push(issue); } } } // Extract issues from content const contentMatches = memory.content.matchAll(issuePattern); for (const match of contentMatches) { const issue = match[1] ? `#${match[1]}` : match[2]; if (issue && !issues.includes(issue)) { issues.push(issue); } } // Extract issues from title const titleMatches = memory.title.matchAll(issuePattern); for (const match of titleMatches) { const issue = match[1] ? `#${match[1]}` : match[2]; if (issue && !issues.includes(issue)) { issues.push(issue); } } } return [...new Set(issues)].slice(0, 5); } function generateSummary(commits: string[], filesChanged: string[]): string { // Analyze commits to generate a summary const types = new Map<string, number>(); const scopes = new Set<string>(); for (const commit of commits) { const match = commit.match( /^(feat|fix|docs|style|refactor|test|chore)(?:\((.+)\))?:/i, ); if (match) { const type = match[1].toLowerCase(); types.set(type, (types.get(type) ?? 0) + 1); if (match[2]) scopes.add(match[2]); } } const parts: string[] = []; if (types.has("feat")) { parts.push(`Adds ${types.get("feat")} new feature(s)`); } if (types.has("fix")) { parts.push(`Fixes ${types.get("fix")} bug(s)`); } if (types.has("refactor")) { parts.push(`Refactors ${types.get("refactor")} component(s)`); } if (types.has("docs")) { parts.push("Updates documentation"); } if (types.has("test")) { parts.push("Adds/updates tests"); } if (parts.length === 0) { return `This PR includes ${commits.length} commit(s) affecting ${filesChanged.length} file(s).`; } return parts.join(". ") + "."; } function groupFilesByDirectory(files: string[]): Record<string, string[]> { const result: Record<string, string[]> = {}; for (const file of files) { const parts = file.split("/"); const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : ""; const fileName = parts[parts.length - 1]; if (!result[dir]) result[dir] = []; if (fileName) { result[dir].push(fileName); } } return result; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/docleaai/doclea-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server