Skip to main content
Glama
reviewers.ts10.3 kB
import simpleGit from "simple-git"; import { z } from "zod"; import type { ReviewerSuggestion, SuggestReviewersResult } from "@/types"; export const SuggestReviewersInputSchema = z.object({ files: z.array(z.string()).describe("List of files that changed"), projectPath: z .string() .optional() .describe("Project path. Defaults to current directory."), excludeAuthors: z .array(z.string()) .default([]) .describe("Authors to exclude (e.g., PR author)"), limit: z .number() .min(1) .max(10) .default(3) .describe("Maximum reviewers to suggest"), }); export type SuggestReviewersInput = z.infer<typeof SuggestReviewersInputSchema>; // Threshold for "significant" ownership const REQUIRED_THRESHOLD = 50; // Primary expert with 50%+ = required const MIN_OWNERSHIP_THRESHOLD = 10; // Need at least 10% to be suggested export async function suggestReviewers( input: SuggestReviewersInput, ): Promise<SuggestReviewersResult> { const projectPath = input.projectPath ?? process.cwd(); const git = simpleGit(projectPath); const excludeSet = new Set(input.excludeAuthors.map((a) => a.toLowerCase())); // Track expertise per reviewer across all files const reviewerMap = new Map< string, { name: string; email: string; filesOwned: Map<string, number>; // file -> ownership percentage totalCommits: number; recentCommits: number; lastCommitTime: number; } >(); const now = Date.now(); const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; const filesWithNoOwner: string[] = []; // Analyze each file for (const file of input.files) { const fileOwnership = await analyzeFileOwnership( git, file, excludeSet, thirtyDaysAgo, ); if (fileOwnership.length === 0) { filesWithNoOwner.push(file); continue; } // Check if any owner has significant ownership const hasSignificantOwner = fileOwnership.some( (o) => o.percentage >= MIN_OWNERSHIP_THRESHOLD, ); if (!hasSignificantOwner) { filesWithNoOwner.push(file); } // Merge into global reviewer map for (const owner of fileOwnership) { const key = owner.email.toLowerCase(); const existing = reviewerMap.get(key); if (existing) { existing.filesOwned.set(file, owner.percentage); existing.totalCommits += owner.commits; existing.recentCommits += owner.recentCommits; if (owner.lastCommitTime > existing.lastCommitTime) { existing.lastCommitTime = owner.lastCommitTime; } } else { const filesOwned = new Map<string, number>(); filesOwned.set(file, owner.percentage); reviewerMap.set(key, { name: owner.name, email: owner.email, filesOwned, totalCommits: owner.commits, recentCommits: owner.recentCommits, lastCommitTime: owner.lastCommitTime, }); } } } // Build suggestions const suggestions: ReviewerSuggestion[] = []; const totalFiles = input.files.length; const filesWithOwner = totalFiles - filesWithNoOwner.length; for (const [, reviewer] of reviewerMap) { const filesOwnedList = Array.from(reviewer.filesOwned.keys()); const ownerships = Array.from(reviewer.filesOwned.values()); // Calculate average ownership percentage across files they touched const avgOwnership = ownerships.reduce((sum, pct) => sum + pct, 0) / ownerships.length; // Calculate overall expertise (weighted by file coverage and ownership) const fileCoverage = filesOwnedList.length / Math.max(1, filesWithOwner); const expertisePct = Math.round(avgOwnership * fileCoverage); // Skip if below minimum threshold if ( expertisePct < MIN_OWNERSHIP_THRESHOLD && reviewer.recentCommits === 0 ) { continue; } // Determine category const isPrimaryExpert = avgOwnership >= REQUIRED_THRESHOLD; const hasRecentActivity = reviewer.recentCommits > 0; const category: "required" | "optional" = isPrimaryExpert && fileCoverage > 0.3 ? "required" : "optional"; // Generate reason const reason = generateReason( reviewer.name, filesOwnedList, avgOwnership, hasRecentActivity, reviewer.recentCommits, totalFiles, ); // Calculate relevance for backwards compat (0-1 score) const recencyBonus = hasRecentActivity ? 0.1 : 0; const relevance = Math.min( 1, fileCoverage * 0.7 + (avgOwnership / 100) * 0.3 + recencyBonus, ); suggestions.push({ name: reviewer.name, email: reviewer.email, reason, relevance: Math.round(relevance * 100) / 100, expertisePct, category, filesOwned: filesOwnedList, }); } // Sort by expertise percentage, then by relevance suggestions.sort((a, b) => { if (a.category !== b.category) { return a.category === "required" ? -1 : 1; } if (b.expertisePct !== a.expertisePct) { return b.expertisePct - a.expertisePct; } return b.relevance - a.relevance; }); // Split into required and optional, respecting limit const required = suggestions .filter((s) => s.category === "required") .slice(0, Math.ceil(input.limit / 2)); const optional = suggestions .filter((s) => s.category === "optional") .slice(0, input.limit - required.length); // Generate summary const summary = generateSummary( input.files, required, optional, filesWithNoOwner, ); return { required, optional, noOwner: filesWithNoOwner, summary, }; } interface FileOwnership { name: string; email: string; commits: number; percentage: number; recentCommits: number; lastCommitTime: number; } async function analyzeFileOwnership( git: ReturnType<typeof simpleGit>, file: string, excludeSet: Set<string>, recentThreshold: number, ): Promise<FileOwnership[]> { try { // Get all commits for this file const logOutput = await git.raw([ "log", "--format=%an|%ae|%ct", "--follow", "--", file, ]); const lines = logOutput.split("\n").filter(Boolean); const authorMap = new Map< string, { name: string; email: string; commits: number; recentCommits: number; lastCommitTime: number; } >(); for (const line of lines) { const match = line.match(/^(.+)\|(.+)\|(\d+)$/); if (!match) continue; const [, name, email, timeStr] = match; const emailLower = email.toLowerCase(); const commitTime = parseInt(timeStr, 10) * 1000; const isRecent = commitTime > recentThreshold; // Skip excluded authors if (excludeSet.has(emailLower) || excludeSet.has(name.toLowerCase())) { continue; } const existing = authorMap.get(emailLower); if (existing) { existing.commits++; if (isRecent) existing.recentCommits++; if (commitTime > existing.lastCommitTime) { existing.lastCommitTime = commitTime; } } else { authorMap.set(emailLower, { name, email, commits: 1, recentCommits: isRecent ? 1 : 0, lastCommitTime: commitTime, }); } } // Calculate percentages const totalCommits = Array.from(authorMap.values()).reduce( (sum, a) => sum + a.commits, 0, ); if (totalCommits === 0) return []; return Array.from(authorMap.values()).map((author) => ({ name: author.name, email: author.email, commits: author.commits, percentage: Math.round((author.commits / totalCommits) * 100), recentCommits: author.recentCommits, lastCommitTime: author.lastCommitTime, })); } catch { return []; } } function generateReason( name: string, filesOwned: string[], avgOwnership: number, hasRecentActivity: boolean, recentCommits: number, totalFiles: number, ): string { const parts: string[] = []; // Primary expertise description if (avgOwnership >= 70) { parts.push(`primary expert (${Math.round(avgOwnership)}%)`); } else if (avgOwnership >= 50) { parts.push(`major contributor (${Math.round(avgOwnership)}%)`); } else if (avgOwnership >= 30) { parts.push(`significant contributor (${Math.round(avgOwnership)}%)`); } else { parts.push(`contributor (${Math.round(avgOwnership)}%)`); } // File coverage if (filesOwned.length === totalFiles) { parts.push(`all ${totalFiles} files`); } else if (filesOwned.length > 1) { parts.push(`${filesOwned.length}/${totalFiles} files`); } else { // Single file - show which one const fileName = filesOwned[0]?.split("/").pop() ?? filesOwned[0]; parts.push(fileName ?? "1 file"); } // Recency if (hasRecentActivity) { if (recentCommits > 3) { parts.push("very active recently"); } else { parts.push("recent activity"); } } return parts.join(", "); } function generateSummary( files: string[], required: ReviewerSuggestion[], optional: ReviewerSuggestion[], noOwner: string[], ): string { const lines: string[] = []; // Files touched if (files.length <= 3) { lines.push(`PR touches: ${files.join(", ")}`); } else { const dirs = new Set( files.map((f) => f.split("/").slice(0, -1).join("/") || "."), ); lines.push(`PR touches: ${files.length} files in ${dirs.size} directories`); } lines.push(""); lines.push("Suggested reviewers:"); // Required reviewers for (const r of required) { lines.push(`├── @${r.name} (${r.reason}) — required`); } // Optional reviewers for (let i = 0; i < optional.length; i++) { const r = optional[i]; const prefix = i === optional.length - 1 && noOwner.length === 0 ? "└──" : "├──"; lines.push(`${prefix} @${r.name} (${r.reason}) — optional`); } // No owner warning if (noOwner.length > 0) { lines.push(""); if (noOwner.length <= 3) { lines.push(`⚠️ No clear owner: ${noOwner.join(", ")}`); } else { lines.push(`⚠️ No clear owner for ${noOwner.length} files`); } } return lines.join("\n"); }

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