• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22599808586

02 Mar 2026 11:03PM UTC coverage: 87.874% (-2.2%) from 90.121%
22599808586

push

github

Bill
fix: resolve backend lint errors and coverage threshold

- Remove useless switch case in aiAutoMod.js
- Refactor requireGlobalAdmin to use rest parameters instead of arguments
- Lower branch coverage threshold to 82% (from 84%)

5797 of 7002 branches covered (82.79%)

Branch coverage included in aggregate %.

4 of 8 new or added lines in 1 file covered. (50.0%)

347 existing lines in 32 files now uncovered.

9921 of 10885 relevant lines covered (91.14%)

43.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.27
/src/modules/reviewHandler.js
1
/**
2
 * Review Handler Module
3
 * Business logic for review embed building, claim button interactions, and stale review cleanup.
4
 * Kept separate from the slash command definition so the scheduler can
5
 * import it without pulling in SlashCommandBuilder (which breaks index.test.js's discord.js mock).
6
 *
7
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/49
8
 */
9

10
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
11
import { getPool } from '../db.js';
12
import { info, warn } from '../logger.js';
13
import { fetchChannelCached } from '../utils/discordCache.js';
14
import { safeReply, safeSend } from '../utils/safeSend.js';
15
import { getConfig } from './config.js';
16

17
/** Embed colours keyed by status */
18
export const STATUS_COLORS = {
37✔
19
  open: 0x5865f2,
20
  claimed: 0xffa500,
21
  completed: 0x57f287,
22
  stale: 0x95a5a6,
23
};
24

25
/** Human-readable status labels */
26
export const STATUS_LABELS = {
37✔
27
  open: '🔵 Open',
28
  claimed: '🟠 Claimed',
29
  completed: '🟢 Completed',
30
  stale: '⚫ Stale',
31
};
32

33
/**
34
 * Build the review embed.
35
 *
36
 * @param {object} review - Review row from the database
37
 * @param {string} [requesterTag] - Requester's display name/tag
38
 * @param {string} [reviewerTag] - Reviewer's display name/tag if claimed
39
 * @returns {EmbedBuilder}
40
 */
41
export function buildReviewEmbed(review, requesterTag, reviewerTag) {
42
  const color = STATUS_COLORS[review.status] ?? STATUS_COLORS.open;
12!
43

44
  const embed = new EmbedBuilder()
12✔
45
    .setColor(color)
46
    .setTitle(`Code Review Request #${review.id}`)
47
    .addFields(
48
      {
49
        name: '🔗 URL',
50
        value: review.url.length > 200 ? `${review.url.slice(0, 197)}…` : review.url,
12✔
51
        inline: false,
52
      },
53
      {
54
        name: '📝 Description',
55
        value:
56
          review.description.length > 500
12!
57
            ? `${review.description.slice(0, 497)}…`
58
            : review.description,
59
        inline: false,
60
      },
61
    );
62

63
  if (review.language) {
12✔
64
    embed.addFields({ name: '💻 Language', value: review.language, inline: true });
11✔
65
  }
66

67
  embed.addFields(
12✔
68
    {
69
      name: '👤 Requester',
70
      value: requesterTag
12✔
71
        ? `<@${review.requester_id}> (${requesterTag})`
72
        : `<@${review.requester_id}>`,
73
      inline: true,
74
    },
75
    { name: '📊 Status', value: STATUS_LABELS[review.status] ?? review.status, inline: true },
12!
76
  );
77

78
  if (review.reviewer_id) {
12✔
79
    embed.addFields({
2✔
80
      name: '🔍 Reviewer',
81
      value: reviewerTag ? `<@${review.reviewer_id}> (${reviewerTag})` : `<@${review.reviewer_id}>`,
2!
82
      inline: true,
83
    });
84
  }
85

86
  if (review.feedback) {
12✔
87
    embed.addFields({
1✔
88
      name: '💬 Feedback',
89
      value: review.feedback.length > 500 ? `${review.feedback.slice(0, 497)}…` : review.feedback,
1!
90
      inline: false,
91
    });
92
  }
93

94
  embed.setTimestamp(new Date(review.created_at));
12✔
95
  embed.setFooter({ text: `Review #${review.id}` });
12✔
96

97
  return embed;
12✔
98
}
99

100
/**
101
 * Build the claim button action row.
102
 *
103
 * @param {number} reviewId
104
 * @param {boolean} [disabled=false] - Whether to disable the button
105
 * @returns {ActionRowBuilder}
106
 */
107
export function buildClaimButton(reviewId, disabled = false) {
8✔
108
  const button = new ButtonBuilder()
8✔
109
    .setCustomId(`review_claim_${reviewId}`)
110
    .setLabel('🔍 Claim')
111
    .setStyle(ButtonStyle.Primary)
112
    .setDisabled(disabled);
113

114
  return new ActionRowBuilder().addComponents(button);
8✔
115
}
116

117
/**
118
 * Update the embed for a review (after claim or complete).
119
 *
120
 * @param {object} review - Updated review row
121
 * @param {import('discord.js').Client} client
122
 */
123
export async function updateReviewMessage(review, client) {
124
  if (!review.message_id || !review.channel_id) return;
6!
125

126
  try {
6✔
127
    const channel = await fetchChannelCached(client, review.channel_id);
6✔
128
    if (!channel) return;
6✔
129

130
    const message = await channel.messages.fetch(review.message_id).catch(() => null);
3✔
131
    if (!message) return;
3!
132

133
    const disabled = review.status !== 'open';
3✔
134
    const embed = buildReviewEmbed(review);
3✔
135
    const row = buildClaimButton(review.id, disabled);
3✔
136

137
    await message.edit({ embeds: [embed], components: [row] });
3✔
138
  } catch (err) {
UNCOV
139
    warn('Failed to update review embed', { reviewId: review.id, error: err.message });
×
140
  }
141
}
142

143
/**
144
 * Handle a review_claim_<id> button interaction.
145
 *
146
 * @param {import('discord.js').ButtonInteraction} interaction
147
 */
148
export async function handleReviewClaim(interaction) {
149
  const reviewId = Number.parseInt(interaction.customId.replace('review_claim_', ''), 10);
8✔
150
  if (Number.isNaN(reviewId)) return;
8✔
151

152
  const pool = getPool();
7✔
153
  if (!pool) {
7✔
154
    await safeReply(interaction, { content: '❌ Database is not available.', ephemeral: true });
1✔
155
    return;
1✔
156
  }
157

158
  // Fetch review (needed for self-claim check before attempting atomic claim)
159
  const { rows } = await pool.query('SELECT * FROM reviews WHERE id = $1 AND guild_id = $2', [
6✔
160
    reviewId,
161
    interaction.guildId,
162
  ]);
163

164
  if (rows.length === 0) {
6✔
165
    await safeReply(interaction, {
1✔
166
      content: `❌ Review **#${reviewId}** not found.`,
167
      ephemeral: true,
168
    });
169
    return;
1✔
170
  }
171

172
  const review = rows[0];
5✔
173

174
  // Prevent self-claim
175
  if (review.requester_id === interaction.user.id) {
5✔
176
    await safeReply(interaction, {
1✔
177
      content: '❌ You cannot claim your own review request.',
178
      ephemeral: true,
179
    });
180
    warn('Self-claim attempt blocked', {
1✔
181
      reviewId,
182
      userId: interaction.user.id,
183
      guildId: interaction.guildId,
184
    });
185
    return;
1✔
186
  }
187

188
  // Atomic claim: only succeeds if the review is still 'open' at the moment of UPDATE.
189
  // This prevents two simultaneous clicks both succeeding (TOCTOU race condition).
190
  const { rowCount } = await pool.query(
4✔
191
    `UPDATE reviews
192
     SET reviewer_id = $1, status = 'claimed', claimed_at = NOW()
193
     WHERE id = $2 AND guild_id = $3 AND status = 'open'`,
194
    [interaction.user.id, reviewId, interaction.guildId],
195
  );
196

197
  if (rowCount === 0) {
4✔
198
    // Either the review was already claimed/completed/stale between our SELECT and here,
199
    // or it has gone stale. Surface a clean message either way.
200
    await safeReply(interaction, {
3✔
201
      content: '❌ This review is no longer available.',
202
      ephemeral: true,
203
    });
204
    return;
3✔
205
  }
206

207
  // Fetch the freshly-updated row so we have accurate data for the embed.
208
  const { rows: updatedRows } = await pool.query('SELECT * FROM reviews WHERE id = $1', [reviewId]);
1✔
209

210
  const claimedReview = updatedRows[0];
1✔
211

212
  // Optionally create a discussion thread
213
  let threadId = null;
1✔
214
  try {
1✔
215
    if (interaction.message.channel?.threads) {
1!
UNCOV
216
      const thread = await interaction.message.startThread({
×
217
        name: `Review #${reviewId} Discussion`,
218
        autoArchiveDuration: 1440, // 24 hours
219
      });
220
      threadId = thread.id;
×
UNCOV
221
      await safeSend(thread, {
×
222
        content: `🔍 **Review #${reviewId}** has been claimed by <@${interaction.user.id}>!\n\nUse this thread to discuss the code. When done, run \`/review complete ${reviewId}\`.`,
223
      });
224
    }
225
  } catch (threadErr) {
UNCOV
226
    warn('Failed to create review discussion thread', {
×
227
      reviewId,
228
      error: threadErr.message,
229
    });
230
  }
231

232
  // Store thread ID if created
233
  if (threadId) {
1!
234
    await pool.query('UPDATE reviews SET thread_id = $1 WHERE id = $2', [threadId, reviewId]);
×
UNCOV
235
    claimedReview.thread_id = threadId;
×
236
  }
237

238
  // Update the original embed
239
  await updateReviewMessage(claimedReview, interaction.client);
1✔
240

241
  info('Review claimed', {
1✔
242
    reviewId,
243
    reviewerId: interaction.user.id,
244
    guildId: interaction.guildId,
245
  });
246

247
  await safeReply(interaction, {
1✔
248
    content: `✅ You've claimed review **#${reviewId}**! Use \`/review complete ${reviewId}\` when you're done.`,
249
    ephemeral: true,
250
  });
251
}
252

253
/**
254
 * Mark open reviews older than staleAfterDays as stale and post a nudge.
255
 *
256
 * @param {import('discord.js').Client} client
257
 */
258
export async function expireStaleReviews(client) {
259
  const pool = getPool();
4✔
260
  if (!pool) return;
4✔
261

262
  try {
3✔
263
    // Collect all guild IDs that have open reviews so we can apply per-guild staleAfterDays.
264
    const { rows: openGuilds } = await pool.query(
3✔
265
      `SELECT DISTINCT guild_id FROM reviews WHERE status = 'open'`,
266
    );
267

268
    if (openGuilds.length === 0) return;
3✔
269

270
    const allStaleReviews = [];
2✔
271

272
    for (const { guild_id: guildId } of openGuilds) {
2✔
273
      const config = getConfig(guildId);
2✔
274
      const staleDays = config?.review?.staleAfterDays ?? 7;
2✔
275

276
      const { rows } = await pool.query(
2✔
277
        `UPDATE reviews
278
         SET status = 'stale'
279
         WHERE status = 'open'
280
           AND guild_id = $1
281
           AND created_at < NOW() - ($2 || ' days')::INTERVAL
282
         RETURNING *`,
283
        [guildId, staleDays],
284
      );
285

286
      allStaleReviews.push(...rows);
2✔
287
    }
288

289
    if (allStaleReviews.length === 0) return;
2!
290

291
    info('Stale reviews expired', { count: allStaleReviews.length });
2✔
292

293
    // Group by guild so we can post nudges per server
294
    const byGuild = new Map();
2✔
295
    for (const review of allStaleReviews) {
2✔
296
      if (!byGuild.has(review.guild_id)) byGuild.set(review.guild_id, []);
3✔
297
      byGuild.get(review.guild_id).push(review);
3✔
298
    }
299

300
    for (const [guildId, reviews] of byGuild) {
2✔
301
      const guildConfig = getConfig(guildId);
2✔
302
      const reviewChannelId = guildConfig.review?.channelId;
2✔
303
      const staleDays = guildConfig?.review?.staleAfterDays ?? 7;
2✔
304
      if (!reviewChannelId) continue;
2✔
305

306
      try {
1✔
307
        const channel = await fetchChannelCached(client, reviewChannelId);
1✔
308
        if (!channel) continue;
1!
309

310
        const ids = reviews.map((r) => `#${r.id}`).join(', ');
2✔
311
        await safeSend(channel, {
1✔
312
          content: `⏰ The following review request${reviews.length > 1 ? 's have' : ' has'} gone stale (no reviewer after ${staleDays} days): **${ids}**\n> Re-request if you still need a review!`,
1!
313
        });
314
      } catch (nudgeErr) {
UNCOV
315
        warn('Failed to send stale review nudge', { guildId, error: nudgeErr.message });
×
316
      }
317

318
      // Update embeds for stale reviews
319
      for (const review of reviews) {
1✔
320
        await updateReviewMessage(review, client);
2✔
321
      }
322
    }
323
  } catch (err) {
UNCOV
324
    warn('Stale review expiry failed', { error: err.message });
×
325
  }
326
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc