Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitcb5a796unknown_key

feat(comment-moderation): public-repo comments from non-collaborators require author approval

Claude committed on May 25, 2026Parent: d199847
14 files changed+203021cb5a79664a301b3b94438cd03361121854393ef1
14 changed files+2030−21
Addeddrizzle/0066_comment_moderation.sql+86−0View fileUnifiedSplit
1-- Comment moderation (anti-impersonation gate).
2--
3-- Public-repo abuse vector: non-contributors leaving comments to dress up
4-- their activity feed as "I contributed there." Per the platform owner:
5-- "Users will not be allowed to comment on another public repo unless
6-- they have the permission of the author."
7--
8-- This migration introduces:
9--
10-- * `moderation_status` on both `issue_comments` and `pr_comments`,
11-- with companion `moderated_at` / `moderated_by_user_id` audit
12-- columns. Default 'approved' so every existing row stays visible —
13-- only NEW comments from non-collaborators flow into the queue.
14--
15-- * `repo_commenter_trust` — per-repo allow/deny list. A 'trusted'
16-- row makes `shouldRequireApproval` return false (auto-approve);
17-- a 'banned' row makes the moderator's "mark as spam" decision
18-- sticky, so the next comment from that user on that repo also
19-- auto-routes to status='rejected' without bothering the owner.
20--
21-- * Two filtering indexes — one for the owner-facing pending queue
22-- (`/:owner/:repo/comments/pending`) and one for moderator-history
23-- queries.
24--
25-- Strictly additive: no existing rows mutate, every query that hasn't
26-- been taught about the new column continues to work because the
27-- default backfills 'approved' for the legacy population.
28
29ALTER TABLE issue_comments
30 ADD COLUMN IF NOT EXISTS moderation_status text NOT NULL DEFAULT 'approved';
31
32ALTER TABLE issue_comments
33 ADD COLUMN IF NOT EXISTS moderated_at timestamptz;
34
35ALTER TABLE issue_comments
36 ADD COLUMN IF NOT EXISTS moderated_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL;
37
38ALTER TABLE pr_comments
39 ADD COLUMN IF NOT EXISTS moderation_status text NOT NULL DEFAULT 'approved';
40
41ALTER TABLE pr_comments
42 ADD COLUMN IF NOT EXISTS moderated_at timestamptz;
43
44ALTER TABLE pr_comments
45 ADD COLUMN IF NOT EXISTS moderated_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL;
46
47CREATE TABLE IF NOT EXISTS repo_commenter_trust (
48 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
49 repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
50 commenter_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
51 status text NOT NULL,
52 granted_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
53 granted_at timestamptz NOT NULL DEFAULT now()
54);
55
56CREATE UNIQUE INDEX IF NOT EXISTS repo_commenter_trust_unique
57 ON repo_commenter_trust (repository_id, commenter_user_id);
58
59CREATE INDEX IF NOT EXISTS repo_commenter_trust_repo_status
60 ON repo_commenter_trust (repository_id, status);
61
62-- Owner-facing queue: "list every pending comment on issues in MY repo".
63-- A partial index on the pending state keeps this fast even when the
64-- repo has tens of thousands of approved comments.
65CREATE INDEX IF NOT EXISTS issue_comments_pending_status
66 ON issue_comments (moderation_status)
67 WHERE moderation_status = 'pending';
68
69CREATE INDEX IF NOT EXISTS pr_comments_pending_status
70 ON pr_comments (moderation_status)
71 WHERE moderation_status = 'pending';
72
73-- Moderator history queries — "everything user X has actioned, newest
74-- first". Useful for the audit trail and any future moderator-leaderboard.
75CREATE INDEX IF NOT EXISTS issue_comments_moderated_by
76 ON issue_comments (moderated_by_user_id, moderated_at DESC);
77
78CREATE INDEX IF NOT EXISTS pr_comments_moderated_by
79 ON pr_comments (moderated_by_user_id, moderated_at DESC);
80
81-- /settings/notifications toggle — "Pending comment requests". Defaults
82-- ON so a new repo owner doesn't miss queued comments out of the gate.
83-- The notification is always written (so it shows up in /inbox); this
84-- flag gates email/push fan-out only.
85ALTER TABLE users
86 ADD COLUMN IF NOT EXISTS notify_email_on_pending_comment boolean NOT NULL DEFAULT true;
Addedsrc/__tests__/comment-moderation.test.ts+374−0View fileUnifiedSplit
1/**
2 * Comment moderation tests — the anti-impersonation gate.
3 *
4 * Two halves:
5 * 1. Pure-decision tests (`shouldRequireApproval`) — set up a repo
6 * with a fixed owner + a couple of commenters and assert each
7 * branch of the decision matrix (owner / write collab / read-only
8 * stranger / trusted / banned / thread-author).
9 * 2. End-to-end POST → render flow — submit a comment as a stranger
10 * via `app.request`, confirm it lands as 'pending', isn't shown in
11 * the JSON API list, and that the approve flow promotes it.
12 *
13 * Every DB-touching test sits behind `describe.skipIf(!HAS_DB)` so the
14 * suite stays green on machines without Postgres (mirroring the
15 * established pattern across the repo's other test files).
16 */
17
18import { describe, it, expect } from "bun:test";
19
20const HAS_DB = Boolean(process.env.DATABASE_URL);
21
22describe.skipIf(!HAS_DB)("comment-moderation — shouldRequireApproval", () => {
23 it("returns false for the repo owner", async () => {
24 const { shouldRequireApproval } = await import(
25 "../lib/comment-moderation"
26 );
27 const fx = await seed();
28 const r = await shouldRequireApproval({
29 commenterUserId: fx.ownerId,
30 repositoryId: fx.repoId,
31 kind: "issue",
32 threadId: fx.issueId,
33 });
34 expect(r.requireApproval).toBe(false);
35 });
36
37 it("returns false for a write collaborator", async () => {
38 const { shouldRequireApproval } = await import(
39 "../lib/comment-moderation"
40 );
41 const fx = await seed();
42 await addCollaborator(fx.repoId, fx.writerId, "write");
43 const r = await shouldRequireApproval({
44 commenterUserId: fx.writerId,
45 repositoryId: fx.repoId,
46 kind: "issue",
47 threadId: fx.issueId,
48 });
49 expect(r.requireApproval).toBe(false);
50 });
51
52 it("returns true for a read-only stranger on a public repo", async () => {
53 const { shouldRequireApproval } = await import(
54 "../lib/comment-moderation"
55 );
56 const fx = await seed();
57 const r = await shouldRequireApproval({
58 commenterUserId: fx.strangerId,
59 repositoryId: fx.repoId,
60 kind: "issue",
61 threadId: fx.issueId,
62 });
63 expect(r.requireApproval).toBe(true);
64 expect(r.autoReject).toBe(false);
65 });
66
67 it("returns false when the commenter has a 'trusted' trust row", async () => {
68 const { shouldRequireApproval } = await import(
69 "../lib/comment-moderation"
70 );
71 const { db } = await import("../db");
72 const { repoCommenterTrust } = await import("../db/schema");
73 const fx = await seed();
74 await db.insert(repoCommenterTrust).values({
75 repositoryId: fx.repoId,
76 commenterUserId: fx.strangerId,
77 status: "trusted",
78 grantedByUserId: fx.ownerId,
79 });
80 const r = await shouldRequireApproval({
81 commenterUserId: fx.strangerId,
82 repositoryId: fx.repoId,
83 kind: "issue",
84 threadId: fx.issueId,
85 });
86 expect(r.requireApproval).toBe(false);
87 });
88
89 it("returns false when the commenter is the original issue author", async () => {
90 const { shouldRequireApproval } = await import(
91 "../lib/comment-moderation"
92 );
93 const { db } = await import("../db");
94 const { issues } = await import("../db/schema");
95 const fx = await seed();
96 // Open a SECOND issue authored by the stranger themselves so they
97 // are the thread author (the seed issue is opened by `ownerId`).
98 const [strangerIssue] = await db
99 .insert(issues)
100 .values({
101 repositoryId: fx.repoId,
102 authorId: fx.strangerId,
103 title: "stranger-issue",
104 })
105 .returning({ id: issues.id });
106 const r = await shouldRequireApproval({
107 commenterUserId: fx.strangerId,
108 repositoryId: fx.repoId,
109 kind: "issue",
110 threadId: strangerIssue!.id,
111 });
112 expect(r.requireApproval).toBe(false);
113 expect(r.reason).toContain("opened this issue");
114 });
115
116 it("auto-rejects when the commenter is banned", async () => {
117 const { shouldRequireApproval } = await import(
118 "../lib/comment-moderation"
119 );
120 const { db } = await import("../db");
121 const { repoCommenterTrust } = await import("../db/schema");
122 const fx = await seed();
123 await db.insert(repoCommenterTrust).values({
124 repositoryId: fx.repoId,
125 commenterUserId: fx.strangerId,
126 status: "banned",
127 grantedByUserId: fx.ownerId,
128 });
129 const r = await shouldRequireApproval({
130 commenterUserId: fx.strangerId,
131 repositoryId: fx.repoId,
132 kind: "issue",
133 threadId: fx.issueId,
134 });
135 expect(r.requireApproval).toBe(true);
136 expect(r.autoReject).toBe(true);
137 });
138});
139
140describe.skipIf(!HAS_DB)("comment-moderation — POST + render flow", () => {
141 it("inserts a stranger's comment as 'pending' and hides it from the public API", async () => {
142 const app = (await import("../app")).default;
143 const { db } = await import("../db");
144 const { issueComments, sessions } = await import("../db/schema");
145 const { eq } = await import("drizzle-orm");
146 const { randomBytes } = await import("crypto");
147
148 const fx = await seed();
149 // Mint a stranger session.
150 const token = randomBytes(32).toString("hex");
151 await db.insert(sessions).values({
152 userId: fx.strangerId,
153 token,
154 expiresAt: new Date(Date.now() + 60_000),
155 });
156
157 const res = await app.request(
158 `/${fx.ownerUsername}/${fx.repoName}/issues/${fx.issueNumber}/comment`,
159 {
160 method: "POST",
161 headers: {
162 cookie: `session=${token}`,
163 "content-type": "application/x-www-form-urlencoded",
164 },
165 body: new URLSearchParams({ body: "Hi from a stranger!" }),
166 }
167 );
168 // Hono's c.redirect → 302
169 expect([200, 302]).toContain(res.status);
170
171 const inserted = await db
172 .select()
173 .from(issueComments)
174 .where(eq(issueComments.authorId, fx.strangerId));
175 expect(inserted.length).toBe(1);
176 expect(inserted[0]!.moderationStatus).toBe("pending");
177
178 // Public API hides pending.
179 const apiRes = await app.request(
180 `/api/v2/repos/${fx.ownerUsername}/${fx.repoName}/issues/${fx.issueNumber}`
181 );
182 if (apiRes.status === 200) {
183 const json = (await apiRes.json()) as { comments: unknown[] };
184 expect(Array.isArray(json.comments)).toBe(true);
185 // The stranger's comment must not leak.
186 const bodies = (json.comments as Array<{ body: string }>).map(
187 (c) => c.body
188 );
189 expect(bodies.includes("Hi from a stranger!")).toBe(false);
190 }
191 });
192
193 it("approve flow flips status to 'approved' and notifies the author", async () => {
194 const { approveComment } = await import("../lib/comment-moderation");
195 const { db } = await import("../db");
196 const { issueComments, notifications } = await import("../db/schema");
197 const { eq, and } = await import("drizzle-orm");
198
199 const fx = await seed();
200 // Seed a pending comment from the stranger directly.
201 const [c] = await db
202 .insert(issueComments)
203 .values({
204 issueId: fx.issueId,
205 authorId: fx.strangerId,
206 body: "Approve me please",
207 moderationStatus: "pending",
208 })
209 .returning({ id: issueComments.id });
210
211 const res = await approveComment({
212 commentId: c!.id,
213 kind: "issue",
214 moderatorUserId: fx.ownerId,
215 });
216 expect(res.ok).toBe(true);
217
218 const [after] = await db
219 .select({ status: issueComments.moderationStatus })
220 .from(issueComments)
221 .where(eq(issueComments.id, c!.id));
222 expect(after!.status).toBe("approved");
223
224 // Notification fired.
225 const notif = await db
226 .select()
227 .from(notifications)
228 .where(
229 and(
230 eq(notifications.userId, fx.strangerId),
231 eq(notifications.kind, "comment.approved")
232 )
233 );
234 expect(notif.length).toBeGreaterThanOrEqual(1);
235 });
236
237 it("markAsSpam flow inserts a 'banned' trust row and auto-rejects next comment", async () => {
238 const {
239 markAsSpam,
240 decideInitialStatus,
241 } = await import("../lib/comment-moderation");
242 const { db } = await import("../db");
243 const { issueComments, repoCommenterTrust } = await import("../db/schema");
244 const { eq, and } = await import("drizzle-orm");
245
246 const fx = await seed();
247 const [c] = await db
248 .insert(issueComments)
249 .values({
250 issueId: fx.issueId,
251 authorId: fx.strangerId,
252 body: "Spammy stuff",
253 moderationStatus: "pending",
254 })
255 .returning({ id: issueComments.id });
256
257 const res = await markAsSpam({
258 commentId: c!.id,
259 kind: "issue",
260 moderatorUserId: fx.ownerId,
261 });
262 expect(res.ok).toBe(true);
263
264 // Banned trust row exists.
265 const trust = await db
266 .select({ status: repoCommenterTrust.status })
267 .from(repoCommenterTrust)
268 .where(
269 and(
270 eq(repoCommenterTrust.repositoryId, fx.repoId),
271 eq(repoCommenterTrust.commenterUserId, fx.strangerId)
272 )
273 );
274 expect(trust.length).toBe(1);
275 expect(trust[0]!.status).toBe("banned");
276
277 // Next comment auto-rejects.
278 const decision = await decideInitialStatus({
279 commenterUserId: fx.strangerId,
280 repositoryId: fx.repoId,
281 kind: "issue",
282 threadId: fx.issueId,
283 });
284 expect(decision.status).toBe("rejected");
285 });
286});
287
288// ─── seed helpers ─────────────────────────────────────────────────────
289
290async function seed(): Promise<{
291 ownerId: string;
292 ownerUsername: string;
293 writerId: string;
294 strangerId: string;
295 repoId: string;
296 repoName: string;
297 issueId: string;
298 issueNumber: number;
299}> {
300 const { db } = await import("../db");
301 const { users, repositories, issues } = await import("../db/schema");
302 const { randomBytes } = await import("crypto");
303 const tag = randomBytes(4).toString("hex");
304
305 const [owner] = await db
306 .insert(users)
307 .values({
308 username: `modq_owner_${tag}`,
309 email: `modq_owner_${tag}@test.local`,
310 passwordHash: "x",
311 })
312 .returning({ id: users.id, username: users.username });
313 const [writer] = await db
314 .insert(users)
315 .values({
316 username: `modq_writer_${tag}`,
317 email: `modq_writer_${tag}@test.local`,
318 passwordHash: "x",
319 })
320 .returning({ id: users.id });
321 const [stranger] = await db
322 .insert(users)
323 .values({
324 username: `modq_stranger_${tag}`,
325 email: `modq_stranger_${tag}@test.local`,
326 passwordHash: "x",
327 })
328 .returning({ id: users.id });
329 const [repo] = await db
330 .insert(repositories)
331 .values({
332 ownerId: owner!.id,
333 name: `modq-repo-${tag}`,
334 diskPath: `/tmp/modq/${tag}`,
335 defaultBranch: "main",
336 isPrivate: false,
337 })
338 .returning({ id: repositories.id, name: repositories.name });
339 const [issue] = await db
340 .insert(issues)
341 .values({
342 repositoryId: repo!.id,
343 authorId: owner!.id,
344 title: "Seed issue for moderation tests",
345 })
346 .returning({ id: issues.id, number: issues.number });
347
348 return {
349 ownerId: owner!.id,
350 ownerUsername: owner!.username,
351 writerId: writer!.id,
352 strangerId: stranger!.id,
353 repoId: repo!.id,
354 repoName: repo!.name,
355 issueId: issue!.id,
356 issueNumber: issue!.number,
357 };
358}
359
360async function addCollaborator(
361 repoId: string,
362 userId: string,
363 role: "read" | "write" | "admin"
364): Promise<void> {
365 const { db } = await import("../db");
366 const { repoCollaborators } = await import("../db/schema");
367 await db.insert(repoCollaborators).values({
368 repositoryId: repoId,
369 userId,
370 role,
371 invitedBy: userId,
372 acceptedAt: new Date(),
373 });
374}
Modifiedsrc/app.tsx+7−0View fileUnifiedSplit
2828import integrationsChatRoutes from "./routes/integrations-chat";
2929import agentsRoutes from "./routes/agents";
3030import issueRoutes from "./routes/issues";
31import commentModerationRoutes from "./routes/comment-moderation";
3132import repoSettings from "./routes/repo-settings";
3233import collaboratorRoutes from "./routes/collaborators";
3334import teamCollaboratorRoutes from "./routes/team-collaborators";
447448// Issue tracker
448449app.route("/", issueRoutes);
449450
451// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
452// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
453// the `/:owner/:repo/comments/*` paths resolve before the broader PR
454// patterns kick in.
455app.route("/", commentModerationRoutes);
456
450457// Pull requests
451458app.route("/", pullRoutes);
452459// PR sandboxes — runnable per-PR environments. Migration 0067.
Modifiedsrc/db/schema.ts+64−0View fileUnifiedSplit
6767 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
6868 // Block I7 — weekly digest opt-in.
6969 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
70 // Drizzle/0066 — Pending comment moderation requests. Defaults ON so
71 // a new repo owner is alerted as soon as the first comment lands in
72 // the queue. The in-app notification is always written regardless;
73 // this controls fan-out (currently the audit/notification surface,
74 // future email).
75 notifyEmailOnPendingComment: boolean("notify_email_on_pending_comment")
76 .default(true)
77 .notNull(),
7078 lastDigestSentAt: timestamp("last_digest_sent_at"),
7179 // Block L1 — Sleep Mode. When enabled, the autopilot sleep-mode-digest
7280 // task delivers a daily "what Claude shipped overnight" report at the
576584 .notNull()
577585 .references(() => users.id),
578586 body: text("body").notNull(),
587 // Comment moderation (drizzle/0066). 'approved' | 'pending' | 'rejected'
588 // | 'spam'. Default 'approved' keeps every legacy + collaborator path
589 // unchanged; the gate in `lib/comment-moderation.ts` only routes new
590 // non-collaborator comments into 'pending'.
591 moderationStatus: text("moderation_status").notNull().default("approved"),
592 moderatedAt: timestamp("moderated_at", { withTimezone: true }),
593 moderatedByUserId: uuid("moderated_by_user_id").references(() => users.id, {
594 onDelete: "set null",
595 }),
579596 createdAt: timestamp("created_at").defaultNow().notNull(),
580597 updatedAt: timestamp("updated_at").defaultNow().notNull(),
581598 },
659676 isAiReview: boolean("is_ai_review").default(false).notNull(),
660677 filePath: text("file_path"),
661678 lineNumber: integer("line_number"),
679 // Comment moderation (drizzle/0066). See `issueComments.moderationStatus`.
680 moderationStatus: text("moderation_status").notNull().default("approved"),
681 moderatedAt: timestamp("moderated_at", { withTimezone: true }),
682 moderatedByUserId: uuid("moderated_by_user_id").references(() => users.id, {
683 onDelete: "set null",
684 }),
662685 createdAt: timestamp("created_at").defaultNow().notNull(),
663686 updatedAt: timestamp("updated_at").defaultNow().notNull(),
664687 },
665688 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
666689);
667690
691/**
692 * Comment moderation (drizzle/0066) — per-repo allow/deny list for
693 * commenters. A 'trusted' row makes `shouldRequireApproval` short-circuit
694 * to false; a 'banned' row makes it short-circuit to "auto-reject without
695 * notifying the owner" so a single spam decision sticks.
696 *
697 * Indexed by (repository_id, commenter_user_id) for the per-comment gate
698 * lookup and by (repository_id, status) for the owner's trust-list page.
699 */
700export const repoCommenterTrust = pgTable(
701 "repo_commenter_trust",
702 {
703 id: uuid("id").primaryKey().defaultRandom(),
704 repositoryId: uuid("repository_id")
705 .notNull()
706 .references(() => repositories.id, { onDelete: "cascade" }),
707 commenterUserId: uuid("commenter_user_id")
708 .notNull()
709 .references(() => users.id, { onDelete: "cascade" }),
710 status: text("status").notNull(), // 'trusted' | 'banned'
711 grantedByUserId: uuid("granted_by_user_id").references(() => users.id, {
712 onDelete: "set null",
713 }),
714 grantedAt: timestamp("granted_at", { withTimezone: true })
715 .defaultNow()
716 .notNull(),
717 },
718 (table) => [
719 uniqueIndex("repo_commenter_trust_unique").on(
720 table.repositoryId,
721 table.commenterUserId
722 ),
723 index("repo_commenter_trust_repo_status").on(
724 table.repositoryId,
725 table.status
726 ),
727 ]
728);
729
668730export const activityFeed = pgTable(
669731 "activity_feed",
670732 {
818880export type Label = typeof labels.$inferSelect;
819881export type PullRequest = typeof pullRequests.$inferSelect;
820882export type PrComment = typeof prComments.$inferSelect;
883export type RepoCommenterTrust = typeof repoCommenterTrust.$inferSelect;
884export type NewRepoCommenterTrust = typeof repoCommenterTrust.$inferInsert;
821885export type ActivityEntry = typeof activityFeed.$inferSelect;
822886export type Webhook = typeof webhooks.$inferSelect;
823887export type ApiToken = typeof apiTokens.$inferSelect;
Addedsrc/lib/comment-moderation.ts+646−0View fileUnifiedSplit
1/**
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}
Modifiedsrc/routes/api-v2.ts+16−2View fileUnifiedSplit
991991
992992 if (!issue) return c.json({ error: "Issue not found" }, 404);
993993
994 // Only return approved comments through the public API surface —
995 // pending/rejected/spam should never leak via JSON. Moderation
996 // queue lives at the HTML `/comments/pending` page (owner only).
994997 const comments = await db
995998 .select({
996999 comment: issueComments,
9981001 })
9991002 .from(issueComments)
10001003 .innerJoin(users, eq(issueComments.authorId, users.id))
1001 .where(eq(issueComments.issueId, issue.id))
1004 .where(
1005 and(
1006 eq(issueComments.issueId, issue.id),
1007 eq(issueComments.moderationStatus, "approved")
1008 )
1009 )
10021010 .orderBy(asc(issueComments.createdAt));
10031011
10041012 return c.json({
11431151
11441152 if (!pr) return c.json({ error: "PR not found" }, 404);
11451153
1154 // Only return approved comments through the public API surface.
11461155 const comments = await db
11471156 .select({
11481157 comment: prComments,
11501159 })
11511160 .from(prComments)
11521161 .innerJoin(users, eq(prComments.authorId, users.id))
1153 .where(eq(prComments.pullRequestId, pr.id))
1162 .where(
1163 and(
1164 eq(prComments.pullRequestId, pr.id),
1165 eq(prComments.moderationStatus, "approved")
1166 )
1167 )
11541168 .orderBy(asc(prComments.createdAt));
11551169
11561170 return c.json({
Addedsrc/routes/comment-moderation.tsx+469−0View fileUnifiedSplit
1/**
2 * Comment moderation queue UI.
3 *
4 * Owner-only page at `/:owner/:repo/comments/pending` that lists every
5 * comment from a non-collaborator awaiting approval, with per-row
6 * Approve / Reject / Mark spam buttons plus a "trust this user" tickbox
7 * (alongside Approve) that promotes the commenter to the per-repo
8 * trust list so future comments auto-approve.
9 *
10 * Bulk-action toolbar: "Reject all" / "Mark all as spam" applied to
11 * every checked row. The single-row buttons are POST forms; the
12 * bulk submitter is a single form whose checkboxes carry the comment
13 * ids and `kind:<commentId>` mapping so the handler knows which table
14 * each id lives in.
15 *
16 * All styling is scoped under `.modq-*` per the build bible's "no
17 * touching shared components" rule.
18 */
19
20import { Hono } from "hono";
21import { eq } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import {
30 listPendingComments,
31 approveComment,
32 rejectComment,
33 markAsSpam,
34 type CommentKind,
35} from "../lib/comment-moderation";
36
37const moderationRoutes = new Hono<AuthEnv>();
38
39const QUEUE_STYLES = `
40 .modq-shell {
41 max-width: 920px;
42 margin: 18px auto 60px;
43 padding: 0 16px;
44 }
45 .modq-head {
46 margin: 12px 0 18px;
47 padding: 18px 22px;
48 border-radius: 14px;
49 background: linear-gradient(135deg, rgba(245, 191, 79, 0.10), rgba(140, 109, 255, 0.06));
50 border: 1px solid var(--border, #2a2f3a);
51 }
52 .modq-head h1 {
53 margin: 0 0 6px 0;
54 font-size: 22px;
55 font-weight: 700;
56 color: var(--text-strong, #fff);
57 letter-spacing: -0.01em;
58 }
59 .modq-head p {
60 margin: 0;
61 color: var(--text-muted, #9aa4b2);
62 font-size: 14px;
63 line-height: 1.5;
64 }
65 .modq-empty {
66 margin: 32px 0;
67 padding: 40px 22px;
68 text-align: center;
69 border-radius: 12px;
70 border: 1px dashed var(--border, #2a2f3a);
71 color: var(--text-muted, #9aa4b2);
72 }
73 .modq-empty strong { color: var(--text-strong, #fff); display: block; margin-bottom: 6px; }
74 .modq-bulkbar {
75 display: flex;
76 align-items: center;
77 gap: 10px;
78 margin: 12px 0;
79 padding: 10px 14px;
80 border-radius: 10px;
81 background: var(--bg-elevated, #161b22);
82 border: 1px solid var(--border, #2a2f3a);
83 font-size: 13px;
84 }
85 .modq-bulkbar-left { flex: 1 1 auto; color: var(--text-muted, #9aa4b2); }
86 .modq-list { display: flex; flex-direction: column; gap: 12px; }
87 .modq-row {
88 padding: 14px 16px;
89 border-radius: 12px;
90 background: var(--bg-elevated, #161b22);
91 border: 1px solid var(--border, #2a2f3a);
92 }
93 .modq-row-head {
94 display: flex;
95 align-items: center;
96 gap: 10px;
97 margin-bottom: 10px;
98 font-size: 13px;
99 color: var(--text-muted, #9aa4b2);
100 }
101 .modq-avatar {
102 width: 24px; height: 24px;
103 border-radius: 50%;
104 background: var(--bg, #0d1117);
105 border: 1px solid var(--border, #2a2f3a);
106 display: inline-flex; align-items: center; justify-content: center;
107 font-weight: 700; font-size: 11px;
108 color: var(--text-strong, #fff);
109 overflow: hidden;
110 }
111 .modq-avatar img { width: 100%; height: 100%; object-fit: cover; }
112 .modq-username { color: var(--text-strong, #fff); font-weight: 600; }
113 .modq-target { color: var(--accent, #8c6dff); text-decoration: none; }
114 .modq-target:hover { text-decoration: underline; }
115 .modq-body {
116 padding: 10px 12px;
117 background: var(--bg, #0d1117);
118 border-radius: 8px;
119 margin-bottom: 12px;
120 font-size: 13.5px;
121 white-space: pre-wrap;
122 word-break: break-word;
123 color: var(--text, #e6edf3);
124 max-height: 220px;
125 overflow: auto;
126 }
127 .modq-actions {
128 display: flex;
129 align-items: center;
130 flex-wrap: wrap;
131 gap: 10px;
132 }
133 .modq-btn {
134 appearance: none;
135 border: 1px solid var(--border, #2a2f3a);
136 background: var(--bg, #0d1117);
137 color: var(--text, #e6edf3);
138 border-radius: 6px;
139 padding: 6px 12px;
140 font-size: 13px;
141 font-weight: 600;
142 cursor: pointer;
143 text-decoration: none;
144 display: inline-flex;
145 align-items: center;
146 gap: 6px;
147 }
148 .modq-btn:hover { border-color: var(--text-muted, #9aa4b2); }
149 .modq-btn-approve {
150 color: #56d364;
151 border-color: rgba(86, 211, 100, 0.45);
152 background: rgba(86, 211, 100, 0.08);
153 }
154 .modq-btn-approve:hover { background: rgba(86, 211, 100, 0.16); }
155 .modq-btn-reject {
156 color: #f5bf4f;
157 border-color: rgba(245, 191, 79, 0.45);
158 background: rgba(245, 191, 79, 0.08);
159 }
160 .modq-btn-reject:hover { background: rgba(245, 191, 79, 0.16); }
161 .modq-btn-spam {
162 color: #f85149;
163 border-color: rgba(248, 81, 73, 0.45);
164 background: rgba(248, 81, 73, 0.08);
165 }
166 .modq-btn-spam:hover { background: rgba(248, 81, 73, 0.16); }
167 .modq-trust {
168 margin-left: auto;
169 display: inline-flex;
170 align-items: center;
171 gap: 6px;
172 font-size: 12.5px;
173 color: var(--text-muted, #9aa4b2);
174 }
175 .modq-trust input { transform: translateY(1px); }
176 .modq-rowcheck {
177 margin-right: 6px;
178 transform: translateY(2px);
179 }
180 .modq-inlineform { display: inline; }
181`;
182
183// ---------------------------------------------------------------------------
184// GET /:owner/:repo/comments/pending — render the queue
185// ---------------------------------------------------------------------------
186
187moderationRoutes.get(
188 "/:owner/:repo/comments/pending",
189 softAuth,
190 requireAuth,
191 requireRepoAccess("read"),
192 async (c) => {
193 const { owner: ownerName, repo: repoName } = c.req.param();
194 const user = c.get("user")!;
195 const repository = c.get("repository") as {
196 id: string;
197 ownerId: string;
198 };
199
200 // Owner-only. Collaborators with write access can comment freely
201 // but the moderation decision is the OWNER's call — see the platform
202 // owner's verbatim brief in `comment-moderation.ts`.
203 if (repository.ownerId !== user.id) {
204 return c.html(
205 <Layout title="Forbidden" user={user}>
206 <div style="max-width: 600px; margin: 80px auto; padding: 24px; text-align: center;">
207 <h1>403 — Moderator only</h1>
208 <p>Only the repository owner can review pending comments.</p>
209 </div>
210 </Layout>,
211 403
212 );
213 }
214
215 const pending = await listPendingComments(repository.id);
216
217 return c.html(
218 <Layout title={`Pending comments — ${ownerName}/${repoName}`} user={user}>
219 <RepoHeader owner={ownerName} repo={repoName} />
220 <style dangerouslySetInnerHTML={{ __html: QUEUE_STYLES }} />
221 <div class="modq-shell">
222 <div class="modq-head">
223 <h1>Comment moderation</h1>
224 <p>
225 Non-collaborators on public repositories can leave a comment,
226 but it stays hidden until you approve it. This stops drive-by
227 comments that exist only to make someone look like a contributor.
228 </p>
229 </div>
230
231 {pending.length === 0 ? (
232 <div class="modq-empty">
233 <strong>Inbox zero.</strong>
234 No pending comments for {ownerName}/{repoName}.
235 </div>
236 ) : (
237 <form
238 method="post"
239 action={`/${ownerName}/${repoName}/comments/pending/bulk`}
240 >
241 <div class="modq-bulkbar">
242 <div class="modq-bulkbar-left">
243 {pending.length} comment{pending.length === 1 ? "" : "s"}{" "}
244 awaiting your decision. Tick rows to bulk-action.
245 </div>
246 <button
247 type="submit"
248 name="bulk_action"
249 value="reject"
250 class="modq-btn modq-btn-reject"
251 >
252 Reject checked
253 </button>
254 <button
255 type="submit"
256 name="bulk_action"
257 value="spam"
258 class="modq-btn modq-btn-spam"
259 >
260 Mark checked as spam
261 </button>
262 </div>
263
264 <div class="modq-list">
265 {pending.map((row) => (
266 <article class="modq-row">
267 <header class="modq-row-head">
268 <input
269 type="checkbox"
270 name="comment_ids"
271 value={`${row.kind}:${row.commentId}`}
272 class="modq-rowcheck"
273 aria-label={`Select comment by ${row.commenter.username}`}
274 />
275 <span class="modq-avatar" aria-hidden="true">
276 {row.commenter.avatarUrl ? (
277 <img src={row.commenter.avatarUrl} alt="" />
278 ) : (
279 row.commenter.username.slice(0, 1).toUpperCase()
280 )}
281 </span>
282 <span class="modq-username">{row.commenter.username}</span>
283 <span>commented on</span>
284 <a class="modq-target" href={row.threadUrl}>
285 {row.kind === "issue" ? "issue" : "PR"} #
286 {row.threadNumber} — {row.threadTitle}
287 </a>
288 </header>
289 <div class="modq-body">{row.body}</div>
290 <div class="modq-actions">
291 <form
292 method="post"
293 action={`/${ownerName}/${repoName}/comments/${row.kind}/${row.commentId}/approve`}
294 class="modq-inlineform"
295 >
296 <button type="submit" class="modq-btn modq-btn-approve">
297 Approve
298 </button>
299 <label class="modq-trust" title="Future comments from this user on this repo will be auto-approved.">
300 <input
301 type="checkbox"
302 name="trust"
303 value="1"
304 />
305 Trust this user
306 </label>
307 </form>
308 <form
309 method="post"
310 action={`/${ownerName}/${repoName}/comments/${row.kind}/${row.commentId}/reject`}
311 class="modq-inlineform"
312 >
313 <button type="submit" class="modq-btn modq-btn-reject">
314 Reject
315 </button>
316 </form>
317 <form
318 method="post"
319 action={`/${ownerName}/${repoName}/comments/${row.kind}/${row.commentId}/spam`}
320 class="modq-inlineform"
321 >
322 <button type="submit" class="modq-btn modq-btn-spam">
323 Mark spam
324 </button>
325 </form>
326 </div>
327 </article>
328 ))}
329 </div>
330 </form>
331 )}
332 </div>
333 </Layout>
334 );
335 }
336);
337
338// ---------------------------------------------------------------------------
339// Per-row actions: approve / reject / spam
340// ---------------------------------------------------------------------------
341
342function ownerGate(
343 c: any
344): { user: { id: string }; repository: { id: string; ownerId: string } } | Response {
345 const user = c.get("user")! as { id: string };
346 const repository = c.get("repository") as { id: string; ownerId: string };
347 if (repository.ownerId !== user.id) {
348 return c.text("Forbidden", 403);
349 }
350 return { user, repository };
351}
352
353moderationRoutes.post(
354 "/:owner/:repo/comments/:kind/:commentId/approve",
355 softAuth,
356 requireAuth,
357 requireRepoAccess("read"),
358 async (c) => {
359 const gate = ownerGate(c);
360 if (gate instanceof Response) return gate;
361 const kind = c.req.param("kind") as CommentKind;
362 if (kind !== "issue" && kind !== "pr") return c.notFound();
363 const commentId = c.req.param("commentId");
364 const body = await c.req.parseBody().catch(() => ({}) as any);
365 const alsoTrust = String(body.trust || "") === "1";
366 await approveComment({
367 commentId,
368 kind,
369 moderatorUserId: gate.user.id,
370 alsoTrust,
371 });
372 const { owner, repo } = c.req.param();
373 return c.redirect(`/${owner}/${repo}/comments/pending`);
374 }
375);
376
377moderationRoutes.post(
378 "/:owner/:repo/comments/:kind/:commentId/reject",
379 softAuth,
380 requireAuth,
381 requireRepoAccess("read"),
382 async (c) => {
383 const gate = ownerGate(c);
384 if (gate instanceof Response) return gate;
385 const kind = c.req.param("kind") as CommentKind;
386 if (kind !== "issue" && kind !== "pr") return c.notFound();
387 const commentId = c.req.param("commentId");
388 const body = await c.req.parseBody().catch(() => ({}) as any);
389 const reason = String(body.reason || "").trim() || undefined;
390 await rejectComment({
391 commentId,
392 kind,
393 moderatorUserId: gate.user.id,
394 reason,
395 });
396 const { owner, repo } = c.req.param();
397 return c.redirect(`/${owner}/${repo}/comments/pending`);
398 }
399);
400
401moderationRoutes.post(
402 "/:owner/:repo/comments/:kind/:commentId/spam",
403 softAuth,
404 requireAuth,
405 requireRepoAccess("read"),
406 async (c) => {
407 const gate = ownerGate(c);
408 if (gate instanceof Response) return gate;
409 const kind = c.req.param("kind") as CommentKind;
410 if (kind !== "issue" && kind !== "pr") return c.notFound();
411 const commentId = c.req.param("commentId");
412 await markAsSpam({
413 commentId,
414 kind,
415 moderatorUserId: gate.user.id,
416 });
417 const { owner, repo } = c.req.param();
418 return c.redirect(`/${owner}/${repo}/comments/pending`);
419 }
420);
421
422// Bulk action — reject or spam every checked row.
423moderationRoutes.post(
424 "/:owner/:repo/comments/pending/bulk",
425 softAuth,
426 requireAuth,
427 requireRepoAccess("read"),
428 async (c) => {
429 const gate = ownerGate(c);
430 if (gate instanceof Response) return gate;
431 const { owner, repo } = c.req.param();
432 const body = await c.req.parseBody({ all: true });
433 const action = String(body.bulk_action || "");
434 const idsRaw = body.comment_ids;
435 const ids = Array.isArray(idsRaw)
436 ? idsRaw.map(String)
437 : idsRaw
438 ? [String(idsRaw)]
439 : [];
440
441 for (const compound of ids) {
442 const [kind, commentId] = compound.split(":");
443 if (kind !== "issue" && kind !== "pr") continue;
444 if (!commentId) continue;
445 if (action === "spam") {
446 await markAsSpam({
447 commentId,
448 kind: kind as CommentKind,
449 moderatorUserId: gate.user.id,
450 });
451 } else if (action === "reject") {
452 await rejectComment({
453 commentId,
454 kind: kind as CommentKind,
455 moderatorUserId: gate.user.id,
456 });
457 }
458 }
459 return c.redirect(`/${owner}/${repo}/comments/pending`);
460 }
461);
462
463// Silence unused-import noise from drizzle helpers we may not call here.
464void eq;
465void db;
466void repositories;
467void users;
468
469export default moderationRoutes;
Modifiedsrc/routes/inbox.tsx+12−2View fileUnifiedSplit
553553 })
554554 .from(prComments)
555555 .innerJoin(pullRequests, eq(pullRequests.id, prComments.pullRequestId))
556 .where(sql`${prComments.body} ILIKE ${needle}`)
556 .where(
557 and(
558 sql`${prComments.body} ILIKE ${needle}`,
559 eq(prComments.moderationStatus, "approved")
560 )
561 )
557562 .orderBy(desc(prComments.createdAt))
558563 .limit(50);
559564 const repoIds = Array.from(new Set(prRows.map((r) => r.repoId)));
588593 })
589594 .from(issueComments)
590595 .innerJoin(issues, eq(issues.id, issueComments.issueId))
591 .where(sql`${issueComments.body} ILIKE ${needle}`)
596 .where(
597 and(
598 sql`${issueComments.body} ILIKE ${needle}`,
599 eq(issueComments.moderationStatus, "approved")
600 )
601 )
592602 .orderBy(desc(issueComments.createdAt))
593603 .limit(50);
594604 const repoIds = Array.from(new Set(issueRows.map((r) => r.repoId)));
Modifiedsrc/routes/issues-dashboard.tsx+8−1View fileUnifiedSplit
459459 }
460460
461461 try {
462 // Only approved comments count toward the public-facing dashboard
463 // tally — see drizzle/0066 and src/lib/comment-moderation.ts.
462464 const countRows = await db
463465 .select({ c: sql<number>`count(*)::int` })
464466 .from(issueComments)
465 .where(eq(issueComments.issueId, issueId));
467 .where(
468 and(
469 eq(issueComments.issueId, issueId),
470 eq(issueComments.moderationStatus, "approved")
471 )
472 );
466473 commentCount = Number(countRows[0]?.c ?? 0);
467474 } catch {
468475 /* skip */
Modifiedsrc/routes/issues.tsx+99−9View fileUnifiedSplit
1515} from "../db/schema";
1616import { Layout } from "../views/layout";
1717import { RepoHeader, RepoNav } from "../views/components";
18import { PendingCommentsBanner } from "../views/pending-comments-banner";
1819import { ReactionsBar } from "../views/reactions";
1920import { summariseReactions } from "../lib/reactions";
2021import { loadIssueTemplate } from "../lib/templates";
2425import { softAuth, requireAuth } from "../middleware/auth";
2526import type { AuthEnv } from "../middleware/auth";
2627import { requireRepoAccess } from "../middleware/repo-access";
28import {
29 decideInitialStatus,
30 notifyOwnerOfPendingComment,
31 countPendingForRepo,
32} from "../lib/comment-moderation";
2733import {
2834 Flex,
2935 Container,
700706 .from(issues)
701707 .where(eq(issues.repositoryId, repo.id));
702708
709 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
710 const pendingCountList = viewerIsOwnerOnList
711 ? await countPendingForRepo(repo.id)
712 : 0;
713
703714 return c.html(
704715 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
705716 <IssuesStyle />
706717 <RepoHeader owner={ownerName} repo={repoName} />
707718 <IssueNav owner={ownerName} repo={repoName} active="issues" />
719 <PendingCommentsBanner
720 owner={ownerName}
721 repo={repoName}
722 count={pendingCountList}
723 />
708724 <section class="issues-hero">
709725 <div class="issues-hero-bg" aria-hidden="true">
710726 <div class="issues-hero-orb" />
10121028 .where(eq(users.id, issue.authorId))
10131029 .limit(1);
10141030
1015 // Get comments
1016 const comments = await db
1031 // Get comments. We pull every row and filter by moderation_status +
1032 // viewer identity client-side (in the JSX below) so a moderator/owner
1033 // sees them all while non-author viewers only ever see 'approved'.
1034 const allComments = await db
10171035 .select({
10181036 comment: issueComments,
1019 author: { username: users.username },
1037 author: { id: users.id, username: users.username },
10201038 })
10211039 .from(issueComments)
10221040 .innerJoin(users, eq(issueComments.authorId, users.id))
10231041 .where(eq(issueComments.issueId, issue.id))
10241042 .orderBy(asc(issueComments.createdAt));
10251043
1044 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1045 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1046 // Owner always sees everything (so they can spot conversations going
1047 // sideways even before they hit the queue page).
1048 if (viewerIsOwner) return true;
1049 if (comment.moderationStatus === "approved") return true;
1050 // The comment's own author sees their pending comment so they know
1051 // it landed (with an "Awaiting approval" badge in the render below).
1052 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1053 return true;
1054 }
1055 return false;
1056 });
1057
1058 // Pending banner — when the viewer is the repo owner, show a strip at
1059 // the top of the page nudging them to the moderation queue. Inline on
1060 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1061 const pendingCount = viewerIsOwner
1062 ? await countPendingForRepo(resolved.repo.id)
1063 : 0;
1064
10261065 // Load reactions for the issue + each comment in parallel.
10271066 const [issueReactions, ...commentReactions] = await Promise.all([
10281067 summariseReactions("issue", issue.id, user?.id),
10441083 <IssuesStyle />
10451084 <RepoHeader owner={ownerName} repo={repoName} />
10461085 <IssueNav owner={ownerName} repo={repoName} active="issues" />
1086 <PendingCommentsBanner
1087 owner={ownerName}
1088 repo={repoName}
1089 count={pendingCount}
1090 />
10471091 <div
10481092 id="live-comment-banner"
10491093 class="alert"
11211165
11221166 {comments.map(({ comment, author: commentAuthor }) => {
11231167 const isAi = isAiTriageComment(comment.body);
1168 const isPending = comment.moderationStatus === "pending";
11241169 return (
1125 <article class={`issues-comment${isAi ? " is-ai" : ""}`}>
1170 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
11261171 <header class="issues-comment-header">
11271172 <strong>{commentAuthor.username}</strong>
11281173 {isAi ? (
11301175 AI Review
11311176 </span>
11321177 ) : null}
1178 {isPending ? (
1179 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1180 Awaiting approval
1181 </span>
1182 ) : null}
11331183 <span>commented {formatRelative(comment.createdAt)}</span>
11341184 </header>
11351185 <div class="issues-comment-body">
12021252 );
12031253});
12041254
1205// Add comment
1255// Add comment.
1256//
1257// Permission model: we accept comments from ANY authenticated user (read
1258// access required — public repos satisfy that, private repos still need a
1259// collaborator row). The moderation gate in `decideInitialStatus`
1260// determines whether the comment is published immediately or queued for
1261// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1262// "anti-impersonation" rationale.
12061263issueRoutes.post(
12071264 "/:owner/:repo/issues/:number/comment",
12081265 softAuth,
12091266 requireAuth,
1210 requireRepoAccess("write"),
1267 requireRepoAccess("read"),
12111268 async (c) => {
12121269 const { owner: ownerName, repo: repoName } = c.req.param();
12131270 const issueNum = parseInt(c.req.param("number"), 10);
12371294
12381295 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
12391296
1297 // Decide moderation status BEFORE insert so the row lands in the right
1298 // initial state. Collaborators, the issue author, and trusted users
1299 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1300 // else gets 'pending'.
1301 const decision = await decideInitialStatus({
1302 commenterUserId: user.id,
1303 repositoryId: resolved.repo.id,
1304 kind: "issue",
1305 threadId: issue.id,
1306 });
1307
12401308 const [inserted] = await db
12411309 .insert(issueComments)
12421310 .values({
12431311 issueId: issue.id,
12441312 authorId: user.id,
12451313 body: commentBody,
1314 moderationStatus: decision.status,
12461315 })
12471316 .returning();
12481317
1249 // Live update: nudge any browser tabs subscribed to this issue. Pure
1250 // fanout — never blocks the redirect, never throws into the request.
1251 if (inserted) {
1318 // Live update only when visible. Don't leak pending/rejected via SSE.
1319 if (inserted && decision.status === "approved") {
12521320 try {
12531321 const { publish } = await import("../lib/sse");
12541322 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
12651333 }
12661334 }
12671335
1336 if (decision.status === "pending") {
1337 // Notify repo owner that a comment is awaiting review.
1338 void notifyOwnerOfPendingComment({
1339 repositoryId: resolved.repo.id,
1340 commenterUsername: user.username,
1341 kind: "issue",
1342 threadNumber: issueNum,
1343 ownerUsername: ownerName,
1344 repoName,
1345 });
1346 return c.redirect(
1347 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1348 );
1349 }
1350 if (decision.status === "rejected") {
1351 // Silent ban — the commenter is on the banned list. Don't reveal
1352 // the gate; just send them back to the page as if it posted.
1353 return c.redirect(
1354 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1355 );
1356 }
1357
12681358 return c.redirect(
12691359 `/${ownerName}/${repoName}/issues/${issueNum}`
12701360 );
Modifiedsrc/routes/pulls.tsx+93−7View fileUnifiedSplit
2626} from "../db/schema";
2727import { Layout } from "../views/layout";
2828import { RepoHeader } from "../views/components";
29import { PendingCommentsBanner } from "../views/pending-comments-banner";
2930import { DiffView } from "../views/diff-view";
3031import { ReactionsBar } from "../views/reactions";
3132import { summariseReactions } from "../lib/reactions";
4142import { softAuth, requireAuth } from "../middleware/auth";
4243import type { AuthEnv } from "../middleware/auth";
4344import { requireRepoAccess } from "../middleware/repo-access";
45import {
46 decideInitialStatus,
47 notifyOwnerOfPendingComment,
48 countPendingForRepo,
49} from "../lib/comment-moderation";
4450import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
4551import {
4652 TRIO_COMMENT_MARKER,
17271733 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
17281734 ];
17291735 const isAllState = state === "all";
1736 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1737 const prListPendingCount = viewerIsOwnerOnPrList
1738 ? await countPendingForRepo(resolved.repo.id)
1739 : 0;
17301740
17311741 return c.html(
17321742 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
17331743 <RepoHeader owner={ownerName} repo={repoName} />
17341744 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1745 <PendingCommentsBanner
1746 owner={ownerName}
1747 repo={repoName}
1748 count={prListPendingCount}
1749 />
17351750 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
17361751
17371752 <div class="prs-hero">
21632178 .where(eq(users.id, pr.authorId))
21642179 .limit(1);
21652180
2166 const comments = await db
2181 const allCommentsRaw = await db
21672182 .select({
21682183 comment: prComments,
2169 author: { username: users.username },
2184 author: { id: users.id, username: users.username },
21702185 })
21712186 .from(prComments)
21722187 .innerJoin(users, eq(prComments.authorId, users.id))
21732188 .where(eq(prComments.pullRequestId, pr.id))
21742189 .orderBy(asc(prComments.createdAt));
21752190
2191 // Filter pending/rejected/spam for non-owner, non-author viewers.
2192 // Owner always sees everything; comment author sees their own pending
2193 // with an "Awaiting approval" badge in the render below.
2194 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2195 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2196 if (viewerIsRepoOwner) return true;
2197 if (comment.moderationStatus === "approved") return true;
2198 if (
2199 user &&
2200 cAuthor.id === user.id &&
2201 comment.moderationStatus === "pending"
2202 ) {
2203 return true;
2204 }
2205 return false;
2206 });
2207 const prPendingCount = viewerIsRepoOwner
2208 ? await countPendingForRepo(resolved.repo.id)
2209 : 0;
2210
21762211 // Reactions for the PR body + each comment, in parallel.
21772212 const [prReactions, ...prCommentReactions] = await Promise.all([
21782213 summariseReactions("pr", pr.id, user?.id),
23252360 >
23262361 <RepoHeader owner={ownerName} repo={repoName} />
23272362 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2363 <PendingCommentsBanner
2364 owner={ownerName}
2365 repo={repoName}
2366 count={prPendingCount}
2367 />
23282368 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
23292369 <div
23302370 id="live-comment-banner"
24782518 </div>
24792519 );
24802520 }
2521 const isPending = comment.moderationStatus === "pending";
24812522 return (
2482 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
2523 <div
2524 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
2525 >
24832526 <div class="prs-comment-head">
24842527 <strong>{commentAuthor.username}</strong>
24852528 {comment.isAiReview && (
24862529 <span class="prs-ai-badge">AI Review</span>
24872530 )}
2531 {isPending && (
2532 <span
2533 class="modq-pending-badge"
2534 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
2535 >
2536 Awaiting approval
2537 </span>
2538 )}
24882539 <span class="prs-comment-time">
24892540 commented {formatRelative(comment.createdAt)}
24902541 </span>
27662817 );
27672818});
27682819
2769// Add comment to PR
2820// Add comment to PR.
2821//
2822// Permission model mirrors `issues.tsx`: any logged-in user with read
2823// access can submit; `decideInitialStatus` routes non-collaborators
2824// through the moderation queue. Slash commands only fire when the
2825// comment is auto-approved — we don't want a banned/pending comment to
2826// silently trigger AI work on the PR.
27702827pulls.post(
27712828 "/:owner/:repo/pulls/:number/comment",
27722829 softAuth,
27732830 requireAuth,
2774 requireRepoAccess("write"),
2831 requireRepoAccess("read"),
27752832 async (c) => {
27762833 const { owner: ownerName, repo: repoName } = c.req.param();
27772834 const prNum = parseInt(c.req.param("number"), 10);
27992856
28002857 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
28012858
2859 const decision = await decideInitialStatus({
2860 commenterUserId: user.id,
2861 repositoryId: resolved.repo.id,
2862 kind: "pr",
2863 threadId: pr.id,
2864 });
2865
28022866 const [inserted] = await db
28032867 .insert(prComments)
28042868 .values({
28052869 pullRequestId: pr.id,
28062870 authorId: user.id,
28072871 body: commentBody,
2872 moderationStatus: decision.status,
28082873 })
28092874 .returning();
28102875
2811 // Live update: nudge any browser tabs subscribed to this PR.
2812 if (inserted) {
2876 // Live update: only when the comment is actually visible.
2877 if (inserted && decision.status === "approved") {
28132878 try {
28142879 const { publish } = await import("../lib/sse");
28152880 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
28262891 }
28272892 }
28282893
2894 if (decision.status === "pending") {
2895 void notifyOwnerOfPendingComment({
2896 repositoryId: resolved.repo.id,
2897 commenterUsername: user.username,
2898 kind: "pr",
2899 threadNumber: prNum,
2900 ownerUsername: ownerName,
2901 repoName,
2902 });
2903 return c.redirect(
2904 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2905 );
2906 }
2907 if (decision.status === "rejected") {
2908 // Silent ban path — same UX as 'pending' so we don't leak the gate.
2909 return c.redirect(
2910 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2911 );
2912 }
2913
28292914 // Slash-command handoff. We always store the original comment above
28302915 // first so free-form text that happens to start with `/` is preserved
28312916 // verbatim; only recognised commands trigger a follow-up bot comment.
2917 // (Only reachable when decision.status === 'approved'.)
28322918 const parsed = parseSlashCommand(commentBody);
28332919 if (parsed) {
28342920 try {
Modifiedsrc/routes/settings.tsx+18−0View fileUnifiedSplit
17781778 </span>
17791779 </span>
17801780 </label>
1781 <label class="notifset-rule">
1782 <input
1783 type="checkbox"
1784 name="notify_email_on_pending_comment"
1785 value="1"
1786 checked={user.notifyEmailOnPendingComment}
1787 aria-label="Someone leaves a comment on a public repo you own and it needs your approval"
1788 />
1789 <span class="notifset-rule-text">
1790 Pending comment requests on my repos
1791 <span class="notifset-rule-hint">
1792 A non-collaborator left a comment on a public repo you
1793 own. Comments stay hidden until you review them.
1794 </span>
1795 </span>
1796 </label>
17811797 </div>
17821798 </section>
17831799
19241940 String(body.notify_email_on_gate_fail || "") === "1",
19251941 notifyEmailDigestWeekly:
19261942 String(body.notify_email_digest_weekly || "") === "1",
1943 notifyEmailOnPendingComment:
1944 String(body.notify_email_on_pending_comment || "") === "1",
19271945 sleepModeEnabled: String(body.sleep_mode_enabled || "") === "1",
19281946 sleepModeDigestHourUtc: hour,
19291947 // Block M2 — per-event push preferences.
Modifiedsrc/routes/web.tsx+30−0View fileUnifiedSplit
1515 commitVerifications,
1616} from "../db/schema";
1717import { Layout } from "../views/layout";
18import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
1819import {
1920 RepoHeader,
2021 RepoNav,
23022303 description: null as string | null,
23032304 pushedAt: null as Date | null,
23042305 createdAt: null as Date | null,
2306 repoId: null as string | null,
2307 repoOwnerId: null as string | null,
23052308 };
23062309 const [repoRow] = await db
23072310 .select()
23232326 description: null as string | null,
23242327 pushedAt: null as Date | null,
23252328 createdAt: null as Date | null,
2329 repoId: null as string | null,
2330 repoOwnerId: null as string | null,
23262331 };
23272332 let starred = false;
23282333 if (user) {
23472352 description: repoRow.description as string | null,
23482353 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
23492354 createdAt: (repoRow.createdAt as Date | null) ?? null,
2355 repoId: repoRow.id as string,
2356 repoOwnerId: repoRow.ownerId as string,
23502357 };
23512358 } catch {
23522359 return {
23582365 description: null as string | null,
23592366 pushedAt: null as Date | null,
23602367 createdAt: null as Date | null,
2368 repoId: null as string | null,
2369 repoOwnerId: null as string | null,
23612370 };
23622371 }
23632372 })(),
23712380 description,
23722381 pushedAt,
23732382 createdAt,
2383 repoId,
2384 repoOwnerId,
23742385 } = starInfo;
23752386
2387 // Pending-comments banner data (lazy + best-effort). Only the repo
2388 // owner sees the banner, so non-owner views skip the DB hit entirely.
2389 let repoHomePendingCount = 0;
2390 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2391 try {
2392 const { countPendingForRepo } = await import(
2393 "../lib/comment-moderation"
2394 );
2395 repoHomePendingCount = await countPendingForRepo(repoId);
2396 } catch {
2397 /* swallow */
2398 }
2399 }
2400
23762401 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
23772402 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
23782403 const repoHomeCss = `
28912916 </div>
28922917 )}
28932918 <RepoNav owner={owner} repo={repo} active="code" />
2919 <RepoHomePendingBanner
2920 owner={owner}
2921 repo={repo}
2922 count={repoHomePendingCount}
2923 />
28942924 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
28952925 row sits just below the nav as a slim CTA strip. Scoped under
28962926 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
Addedsrc/views/pending-comments-banner.tsx+108−0View fileUnifiedSplit
1/**
2 * Pending-comments banner — surfaced inline on repo pages (issues list,
3 * issue detail, PR list, PR detail, repo home) when the viewer is the
4 * repo owner AND there are 1+ comments awaiting moderation.
5 *
6 * We can't add this to `RepoNav` because that component is locked per
7 * the build bible. Living here as a thin functional component lets each
8 * page wrapper drop it in below the nav with a single import.
9 *
10 * Styling is scoped under `.modq-banner-*` so it can't bleed into other
11 * surfaces. CSS injected inline once per render — the styles are <1KB
12 * and the duplicate-injection cost is irrelevant given how rare the
13 * banner appears (only on owner-viewed pages with pending items).
14 */
15
16const styles = `
17 .modq-banner {
18 display: flex;
19 align-items: center;
20 gap: 14px;
21 margin: 12px 0 18px;
22 padding: 12px 16px;
23 border-radius: 10px;
24 background: linear-gradient(135deg, rgba(245, 191, 79, 0.14), rgba(220, 130, 47, 0.10));
25 border: 1px solid rgba(245, 191, 79, 0.45);
26 color: var(--text, #e6edf3);
27 font-size: 14px;
28 line-height: 1.4;
29 }
30 .modq-banner-icon {
31 flex: 0 0 auto;
32 width: 28px;
33 height: 28px;
34 border-radius: 50%;
35 background: rgba(245, 191, 79, 0.20);
36 display: inline-flex;
37 align-items: center;
38 justify-content: center;
39 font-weight: 700;
40 color: #f5bf4f;
41 }
42 .modq-banner-text { flex: 1 1 auto; }
43 .modq-banner-text strong { color: var(--text-strong, #fff); }
44 .modq-banner-action {
45 flex: 0 0 auto;
46 padding: 6px 14px;
47 border-radius: 6px;
48 background: rgba(245, 191, 79, 0.18);
49 color: #f5bf4f;
50 border: 1px solid rgba(245, 191, 79, 0.45);
51 text-decoration: none;
52 font-weight: 600;
53 font-size: 13px;
54 }
55 .modq-banner-action:hover {
56 background: rgba(245, 191, 79, 0.30);
57 text-decoration: none;
58 }
59 .modq-pending-badge {
60 display: inline-block;
61 margin-left: 6px;
62 padding: 2px 8px;
63 border-radius: 9999px;
64 background: rgba(245, 191, 79, 0.18);
65 border: 1px solid rgba(245, 191, 79, 0.45);
66 color: #f5bf4f;
67 font-size: 11px;
68 font-weight: 600;
69 letter-spacing: 0.02em;
70 }
71 .modq-comment-pending {
72 border-left: 3px solid rgba(245, 191, 79, 0.55) !important;
73 background: rgba(245, 191, 79, 0.04);
74 }
75`;
76
77export function PendingCommentsBanner({
78 owner,
79 repo,
80 count,
81}: {
82 owner: string;
83 repo: string;
84 count: number;
85}) {
86 if (!count || count <= 0) return null;
87 return (
88 <>
89 <style dangerouslySetInnerHTML={{ __html: styles }} />
90 <div class="modq-banner" role="status" aria-live="polite">
91 <span class="modq-banner-icon" aria-hidden="true">!</span>
92 <span class="modq-banner-text">
93 <strong>
94 {count} comment{count === 1 ? "" : "s"}
95 </strong>{" "}
96 from non-collaborators {count === 1 ? "is" : "are"} awaiting your
97 approval. Review them before they go public.
98 </span>
99 <a
100 class="modq-banner-action"
101 href={`/${owner}/${repo}/comments/pending`}
102 >
103 Review queue
104 </a>
105 </div>
106 </>
107 );
108}
0109