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

comment-moderation.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.

comment-moderation.tsBlame646 lines · 1 contributor
cb5a796Claude1/**
2 * Comment moderation — anti-impersonation gate for public-repo comments.
3 *
4 * Background (from the platform owner, verbatim):
5 * "Users will not be allowed to comment on another public repo unless
6 * they have the permission of the author. If somebody seems a comment
7 * or comments on a public repo, the author will be notified and it's
8 * up to them whether they want to accept it or not. People pass
9 * comments to make themselves look like they're contributors — that's
10 * not going to happen on this platform."
11 *
12 * The implementation is a single decision function (`shouldRequireApproval`)
13 * plus three side-effect helpers used by the route handlers and the
14 * `/comments/pending` queue page. The decision is intentionally pure /
15 * easily testable: collaborators always skip, the thread author always
16 * skips, and a per-repo trust row can either short-circuit to "auto
17 * approve" ('trusted') or "auto reject" ('banned' — equivalent to a
18 * silent ban so spammers don't get to re-pester the owner).
19 *
20 * All notification/audit emissions are best-effort: a failed insert never
21 * leaks back into the comment-create flow.
22 */
23
24import { and, eq } from "drizzle-orm";
25import { db } from "../db";
26import {
27 issueComments,
28 prComments,
29 issues,
30 pullRequests,
31 repositories,
32 repoCommenterTrust,
33 users,
34} from "../db/schema";
35import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
36import { notify, audit } from "./notify";
37
38export type CommentKind = "issue" | "pr";
39export type ModerationStatus = "approved" | "pending" | "rejected" | "spam";
40export type TrustStatus = "trusted" | "banned";
41
42/**
43 * Decision: should this comment go to the moderation queue instead of
44 * being published immediately?
45 *
46 * Returns FALSE (auto-approve) when:
47 * • The commenter is the repo owner / admin / write collaborator
48 * (i.e. `resolveRepoAccess` returns >= "write").
49 * • The commenter has a `repo_commenter_trust` row with status='trusted'.
50 * • The commenter is the original author of the thread (issue/PR) —
51 * a non-collaborator who opened an issue must always be able to
52 * follow up on it without owner approval, otherwise the issue
53 * tracker is dead.
54 *
55 * Returns TRUE (gate to 'pending') for everyone else, including users
56 * with a 'banned' trust row — banned users still hit the moderation
57 * layer, where the route handler immediately auto-rejects them silently
58 * (see `applyModerationDecision`).
59 *
60 * `repositoryId` and either `issueId` or `pullRequestId` (depending on
61 * `kind`) are required; the function does its own DB lookups for the
62 * thread author + repo public/private flag.
63 */
64export async function shouldRequireApproval(args: {
65 commenterUserId: string;
66 repositoryId: string;
67 kind: CommentKind;
68 threadId: string;
69}): Promise<{
70 requireApproval: boolean;
71 autoReject: boolean;
72 reason: string;
73}> {
74 const { commenterUserId, repositoryId, kind, threadId } = args;
75
76 // 1. Banned commenter? Short-circuit to auto-reject. We surface this as
77 // requireApproval=true so the route handler still uses the moderation
78 // storage path, but it'll flip the status to 'rejected' immediately
79 // (and skip the owner notification).
80 let trustRow: { status: string } | undefined;
81 try {
82 const rows = await db
83 .select({ status: repoCommenterTrust.status })
84 .from(repoCommenterTrust)
85 .where(
86 and(
87 eq(repoCommenterTrust.repositoryId, repositoryId),
88 eq(repoCommenterTrust.commenterUserId, commenterUserId)
89 )
90 )
91 .limit(1);
92 trustRow = rows[0];
93 } catch {
94 // DB hiccup → fall through. We'd rather queue than silently let
95 // through, so we treat the trust lookup as "unknown".
96 }
97
98 if (trustRow?.status === "banned") {
99 return {
100 requireApproval: true,
101 autoReject: true,
102 reason: "commenter is banned on this repo",
103 };
104 }
105 if (trustRow?.status === "trusted") {
106 return {
107 requireApproval: false,
108 autoReject: false,
109 reason: "commenter is trusted on this repo",
110 };
111 }
112
113 // 2. Collaborator check — repo owner / admin / write all skip.
114 // We need the repo's `isPrivate` flag for `resolveRepoAccess`.
115 let isPublic = true;
116 try {
117 const [repo] = await db
118 .select({ isPrivate: repositories.isPrivate })
119 .from(repositories)
120 .where(eq(repositories.id, repositoryId))
121 .limit(1);
122 if (repo) {
123 isPublic = !repo.isPrivate;
124 }
125 } catch {
126 // Treat unknown repo as public (worst case: we queue a comment that
127 // would otherwise have been auto-approved — strictly safer than the
128 // opposite).
129 }
130
131 const access = await resolveRepoAccess({
132 repoId: repositoryId,
133 userId: commenterUserId,
134 isPublic,
135 });
136 if (satisfiesAccess(access, "write")) {
137 return {
138 requireApproval: false,
139 autoReject: false,
140 reason: `commenter has ${access} access`,
141 };
142 }
143
144 // 3. Thread-author check — if the commenter opened the issue/PR they
145 // are commenting on, let them follow up without approval.
146 try {
147 if (kind === "issue") {
148 const [row] = await db
149 .select({ authorId: issues.authorId })
150 .from(issues)
151 .where(eq(issues.id, threadId))
152 .limit(1);
153 if (row?.authorId === commenterUserId) {
154 return {
155 requireApproval: false,
156 autoReject: false,
157 reason: "commenter opened this issue",
158 };
159 }
160 } else {
161 const [row] = await db
162 .select({ authorId: pullRequests.authorId })
163 .from(pullRequests)
164 .where(eq(pullRequests.id, threadId))
165 .limit(1);
166 if (row?.authorId === commenterUserId) {
167 return {
168 requireApproval: false,
169 autoReject: false,
170 reason: "commenter opened this pull request",
171 };
172 }
173 }
174 } catch {
175 // Fall through — safer to require approval.
176 }
177
178 return {
179 requireApproval: true,
180 autoReject: false,
181 reason: "commenter is not a collaborator or trusted user",
182 };
183}
184
185/**
186 * Convenience wrapper used by the issue/PR route handlers.
187 *
188 * Returns the moderation_status the row should be inserted with:
189 * - 'approved' → caller inserts + publishes as usual
190 * - 'pending' → caller inserts hidden, notifies repo owner
191 * - 'rejected' → caller inserts hidden (banned user, silent drop)
192 */
193export async function decideInitialStatus(args: {
194 commenterUserId: string;
195 repositoryId: string;
196 kind: CommentKind;
197 threadId: string;
198}): Promise<{ status: ModerationStatus; reason: string }> {
199 const d = await shouldRequireApproval(args);
200 if (!d.requireApproval) {
201 return { status: "approved", reason: d.reason };
202 }
203 if (d.autoReject) {
204 return { status: "rejected", reason: d.reason };
205 }
206 return { status: "pending", reason: d.reason };
207}
208
209/**
210 * Notify the repo owner that a non-collaborator left a comment that
211 * needs their approval. Best-effort; never throws.
212 *
213 * The notification kind `comment.pending` is a string literal (the
214 * `notifications.kind` column is free-form text), so we cast through
215 * `any` to avoid a per-kind union expansion on the `NotificationKind`
216 * union in `notify.ts`.
217 */
218export async function notifyOwnerOfPendingComment(args: {
219 repositoryId: string;
220 commenterUsername: string;
221 kind: CommentKind;
222 threadNumber: number;
223 ownerUsername: string;
224 repoName: string;
225}): Promise<void> {
226 const { repositoryId } = args;
227 try {
228 const [repo] = await db
229 .select({ ownerId: repositories.ownerId })
230 .from(repositories)
231 .where(eq(repositories.id, repositoryId))
232 .limit(1);
233 if (!repo) return;
234 const url = `/${args.ownerUsername}/${args.repoName}/comments/pending`;
235 const title = `${args.commenterUsername} commented on ${args.kind === "issue" ? "issue" : "PR"} #${args.threadNumber}`;
236 const body = `Awaiting your approval. Review at ${url}`;
237 await notify(repo.ownerId, {
238 // Free-form string — `notify()` accepts any NotificationKind and we
239 // stash this new value through.
240 kind: "comment.pending" as any,
241 title,
242 body,
243 url,
244 repositoryId,
245 });
246 } catch (err) {
247 console.error("[comment-moderation] notifyOwner failed:", err);
248 }
249}
250
251/**
252 * Approve a pending comment. Flips moderation_status to 'approved',
253 * stamps `moderated_at` / `moderated_by_user_id`, optionally inserts a
254 * 'trusted' trust row so future comments auto-approve, and notifies the
255 * comment author so they know their comment is now visible.
256 */
257export async function approveComment(args: {
258 commentId: string;
259 kind: CommentKind;
260 moderatorUserId: string;
261 alsoTrust?: boolean;
262}): Promise<{ ok: boolean; commenterId?: string; repositoryId?: string }> {
263 const { commentId, kind, moderatorUserId, alsoTrust } = args;
264 const table = kind === "issue" ? issueComments : prComments;
265 const now = new Date();
266
267 try {
268 const [row] = await db
269 .update(table)
270 .set({
271 moderationStatus: "approved",
272 moderatedAt: now,
273 moderatedByUserId: moderatorUserId,
274 })
275 .where(eq(table.id, commentId))
276 .returning({
277 id: table.id,
278 authorId: table.authorId,
279 });
280 if (!row) return { ok: false };
281
282 const repoId = await repoIdForComment(commentId, kind);
283 if (alsoTrust && repoId) {
284 await upsertTrust({
285 repositoryId: repoId,
286 commenterUserId: row.authorId,
287 status: "trusted",
288 grantedByUserId: moderatorUserId,
289 });
290 }
291
292 await notify(row.authorId, {
293 kind: "comment.approved" as any,
294 title: "Your comment was approved",
295 body: "The repo owner accepted your comment — it's now visible to everyone.",
296 repositoryId: repoId ?? undefined,
297 });
298 await audit({
299 userId: moderatorUserId,
300 repositoryId: repoId ?? undefined,
301 action: "comment.moderation.approved",
302 targetType: `${kind}_comment`,
303 targetId: commentId,
304 metadata: { alsoTrust: !!alsoTrust },
305 });
306 if (alsoTrust && repoId) {
307 await audit({
308 userId: moderatorUserId,
309 repositoryId: repoId,
310 action: "comment.moderation.trusted",
311 targetType: "user",
312 targetId: row.authorId,
313 });
314 }
315 return { ok: true, commenterId: row.authorId, repositoryId: repoId ?? undefined };
316 } catch (err) {
317 console.error("[comment-moderation] approveComment failed:", err);
318 return { ok: false };
319 }
320}
321
322/**
323 * Reject a pending comment. Flips to 'rejected', stamps the moderator,
324 * notifies the author with an optional polite reason.
325 */
326export async function rejectComment(args: {
327 commentId: string;
328 kind: CommentKind;
329 moderatorUserId: string;
330 reason?: string;
331}): Promise<{ ok: boolean }> {
332 const { commentId, kind, moderatorUserId, reason } = args;
333 const table = kind === "issue" ? issueComments : prComments;
334 const now = new Date();
335
336 try {
337 const [row] = await db
338 .update(table)
339 .set({
340 moderationStatus: "rejected",
341 moderatedAt: now,
342 moderatedByUserId: moderatorUserId,
343 })
344 .where(eq(table.id, commentId))
345 .returning({ id: table.id, authorId: table.authorId });
346 if (!row) return { ok: false };
347
348 const repoId = await repoIdForComment(commentId, kind);
349 const body = reason
350 ? `The repo owner did not approve your comment. Reason: ${reason}`
351 : "The repo owner did not approve your comment.";
352 await notify(row.authorId, {
353 kind: "comment.rejected" as any,
354 title: "Your comment was not approved",
355 body,
356 repositoryId: repoId ?? undefined,
357 });
358 await audit({
359 userId: moderatorUserId,
360 repositoryId: repoId ?? undefined,
361 action: "comment.moderation.rejected",
362 targetType: `${kind}_comment`,
363 targetId: commentId,
364 metadata: reason ? { reason } : undefined,
365 });
366 return { ok: true };
367 } catch (err) {
368 console.error("[comment-moderation] rejectComment failed:", err);
369 return { ok: false };
370 }
371}
372
373/**
374 * Mark a pending comment as spam. Flips to 'spam', adds a 'banned' trust
375 * row so future comments from this user on this repo are silently
376 * auto-rejected, and audits both decisions. No notification to the
377 * commenter — that's the point of "spam".
378 */
379export async function markAsSpam(args: {
380 commentId: string;
381 kind: CommentKind;
382 moderatorUserId: string;
383}): Promise<{ ok: boolean; commenterId?: string }> {
384 const { commentId, kind, moderatorUserId } = args;
385 const table = kind === "issue" ? issueComments : prComments;
386 const now = new Date();
387
388 try {
389 const [row] = await db
390 .update(table)
391 .set({
392 moderationStatus: "spam",
393 moderatedAt: now,
394 moderatedByUserId: moderatorUserId,
395 })
396 .where(eq(table.id, commentId))
397 .returning({ id: table.id, authorId: table.authorId });
398 if (!row) return { ok: false };
399
400 const repoId = await repoIdForComment(commentId, kind);
401 if (repoId) {
402 await upsertTrust({
403 repositoryId: repoId,
404 commenterUserId: row.authorId,
405 status: "banned",
406 grantedByUserId: moderatorUserId,
407 });
408 }
409 await audit({
410 userId: moderatorUserId,
411 repositoryId: repoId ?? undefined,
412 action: "comment.moderation.spam",
413 targetType: `${kind}_comment`,
414 targetId: commentId,
415 });
416 if (repoId) {
417 await audit({
418 userId: moderatorUserId,
419 repositoryId: repoId,
420 action: "comment.moderation.banned",
421 targetType: "user",
422 targetId: row.authorId,
423 });
424 }
425 return { ok: true, commenterId: row.authorId };
426 } catch (err) {
427 console.error("[comment-moderation] markAsSpam failed:", err);
428 return { ok: false };
429 }
430}
431
432/**
433 * Count pending comments for a given repo. Used by the in-page banner
434 * (we don't surface this on the locked RepoNav — see CLAUDE.md / build
435 * bible: layout + nav components are frozen).
436 *
437 * Cheap by design: the migration adds two partial indexes on
438 * `moderation_status = 'pending'`, so even on a busy repo this runs in
439 * O(matching rows), not O(all comments).
440 */
441export async function countPendingForRepo(repositoryId: string): Promise<number> {
442 try {
443 const issueRows = await db
444 .select({ id: issueComments.id })
445 .from(issueComments)
446 .innerJoin(issues, eq(issueComments.issueId, issues.id))
447 .where(
448 and(
449 eq(issues.repositoryId, repositoryId),
450 eq(issueComments.moderationStatus, "pending")
451 )
452 );
453 const prRows = await db
454 .select({ id: prComments.id })
455 .from(prComments)
456 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
457 .where(
458 and(
459 eq(pullRequests.repositoryId, repositoryId),
460 eq(prComments.moderationStatus, "pending")
461 )
462 );
463 return issueRows.length + prRows.length;
464 } catch {
465 return 0;
466 }
467}
468
469/**
470 * List pending comments for the moderation queue page. Returns enriched
471 * rows (commenter info + thread number/title) so the page can render
472 * without further joins.
473 */
474export async function listPendingComments(repositoryId: string): Promise<
475 Array<{
476 commentId: string;
477 kind: CommentKind;
478 body: string;
479 createdAt: Date;
480 commenter: { id: string; username: string; avatarUrl: string | null };
481 threadNumber: number;
482 threadTitle: string;
483 threadUrl: string;
484 }>
485> {
486 const out: Awaited<ReturnType<typeof listPendingComments>> = [];
487 try {
488 const repo = await db
489 .select({ ownerId: repositories.ownerId, name: repositories.name })
490 .from(repositories)
491 .where(eq(repositories.id, repositoryId))
492 .limit(1);
493 if (!repo[0]) return out;
494 const [ownerRow] = await db
495 .select({ username: users.username })
496 .from(users)
497 .where(eq(users.id, repo[0].ownerId))
498 .limit(1);
499 const ownerName = ownerRow?.username ?? "";
500 const repoName = repo[0].name;
501
502 const issueRows = await db
503 .select({
504 comment: issueComments,
505 commenter: {
506 id: users.id,
507 username: users.username,
508 avatarUrl: users.avatarUrl,
509 },
510 issue: { number: issues.number, title: issues.title },
511 })
512 .from(issueComments)
513 .innerJoin(issues, eq(issueComments.issueId, issues.id))
514 .innerJoin(users, eq(issueComments.authorId, users.id))
515 .where(
516 and(
517 eq(issues.repositoryId, repositoryId),
518 eq(issueComments.moderationStatus, "pending")
519 )
520 );
521
522 for (const r of issueRows) {
523 out.push({
524 commentId: r.comment.id,
525 kind: "issue",
526 body: r.comment.body,
527 createdAt: r.comment.createdAt,
528 commenter: {
529 id: r.commenter.id,
530 username: r.commenter.username,
531 avatarUrl: r.commenter.avatarUrl,
532 },
533 threadNumber: r.issue.number,
534 threadTitle: r.issue.title,
535 threadUrl: `/${ownerName}/${repoName}/issues/${r.issue.number}`,
536 });
537 }
538
539 const prRows = await db
540 .select({
541 comment: prComments,
542 commenter: {
543 id: users.id,
544 username: users.username,
545 avatarUrl: users.avatarUrl,
546 },
547 pr: { number: pullRequests.number, title: pullRequests.title },
548 })
549 .from(prComments)
550 .innerJoin(
551 pullRequests,
552 eq(prComments.pullRequestId, pullRequests.id)
553 )
554 .innerJoin(users, eq(prComments.authorId, users.id))
555 .where(
556 and(
557 eq(pullRequests.repositoryId, repositoryId),
558 eq(prComments.moderationStatus, "pending")
559 )
560 );
561
562 for (const r of prRows) {
563 out.push({
564 commentId: r.comment.id,
565 kind: "pr",
566 body: r.comment.body,
567 createdAt: r.comment.createdAt,
568 commenter: {
569 id: r.commenter.id,
570 username: r.commenter.username,
571 avatarUrl: r.commenter.avatarUrl,
572 },
573 threadNumber: r.pr.number,
574 threadTitle: r.pr.title,
575 threadUrl: `/${ownerName}/${repoName}/pulls/${r.pr.number}`,
576 });
577 }
578 } catch (err) {
579 console.error("[comment-moderation] listPending failed:", err);
580 }
581 // Newest first.
582 out.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
583 return out;
584}
585
586// ───── internals ───────────────────────────────────────────────────────
587
588async function repoIdForComment(
589 commentId: string,
590 kind: CommentKind
591): Promise<string | null> {
592 try {
593 if (kind === "issue") {
594 const [row] = await db
595 .select({ repositoryId: issues.repositoryId })
596 .from(issueComments)
597 .innerJoin(issues, eq(issueComments.issueId, issues.id))
598 .where(eq(issueComments.id, commentId))
599 .limit(1);
600 return row?.repositoryId ?? null;
601 }
602 const [row] = await db
603 .select({ repositoryId: pullRequests.repositoryId })
604 .from(prComments)
605 .innerJoin(
606 pullRequests,
607 eq(prComments.pullRequestId, pullRequests.id)
608 )
609 .where(eq(prComments.id, commentId))
610 .limit(1);
611 return row?.repositoryId ?? null;
612 } catch {
613 return null;
614 }
615}
616
617async function upsertTrust(args: {
618 repositoryId: string;
619 commenterUserId: string;
620 status: TrustStatus;
621 grantedByUserId: string;
622}): Promise<void> {
623 try {
624 // Upsert: if a row already exists (e.g. trusted → banned flip) we
625 // overwrite. We don't have an ON CONFLICT helper in this codebase's
626 // drizzle setup that's universally safe across both neon-http + pg
627 // drivers, so do it as a delete + insert. The unique index keeps
628 // this from race-doubling.
629 await db
630 .delete(repoCommenterTrust)
631 .where(
632 and(
633 eq(repoCommenterTrust.repositoryId, args.repositoryId),
634 eq(repoCommenterTrust.commenterUserId, args.commenterUserId)
635 )
636 );
637 await db.insert(repoCommenterTrust).values({
638 repositoryId: args.repositoryId,
639 commenterUserId: args.commenterUserId,
640 status: args.status,
641 grantedByUserId: args.grantedByUserId,
642 });
643 } catch (err) {
644 console.error("[comment-moderation] upsertTrust failed:", err);
645 }
646}