Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@
"modlog": "moderator",
"announce": "moderator",
"tldr": "everyone",
"afk": "everyone"
"afk": "everyone",
"github": "everyone"
}
},
"help": {
Expand All @@ -161,6 +162,15 @@
"poll": {
"enabled": false
},
"github": {
"feed": {
"enabled": false,
"channelId": null,
"repos": [],
"events": ["pr", "issue", "release", "push"],
"pollIntervalMinutes": 5
}
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning: Missing README.md documentation for github.feed config section

Per AGENTS.md (line 180): "if you add a new config section or key, document it in README.md's config reference". The README has a ⚙️ Configuration section documenting all other config blocks (ai, triage, welcome, moderation, etc.) but github.feed is missing.

Also: the gh CLI is a runtime prerequisite that should be listed in README's Prerequisites section (currently only lists Node.js 22+, Discord bot token, and PostgreSQL).

"tldr": {
"enabled": false,
"defaultMessages": 50,
Expand Down
27 changes: 27 additions & 0 deletions migrations/005_github-feed.cjs
Original file line number Diff line number Diff line change
@@ -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');
};
2 changes: 1 addition & 1 deletion src/api/routes/moderation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}

Expand Down
227 changes: 227 additions & 0 deletions src/commands/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* 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 } 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
repos.push(repo);
await setConfigValue('github.feed.repos', repos, 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) {
const pool = getPool();
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
await pool.query('DELETE FROM github_feed_state WHERE guild_id = $1 AND repo = $2', [
interaction.guildId,
repo,
]);

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}>.`,
});
}
5 changes: 4 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -454,6 +456,7 @@ async function startup() {
if (dbPool) {
startTempbanScheduler(client);
startScheduler(client);
startGithubFeed(client);
}

// Load commands and login
Expand Down
Loading
Loading