diff --git a/config.json b/config.json index a6761646..69f9ebe7 100644 --- a/config.json +++ b/config.json @@ -146,7 +146,8 @@ "modlog": "moderator", "announce": "moderator", "tldr": "everyone", - "afk": "everyone" + "afk": "everyone", + "github": "everyone" } }, "help": { @@ -161,6 +162,14 @@ "poll": { "enabled": false }, + "github": { + "feed": { + "enabled": false, + "channelId": null, + "repos": [], + "events": ["pr", "issue", "release", "push"] + } + }, "tldr": { "enabled": false, "defaultMessages": 50, diff --git a/migrations/005_github-feed.cjs b/migrations/005_github-feed.cjs new file mode 100644 index 00000000..40e858fd --- /dev/null +++ b/migrations/005_github-feed.cjs @@ -0,0 +1,27 @@ +/** + * Add github_feed_state table for GitHub activity feed module. + * Tracks per-guild, per-repo polling state for dedup. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/51 + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS github_feed_state ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + repo TEXT NOT NULL, + last_event_id TEXT, + last_poll_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(guild_id, repo) + ) + `); + + pgm.sql('CREATE INDEX IF NOT EXISTS idx_github_feed_guild ON github_feed_state(guild_id)'); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS github_feed_state CASCADE'); +}; diff --git a/src/api/routes/moderation.js b/src/api/routes/moderation.js index e9d3c1b2..7c07cbee 100644 --- a/src/api/routes/moderation.js +++ b/src/api/routes/moderation.js @@ -103,7 +103,7 @@ router.get('/cases', async (req, res) => { */ router.get('/cases/:caseNumber', async (req, res) => { const caseNumber = parseInt(req.params.caseNumber, 10); - if (isNaN(caseNumber)) { + if (Number.isNaN(caseNumber)) { return res.status(400).json({ error: 'Invalid case number' }); } diff --git a/src/commands/github.js b/src/commands/github.js new file mode 100644 index 00000000..2e8d1da9 --- /dev/null +++ b/src/commands/github.js @@ -0,0 +1,242 @@ +/** + * GitHub Command + * Manage GitHub activity feed settings per guild. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/51 + */ + +import { ChannelType, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, warn } from '../logger.js'; +import { getConfig, setConfigValue } from '../modules/config.js'; +import { isAdmin } from '../utils/permissions.js'; +import { safeEditReply, safeReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('github') + .setDescription('Manage GitHub activity feed') + .addSubcommandGroup((group) => + group + .setName('feed') + .setDescription('GitHub feed settings') + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a repo to track (Admin only)') + .addStringOption((opt) => + opt + .setName('repo') + .setDescription('Repo in owner/repo format (e.g. VolvoxLLC/volvox-bot)') + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a tracked repo (Admin only)') + .addStringOption((opt) => + opt.setName('repo').setDescription('Repo in owner/repo format').setRequired(true), + ), + ) + .addSubcommand((sub) => sub.setName('list').setDescription('List tracked repos')) + .addSubcommand((sub) => + sub + .setName('channel') + .setDescription('Set the feed channel (Admin only)') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to post GitHub events in') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ), + ), + ); + +/** + * Validate that a string is in "owner/repo" format. + * + * @param {string} repo - The repo string to validate + * @returns {boolean} True if valid + */ +export function isValidRepo(repo) { + if (!repo || typeof repo !== 'string') return false; + const parts = repo.split('/'); + if (parts.length !== 2) return false; + const [owner, name] = parts; + return /^[a-zA-Z0-9._-]+$/.test(owner) && /^[a-zA-Z0-9._-]+$/.test(name); +} + +/** + * Execute the /github command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + const config = getConfig(interaction.guildId); + + if (!config?.github?.feed?.enabled) { + await safeReply(interaction, { + content: '❌ The GitHub feed is not enabled on this server.', + ephemeral: true, + }); + return; + } + + const subcommandGroup = interaction.options.getSubcommandGroup(); + const subcommand = interaction.options.getSubcommand(); + + // All feed subcommands except "list" require admin + if (subcommandGroup === 'feed' && subcommand !== 'list') { + if (!isAdmin(interaction.member, config)) { + await safeReply(interaction, { + content: '❌ You need Administrator permission to manage the GitHub feed.', + ephemeral: true, + }); + return; + } + } + + await interaction.deferReply({ ephemeral: true }); + + if (subcommandGroup === 'feed') { + if (subcommand === 'add') { + await handleAdd(interaction, config); + } else if (subcommand === 'remove') { + await handleRemove(interaction, config); + } else if (subcommand === 'list') { + await handleList(interaction, config); + } else if (subcommand === 'channel') { + await handleChannel(interaction, config); + } + } +} + +/** + * Handle /github feed add + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {object} config + */ +async function handleAdd(interaction, config) { + const repo = interaction.options.getString('repo'); + + if (!isValidRepo(repo)) { + await safeEditReply(interaction, { + content: '❌ Invalid repo format. Use `owner/repo` (e.g. `VolvoxLLC/volvox-bot`).', + }); + return; + } + + const repos = [...(config.github?.feed?.repos ?? [])]; + + if (repos.includes(repo)) { + await safeEditReply(interaction, { + content: `⚠️ \`${repo}\` is already being tracked.`, + }); + return; + } + + // Persist by updating config via setConfigValue + const updated = [...repos, repo]; + await setConfigValue('github.feed.repos', updated, interaction.guildId); + + info('GitHub feed: repo added', { guildId: interaction.guildId, repo }); + + await safeEditReply(interaction, { + content: `✅ Now tracking \`${repo}\`.`, + }); +} + +/** + * Handle /github feed remove + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {object} config + */ +async function handleRemove(interaction, config) { + let pool; + try { + pool = getPool(); + } catch { + await safeEditReply(interaction, { content: '❌ Database is not available.' }); + return; + } + + const repo = interaction.options.getString('repo'); + + const repos = config.github.feed.repos || []; + + if (!repos.includes(repo)) { + await safeEditReply(interaction, { + content: `⚠️ \`${repo}\` is not currently tracked.`, + }); + return; + } + + const updated = repos.filter((r) => r !== repo); + await setConfigValue('github.feed.repos', updated, interaction.guildId); + + // Remove state row from DB so next add starts fresh + try { + await pool.query('DELETE FROM github_feed_state WHERE guild_id = $1 AND repo = $2', [ + interaction.guildId, + repo, + ]); + } catch (err) { + warn('GitHub feed: failed to clean up state row', { + guildId: interaction.guildId, + repo, + error: err.message, + }); + } + + info('GitHub feed: repo removed', { guildId: interaction.guildId, repo }); + + await safeEditReply(interaction, { + content: `✅ Stopped tracking \`${repo}\`.`, + }); +} + +/** + * Handle /github feed list + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {object} config + */ +async function handleList(interaction, config) { + const repos = config.github.feed.repos || []; + const channelId = config.github.feed.channelId; + + if (repos.length === 0) { + await safeEditReply(interaction, { + content: '📭 No repos are currently being tracked.', + }); + return; + } + + const lines = repos.map((r) => `• \`${r}\``).join('\n'); + const channelLine = channelId ? `\n📢 Feed channel: <#${channelId}>` : '\n⚠️ No feed channel set.'; + + await safeEditReply(interaction, { + content: `📋 **Tracked repos (${repos.length}):**\n${lines}${channelLine}`, + }); +} + +/** + * Handle /github feed channel + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {object} config + */ +async function handleChannel(interaction, _config) { + const channel = interaction.options.getChannel('channel'); + + await setConfigValue('github.feed.channelId', channel.id, interaction.guildId); + + info('GitHub feed: channel set', { guildId: interaction.guildId, channelId: channel.id }); + + await safeEditReply(interaction, { + content: `✅ GitHub feed will now post to <#${channel.id}>.`, + }); +} diff --git a/src/index.js b/src/index.js index 77b5894b..ff636155 100644 --- a/src/index.js +++ b/src/index.js @@ -46,6 +46,7 @@ import { } from './modules/ai.js'; import { getConfig, loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; +import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.js'; @@ -264,11 +265,12 @@ client.on('interactionCreate', async (interaction) => { async function gracefulShutdown(signal) { info('Shutdown initiated', { signal }); - // 1. Stop triage, conversation cleanup timer, tempban scheduler, and announcement scheduler + // 1. Stop triage, conversation cleanup timer, tempban scheduler, announcement scheduler, and GitHub feed stopTriage(); stopConversationCleanup(); stopTempbanScheduler(); stopScheduler(); + stopGithubFeed(); // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) try { @@ -454,6 +456,7 @@ async function startup() { if (dbPool) { startTempbanScheduler(client); startScheduler(client); + startGithubFeed(client); } // Load commands and login diff --git a/src/modules/githubFeed.js b/src/modules/githubFeed.js new file mode 100644 index 00000000..71a36a4e --- /dev/null +++ b/src/modules/githubFeed.js @@ -0,0 +1,400 @@ +/** + * GitHub Activity Feed Module + * Polls GitHub repos and posts activity embeds to a Discord channel. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/51 + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { EmbedBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, error as logError, warn as logWarn } from '../logger.js'; +import { safeSend } from '../utils/safeSend.js'; +import { getConfig } from './config.js'; + +const execFileAsync = promisify(execFile); + +/** @type {ReturnType | null} */ +let feedInterval = null; + +/** @type {ReturnType | null} */ +let firstPollTimeout = null; + +/** Re-entrancy guard */ +let pollInFlight = false; + +/** + * Fetch recent GitHub events for a repo via the `gh` CLI. + * + * @param {string} owner - GitHub owner (user or org) + * @param {string} repo - Repository name + * @returns {Promise} Array of event objects (up to 10) + */ +export async function fetchRepoEvents(owner, repo) { + const { stdout } = await execFileAsync( + 'gh', + ['api', `repos/${owner}/${repo}/events?per_page=10`], + { timeout: 30_000 }, + ); + const text = stdout.trim(); + if (!text) return []; + return JSON.parse(text); +} + +/** + * Build a Discord embed for a PullRequestEvent. + * + * @param {object} event - GitHub event object + * @returns {EmbedBuilder|null} Embed or null if action not handled + */ +export function buildPrEmbed(event) { + const pr = event.payload?.pull_request; + const action = event.payload?.action; + if (!pr) return null; + + let color; + let actionLabel; + + if (action === 'opened') { + color = 0x2ecc71; // green + actionLabel = 'opened'; + } else if (action === 'closed' && pr.merged) { + color = 0x9b59b6; // purple + actionLabel = 'merged'; + } else if (action === 'closed') { + color = 0xe74c3c; // red + actionLabel = 'closed'; + } else { + return null; + } + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`[PR #${pr.number}] ${pr.title}`) + .setURL(pr.html_url) + .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url }) + .addFields( + { name: 'Action', value: actionLabel, inline: true }, + { name: 'Repo', value: event.repo?.name || 'unknown', inline: true }, + ) + .setTimestamp(new Date(event.created_at)); + + if (pr.additions !== undefined && pr.deletions !== undefined) { + embed.addFields({ + name: 'Changes', + value: `+${pr.additions} / -${pr.deletions}`, + inline: true, + }); + } + + return embed; +} + +/** + * Build a Discord embed for an IssuesEvent. + * + * @param {object} event - GitHub event object + * @returns {EmbedBuilder|null} Embed or null if action not handled + */ +export function buildIssueEmbed(event) { + const issue = event.payload?.issue; + const action = event.payload?.action; + if (!issue) return null; + + let color; + let actionLabel; + + if (action === 'opened') { + color = 0x3498db; // blue + actionLabel = 'opened'; + } else if (action === 'closed') { + color = 0xe74c3c; // red + actionLabel = 'closed'; + } else { + return null; + } + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`[Issue #${issue.number}] ${issue.title}`) + .setURL(issue.html_url) + .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url }) + .addFields( + { name: 'Action', value: actionLabel, inline: true }, + { name: 'Repo', value: event.repo?.name || 'unknown', inline: true }, + ) + .setTimestamp(new Date(event.created_at)); + + if (issue.labels?.length) { + embed.addFields({ + name: 'Labels', + value: issue.labels.map((l) => l.name).join(', '), + inline: true, + }); + } + + if (issue.assignee) { + embed.addFields({ name: 'Assignee', value: issue.assignee.login, inline: true }); + } + + return embed; +} + +/** + * Build a Discord embed for a ReleaseEvent. + * + * @param {object} event - GitHub event object + * @returns {EmbedBuilder|null} Embed or null if not a published release + */ +export function buildReleaseEmbed(event) { + const release = event.payload?.release; + if (!release) return null; + + const bodyPreview = release.body ? release.body.slice(0, 200) : ''; + + const embed = new EmbedBuilder() + .setColor(0xf1c40f) // gold + .setTitle(`🚀 Release: ${release.tag_name}`) + .setURL(release.html_url) + .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url }) + .addFields({ name: 'Repo', value: event.repo?.name || 'unknown', inline: true }) + .setTimestamp(new Date(event.created_at)); + + if (bodyPreview) { + embed.addFields({ name: 'Notes', value: bodyPreview }); + } + + return embed; +} + +/** + * Build a Discord embed for a PushEvent. + * + * @param {object} event - GitHub event object + * @returns {EmbedBuilder|null} Embed or null if no commits + */ +export function buildPushEmbed(event) { + const payload = event.payload; + if (!payload) return null; + + const commits = payload.commits || []; + if (commits.length === 0) return null; + + // Extract branch name from ref (refs/heads/main → main) + const branch = payload.ref ? payload.ref.replace('refs/heads/', '') : 'unknown'; + + const commitLines = commits + .slice(0, 3) + .map((c) => `• \`${c.sha?.slice(0, 7) || '???????'}\` ${c.message?.split('\n')[0] || ''}`) + .join('\n'); + + const embed = new EmbedBuilder() + .setColor(0x95a5a6) // gray + .setTitle(`⬆️ Push to ${branch} (${commits.length} commit${commits.length !== 1 ? 's' : ''})`) + .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url }) + .addFields( + { name: 'Repo', value: event.repo?.name || 'unknown', inline: true }, + { name: 'Branch', value: branch, inline: true }, + { name: 'Commits', value: commitLines || '—' }, + ) + .setTimestamp(new Date(event.created_at)); + + return embed; +} + +/** + * Build an embed for a GitHub event based on its type. + * + * @param {object} event - GitHub event object + * @param {string[]} enabledEvents - List of enabled event type keys ('pr','issue','release','push') + * @returns {EmbedBuilder|null} Embed or null if type not handled / not enabled + */ +export function buildEmbed(event, enabledEvents) { + switch (event.type) { + case 'PullRequestEvent': + if (!enabledEvents.includes('pr')) return null; + return buildPrEmbed(event); + case 'IssuesEvent': + if (!enabledEvents.includes('issue')) return null; + return buildIssueEmbed(event); + case 'ReleaseEvent': + if (!enabledEvents.includes('release')) return null; + return buildReleaseEmbed(event); + case 'PushEvent': + if (!enabledEvents.includes('push')) return null; + return buildPushEmbed(event); + default: + return null; + } +} + +/** + * Poll a single guild's GitHub feed. + * + * @param {import('discord.js').Client} client - Discord client + * @param {string} guildId - Guild ID + * @param {object} feedConfig - Feed configuration section + */ +async function pollGuildFeed(client, guildId, feedConfig) { + const pool = getPool(); + const channelId = feedConfig.channelId; + const repos = feedConfig.repos || []; + const enabledEvents = feedConfig.events || ['pr', 'issue', 'release', 'push']; + + if (!channelId) { + logWarn('GitHub feed: no channelId configured', { guildId }); + return; + } + + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel) { + logWarn('GitHub feed: channel not found', { guildId, channelId }); + return; + } + + for (const repoFullName of repos) { + const [owner, repo] = repoFullName.split('/'); + if (!owner || !repo) { + logWarn('GitHub feed: invalid repo format', { guildId, repo: repoFullName }); + continue; + } + + try { + // Get last seen event ID from DB + const { rows } = await pool.query( + 'SELECT last_event_id FROM github_feed_state WHERE guild_id = $1 AND repo = $2', + [guildId, repoFullName], + ); + const lastEventId = rows[0]?.last_event_id || null; + + // Fetch events + const events = await fetchRepoEvents(owner, repo); + + // Filter to events newer than last seen (events are newest-first) + const newEvents = lastEventId + ? events.filter((e) => BigInt(e.id) > BigInt(lastEventId)) + : events.slice(0, 1); // first run: only latest to avoid spam + + if (newEvents.length === 0) { + // Update poll time even if no new events + await pool.query( + `INSERT INTO github_feed_state (guild_id, repo, last_event_id, last_poll_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (guild_id, repo) DO UPDATE + SET last_poll_at = NOW()`, + [guildId, repoFullName, lastEventId || (events[0]?.id ?? null)], + ); + continue; + } + + // Process events oldest-first so they appear in chronological order + const orderedEvents = [...newEvents].reverse(); + let newestId = lastEventId; + + for (const event of orderedEvents) { + const embed = buildEmbed(event, enabledEvents); + if (embed) { + await safeSend(channel, { embeds: [embed] }); + info('GitHub feed: event posted', { + guildId, + repo: repoFullName, + type: event.type, + eventId: event.id, + }); + } + // Track newest ID regardless of whether we posted (skip unsupported types) + if (!newestId || BigInt(event.id) > BigInt(newestId)) { + newestId = event.id; + } + } + + // Upsert state with new last_event_id + await pool.query( + `INSERT INTO github_feed_state (guild_id, repo, last_event_id, last_poll_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (guild_id, repo) DO UPDATE + SET last_event_id = $3, last_poll_at = NOW()`, + [guildId, repoFullName, newestId], + ); + } catch (err) { + logError('GitHub feed: error polling repo', { + guildId, + repo: repoFullName, + error: err.message, + }); + } + } +} + +/** + * Poll GitHub feeds for all guilds that have it enabled. + * + * @param {import('discord.js').Client} client - Discord client + */ +async function pollAllFeeds(client) { + if (pollInFlight) return; + pollInFlight = true; + + try { + // Iterate over all guilds the bot is in + for (const [guildId] of client.guilds.cache) { + const config = getConfig(guildId); + if (!config?.github?.feed?.enabled) continue; + + await pollGuildFeed(client, guildId, config.github.feed).catch((err) => { + logError('GitHub feed: guild poll failed', { guildId, error: err.message }); + }); + } + } catch (err) { + logError('GitHub feed: poll error', { error: err.message }); + } finally { + pollInFlight = false; + } +} + +/** + * Start the GitHub feed polling interval. + * + * @param {import('discord.js').Client} client - Discord client + */ +export function startGithubFeed(client) { + if (feedInterval) return; + + const defaultMinutes = 5; + + // Fixed 5-minute poll interval. + const intervalMs = defaultMinutes * 60_000; + + // Kick off first poll after bot is settled (5s delay) + firstPollTimeout = setTimeout(() => { + firstPollTimeout = null; + pollAllFeeds(client).catch((err) => { + logError('GitHub feed: initial poll failed', { error: err.message }); + }); + }, 5_000); + + // Note: intervalMs is captured at setInterval creation time and does not change dynamically. + feedInterval = setInterval(() => { + pollAllFeeds(client).catch((err) => { + logError('GitHub feed: poll failed', { error: err.message }); + }); + }, intervalMs); + + info('GitHub feed started'); +} + +/** + * Stop the GitHub feed polling interval. + */ +export function stopGithubFeed() { + if (firstPollTimeout) { + clearTimeout(firstPollTimeout); + firstPollTimeout = null; + } + if (feedInterval) { + clearInterval(feedInterval); + feedInterval = null; + info('GitHub feed stopped'); + } +} diff --git a/tests/commands/github.test.js b/tests/commands/github.test.js new file mode 100644 index 00000000..5fa3924b --- /dev/null +++ b/tests/commands/github.test.js @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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(), + setConfigValue: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/utils/permissions.js', () => ({ + isAdmin: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: vi.fn(), + safeReply: (t, opts) => t.reply(opts), + safeEditReply: (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(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addSubcommandGroup(fn) { + const group = { + setName: () => ({ + setDescription: () => ({ + addSubcommand: function self(fn2) { + const sub = { + setName: () => ({ + setDescription: () => ({ + addStringOption: function opt(fn3) { + fn3(chainable()); + return { addStringOption: opt, addChannelOption: opt }; + }, + addChannelOption: function opt(fn3) { + fn3(chainable()); + return { addStringOption: opt, addChannelOption: opt }; + }, + }), + }), + }; + fn2(sub); + return { addSubcommand: self }; + }, + }), + }), + }; + fn(group); + return this; + } + toJSON() { + return { name: this.name }; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + ChannelType: { GuildText: 0 }, + }; +}); + +import { data, execute, isValidRepo } from '../../src/commands/github.js'; +import { getPool } from '../../src/db.js'; +import { getConfig, setConfigValue } from '../../src/modules/config.js'; +import { isAdmin } from '../../src/utils/permissions.js'; + +/** Build a mock interaction */ +function makeInteraction(subcommandGroup, subcommand, options = {}) { + return { + guildId: 'guild-123', + user: { id: 'user-456' }, + member: { + id: 'user-456', + permissions: { has: vi.fn().mockReturnValue(true) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }, + options: { + getSubcommandGroup: vi.fn().mockReturnValue(subcommandGroup), + getSubcommand: vi.fn().mockReturnValue(subcommand), + getString: vi.fn((name) => options[name] ?? null), + getChannel: vi.fn(() => options.channel ?? null), + }, + reply: vi.fn(), + editReply: vi.fn(), + deferReply: vi.fn(), + }; +} + +describe('isValidRepo', () => { + it('accepts valid owner/repo', () => { + expect(isValidRepo('VolvoxLLC/volvox-bot')).toBe(true); + expect(isValidRepo('bill/my-project')).toBe(true); + }); + + it('rejects missing slash', () => { + expect(isValidRepo('noslash')).toBe(false); + }); + + it('rejects too many slashes', () => { + expect(isValidRepo('too/many/parts')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidRepo('')).toBe(false); + }); + + it('rejects null / undefined', () => { + expect(isValidRepo(null)).toBe(false); + expect(isValidRepo(undefined)).toBe(false); + }); +}); + +describe('github command', () => { + let mockPool; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + }; + getPool.mockReturnValue(mockPool); + isAdmin.mockReturnValue(true); + + getConfig.mockReturnValue({ + github: { + feed: { + enabled: true, + channelId: 'ch-1', + repos: [], + events: ['pr', 'issue', 'release', 'push'], + pollIntervalMinutes: 5, + }, + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with name "github"', () => { + expect(data.name).toBe('github'); + }); + + describe('when feed is disabled', () => { + it('should reply with disabled message', async () => { + getConfig.mockReturnValue({ github: { feed: { enabled: false } } }); + const interaction = makeInteraction('feed', 'list'); + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('not enabled'), + ephemeral: true, + }), + ); + }); + }); + + describe('admin-only subcommands', () => { + it('should deny non-admins for add', async () => { + isAdmin.mockReturnValue(false); + const interaction = makeInteraction('feed', 'add', { repo: 'owner/repo' }); + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Administrator permission'), + ephemeral: true, + }), + ); + }); + + it('should deny non-admins for remove', async () => { + isAdmin.mockReturnValue(false); + const interaction = makeInteraction('feed', 'remove', { repo: 'owner/repo' }); + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Administrator permission'), + ephemeral: true, + }), + ); + }); + + it('should deny non-admins for channel', async () => { + isAdmin.mockReturnValue(false); + const interaction = makeInteraction('feed', 'channel', { + channel: { id: 'ch-2' }, + }); + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Administrator permission'), + ephemeral: true, + }), + ); + }); + + it('should allow non-admins to use list', async () => { + isAdmin.mockReturnValue(false); + const interaction = makeInteraction('feed', 'list'); + await execute(interaction); + // Should reach list handler (deferReply called, not blocked) + expect(interaction.deferReply).toHaveBeenCalled(); + }); + }); + + describe('feed add', () => { + it('should add a valid repo', async () => { + const interaction = makeInteraction('feed', 'add', { repo: 'VolvoxLLC/volvox-bot' }); + await execute(interaction); + expect(setConfigValue).toHaveBeenCalledWith( + 'github.feed.repos', + expect.arrayContaining(['VolvoxLLC/volvox-bot']), + 'guild-123', + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Now tracking') }), + ); + }); + + it('should reject invalid repo format', async () => { + const interaction = makeInteraction('feed', 'add', { repo: 'notarepo' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Invalid repo format'), + }), + ); + expect(mockPool.query).not.toHaveBeenCalled(); + }); + + it('should warn if repo already tracked', async () => { + getConfig.mockReturnValue({ + github: { + feed: { + enabled: true, + channelId: 'ch-1', + repos: ['VolvoxLLC/volvox-bot'], + events: ['pr'], + pollIntervalMinutes: 5, + }, + }, + }); + const interaction = makeInteraction('feed', 'add', { repo: 'VolvoxLLC/volvox-bot' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('already being tracked') }), + ); + }); + }); + + describe('feed remove', () => { + it('should remove a tracked repo', async () => { + getConfig.mockReturnValue({ + github: { + feed: { + enabled: true, + channelId: 'ch-1', + repos: ['VolvoxLLC/volvox-bot'], + events: ['pr'], + pollIntervalMinutes: 5, + }, + }, + }); + const interaction = makeInteraction('feed', 'remove', { repo: 'VolvoxLLC/volvox-bot' }); + await execute(interaction); + expect(setConfigValue).toHaveBeenCalledWith('github.feed.repos', [], 'guild-123'); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Stopped tracking') }), + ); + }); + + it('should warn if repo not tracked', async () => { + const interaction = makeInteraction('feed', 'remove', { repo: 'nobody/nothing' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('not currently tracked') }), + ); + }); + }); + + describe('feed list', () => { + it('should list tracked repos', async () => { + getConfig.mockReturnValue({ + github: { + feed: { + enabled: true, + channelId: 'ch-1', + repos: ['VolvoxLLC/volvox-bot', 'bill/other'], + events: ['pr'], + pollIntervalMinutes: 5, + }, + }, + }); + const interaction = makeInteraction('feed', 'list'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('VolvoxLLC/volvox-bot'), + }), + ); + }); + + it('should show empty message when no repos', async () => { + const interaction = makeInteraction('feed', 'list'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('No repos'), + }), + ); + }); + }); + + describe('feed channel', () => { + it('should set the feed channel', async () => { + const interaction = makeInteraction('feed', 'channel', { + channel: { id: 'ch-new' }, + }); + await execute(interaction); + expect(setConfigValue).toHaveBeenCalledWith('github.feed.channelId', 'ch-new', 'guild-123'); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('ch-new'), + }), + ); + }); + }); +}); diff --git a/tests/modules/githubFeed.test.js b/tests/modules/githubFeed.test.js new file mode 100644 index 00000000..bed1b963 --- /dev/null +++ b/tests/modules/githubFeed.test.js @@ -0,0 +1,569 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock child_process for gh CLI calls +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('node:util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: (fn) => { + // Return a promisified version that defers to our mock + return (...args) => + new Promise((resolve, reject) => + fn(...args, (err, result) => { + if (err) reject(err); + else resolve(result); + }), + ); + }, + }; +}); + +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({}), +})); + +vi.mock('discord.js', () => { + class EmbedBuilder { + constructor() { + this._data = {}; + } + setColor(c) { + this._data.color = c; + return this; + } + setTitle(t) { + this._data.title = t; + return this; + } + setURL(u) { + this._data.url = u; + return this; + } + setAuthor(a) { + this._data.author = a; + return this; + } + addFields(...fields) { + this._data.fields = [...(this._data.fields || []), ...fields.flat()]; + return this; + } + setTimestamp(t) { + this._data.timestamp = t; + return this; + } + setDescription(d) { + this._data.description = d; + return this; + } + } + return { EmbedBuilder }; +}); + +import { execFile } from 'node:child_process'; +import { getPool } from '../../src/db.js'; +import { getConfig } from '../../src/modules/config.js'; +import { + buildEmbed, + buildIssueEmbed, + buildPrEmbed, + buildPushEmbed, + buildReleaseEmbed, + fetchRepoEvents, + startGithubFeed, + stopGithubFeed, +} from '../../src/modules/githubFeed.js'; +import { safeSend } from '../../src/utils/safeSend.js'; + +/** Helper: build a base GitHub event object */ +function makeEvent(overrides = {}) { + return { + id: '12345', + type: 'PushEvent', + actor: { login: 'testuser', avatar_url: 'https://example.com/avatar.png' }, + repo: { name: 'owner/repo' }, + created_at: '2026-02-27T10:00:00Z', + payload: {}, + ...overrides, + }; +} + +describe('fetchRepoEvents', () => { + it('should call gh api and return parsed JSON', async () => { + const fakeEvents = [{ id: '1', type: 'PushEvent' }]; + execFile.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, { stdout: JSON.stringify(fakeEvents) }); + }); + + const result = await fetchRepoEvents('VolvoxLLC', 'volvox-bot'); + expect(result).toEqual(fakeEvents); + expect(execFile).toHaveBeenCalledWith( + 'gh', + ['api', 'repos/VolvoxLLC/volvox-bot/events?per_page=10'], + { timeout: 30_000 }, + expect.any(Function), + ); + }); + + it('should return empty array for empty stdout', async () => { + execFile.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, { stdout: '' }); + }); + const result = await fetchRepoEvents('owner', 'repo'); + expect(result).toEqual([]); + }); + + it('should throw on gh CLI error', async () => { + execFile.mockImplementation((_cmd, _args, _opts, cb) => { + cb(new Error('gh: not found')); + }); + await expect(fetchRepoEvents('owner', 'repo')).rejects.toThrow('gh: not found'); + }); +}); + +describe('buildPrEmbed', () => { + it('should build green embed for opened PR', () => { + const event = makeEvent({ + type: 'PullRequestEvent', + payload: { + action: 'opened', + pull_request: { + number: 42, + title: 'Add feature X', + html_url: 'https://github.com/owner/repo/pull/42', + additions: 100, + deletions: 10, + }, + }, + }); + const embed = buildPrEmbed(event); + expect(embed).not.toBeNull(); + expect(embed._data.color).toBe(0x2ecc71); + expect(embed._data.title).toContain('#42'); + expect(embed._data.title).toContain('Add feature X'); + }); + + it('should build purple embed for merged PR', () => { + const event = makeEvent({ + type: 'PullRequestEvent', + payload: { + action: 'closed', + pull_request: { + number: 43, + title: 'Merge something', + html_url: 'https://github.com/owner/repo/pull/43', + merged: true, + }, + }, + }); + const embed = buildPrEmbed(event); + expect(embed).not.toBeNull(); + expect(embed._data.color).toBe(0x9b59b6); + }); + + it('should build red embed for closed (not merged) PR', () => { + const event = makeEvent({ + type: 'PullRequestEvent', + payload: { + action: 'closed', + pull_request: { + number: 44, + title: 'Closed PR', + html_url: 'https://github.com/owner/repo/pull/44', + merged: false, + }, + }, + }); + const embed = buildPrEmbed(event); + expect(embed).not.toBeNull(); + expect(embed._data.color).toBe(0xe74c3c); + }); + + it('should return null for unhandled PR action', () => { + const event = makeEvent({ + type: 'PullRequestEvent', + payload: { + action: 'labeled', + pull_request: { number: 45, title: 'PR', html_url: 'https://x.com' }, + }, + }); + const embed = buildPrEmbed(event); + expect(embed).toBeNull(); + }); + + it('should return null for missing pull_request', () => { + const event = makeEvent({ type: 'PullRequestEvent', payload: { action: 'opened' } }); + expect(buildPrEmbed(event)).toBeNull(); + }); +}); + +describe('buildIssueEmbed', () => { + it('should build blue embed for opened issue', () => { + const event = makeEvent({ + type: 'IssuesEvent', + payload: { + action: 'opened', + issue: { + number: 51, + title: 'GitHub feed', + html_url: 'https://github.com/owner/repo/issues/51', + labels: [{ name: 'enhancement' }], + assignee: { login: 'bill' }, + }, + }, + }); + const embed = buildIssueEmbed(event); + expect(embed).not.toBeNull(); + expect(embed._data.color).toBe(0x3498db); + expect(embed._data.title).toContain('#51'); + const labelField = embed._data.fields.find((f) => f.name === 'Labels'); + expect(labelField?.value).toBe('enhancement'); + const assigneeField = embed._data.fields.find((f) => f.name === 'Assignee'); + expect(assigneeField?.value).toBe('bill'); + }); + + it('should build red embed for closed issue', () => { + const event = makeEvent({ + type: 'IssuesEvent', + payload: { + action: 'closed', + issue: { number: 52, title: 'Bug', html_url: 'https://x.com', labels: [], assignee: null }, + }, + }); + const embed = buildIssueEmbed(event); + expect(embed._data.color).toBe(0xe74c3c); + }); + + it('should return null for unhandled action', () => { + const event = makeEvent({ + type: 'IssuesEvent', + payload: { + action: 'assigned', + issue: { number: 53, title: 'X', html_url: 'https://x.com' }, + }, + }); + expect(buildIssueEmbed(event)).toBeNull(); + }); +}); + +describe('buildReleaseEmbed', () => { + it('should build gold embed for a release', () => { + const event = makeEvent({ + type: 'ReleaseEvent', + payload: { + release: { + tag_name: 'v1.2.0', + html_url: 'https://github.com/owner/repo/releases/tag/v1.2.0', + body: 'Added cool features!', + }, + }, + }); + const embed = buildReleaseEmbed(event); + expect(embed).not.toBeNull(); + expect(embed._data.color).toBe(0xf1c40f); + expect(embed._data.title).toContain('v1.2.0'); + const notesField = embed._data.fields.find((f) => f.name === 'Notes'); + expect(notesField?.value).toBe('Added cool features!'); + }); + + it('should truncate body to 200 chars', () => { + const longBody = 'x'.repeat(300); + const event = makeEvent({ + type: 'ReleaseEvent', + payload: { + release: { + tag_name: 'v2.0.0', + html_url: 'https://x.com', + body: longBody, + }, + }, + }); + const embed = buildReleaseEmbed(event); + const notesField = embed._data.fields.find((f) => f.name === 'Notes'); + expect(notesField?.value.length).toBe(200); + }); + + it('should return null for missing release', () => { + const event = makeEvent({ type: 'ReleaseEvent', payload: {} }); + expect(buildReleaseEmbed(event)).toBeNull(); + }); +}); + +describe('buildPushEmbed', () => { + it('should build gray embed for push event', () => { + const event = makeEvent({ + type: 'PushEvent', + payload: { + ref: 'refs/heads/main', + commits: [ + { sha: 'abc1234', message: 'fix: something' }, + { sha: 'def5678', message: 'feat: another thing' }, + ], + }, + }); + const embed = buildPushEmbed(event); + expect(embed).not.toBeNull(); + expect(embed._data.color).toBe(0x95a5a6); + expect(embed._data.title).toContain('main'); + expect(embed._data.title).toContain('2 commit'); + }); + + it('should show only first 3 commits', () => { + const commits = Array.from({ length: 5 }, (_, i) => ({ + sha: `sha${i}`, + message: `commit ${i}`, + })); + const event = makeEvent({ + type: 'PushEvent', + payload: { ref: 'refs/heads/feat', commits }, + }); + const embed = buildPushEmbed(event); + const commitField = embed._data.fields.find((f) => f.name === 'Commits'); + const lines = commitField.value.split('\n'); + expect(lines).toHaveLength(3); + }); + + it('should return null for empty commits', () => { + const event = makeEvent({ + type: 'PushEvent', + payload: { ref: 'refs/heads/main', commits: [] }, + }); + expect(buildPushEmbed(event)).toBeNull(); + }); + + it('should return null for missing payload', () => { + const event = makeEvent({ type: 'PushEvent', payload: null }); + expect(buildPushEmbed(event)).toBeNull(); + }); +}); + +describe('buildEmbed', () => { + const enabledAll = ['pr', 'issue', 'release', 'push']; + + it('should dispatch PullRequestEvent', () => { + const event = makeEvent({ + type: 'PullRequestEvent', + payload: { + action: 'opened', + pull_request: { number: 1, title: 'T', html_url: 'https://x.com' }, + }, + }); + expect(buildEmbed(event, enabledAll)).not.toBeNull(); + }); + + it('should dispatch IssuesEvent', () => { + const event = makeEvent({ + type: 'IssuesEvent', + payload: { + action: 'opened', + issue: { number: 1, title: 'T', html_url: 'https://x.com', labels: [] }, + }, + }); + expect(buildEmbed(event, enabledAll)).not.toBeNull(); + }); + + it('should dispatch ReleaseEvent', () => { + const event = makeEvent({ + type: 'ReleaseEvent', + payload: { release: { tag_name: 'v1.0.0', html_url: 'https://x.com', body: '' } }, + }); + expect(buildEmbed(event, enabledAll)).not.toBeNull(); + }); + + it('should dispatch PushEvent', () => { + const event = makeEvent({ + type: 'PushEvent', + payload: { ref: 'refs/heads/main', commits: [{ sha: 'abc', message: 'test' }] }, + }); + expect(buildEmbed(event, enabledAll)).not.toBeNull(); + }); + + it('should return null for unknown event type', () => { + const event = makeEvent({ type: 'WatchEvent' }); + expect(buildEmbed(event, enabledAll)).toBeNull(); + }); + + it('should return null when event type is not in enabledEvents', () => { + const event = makeEvent({ + type: 'PullRequestEvent', + payload: { + action: 'opened', + pull_request: { number: 1, title: 'T', html_url: 'https://x.com' }, + }, + }); + expect(buildEmbed(event, ['issue', 'release', 'push'])).toBeNull(); + }); +}); + +describe('startGithubFeed / stopGithubFeed', () => { + let mockPool; + let mockClient; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + }; + getPool.mockReturnValue(mockPool); + + mockClient = { + guilds: { + cache: new Map([['guild-1', {}]]), + }, + channels: { + fetch: vi.fn().mockResolvedValue({ + id: 'ch-1', + send: vi.fn().mockResolvedValue({}), + }), + }, + }; + + getConfig.mockReturnValue({ + github: { + feed: { + enabled: false, + channelId: null, + repos: [], + events: ['pr', 'issue', 'release', 'push'], + pollIntervalMinutes: 5, + }, + }, + }); + }); + + afterEach(() => { + stopGithubFeed(); + vi.useRealTimers(); + }); + + it('should start and stop without errors', () => { + expect(() => startGithubFeed(mockClient)).not.toThrow(); + expect(() => stopGithubFeed()).not.toThrow(); + }); + + it('should not double-start', () => { + startGithubFeed(mockClient); + // Starting again should be a no-op (interval is already set) + expect(() => startGithubFeed(mockClient)).not.toThrow(); + stopGithubFeed(); + }); + + it('should skip guilds with feed disabled', async () => { + getConfig.mockReturnValue({ + github: { feed: { enabled: false } }, + }); + + startGithubFeed(mockClient); + + // Advance past the initial 5s poll delay only (don't run interval forever) + await vi.advanceTimersByTimeAsync(6_000); + + expect(safeSend).not.toHaveBeenCalled(); + stopGithubFeed(); + }); + + it('should dedup events — not post already-seen events', async () => { + // First call returns a DB row showing last_event_id = '999' + // Second call (upsert) just resolves + mockPool.query + .mockResolvedValueOnce({ rows: [{ last_event_id: '999' }] }) // SELECT + .mockResolvedValueOnce({ rows: [] }); // upsert + + execFile.mockImplementation((_cmd, _args, _opts, cb) => { + // Event ID '999' is same as last_event_id → no new events + cb(null, { + stdout: JSON.stringify([ + { + id: '999', + type: 'WatchEvent', + created_at: '2026-01-01T00:00:00Z', + actor: {}, + repo: { name: 'o/r' }, + payload: {}, + }, + ]), + }); + }); + + getConfig.mockReturnValue({ + github: { + feed: { + enabled: true, + channelId: 'ch-1', + repos: ['owner/repo'], + events: ['pr', 'issue', 'release', 'push'], + pollIntervalMinutes: 5, + }, + }, + }); + + startGithubFeed(mockClient); + // Advance past the initial 5s poll delay only + await vi.advanceTimersByTimeAsync(6_000); + + expect(safeSend).not.toHaveBeenCalled(); + }); + + it('should correctly dedup with numeric ordering (9 < 10)', async () => { + // String comparison: '9' > '10' (lexicographic) — would incorrectly suppress the event + // BigInt comparison: 10n > 9n — correctly passes the event through + mockPool.query + .mockResolvedValueOnce({ rows: [{ last_event_id: '9' }] }) // SELECT → last seen is '9' + .mockResolvedValueOnce({ rows: [] }); // upsert + + execFile.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, { + stdout: JSON.stringify([ + { + id: '10', + type: 'PushEvent', + created_at: '2026-01-01T00:00:01Z', + actor: { login: 'dev', avatar_url: 'https://example.com/avatar.png' }, + repo: { name: 'owner/repo' }, + payload: { + ref: 'refs/heads/main', + commits: [{ sha: 'abc1234', message: 'fix: something' }], + }, + }, + ]), + }); + }); + + getConfig.mockReturnValue({ + github: { + feed: { + enabled: true, + channelId: 'ch-1', + repos: ['owner/repo'], + events: ['pr', 'issue', 'release', 'push'], + }, + }, + }); + + startGithubFeed(mockClient); + await vi.advanceTimersByTimeAsync(6_000); + + // Event id '10' > last_event_id '9' numerically — safeSend should be called + expect(safeSend).toHaveBeenCalledTimes(1); + stopGithubFeed(); + }); +});