Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

reviewer-suggest.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

reviewer-suggest.tsBlame188 lines · 1 contributor
ace34efClaude1/**
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}