Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame189 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());
f4abb8eClaude91 if (uniqueEmails.length === 0) return [];
ace34efClaude92
93 // Step 3 — look up users by email
94 const emailUsers = await db
95 .select({ id: users.id, username: users.username, email: users.email })
96 .from(users)
97 .where(inArray(users.email, uniqueEmails))
98 .limit(20);
99
100 if (emailUsers.length === 0) return [];
101
102 // Step 4 — get repo owner
103 const [ownerRow] = await db
104 .select({ ownerId: repositories.ownerId, ownerUsername: users.username })
105 .from(repositories)
106 .innerJoin(users, eq(repositories.ownerId, users.id))
107 .where(eq(repositories.id, repoId))
108 .limit(1);
109
110 // Step 5 — get accepted collaborators
111 const collaborators = await db
112 .select({ userId: repoCollaborators.userId, username: users.username })
113 .from(repoCollaborators)
114 .innerJoin(users, eq(repoCollaborators.userId, users.id))
115 .where(
116 and(
117 eq(repoCollaborators.repositoryId, repoId),
118 isNotNull(repoCollaborators.acceptedAt)
119 )
120 )
121 .limit(50);
122
123 // Step 6 — build allowed user ID set
124 const allowedIds = new Set<string>(
125 [
126 ...collaborators.map((c) => c.userId),
127 ownerRow?.ownerId,
128 ].filter((id): id is string => Boolean(id))
129 );
130
131 // Step 7 — filter email users, exclude author, must be in allowed set
132 const candidates = emailUsers
133 .filter(
134 (u) =>
135 allowedIds.has(u.id) &&
136 u.id !== authorId &&
137 emailCounts.has(u.email)
138 )
139 .map((u) => ({
140 userId: u.id,
141 username: u.username,
142 commitCount: emailCounts.get(u.email) ?? 0,
143 }));
144
145 // Step 8 — sort by commit count descending, take top 5
146 candidates.sort((a, b) => b.commitCount - a.commitCount);
147 return candidates.slice(0, 5);
148 } catch {
149 return [];
150 }
151}
152
153/**
154 * Sends a review-request notification and logs it to the activity feed.
155 * Does NOT modify prReviews — that table tracks submitted reviews only.
156 */
157export async function requestReview(
158 pullRequestId: string,
159 repositoryId: string,
160 reviewerId: string,
161 requesterId: string
162): Promise<{ ok: boolean; error?: string }> {
163 try {
164 if (reviewerId === requesterId) {
165 return { ok: false, error: "Cannot request review from yourself" };
166 }
167
168 await notify(reviewerId, {
169 kind: "review_requested",
170 title: "Review requested",
171 body: "You have been requested to review a pull request.",
172 repositoryId,
173 });
174
175 db.insert(activityFeed)
176 .values({
177 repositoryId,
178 userId: requesterId,
179 action: "review.requested",
180 targetType: "pr",
181 targetId: pullRequestId,
182 })
183 .catch(() => {});
184
185 return { ok: true };
186 } catch (err) {
187 return { ok: false, error: String(err) };
188 }
189}