Commitace34efunknown_key
feat(M13): PR review assignment automation — git-log blame + request-review
feat(M13): PR review assignment automation — git-log blame + request-review src/lib/reviewer-suggest.ts: - suggestReviewers(): git log base..head to find developers most familiar with changed files, ranked by commit count; includes repo owner + accepted collaborators, excludes PR author; caps at 50 files / top 5 candidates - requestReview(): sends review_requested notification + activity feed entry without touching prReviews (avoids corrupting countHumanApprovals) src/routes/pulls.tsx: - Calls suggestReviewers() best-effort on PR detail page - "Suggested reviewers" section with avatar + commit count + Request button - POST /:owner/:repo/pulls/:number/request-review handler (write-access gated) with self-review guard https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
2 files changed+280−0ace34ef54462e377bfbfd94e60492a82cd60f862
2 changed files+280−0
Addedsrc/lib/reviewer-suggest.ts+188−0View fileUnifiedSplit
@@ -0,0 +1,188 @@
1/**
2 * PR reviewer suggestion + review-request helpers.
3 *
4 * suggestReviewers — analyses the PR diff with `git log` to find the
5 * developers most familiar with the changed code and returns up to 5
6 * candidates, ranked by how many commits touched those files in the
7 * diff range.
8 *
9 * requestReview — sends a notification to a reviewer and logs the request
10 * to the activity feed. Intentionally does NOT insert into prReviews
11 * (that table tracks actual submitted reviews, not pending requests) to
12 * avoid corrupting countHumanApprovals / branch-protection logic.
13 */
14
15import { eq, and, inArray, isNotNull } from "drizzle-orm";
16import { db } from "../db";
17import {
18 users,
19 repositories,
20 repoCollaborators,
21 activityFeed,
22} from "../db/schema";
23import { getRepoPath } from "../git/repository";
24import { notify } from "./notify";
25
26export interface ReviewerCandidate {
27 userId: string;
28 username: string;
29 commitCount: number;
30}
31
32/**
33 * Suggests up to 5 reviewers for a pull request based on git history of
34 * the changed files. Returns an empty array on any error.
35 */
36export async function suggestReviewers(
37 owner: string,
38 repo: string,
39 headBranch: string,
40 baseBranch: string,
41 authorId: string,
42 repoId: string
43): Promise<ReviewerCandidate[]> {
44 try {
45 const repoDir = getRepoPath(owner, repo);
46
47 // Step 1 — get changed files in the diff range
48 const diffProc = Bun.spawn(
49 ["git", "--git-dir", repoDir, "diff", "--name-only", `${baseBranch}...${headBranch}`],
50 { stdout: "pipe", stderr: "pipe" }
51 );
52 const diffText = await new Response(diffProc.stdout).text();
53 await diffProc.exited;
54
55 const files = diffText
56 .split("\n")
57 .map((f) => f.trim())
58 .filter(Boolean)
59 .slice(0, 50);
60
61 if (files.length === 0) return [];
62
63 // Step 2 — get author emails from git log for the diff range
64 const logProc = Bun.spawn(
65 [
66 "git", "--git-dir", repoDir,
67 "log", "--format=%ae",
68 `${baseBranch}..${headBranch}`,
69 "--",
70 ...files,
71 ],
72 { stdout: "pipe", stderr: "pipe" }
73 );
74 const logText = await new Response(logProc.stdout).text();
75 await logProc.exited;
76
77 const rawEmails = logText
78 .split("\n")
79 .map((e) => e.trim().replace(/^<|>$/g, ""))
80 .filter(Boolean);
81
82 if (rawEmails.length === 0) return [];
83
84 // Count occurrences per email
85 const emailCounts = new Map<string, number>();
86 for (const email of rawEmails) {
87 emailCounts.set(email, (emailCounts.get(email) ?? 0) + 1);
88 }
89
90 const uniqueEmails = Array.from(emailCounts.keys());
91
92 // Step 3 — look up users by email
93 const emailUsers = await db
94 .select({ id: users.id, username: users.username, email: users.email })
95 .from(users)
96 .where(inArray(users.email, uniqueEmails))
97 .limit(20);
98
99 if (emailUsers.length === 0) return [];
100
101 // Step 4 — get repo owner
102 const [ownerRow] = await db
103 .select({ ownerId: repositories.ownerId, ownerUsername: users.username })
104 .from(repositories)
105 .innerJoin(users, eq(repositories.ownerId, users.id))
106 .where(eq(repositories.id, repoId))
107 .limit(1);
108
109 // Step 5 — get accepted collaborators
110 const collaborators = await db
111 .select({ userId: repoCollaborators.userId, username: users.username })
112 .from(repoCollaborators)
113 .innerJoin(users, eq(repoCollaborators.userId, users.id))
114 .where(
115 and(
116 eq(repoCollaborators.repositoryId, repoId),
117 isNotNull(repoCollaborators.acceptedAt)
118 )
119 )
120 .limit(50);
121
122 // Step 6 — build allowed user ID set
123 const allowedIds = new Set<string>(
124 [
125 ...collaborators.map((c) => c.userId),
126 ownerRow?.ownerId,
127 ].filter((id): id is string => Boolean(id))
128 );
129
130 // Step 7 — filter email users, exclude author, must be in allowed set
131 const candidates = emailUsers
132 .filter(
133 (u) =>
134 allowedIds.has(u.id) &&
135 u.id !== authorId &&
136 emailCounts.has(u.email)
137 )
138 .map((u) => ({
139 userId: u.id,
140 username: u.username,
141 commitCount: emailCounts.get(u.email) ?? 0,
142 }));
143
144 // Step 8 — sort by commit count descending, take top 5
145 candidates.sort((a, b) => b.commitCount - a.commitCount);
146 return candidates.slice(0, 5);
147 } catch {
148 return [];
149 }
150}
151
152/**
153 * Sends a review-request notification and logs it to the activity feed.
154 * Does NOT modify prReviews — that table tracks submitted reviews only.
155 */
156export async function requestReview(
157 pullRequestId: string,
158 repositoryId: string,
159 reviewerId: string,
160 requesterId: string
161): Promise<{ ok: boolean; error?: string }> {
162 try {
163 if (reviewerId === requesterId) {
164 return { ok: false, error: "Cannot request review from yourself" };
165 }
166
167 await notify(reviewerId, {
168 kind: "review_requested",
169 title: "Review requested",
170 body: "You have been requested to review a pull request.",
171 repositoryId,
172 });
173
174 db.insert(activityFeed)
175 .values({
176 repositoryId,
177 userId: requesterId,
178 action: "review.requested",
179 targetType: "pr",
180 targetId: pullRequestId,
181 })
182 .catch(() => {});
183
184 return { ok: true };
185 } catch (err) {
186 return { ok: false, error: String(err) };
187 }
188}
Modifiedsrc/routes/pulls.tsx+92−0View fileUnifiedSplit
@@ -118,6 +118,8 @@ import {
118118 formatRelative,
119119} from "../views/ui";
120120
121import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
122
121123const pulls = new Hono<AuthEnv>();
122124
123125/* ──────────────────────────────────────────────────────────────────────
@@ -856,6 +858,12 @@ const PRS_DETAIL_STYLES = `
856858 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
857859 .prs-review-approved .prs-review-icon { color: #34d399; }
858860 .prs-review-changes .prs-review-icon { color: #f87171; }
861 .prs-reviewer-avatar {
862 width: 24px; height: 24px; border-radius: 50%;
863 background: var(--accent); color: #fff;
864 display: flex; align-items: center; justify-content: center;
865 font-size: 11px; font-weight: 700; flex-shrink: 0;
866 }
859867
860868 /* Review action buttons */
861869 .prs-review-approve-btn {
@@ -3195,6 +3203,19 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
31953203 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
31963204 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
31973205
3206 // Suggested reviewers — best-effort, never throws
3207 let reviewerSuggestions: ReviewerCandidate[] = [];
3208 try {
3209 if (user) {
3210 reviewerSuggestions = await suggestReviewers(
3211 ownerName, repoName, pr.headBranch, pr.baseBranch,
3212 pr.authorId, resolved.repo.id
3213 );
3214 }
3215 } catch {
3216 // silent degradation
3217 }
3218
31983219 const canManage =
31993220 user &&
32003221 (user.id === resolved.owner.id || user.id === pr.authorId);
@@ -3848,6 +3869,33 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
38483869 </div>
38493870 )}
38503871
3872 {/* Suggested reviewers */}
3873 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
3874 <div class="prs-review-summary" style="margin-top:12px">
3875 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
3876 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
3877 Suggested reviewers
3878 </span>
3879 {reviewerSuggestions.map((r) => (
3880 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
3881 style="display:flex;align-items:center;gap:8px;width:100%">
3882 <input type="hidden" name="reviewerId" value={r.userId} />
3883 <span class="prs-reviewer-avatar">
3884 {r.username.slice(0, 1).toUpperCase()}
3885 </span>
3886 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
3887 {r.username}
3888 </a>
3889 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
3890 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
3891 Request
3892 </button>
3893 </form>
3894 ))}
3895 </div>
3896 </div>
3897 )}
3898
38513899 {pr.state === "open" && gateChecks.length > 0 && (
38523900 <div class="prs-gate-card">
38533901 <div class="prs-gate-head">
@@ -5143,4 +5191,48 @@ pulls.post(
51435191 }
51445192);
51455193
5194// ─── Request review ───────────────────────────────────────────────────────────
5195pulls.post(
5196 "/:owner/:repo/pulls/:number/request-review",
5197 softAuth,
5198 requireAuth,
5199 requireRepoAccess("write"),
5200 async (c) => {
5201 const { owner: ownerName, repo: repoName } = c.req.param();
5202 const prNum = parseInt(c.req.param("number"), 10);
5203 const user = c.get("user")!;
5204
5205 const resolved = await resolveRepo(ownerName, repoName);
5206 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5207
5208 const [pr] = await db
5209 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5210 .from(pullRequests)
5211 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5212 .limit(1);
5213
5214 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5215
5216 const body = await c.req.formData().catch(() => null);
5217 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5218
5219 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5220 return c.redirect(
5221 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5222 );
5223 }
5224
5225 const { requestReview } = await import("../lib/reviewer-suggest");
5226 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5227
5228 const msg = result.ok
5229 ? "Review requested successfully."
5230 : `Failed to request review: ${result.error ?? "unknown error"}`;
5231
5232 return c.redirect(
5233 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5234 );
5235 }
5236);
5237
51465238export default pulls;
51475239