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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
reviewer-suggest.ts5.3 KB · 189 lines
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) };
  }
}