gitUtils added

This commit is contained in:
jrmyr 2025-05-01 21:10:20 +00:00
parent 1f99e26b50
commit c35aeec42f
2 changed files with 84 additions and 59 deletions

View File

@ -1,77 +1,102 @@
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
const execAsync = promisify(exec); const execAsync = promisify(exec);
/** // Wrap Git errors
* Git-related slash commands: /gitstatus and /gitpull class GitError extends Error {
*/ constructor(message) {
super(message);
this.name = 'GitError';
}
}
// Run `git <args>` and return trimmed output or throw
async function runGit(args) {
try {
const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`);
const out = stdout.trim() || stderr.trim();
return out || '(no output)';
} catch (err) {
const msg = err.stderr?.trim() || err.message;
throw new GitError(msg);
}
}
// Wrap content in Markdown code block
function formatCodeBlock(content, lang = '') {
const fence = '```';
return lang
? `${fence}${lang}\n${content}\n${fence}`
: `${fence}\n${content}\n${fence}`;
}
// Split string into chunks of at most chunkSize
function chunkString(str, chunkSize) {
const chunks = [];
for (let i = 0; i < str.length; i += chunkSize) {
chunks.push(str.slice(i, i + chunkSize));
}
return chunks;
}
// Single /git command: run arbitrary git <args>
export const commands = [ export const commands = [
// Show current branch and commit status
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('gitstatus') .setName('git')
.setDescription('Show current git branch and remote status'), .setDescription('Run an arbitrary git command (owner only)')
async execute(interaction, client) { .addStringOption(opt =>
try { opt.setName('args')
// Get current branch and commit .setDescription('Arguments to pass to git')
const { stdout: branchOut } = await execAsync('git rev-parse --abbrev-ref HEAD'); .setRequired(true))
const branch = branchOut.trim(); .addBooleanOption(opt =>
const { stdout: localOut } = await execAsync('git rev-parse HEAD'); opt.setName('ephemeral')
const local = localOut.trim().slice(0, 7); .setDescription('Make the reply ephemeral')
// Fetch updates and get remote commit .setRequired(false)),
await execAsync('git fetch --quiet');
const { stdout: remoteOut } = await execAsync(`git rev-parse origin/${branch}`);
const remote = remoteOut.trim().slice(0, 7);
// Determine ahead/behind counts
const { stdout: behindOut } = await execAsync(`git rev-list --count HEAD..origin/${branch}`);
const behind = parseInt(behindOut.trim(), 10);
const { stdout: aheadOut } = await execAsync(`git rev-list --count origin/${branch}..HEAD`);
const ahead = parseInt(aheadOut.trim(), 10);
const status = (ahead === 0 && behind === 0)
? 'Up-to-date' : `${ahead} ahead, ${behind} behind`;
// Reply with status
await interaction.reply({
content: `Branch: \`${branch}\`\nLocal: \`${local}\`\nRemote: \`${remote}\`\nStatus: ${status}`,
ephemeral: true
});
} catch (err) {
client.logger.error(`Error in gitstatus: ${err.message}`);
await interaction.reply({ content: `Failed to get git status: ${err.message}`, ephemeral: true });
}
}
},
// Pull latest changes and restart bot (owner only)
{
data: new SlashCommandBuilder()
.setName('gitpull')
.setDescription('Pull latest changes and restart bot (Owner only)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction, client) { async execute(interaction, client) {
const ownerId = client.config.owner; const ownerId = client.config.owner;
if (interaction.user.id !== ownerId) { if (interaction.user.id !== ownerId) {
return interaction.reply({ content: 'Only the bot owner can perform this action.', ephemeral: true }); return interaction.reply({ content: 'Only the bot owner can run git commands.', ephemeral: true });
} }
await interaction.deferReply({ ephemeral: true }); const raw = interaction.options.getString('args');
// Disallow semicolons to prevent command chaining
if (raw.includes(';')) {
return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', ephemeral: true });
}
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g)
.map(s => s.replace(/^"(.+)"$/, '$1'));
try { try {
// Pull with fast-forward only // Log the exact git command being executed
const { stdout, stderr } = await execAsync('git pull --ff-only'); const cmdStr = args.join(' ');
const output = stdout.trim() || stderr.trim(); client.logger.warn(`Executing git command: git ${cmdStr}`);
await interaction.editReply({ const output = await runGit(args);
content: `Git pull output:\n\`\`\`\n${output}\n\`\`\`\nRestarting...` // Prepend the git command as a header; keep it intact when chunking
}); const header = `git ${cmdStr}\n`;
setTimeout(() => process.exit(0), 1000); // Discord message limit ~2000; reserve for code fences
const maxContent = 1990;
// Calculate how much output can fit after the header in the first chunk
const firstChunkSize = Math.max(0, maxContent - header.length);
// Split the raw output into chunks
const outputChunks = chunkString(output, firstChunkSize);
// Send first block with header + first output chunk
const firstBlock = header + (outputChunks[0] || '');
await interaction.reply({ content: formatCodeBlock(firstBlock), ephemeral });
// Send any remaining blocks without the header
for (let i = 1; i < outputChunks.length; i++) {
await interaction.followUp({ content: formatCodeBlock(outputChunks[i]), ephemeral });
}
} catch (err) { } catch (err) {
client.logger.error(`Error in gitpull: ${err.message}`); const msg = err instanceof GitError ? err.message : String(err);
await interaction.editReply({ content: `Git pull failed: ${err.message}`, ephemeral: true }); await interaction.reply({ content: `Error: ${msg}`, ephemeral: true });
} }
} }
} }
]; ];
/** // No special init logic
* No special init logic for git utilities export async function init(client) {
*/ client.logger.warn('Git utilities module loaded - dangerous module, use with caution');
export async function init(client, config) {
client.logger.info('Git utilities module loaded');
} }

View File

@ -7,7 +7,7 @@ export default {
{ {
id: 'IO3', id: 'IO3',
enabled: true, enabled: true,
owner: 378741522822070272, owner: process.env.OWNER_ID,
discord: { discord: {
appId: process.env.IO3_DISCORD_APPID, appId: process.env.IO3_DISCORD_APPID,