CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | /**
* PR reviewer suggestion + review-request helpers.
*
* suggestReviewers — analyses the PR diff with `git log` to find the
* developers most familiar with the changed code and returns up to 5
* candidates, ranked by how many commits touched those files in the
* diff range.
*
* requestReview — sends a notification to a reviewer and logs the request
* to the activity feed. Intentionally does NOT insert into prReviews
* (that table tracks actual submitted reviews, not pending requests) to
* avoid corrupting countHumanApprovals / branch-protection logic.
*/
import { eq, and, inArray, isNotNull } from "drizzle-orm";
import { db } from "../db";
import {
users,
repositories,
repoCollaborators,
activityFeed,
} from "../db/schema";
import { getRepoPath } from "../git/repository";
import { notify } from "./notify";
export interface ReviewerCandidate {
userId: string;
username: string;
commitCount: number;
}
/**
* Suggests up to 5 reviewers for a pull request based on git history of
* the changed files. Returns an empty array on any error.
*/
export async function suggestReviewers(
owner: string,
repo: string,
headBranch: string,
baseBranch: string,
authorId: string,
repoId: string
): Promise<ReviewerCandidate[]> {
try {
const repoDir = getRepoPath(owner, repo);
// Step 1 — get changed files in the diff range
const diffProc = Bun.spawn(
["git", "--git-dir", repoDir, "diff", "--name-only", `${baseBranch}...${headBranch}`],
{ stdout: "pipe", stderr: "pipe" }
);
const diffText = await new Response(diffProc.stdout).text();
await diffProc.exited;
const files = diffText
.split("\n")
.map((f) => f.trim())
.filter(Boolean)
.slice(0, 50);
if (files.length === 0) return [];
// Step 2 — get author emails from git log for the diff range
const logProc = Bun.spawn(
[
"git", "--git-dir", repoDir,
"log", "--format=%ae",
`${baseBranch}..${headBranch}`,
"--",
...files,
],
{ stdout: "pipe", stderr: "pipe" }
);
const logText = await new Response(logProc.stdout).text();
await logProc.exited;
const rawEmails = logText
.split("\n")
.map((e) => e.trim().replace(/^<|>$/g, ""))
.filter(Boolean);
if (rawEmails.length === 0) return [];
// Count occurrences per email
const emailCounts = new Map<string, number>();
for (const email of rawEmails) {
emailCounts.set(email, (emailCounts.get(email) ?? 0) + 1);
}
const uniqueEmails = Array.from(emailCounts.keys());
if (uniqueEmails.length === 0) return [];
// Step 3 — look up users by email
const emailUsers = await db
.select({ id: users.id, username: users.username, email: users.email })
.from(users)
.where(inArray(users.email, uniqueEmails))
.limit(20);
if (emailUsers.length === 0) return [];
// Step 4 — get repo owner
const [ownerRow] = await db
.select({ ownerId: repositories.ownerId, ownerUsername: users.username })
.from(repositories)
.innerJoin(users, eq(repositories.ownerId, users.id))
.where(eq(repositories.id, repoId))
.limit(1);
// Step 5 — get accepted collaborators
const collaborators = await db
.select({ userId: repoCollaborators.userId, username: users.username })
.from(repoCollaborators)
.innerJoin(users, eq(repoCollaborators.userId, users.id))
.where(
and(
eq(repoCollaborators.repositoryId, repoId),
isNotNull(repoCollaborators.acceptedAt)
)
)
.limit(50);
// Step 6 — build allowed user ID set
const allowedIds = new Set<string>(
[
...collaborators.map((c) => c.userId),
ownerRow?.ownerId,
].filter((id): id is string => Boolean(id))
);
// Step 7 — filter email users, exclude author, must be in allowed set
const candidates = emailUsers
.filter(
(u) =>
allowedIds.has(u.id) &&
u.id !== authorId &&
emailCounts.has(u.email)
)
.map((u) => ({
userId: u.id,
username: u.username,
commitCount: emailCounts.get(u.email) ?? 0,
}));
// Step 8 — sort by commit count descending, take top 5
candidates.sort((a, b) => b.commitCount - a.commitCount);
return candidates.slice(0, 5);
} catch {
return [];
}
}
/**
* Sends a review-request notification and logs it to the activity feed.
* Does NOT modify prReviews — that table tracks submitted reviews only.
*/
export async function requestReview(
pullRequestId: string,
repositoryId: string,
reviewerId: string,
requesterId: string
): Promise<{ ok: boolean; error?: string }> {
try {
if (reviewerId === requesterId) {
return { ok: false, error: "Cannot request review from yourself" };
}
await notify(reviewerId, {
kind: "review_requested",
title: "Review requested",
body: "You have been requested to review a pull request.",
repositoryId,
});
db.insert(activityFeed)
.values({
repositoryId,
userId: requesterId,
action: "review.requested",
targetType: "pr",
targetId: pullRequestId,
})
.catch(() => {});
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
}
|