diff --git a/config.json b/config.json index 8fd03cad..51a29ef4 100644 --- a/config.json +++ b/config.json @@ -151,6 +151,7 @@ "rank": "everyone", "leaderboard": "everyone", "profile": "everyone", + "review": "everyone", "showcase": "everyone" } }, @@ -204,5 +205,11 @@ "announceChannelId": null, "levelThresholds": [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000], "roleRewards": {} + }, + "review": { + "enabled": false, + "channelId": null, + "staleAfterDays": 7, + "xpReward": 50 } } \ No newline at end of file diff --git a/migrations/010_reviews.cjs b/migrations/010_reviews.cjs new file mode 100644 index 00000000..3edb105f --- /dev/null +++ b/migrations/010_reviews.cjs @@ -0,0 +1,49 @@ +/** + * Migration 010 — Code Review Requests + * Creates the reviews table for the /review command. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/49 + */ + +'use strict'; + +/** + * @param {import('pg').Pool} pool + */ +async function up(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS reviews ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + requester_id TEXT NOT NULL, + reviewer_id TEXT, + url TEXT NOT NULL, + description TEXT NOT NULL, + language TEXT, + status TEXT DEFAULT 'open' CHECK (status IN ('open', 'claimed', 'completed', 'stale')), + message_id TEXT, + channel_id TEXT, + thread_id TEXT, + feedback TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + claimed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_reviews_guild ON reviews(guild_id); + CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(guild_id, status); + `); +} + +/** + * @param {import('pg').Pool} pool + */ +async function down(pool) { + await pool.query(` + DROP INDEX IF EXISTS idx_reviews_status; + DROP INDEX IF EXISTS idx_reviews_guild; + DROP TABLE IF EXISTS reviews; + `); +} + +module.exports = { up, down }; diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index f60dcfa2..5d009ec2 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -23,6 +23,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'reputation', 'engagement', 'github', + 'review', ]); export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging']; diff --git a/src/commands/review.js b/src/commands/review.js new file mode 100644 index 00000000..6ac18a42 --- /dev/null +++ b/src/commands/review.js @@ -0,0 +1,356 @@ +/** + * Review Command + * Peer code review request system — request, claim, and complete code reviews. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/49 + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, warn } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { + buildClaimButton, + buildReviewEmbed, + STATUS_LABELS, + updateReviewMessage, +} from '../modules/reviewHandler.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('review') + .setDescription('Request, claim, and complete peer code reviews') + .addSubcommand((sub) => + sub + .setName('request') + .setDescription('Request a code review') + .addStringOption((opt) => + opt + .setName('url') + .setDescription('URL to the code (PR, gist, GitHub, etc.)') + .setRequired(true), + ) + .addStringOption((opt) => + opt + .setName('description') + .setDescription('What should the reviewer focus on?') + .setRequired(true), + ) + .addStringOption((opt) => + opt + .setName('language') + .setDescription('Programming language (e.g. JavaScript, Python)') + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List review requests') + .addStringOption((opt) => + opt + .setName('status') + .setDescription('Filter by status (default: open)') + .setRequired(false) + .addChoices( + { name: 'Open', value: 'open' }, + { name: 'Claimed', value: 'claimed' }, + { name: 'Completed', value: 'completed' }, + { name: 'Stale', value: 'stale' }, + { name: 'All', value: 'all' }, + ), + ), + ) + .addSubcommand((sub) => + sub + .setName('complete') + .setDescription('Mark a review as completed') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Review ID to complete').setRequired(true), + ) + .addStringOption((opt) => + opt + .setName('feedback') + .setDescription('Optional feedback for the requester') + .setRequired(false), + ), + ); + +/** + * Execute the /review command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const guildConfig = getConfig(interaction.guildId); + if (!guildConfig.review?.enabled) { + await safeEditReply(interaction, { + content: '❌ Code reviews are not enabled on this server.', + }); + return; + } + + const pool = getPool(); + if (!pool) { + await safeEditReply(interaction, { content: '❌ Database is not available.' }); + return; + } + + if (!interaction.guildId) { + await safeEditReply(interaction, { content: '❌ This command can only be used in a server.' }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'request') { + await handleRequest(interaction, pool, guildConfig); + } else if (subcommand === 'list') { + await handleList(interaction, pool); + } else if (subcommand === 'complete') { + await handleComplete(interaction, pool, guildConfig); + } +} + +/** + * Basic URL validity check. + * + * @param {string} str + * @returns {boolean} + */ +function isValidUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } +} + +/** + * Handle /review request + */ +async function handleRequest(interaction, pool, guildConfig) { + const url = interaction.options.getString('url'); + const description = interaction.options.getString('description'); + const language = interaction.options.getString('language'); + + if (!isValidUrl(url)) { + await safeEditReply(interaction, { + content: + '❌ The URL you provided is not valid. Please provide a full URL (e.g. `https://github.com/...`).', + }); + return; + } + + const { rows } = await pool.query( + `INSERT INTO reviews (guild_id, requester_id, url, description, language) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [interaction.guildId, interaction.user.id, url, description, language ?? null], + ); + + const review = rows[0]; + + // Determine where to post the review embed + const reviewChannelId = guildConfig.review?.channelId; + let targetChannel = interaction.channel; + + if (reviewChannelId && reviewChannelId !== interaction.channelId) { + try { + const fetched = await interaction.client.channels.fetch(reviewChannelId); + if (fetched) targetChannel = fetched; + } catch { + warn('Review channel not found, using current channel', { + reviewChannelId, + guildId: interaction.guildId, + }); + } + } + + const embed = buildReviewEmbed(review, interaction.user.username); + const row = buildClaimButton(review.id); + + const message = await targetChannel.send({ embeds: [embed], components: [row] }); + + // Store message + channel reference for later updates + await pool.query('UPDATE reviews SET message_id = $1, channel_id = $2 WHERE id = $3', [ + message.id, + targetChannel.id, + review.id, + ]); + + info('Review request created', { + reviewId: review.id, + guildId: interaction.guildId, + requesterId: interaction.user.id, + language, + }); + + await safeEditReply(interaction, { + content: `✅ Review request **#${review.id}** posted! Someone will claim it soon.`, + }); +} + +/** + * Handle /review list + */ +async function handleList(interaction, pool) { + const statusFilter = interaction.options.getString('status') ?? 'open'; + + let query; + let params; + + if (statusFilter === 'all') { + query = `SELECT * FROM reviews WHERE guild_id = $1 ORDER BY created_at DESC LIMIT 20`; + params = [interaction.guildId]; + } else { + query = `SELECT * FROM reviews WHERE guild_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 20`; + params = [interaction.guildId, statusFilter]; + } + + const { rows } = await pool.query(query, params); + + if (rows.length === 0) { + const label = statusFilter === 'all' ? '' : ` **${statusFilter}**`; + await safeEditReply(interaction, { + content: `📭 No${label} review requests found in this server.`, + }); + return; + } + + const statusLabel = + statusFilter === 'all' ? 'All' : (STATUS_LABELS[statusFilter] ?? statusFilter); + const header = `🔍 **Review Requests — ${statusLabel} (${rows.length})**\n\n`; + const lines = []; + let totalLen = header.length; + + for (const row of rows) { + const age = Math.floor( + (Date.now() - new Date(row.created_at).getTime()) / (1000 * 60 * 60 * 24), + ); + const ageStr = age === 0 ? 'today' : age === 1 ? '1 day ago' : `${age} days ago`; + const urlSnip = row.url.length > 50 ? `${row.url.slice(0, 47)}…` : row.url; + const langStr = row.language ? ` · ${row.language}` : ''; + const reviewerStr = row.reviewer_id ? ` · reviewer: <@${row.reviewer_id}>` : ''; + const line = `**#${row.id}** — <@${row.requester_id}>${langStr} · ${STATUS_LABELS[row.status] ?? row.status} · ${ageStr}${reviewerStr}\n> ${urlSnip}`; + + if (totalLen + line.length + 2 > 1900) { + lines.push(`… and ${rows.length - lines.length} more`); + break; + } + lines.push(line); + totalLen += line.length + 2; + } + + await safeEditReply(interaction, { + content: `${header}${lines.join('\n\n')}`, + }); +} + +/** + * Handle /review complete + */ +async function handleComplete(interaction, pool, guildConfig) { + const reviewId = interaction.options.getInteger('id'); + const feedback = interaction.options.getString('feedback'); + + const { rows } = await pool.query('SELECT * FROM reviews WHERE id = $1 AND guild_id = $2', [ + reviewId, + interaction.guildId, + ]); + + if (rows.length === 0) { + await safeEditReply(interaction, { + content: `❌ No review with ID **#${reviewId}** found in this server.`, + }); + return; + } + + const review = rows[0]; + + // Guard on status before checking reviewer — gives more actionable error messages. + if (review.status === 'open') { + await safeEditReply(interaction, { + content: `❌ Review **#${reviewId}** hasn't been claimed yet.`, + }); + return; + } + + if (review.status === 'completed') { + await safeEditReply(interaction, { + content: `❌ Review **#${reviewId}** is already completed.`, + }); + return; + } + + if (review.status === 'stale') { + await safeEditReply(interaction, { + content: `❌ Review **#${reviewId}** has expired.`, + }); + return; + } + + if (review.reviewer_id !== interaction.user.id) { + await safeEditReply(interaction, { + content: '❌ Only the assigned reviewer can complete this review.', + }); + warn('Review complete permission denied', { + userId: interaction.user.id, + reviewId, + reviewerId: review.reviewer_id, + }); + return; + } + + // Update status to completed + const { rows: updated } = await pool.query( + `UPDATE reviews + SET status = 'completed', completed_at = NOW(), feedback = $1 + WHERE id = $2 + RETURNING *`, + [feedback ?? null, reviewId], + ); + + const completedReview = updated[0]; + + // Try to update the original embed + await updateReviewMessage(completedReview, interaction.client); + + // Award XP to reviewer if reputation enabled + const xpReward = guildConfig.review?.xpReward ?? 50; + if (guildConfig.reputation?.enabled && xpReward > 0) { + try { + await pool.query( + `INSERT INTO reputation (guild_id, user_id, xp, messages_count, last_xp_gain) + VALUES ($1, $2, $3, 0, NOW()) + ON CONFLICT (guild_id, user_id) DO UPDATE + SET xp = reputation.xp + $3, + last_xp_gain = NOW()`, + [interaction.guildId, interaction.user.id, xpReward], + ); + info('Review XP awarded', { + reviewerId: interaction.user.id, + guildId: interaction.guildId, + xp: xpReward, + }); + } catch (err) { + warn('Failed to award review XP', { error: err.message, reviewId }); + } + } + + info('Review completed', { + reviewId, + guildId: interaction.guildId, + reviewerId: interaction.user.id, + hasFeedback: !!feedback, + }); + + const xpNote = guildConfig.reputation?.enabled && xpReward > 0 ? ` +${xpReward} XP awarded!` : ''; + + await safeEditReply(interaction, { + content: `✅ Review **#${reviewId}** marked as completed!${xpNote}`, + }); +} diff --git a/src/modules/events.js b/src/modules/events.js index 0c7cb8d5..f85e3352 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -18,6 +18,7 @@ import { checkLinks } from './linkFilter.js'; import { handlePollVote } from './pollHandler.js'; import { checkRateLimit } from './rateLimit.js'; import { handleXpGain } from './reputation.js'; +import { handleReviewClaim } from './reviewHandler.js'; import { isSpam, sendSpamAlert } from './spam.js'; import { handleReactionAdd, handleReactionRemove } from './starboard.js'; import { accumulateMessage, evaluateNow } from './triage.js'; @@ -352,6 +353,44 @@ export function registerPollButtonHandler(client) { }); } +/** + * Register an interactionCreate handler for review claim buttons. + * Listens for button clicks with customId matching `review_claim_`. + * + * @param {Client} client - Discord client instance + */ +export function registerReviewClaimHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('review_claim_')) return; + + // Gate on review feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (!guildConfig.review?.enabled) return; + + try { + await handleReviewClaim(interaction); + } catch (err) { + logError('Review claim handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong processing your claim.', + ephemeral: true, + }); + } catch { + // Ignore — we tried + } + } + } + }); +} + /** * Register an interactionCreate handler for showcase upvote buttons. * Listens for button clicks with customId matching `showcase_upvote_`. @@ -466,6 +505,7 @@ export function registerEventHandlers(client, config, healthMonitor) { registerMessageCreateHandler(client, config, healthMonitor); registerReactionHandlers(client, config); registerPollButtonHandler(client); + registerReviewClaimHandler(client); registerShowcaseButtonHandler(client); registerShowcaseModalHandler(client); registerErrorHandlers(client); diff --git a/src/modules/reviewHandler.js b/src/modules/reviewHandler.js new file mode 100644 index 00000000..4e074afb --- /dev/null +++ b/src/modules/reviewHandler.js @@ -0,0 +1,325 @@ +/** + * Review Handler Module + * Business logic for review embed building, claim button interactions, and stale review cleanup. + * Kept separate from the slash command definition so the scheduler can import it without + * pulling in SlashCommandBuilder (which breaks index.test.js's discord.js mock). + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/49 + */ + +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, warn } from '../logger.js'; +import { safeReply, safeSend } from '../utils/safeSend.js'; +import { getConfig } from './config.js'; + +/** Embed colours keyed by status */ +export const STATUS_COLORS = { + open: 0x5865f2, + claimed: 0xffa500, + completed: 0x57f287, + stale: 0x95a5a6, +}; + +/** Human-readable status labels */ +export const STATUS_LABELS = { + open: '🔵 Open', + claimed: '🟠 Claimed', + completed: '🟢 Completed', + stale: '⚫ Stale', +}; + +/** + * Build the review embed. + * + * @param {object} review - Review row from the database + * @param {string} [requesterTag] - Requester's display name/tag + * @param {string} [reviewerTag] - Reviewer's display name/tag if claimed + * @returns {EmbedBuilder} + */ +export function buildReviewEmbed(review, requesterTag, reviewerTag) { + const color = STATUS_COLORS[review.status] ?? STATUS_COLORS.open; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`Code Review Request #${review.id}`) + .addFields( + { + name: '🔗 URL', + value: review.url.length > 200 ? `${review.url.slice(0, 197)}…` : review.url, + inline: false, + }, + { + name: '📝 Description', + value: + review.description.length > 500 + ? `${review.description.slice(0, 497)}…` + : review.description, + inline: false, + }, + ); + + if (review.language) { + embed.addFields({ name: '💻 Language', value: review.language, inline: true }); + } + + embed.addFields( + { + name: '👤 Requester', + value: requesterTag + ? `<@${review.requester_id}> (${requesterTag})` + : `<@${review.requester_id}>`, + inline: true, + }, + { name: '📊 Status', value: STATUS_LABELS[review.status] ?? review.status, inline: true }, + ); + + if (review.reviewer_id) { + embed.addFields({ + name: '🔍 Reviewer', + value: reviewerTag ? `<@${review.reviewer_id}> (${reviewerTag})` : `<@${review.reviewer_id}>`, + inline: true, + }); + } + + if (review.feedback) { + embed.addFields({ + name: '💬 Feedback', + value: review.feedback.length > 500 ? `${review.feedback.slice(0, 497)}…` : review.feedback, + inline: false, + }); + } + + embed.setTimestamp(new Date(review.created_at)); + embed.setFooter({ text: `Review #${review.id}` }); + + return embed; +} + +/** + * Build the claim button action row. + * + * @param {number} reviewId + * @param {boolean} [disabled=false] - Whether to disable the button + * @returns {ActionRowBuilder} + */ +export function buildClaimButton(reviewId, disabled = false) { + const button = new ButtonBuilder() + .setCustomId(`review_claim_${reviewId}`) + .setLabel('🔍 Claim') + .setStyle(ButtonStyle.Primary) + .setDisabled(disabled); + + return new ActionRowBuilder().addComponents(button); +} + +/** + * Update the embed for a review (after claim or complete). + * + * @param {object} review - Updated review row + * @param {import('discord.js').Client} client + */ +export async function updateReviewMessage(review, client) { + if (!review.message_id || !review.channel_id) return; + + try { + const channel = await client.channels.fetch(review.channel_id).catch(() => null); + if (!channel) return; + + const message = await channel.messages.fetch(review.message_id).catch(() => null); + if (!message) return; + + const disabled = review.status !== 'open'; + const embed = buildReviewEmbed(review); + const row = buildClaimButton(review.id, disabled); + + await message.edit({ embeds: [embed], components: [row] }); + } catch (err) { + warn('Failed to update review embed', { reviewId: review.id, error: err.message }); + } +} + +/** + * Handle a review_claim_ button interaction. + * + * @param {import('discord.js').ButtonInteraction} interaction + */ +export async function handleReviewClaim(interaction) { + const reviewId = Number.parseInt(interaction.customId.replace('review_claim_', ''), 10); + if (Number.isNaN(reviewId)) return; + + const pool = getPool(); + if (!pool) { + await safeReply(interaction, { content: '❌ Database is not available.', ephemeral: true }); + return; + } + + // Fetch review (needed for self-claim check before attempting atomic claim) + const { rows } = await pool.query('SELECT * FROM reviews WHERE id = $1 AND guild_id = $2', [ + reviewId, + interaction.guildId, + ]); + + if (rows.length === 0) { + await safeReply(interaction, { + content: `❌ Review **#${reviewId}** not found.`, + ephemeral: true, + }); + return; + } + + const review = rows[0]; + + // Prevent self-claim + if (review.requester_id === interaction.user.id) { + await safeReply(interaction, { + content: '❌ You cannot claim your own review request.', + ephemeral: true, + }); + warn('Self-claim attempt blocked', { + reviewId, + userId: interaction.user.id, + guildId: interaction.guildId, + }); + return; + } + + // Atomic claim: only succeeds if the review is still 'open' at the moment of UPDATE. + // This prevents two simultaneous clicks both succeeding (TOCTOU race condition). + const { rowCount } = await pool.query( + `UPDATE reviews + SET reviewer_id = $1, status = 'claimed', claimed_at = NOW() + WHERE id = $2 AND guild_id = $3 AND status = 'open'`, + [interaction.user.id, reviewId, interaction.guildId], + ); + + if (rowCount === 0) { + // Either the review was already claimed/completed/stale between our SELECT and here, + // or it has gone stale. Surface a clean message either way. + await safeReply(interaction, { + content: '❌ This review is no longer available.', + ephemeral: true, + }); + return; + } + + // Fetch the freshly-updated row so we have accurate data for the embed. + const { rows: updatedRows } = await pool.query('SELECT * FROM reviews WHERE id = $1', [reviewId]); + + const claimedReview = updatedRows[0]; + + // Optionally create a discussion thread + let threadId = null; + try { + if (interaction.message.channel?.threads) { + const thread = await interaction.message.startThread({ + name: `Review #${reviewId} Discussion`, + autoArchiveDuration: 1440, // 24 hours + }); + threadId = thread.id; + await safeSend(thread, { + content: `🔍 **Review #${reviewId}** has been claimed by <@${interaction.user.id}>!\n\nUse this thread to discuss the code. When done, run \`/review complete ${reviewId}\`.`, + }); + } + } catch (threadErr) { + warn('Failed to create review discussion thread', { + reviewId, + error: threadErr.message, + }); + } + + // Store thread ID if created + if (threadId) { + await pool.query('UPDATE reviews SET thread_id = $1 WHERE id = $2', [threadId, reviewId]); + claimedReview.thread_id = threadId; + } + + // Update the original embed + await updateReviewMessage(claimedReview, interaction.client); + + info('Review claimed', { + reviewId, + reviewerId: interaction.user.id, + guildId: interaction.guildId, + }); + + await safeReply(interaction, { + content: `✅ You've claimed review **#${reviewId}**! Use \`/review complete ${reviewId}\` when you're done.`, + ephemeral: true, + }); +} + +/** + * Mark open reviews older than staleAfterDays as stale and post a nudge. + * + * @param {import('discord.js').Client} client + */ +export async function expireStaleReviews(client) { + const pool = getPool(); + if (!pool) return; + + try { + // Collect all guild IDs that have open reviews so we can apply per-guild staleAfterDays. + const { rows: openGuilds } = await pool.query( + `SELECT DISTINCT guild_id FROM reviews WHERE status = 'open'`, + ); + + if (openGuilds.length === 0) return; + + const allStaleReviews = []; + + for (const { guild_id: guildId } of openGuilds) { + const config = getConfig(guildId); + const staleDays = config?.review?.staleAfterDays ?? 7; + + const { rows } = await pool.query( + `UPDATE reviews + SET status = 'stale' + WHERE status = 'open' + AND guild_id = $1 + AND created_at < NOW() - ($2 || ' days')::INTERVAL + RETURNING *`, + [guildId, staleDays], + ); + + allStaleReviews.push(...rows); + } + + if (allStaleReviews.length === 0) return; + + info('Stale reviews expired', { count: allStaleReviews.length }); + + // Group by guild so we can post nudges per server + const byGuild = new Map(); + for (const review of allStaleReviews) { + if (!byGuild.has(review.guild_id)) byGuild.set(review.guild_id, []); + byGuild.get(review.guild_id).push(review); + } + + for (const [guildId, reviews] of byGuild) { + const guildConfig = getConfig(guildId); + const reviewChannelId = guildConfig.review?.channelId; + const staleDays = guildConfig?.review?.staleAfterDays ?? 7; + if (!reviewChannelId) continue; + + try { + const channel = await client.channels.fetch(reviewChannelId).catch(() => null); + if (!channel) continue; + + const ids = reviews.map((r) => `#${r.id}`).join(', '); + await safeSend(channel, { + 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!`, + }); + } catch (nudgeErr) { + warn('Failed to send stale review nudge', { guildId, error: nudgeErr.message }); + } + + // Update embeds for stale reviews + for (const review of reviews) { + await updateReviewMessage(review, client); + } + } + } catch (err) { + warn('Stale review expiry failed', { error: err.message }); + } +} diff --git a/src/modules/scheduler.js b/src/modules/scheduler.js index d91d6856..db01f1e2 100644 --- a/src/modules/scheduler.js +++ b/src/modules/scheduler.js @@ -9,6 +9,7 @@ import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; import { safeSend } from '../utils/safeSend.js'; import { closeExpiredPolls } from './pollHandler.js'; +import { expireStaleReviews } from './reviewHandler.js'; /** @type {ReturnType | null} */ let schedulerInterval = null; @@ -181,6 +182,8 @@ async function pollScheduledMessages(client) { } // Close expired polls await closeExpiredPolls(client); + // Expire stale review requests + await expireStaleReviews(client); } catch (err) { logError('Scheduler poll error', { error: err.message }); } finally { diff --git a/tests/commands/review.test.js b/tests/commands/review.test.js new file mode 100644 index 00000000..9ce9ecd2 --- /dev/null +++ b/tests/commands/review.test.js @@ -0,0 +1,806 @@ +/** + * Tests for /review command + * @see https://github.com/VolvoxLLC/volvox-bot/issues/49 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: vi.fn().mockResolvedValue({}), + safeReply: vi.fn((t, opts) => t.reply(opts)), + safeEditReply: vi.fn((t, opts) => t.editReply(opts)), +})); + +vi.mock('discord.js', () => { + function chainable() { + const proxy = new Proxy(() => proxy, { + get: () => () => proxy, + apply: () => proxy, + }); + return proxy; + } + + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + } + setName(n) { + this.name = n; + return this; + } + setDescription(d) { + this.description = d; + return this; + } + addSubcommand(fn) { + const sub = { + setName: () => ({ + setDescription: () => ({ + addStringOption: function self(fn2) { + fn2(chainable()); + return { addStringOption: self, addIntegerOption: self, addBooleanOption: self }; + }, + addIntegerOption: function self(fn2) { + fn2(chainable()); + return { addStringOption: self, addIntegerOption: self, addBooleanOption: self }; + }, + addBooleanOption: function self(fn2) { + fn2(chainable()); + return { addStringOption: self, addIntegerOption: self, addBooleanOption: self }; + }, + }), + }), + }; + fn(sub); + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + + class MockEmbedBuilder { + constructor() { + this.data = {}; + } + setTitle(t) { + this.data.title = t; + return this; + } + setDescription(d) { + this.data.description = d; + return this; + } + setColor(c) { + this.data.color = c; + return this; + } + addFields(...fields) { + this.data.fields = [...(this.data.fields ?? []), ...fields.flat()]; + return this; + } + setTimestamp() { + return this; + } + setFooter(f) { + this.data.footer = f; + return this; + } + } + + class MockButtonBuilder { + constructor() { + this.data = {}; + } + setCustomId(id) { + this.data.customId = id; + return this; + } + setLabel(l) { + this.data.label = l; + return this; + } + setStyle(s) { + this.data.style = s; + return this; + } + setDisabled(d) { + this.data.disabled = d; + return this; + } + } + + class MockActionRowBuilder { + constructor() { + this.components = []; + } + addComponents(...items) { + this.components.push(...items); + return this; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + EmbedBuilder: MockEmbedBuilder, + ButtonBuilder: MockButtonBuilder, + ActionRowBuilder: MockActionRowBuilder, + ButtonStyle: { Primary: 1, Secondary: 2, Danger: 4 }, + }; +}); + +// ── Imports (after mocks) ───────────────────────────────────────────────────── + +import { data, execute } from '../../src/commands/review.js'; +import { getPool } from '../../src/db.js'; +import { getConfig } from '../../src/modules/config.js'; +import { + buildClaimButton, + buildReviewEmbed, + expireStaleReviews, + handleReviewClaim, +} from '../../src/modules/reviewHandler.js'; +import { safeSend } from '../../src/utils/safeSend.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Base review config — enabled */ +const enabledConfig = { + review: { enabled: true, channelId: null, staleAfterDays: 7, xpReward: 50 }, + reputation: { enabled: false }, +}; + +/** Make a mock DB pool */ +function makePool(overrides = {}) { + return { + query: vi.fn(), + ...overrides, + }; +} + +/** Make a mock interaction */ +function makeInteraction(subcommand, options = {}) { + const optionValues = { + url: null, + description: null, + language: null, + id: null, + feedback: null, + status: null, + ...options, + }; + + const interaction = { + guildId: 'guild-123', + channelId: 'ch-456', + user: { id: 'user-789', username: 'TestUser' }, + member: { id: 'user-789' }, + channel: { + id: 'ch-456', + send: vi.fn().mockResolvedValue({ id: 'msg-001' }), + threads: null, // no thread support by default + }, + client: { + channels: { fetch: vi.fn().mockResolvedValue(null) }, + }, + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + getString: vi.fn((name) => optionValues[name] ?? null), + getInteger: vi.fn((name) => optionValues[name] ?? null), + getBoolean: vi.fn((name) => optionValues[name] ?? null), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue({}), + reply: vi.fn().mockResolvedValue({}), + replied: false, + deferred: false, + }; + return interaction; +} + +/** Base review row */ +function makeReview(overrides = {}) { + return { + id: 1, + guild_id: 'guild-123', + requester_id: 'user-789', + reviewer_id: null, + url: 'https://github.com/test/pr/1', + description: 'Please review this', + language: 'JavaScript', + status: 'open', + message_id: 'msg-001', + channel_id: 'ch-456', + thread_id: null, + feedback: null, + created_at: new Date().toISOString(), + claimed_at: null, + completed_at: null, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('/review command', () => { + let pool; + + beforeEach(() => { + pool = makePool(); + getPool.mockReturnValue(pool); + getConfig.mockReturnValue(enabledConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ── data export ──────────────────────────────────────────────────────────── + + describe('data', () => { + it('should export a slash command named review', () => { + expect(data.name).toBe('review'); + }); + }); + + // ── Config gate ──────────────────────────────────────────────────────────── + + describe('config gate', () => { + it('returns error when review is disabled', async () => { + getConfig.mockReturnValue({ review: { enabled: false } }); + const interaction = makeInteraction('request'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('not enabled') }), + ); + }); + + it('returns error when pool is null', async () => { + getPool.mockReturnValue(null); + const interaction = makeInteraction('request'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Database') }), + ); + }); + + it('returns error when no guildId', async () => { + const interaction = makeInteraction('request'); + interaction.guildId = null; + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('server') }), + ); + }); + }); + + // ── /review request ──────────────────────────────────────────────────────── + + describe('/review request', () => { + it('creates a review and posts embed to current channel', async () => { + const review = makeReview(); + pool.query + .mockResolvedValueOnce({ rows: [review] }) // INSERT + .mockResolvedValueOnce({ rows: [] }); // UPDATE message_id + + const interaction = makeInteraction('request', { + url: 'https://github.com/test/pr/1', + description: 'Review my changes', + language: 'JavaScript', + }); + + await execute(interaction); + + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO reviews'), + expect.arrayContaining(['guild-123', 'user-789']), + ); + expect(interaction.channel.send).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), components: expect.any(Array) }), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('✅') }), + ); + }); + + it('creates a review without optional language', async () => { + const review = makeReview({ language: null }); + pool.query.mockResolvedValueOnce({ rows: [review] }).mockResolvedValueOnce({ rows: [] }); + + const interaction = makeInteraction('request', { + url: 'https://github.com/test/pr/1', + description: 'Review my changes', + }); + + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('#1') }), + ); + }); + + it('posts to configured review channel when set', async () => { + const reviewChannelId = 'review-ch-999'; + getConfig.mockReturnValue({ + review: { enabled: true, channelId: reviewChannelId, staleAfterDays: 7, xpReward: 50 }, + reputation: { enabled: false }, + }); + + const mockReviewChannel = { + id: reviewChannelId, + send: vi.fn().mockResolvedValue({ id: 'msg-002' }), + }; + const interaction = makeInteraction('request', { + url: 'https://github.com/test/pr/1', + description: 'Review me', + }); + interaction.client.channels.fetch.mockResolvedValue(mockReviewChannel); + + const review = makeReview(); + pool.query.mockResolvedValueOnce({ rows: [review] }).mockResolvedValueOnce({ rows: [] }); + + await execute(interaction); + + expect(interaction.client.channels.fetch).toHaveBeenCalledWith(reviewChannelId); + expect(mockReviewChannel.send).toHaveBeenCalled(); + }); + }); + + // ── /review list ─────────────────────────────────────────────────────────── + + describe('/review list', () => { + it('lists open reviews by default', async () => { + pool.query.mockResolvedValue({ rows: [makeReview()] }); + const interaction = makeInteraction('list'); + await execute(interaction); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('status = $2'), + expect.arrayContaining(['open']), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('#1') }), + ); + }); + + it('lists claimed reviews', async () => { + pool.query.mockResolvedValue({ + rows: [makeReview({ status: 'claimed', reviewer_id: 'rev-001' })], + }); + const interaction = makeInteraction('list', { status: 'claimed' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('#1') }), + ); + }); + + it('lists all reviews when status=all', async () => { + pool.query.mockResolvedValue({ + rows: [makeReview(), makeReview({ id: 2, status: 'completed' })], + }); + const interaction = makeInteraction('list', { status: 'all' }); + await execute(interaction); + expect(pool.query).toHaveBeenCalledWith( + expect.not.stringContaining('status = $2'), + expect.any(Array), + ); + }); + + it('shows empty message when no reviews', async () => { + pool.query.mockResolvedValue({ rows: [] }); + const interaction = makeInteraction('list'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('📭') }), + ); + }); + + it('lists stale reviews', async () => { + pool.query.mockResolvedValue({ rows: [makeReview({ status: 'stale' })] }); + const interaction = makeInteraction('list', { status: 'stale' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('#1') }), + ); + }); + + it('lists completed reviews', async () => { + pool.query.mockResolvedValue({ + rows: [makeReview({ status: 'completed', reviewer_id: 'rev-001' })], + }); + const interaction = makeInteraction('list', { status: 'completed' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('#1') }), + ); + }); + }); + + // ── /review complete ─────────────────────────────────────────────────────── + + describe('/review complete', () => { + it('completes review when called by assigned reviewer', async () => { + const review = makeReview({ status: 'claimed', reviewer_id: 'user-789' }); + const completed = makeReview({ + status: 'completed', + reviewer_id: 'user-789', + completed_at: new Date().toISOString(), + }); + + pool.query + .mockResolvedValueOnce({ rows: [review] }) // SELECT + .mockResolvedValueOnce({ rows: [completed] }) // UPDATE + .mockResolvedValueOnce({ rows: [] }); // XP (not called — rep disabled) + + const interaction = makeInteraction('complete', { id: 1 }); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('✅') }), + ); + }); + + it('rejects completion by non-reviewer', async () => { + const review = makeReview({ status: 'claimed', reviewer_id: 'other-user' }); + pool.query.mockResolvedValueOnce({ rows: [review] }); + + const interaction = makeInteraction('complete', { id: 1 }); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Only the assigned reviewer') }), + ); + }); + + it('rejects completion when review not found', async () => { + pool.query.mockResolvedValueOnce({ rows: [] }); + const interaction = makeInteraction('complete', { id: 999 }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('No review') }), + ); + }); + + it('rejects double-completion', async () => { + const review = makeReview({ status: 'completed', reviewer_id: 'user-789' }); + pool.query.mockResolvedValueOnce({ rows: [review] }); + const interaction = makeInteraction('complete', { id: 1 }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('already completed') }), + ); + }); + + it('awards XP when reputation is enabled', async () => { + getConfig.mockReturnValue({ + review: { enabled: true, channelId: null, staleAfterDays: 7, xpReward: 75 }, + reputation: { enabled: true }, + }); + + const review = makeReview({ status: 'claimed', reviewer_id: 'user-789' }); + const completed = makeReview({ status: 'completed', reviewer_id: 'user-789' }); + + pool.query + .mockResolvedValueOnce({ rows: [review] }) // SELECT + .mockResolvedValueOnce({ rows: [completed] }) // UPDATE status + .mockResolvedValueOnce({ rows: [] }); // XP INSERT + + const interaction = makeInteraction('complete', { id: 1 }); + await execute(interaction); + + // Third query should be the XP upsert + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO reputation'), + expect.arrayContaining(['guild-123', 'user-789', 75]), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('+75 XP') }), + ); + }); + + it('stores feedback when provided', async () => { + const review = makeReview({ status: 'claimed', reviewer_id: 'user-789' }); + const completed = makeReview({ + status: 'completed', + reviewer_id: 'user-789', + feedback: 'Looks good!', + }); + + pool.query + .mockResolvedValueOnce({ rows: [review] }) + .mockResolvedValueOnce({ rows: [completed] }); + + const interaction = makeInteraction('complete', { id: 1, feedback: 'Looks good!' }); + await execute(interaction); + + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('feedback'), + expect.arrayContaining(['Looks good!']), + ); + }); + }); +}); + +// ── Review Claim Handler ─────────────────────────────────────────────────────── + +describe('handleReviewClaim', () => { + let pool; + + beforeEach(() => { + pool = makePool(); + getPool.mockReturnValue(pool); + getConfig.mockReturnValue(enabledConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function makeButtonInteraction(overrides = {}) { + return { + customId: 'review_claim_1', + guildId: 'guild-123', + user: { id: 'claimer-001', username: 'Claimer' }, + message: { + id: 'msg-001', + channel: null, // no threads by default + edit: vi.fn().mockResolvedValue({}), + startThread: vi.fn(), + }, + client: { + channels: { + fetch: vi.fn().mockResolvedValue({ + messages: { fetch: vi.fn().mockResolvedValue({ edit: vi.fn() }) }, + }), + }, + }, + reply: vi.fn().mockResolvedValue({}), + replied: false, + deferred: false, + ...overrides, + }; + } + + it('claims an open review successfully', async () => { + const review = makeReview(); + const claimed = makeReview({ status: 'claimed', reviewer_id: 'claimer-001' }); + + pool.query + .mockResolvedValueOnce({ rows: [review] }) // SELECT to check for self-claim + .mockResolvedValueOnce({ rowCount: 1 }) // atomic UPDATE succeeds (status was 'open') + .mockResolvedValueOnce({ rows: [claimed] }); // SELECT to fetch updated row + + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("status = 'claimed'"), + expect.arrayContaining(['claimer-001', 1]), + ); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining("You've claimed") }), + ); + }); + + it('prevents self-claim', async () => { + const review = makeReview({ requester_id: 'claimer-001' }); + pool.query.mockResolvedValueOnce({ rows: [review] }); + + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('cannot claim your own') }), + ); + }); + + it('prevents double-claim on already claimed review', async () => { + const review = makeReview({ status: 'claimed', reviewer_id: 'someone-else' }); + pool.query + .mockResolvedValueOnce({ rows: [review] }) // SELECT (status=claimed) + .mockResolvedValueOnce({ rowCount: 0 }); // atomic UPDATE fails (status != 'open') + + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('no longer available') }), + ); + }); + + it('prevents claiming a completed review', async () => { + const review = makeReview({ status: 'completed', reviewer_id: 'someone-else' }); + pool.query + .mockResolvedValueOnce({ rows: [review] }) // SELECT (status=completed) + .mockResolvedValueOnce({ rowCount: 0 }); // atomic UPDATE fails (status != 'open') + + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('no longer available') }), + ); + }); + + it('prevents claiming a stale review', async () => { + const review = makeReview({ status: 'stale' }); + pool.query + .mockResolvedValueOnce({ rows: [review] }) // SELECT (status=stale) + .mockResolvedValueOnce({ rowCount: 0 }); // atomic UPDATE fails (status != 'open') + + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('no longer available') }), + ); + }); + + it('returns error when review not found', async () => { + pool.query.mockResolvedValueOnce({ rows: [] }); + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('not found') }), + ); + }); + + it('returns error when pool is null', async () => { + getPool.mockReturnValue(null); + const interaction = makeButtonInteraction(); + await handleReviewClaim(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Database') }), + ); + }); + + it('handles invalid customId gracefully', async () => { + const interaction = makeButtonInteraction({ customId: 'review_claim_abc' }); + await handleReviewClaim(interaction); + // Should return early without querying DB + expect(pool.query).not.toHaveBeenCalled(); + }); +}); + +// ── expireStaleReviews ───────────────────────────────────────────────────────── + +describe('expireStaleReviews', () => { + let pool; + + beforeEach(() => { + pool = makePool(); + getPool.mockReturnValue(pool); + getConfig.mockReturnValue(enabledConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('does nothing when no stale reviews', async () => { + // New impl first queries for guilds with open reviews; empty means nothing to expire. + pool.query.mockResolvedValueOnce({ rows: [] }); // SELECT DISTINCT guild_id → no guilds + const client = { channels: { fetch: vi.fn() } }; + await expireStaleReviews(client); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('marks stale reviews and posts nudge', async () => { + const staleReviews = [ + makeReview({ id: 1, status: 'stale', guild_id: 'guild-123', channel_id: 'ch-456' }), + makeReview({ id: 2, status: 'stale', guild_id: 'guild-123', channel_id: 'ch-456' }), + ]; + // New impl: 1st query = SELECT DISTINCT guild_id, 2nd = per-guild UPDATE RETURNING + pool.query + .mockResolvedValueOnce({ rows: [{ guild_id: 'guild-123' }] }) // SELECT DISTINCT + .mockResolvedValueOnce({ rows: staleReviews }); // per-guild UPDATE + + getConfig.mockReturnValue({ + review: { enabled: true, channelId: 'review-ch-001', staleAfterDays: 7, xpReward: 50 }, + }); + + const mockChannel = { + messages: { fetch: vi.fn().mockResolvedValue({ edit: vi.fn() }) }, + }; + const client = { + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + }; + + await expireStaleReviews(client); + + // safeSend is mocked at module level — verify it was called with the nudge content + expect(safeSend).toHaveBeenCalledWith( + mockChannel, + expect.objectContaining({ content: expect.stringContaining('#1') }), + ); + }); + + it('skips nudge when no review channelId configured', async () => { + const staleReviews = [makeReview({ status: 'stale', guild_id: 'guild-123' })]; + // New impl: SELECT DISTINCT then per-guild UPDATE + pool.query + .mockResolvedValueOnce({ rows: [{ guild_id: 'guild-123' }] }) // SELECT DISTINCT + .mockResolvedValueOnce({ rows: staleReviews }); // per-guild UPDATE + + getConfig.mockReturnValue({ review: { enabled: true, channelId: null } }); + + const client = { channels: { fetch: vi.fn() } }; + await expireStaleReviews(client); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('handles null pool gracefully', async () => { + getPool.mockReturnValue(null); + const client = { channels: { fetch: vi.fn() } }; + // Should not throw + await expect(expireStaleReviews(client)).resolves.toBeUndefined(); + }); +}); + +// ── buildReviewEmbed ─────────────────────────────────────────────────────────── + +describe('buildReviewEmbed', () => { + it('builds embed with correct color for open status', () => { + const embed = buildReviewEmbed(makeReview()); + expect(embed.data.color).toBe(0x5865f2); + }); + + it('builds embed with claimed color', () => { + const embed = buildReviewEmbed(makeReview({ status: 'claimed', reviewer_id: 'rev-001' })); + expect(embed.data.color).toBe(0xffa500); + }); + + it('builds embed with completed color', () => { + const embed = buildReviewEmbed(makeReview({ status: 'completed' })); + expect(embed.data.color).toBe(0x57f287); + }); + + it('builds embed with stale color', () => { + const embed = buildReviewEmbed(makeReview({ status: 'stale' })); + expect(embed.data.color).toBe(0x95a5a6); + }); + + it('includes feedback field when present', () => { + const embed = buildReviewEmbed(makeReview({ feedback: 'LGTM!' })); + const feedbackField = embed.data.fields?.find((f) => f.name === '💬 Feedback'); + expect(feedbackField).toBeDefined(); + expect(feedbackField.value).toBe('LGTM!'); + }); + + it('truncates long URLs', () => { + const longUrl = `https://example.com/${'a'.repeat(300)}`; + const embed = buildReviewEmbed(makeReview({ url: longUrl })); + const urlField = embed.data.fields?.find((f) => f.name === '🔗 URL'); + expect(urlField.value.length).toBeLessThanOrEqual(204); + }); +}); + +// ── buildClaimButton ─────────────────────────────────────────────────────────── + +describe('buildClaimButton', () => { + it('creates an enabled claim button', () => { + const row = buildClaimButton(42); + expect(row.components[0].data.customId).toBe('review_claim_42'); + expect(row.components[0].data.disabled).toBe(false); + }); + + it('creates a disabled claim button', () => { + const row = buildClaimButton(42, true); + expect(row.components[0].data.disabled).toBe(true); + }); +}); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 243cd61e..1560ee43 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -48,7 +48,7 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und function isGuildConfig(data: unknown): data is GuildConfig { if (typeof data !== "object" || data === null || Array.isArray(data)) return false; const obj = data as Record; - const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "showcase", "tldr", "reputation", "afk", "engagement", "github"] as const; + const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "showcase", "tldr", "reputation", "afk", "engagement", "github", "review"] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; for (const key of knownSections) { @@ -1201,6 +1201,7 @@ export function ConfigEditor() { { key: "snippet", label: "Code Snippets", desc: "/snippet for saving and sharing code" }, { key: "poll", label: "Polls", desc: "/poll for community voting" }, { key: "showcase", label: "Project Showcase", desc: "/showcase to submit, browse, and upvote projects" }, + { key: "review", label: "Code Reviews", desc: "/review peer code review requests with claim workflow" }, { key: "tldr", label: "TL;DR Summaries", desc: "/tldr for AI channel summaries" }, { key: "afk", label: "AFK System", desc: "/afk auto-respond when members are away" }, { key: "engagement", label: "Engagement Tracking", desc: "/profile stats — messages, reactions, days active" },