From bbf997c2235bce7446ec0b384b452faa1c15aaac Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 30 Dec 2025 11:22:58 -0500 Subject: [PATCH 1/8] Adding in fixed --- README.md | 7 +- src/commands/faq/services.ts | 6 - src/commands/faq/store.ts | 104 ------------------ src/commands/faq/subcommands/add.ts | 2 +- src/commands/faq/subcommands/get.ts | 2 +- src/commands/faq/subcommands/list.ts | 2 +- src/commands/faq/subcommands/remove.ts | 2 +- src/commands/faq/types.ts | 37 ------- src/registerCommands.ts | 92 ++++++++++------ .../subcommands => services/faq}/_shared.ts | 8 +- src/services/faq/services.ts | 10 +- src/services/faq/types.ts | 36 ++++++ 12 files changed, 108 insertions(+), 200 deletions(-) delete mode 100644 src/commands/faq/services.ts delete mode 100644 src/commands/faq/store.ts delete mode 100644 src/commands/faq/types.ts rename src/{commands/faq/subcommands => services/faq}/_shared.ts (96%) diff --git a/README.md b/README.md index a9677c03..9c50a1b2 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,11 @@ OmegaBot is online │ │ │ └── changelog.ts │ │ ├── faq │ │ │ ├── subcommands -│ │ │ │ ├── _shared.ts │ │ │ │ ├── add.ts │ │ │ │ ├── get.ts │ │ │ │ ├── list.ts │ │ │ │ └── remove.ts -│ │ │ ├── faq.ts -│ │ │ ├── services.ts -│ │ │ ├── store.ts -│ │ │ └── types.ts +│ │ │ └── faq.ts │ │ ├── fun │ │ │ ├── subcommands │ │ │ │ ├── chucknorris.ts @@ -214,6 +210,7 @@ OmegaBot is online │ │ │ ├── fetchChannelMessages.ts │ │ │ └── interactionHandler.ts │ │ ├── faq +│ │ │ ├── _shared.ts │ │ │ ├── faqService.ts │ │ │ ├── permissions.ts │ │ │ ├── services.test.ts diff --git a/src/commands/faq/services.ts b/src/commands/faq/services.ts deleted file mode 100644 index 367fb53a..00000000 --- a/src/commands/faq/services.ts +++ /dev/null @@ -1,6 +0,0 @@ -// src/commands/faq/services.ts -// -// Thin re-export so command subcommands can import from "../services.js" -// without reaching into src/services/faq directly. - -export * from "../../services/faq/services.js"; diff --git a/src/commands/faq/store.ts b/src/commands/faq/store.ts deleted file mode 100644 index 6f1216a8..00000000 --- a/src/commands/faq/store.ts +++ /dev/null @@ -1,104 +0,0 @@ -// src/services/faq/store.ts -// -// Low-level persistence layer for FAQ data. -// -// Responsibilities: -// - Own the on-disk storage format and location -// - Ensure the data file exists -// - Load and save the FAQ store safely -// -// Non-goals: -// - No business rules (validation, permissions, etc.) -// - No Discord logic -// - No mutation decisions (that belongs in services.ts) -// -// This file is intentionally boring and predictable. - -import fs from "fs"; -import path from "path"; -import { logger } from "../../utils/logger.js"; -import type { FaqStoreV1 } from "./types.js"; - -/** - * Absolute path to the FAQ JSON store. - * - * Using process.cwd() keeps the data location stable regardless - * of where the code is imported from. - */ -const STORE_PATH = path.join(process.cwd(), "data", "faqs.json"); - -/** - * Canonical empty store for version 1. - * - * This acts as: - * - The initial file contents - * - A fallback if loading/parsing fails - */ -const EMPTY_STORE: FaqStoreV1 = { - version: 1, - entries: {}, -}; - -/** - * Ensure the FAQ store file exists on disk. - * - * Behavior: - * - If the file already exists, do nothing - * - If missing, create the parent directory and write EMPTY_STORE - * - * This function is idempotent and safe to call repeatedly. - */ -export function ensureStoreFile(): void { - if (fs.existsSync(STORE_PATH)) return; - - fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); - fs.writeFileSync(STORE_PATH, JSON.stringify(EMPTY_STORE, null, 2), "utf8"); - - logger.info("[faq] created empty faq store"); -} - -/** - * Load the FAQ store from disk. - * - * Guarantees: - * - Always returns a valid FaqStoreV1 object - * - * Failure handling: - * - If the file is missing, it will be created - * - If JSON parsing fails or the shape is invalid, - * logs the error and falls back to EMPTY_STORE - * - * This prevents runtime crashes from malformed data. - */ -export function loadStore(): FaqStoreV1 { - ensureStoreFile(); - - try { - const raw = fs.readFileSync(STORE_PATH, "utf8"); - const parsed = JSON.parse(raw) as FaqStoreV1; - - // Minimal schema guard so downstream code can trust the shape - if (parsed.version !== 1 || typeof parsed.entries !== "object" || !parsed.entries) { - throw new Error("Invalid FAQ store shape"); - } - - return parsed; - } catch (err) { - logger.error({ err }, "[faq] failed to load store, using empty store"); - return { ...EMPTY_STORE }; - } -} - -/** - * Persist the FAQ store back to disk. - * - * Notes: - * - Overwrites the entire file atomically - * - Pretty-prints JSON for human inspection and diffs - * - * Any caller of this function is responsible for ensuring - * the store object is already valid. - */ -export function saveStore(store: FaqStoreV1): void { - fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); -} diff --git a/src/commands/faq/subcommands/add.ts b/src/commands/faq/subcommands/add.ts index a56bebfb..ac03ca2a 100644 --- a/src/commands/faq/subcommands/add.ts +++ b/src/commands/faq/subcommands/add.ts @@ -16,7 +16,7 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { logger } from "../../../utils/logger.js"; import { create } from "../../../services/faq/services.js"; -import { guardFaqAction, parseTags, handleFaqSubcommandError } from "./_shared.js"; +import { guardFaqAction, parseTags, handleFaqSubcommandError } from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/commands/faq/subcommands/get.ts b/src/commands/faq/subcommands/get.ts index 28f28043..0095f446 100644 --- a/src/commands/faq/subcommands/get.ts +++ b/src/commands/faq/subcommands/get.ts @@ -16,7 +16,7 @@ import { readRequiredKey, formatFaqEntry, handleFaqSubcommandError, -} from "./_shared.js"; +} from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/commands/faq/subcommands/list.ts b/src/commands/faq/subcommands/list.ts index c0c253ea..e2118d58 100644 --- a/src/commands/faq/subcommands/list.ts +++ b/src/commands/faq/subcommands/list.ts @@ -17,7 +17,7 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { getAll } from "../../../services/faq/services.js"; -import { guardFaqAction, formatFaqList, handleFaqSubcommandError } from "./_shared.js"; +import { guardFaqAction, formatFaqList, handleFaqSubcommandError } from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/commands/faq/subcommands/remove.ts b/src/commands/faq/subcommands/remove.ts index cbd4ffd7..155f9254 100644 --- a/src/commands/faq/subcommands/remove.ts +++ b/src/commands/faq/subcommands/remove.ts @@ -17,7 +17,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "dis import { logger } from "../../../utils/logger.js"; import { getByKey, remove } from "../../../services/faq/services.js"; -import { guardFaqAction, readRequiredKey, handleFaqSubcommandError } from "./_shared.js"; +import { guardFaqAction, readRequiredKey, handleFaqSubcommandError } from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/commands/faq/types.ts b/src/commands/faq/types.ts deleted file mode 100644 index 4b13a059..00000000 --- a/src/commands/faq/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -// src/services/faq/types.ts - -// Constants (values live here so they can be imported as real values) -export const MAX_KEY_LEN = 48; -export const MAX_TITLE_LEN = 80; // pick something reasonable -export const MAX_BODY_LEN = 4000; // pick something reasonable -export const MAX_TAGS = 10; -export const MAX_TAG_LEN = 24; - -export type FaqEntry = { - key: string; - title: string; - body: string; - tags: string[]; - createdAt: string; // ISO - updatedAt: string; // ISO - createdBy: string; // user id or "system" - updatedBy: string; // user id or "system" - usageCount: number; -}; - -export type FaqStoreV1 = { - version: 1; - entries: Record; // key -> entry -}; - -export type CreateFaqInput = { - key: string; - title: string; - body: string; - tags?: string[]; - actor: string; -}; - -export type UpdateFaqPatch = Partial> & { - actor: string; -}; diff --git a/src/registerCommands.ts b/src/registerCommands.ts index d73f0ad5..e0446a12 100644 --- a/src/registerCommands.ts +++ b/src/registerCommands.ts @@ -1,6 +1,9 @@ +// src/registerCommands.ts + import { REST, Routes } from "discord.js"; import fs from "fs"; import path from "path"; +import { pathToFileURL } from "url"; import { env } from "./config/env.js"; import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord-api-types/v10"; import { logger } from "./utils/logger.js"; @@ -9,41 +12,66 @@ import { logger } from "./utils/logger.js"; * Read all compiled command definitions and prepare them for registration * with the Discord API. * - * This function: - * - walks through dist/commands/ - * - loads every compiled .js command file - * - extracts each command’s JSON definition - * - returns them in an array for API registration + * Only registers REAL slash command modules: + * - must export `data` (SlashCommandBuilder) + * - must export `execute` (function) + * + * Helper files (services.js, store.js, _shared.js, etc.) are skipped. */ -async function loadCommandData() { +async function loadCommandData(): Promise< + RESTPostAPIChatInputApplicationCommandsJSONBody[] +> { const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; - // Commands are loaded from compiled JS in dist/ - const basePath = path.join(process.cwd(), "dist/commands"); + const basePath = path.join(process.cwd(), "dist", "commands"); + + if (!fs.existsSync(basePath)) { + logger.warn({ basePath }, "dist/commands not found. Did you run build?"); + return commands; + } + const groups = fs.readdirSync(basePath); for (const group of groups) { const groupPath = path.join(basePath, group); - - // Skip stray files — only process folders if (!fs.statSync(groupPath).isDirectory()) continue; const files = fs.readdirSync(groupPath); for (const file of files) { - // Only load compiled command files if (!file.endsWith(".js")) continue; const modulePath = path.join(groupPath, file); - const mod = await import(modulePath); - - /* - * Each command module exports `data`, - * which contains a SlashCommandBuilder. - * Convert that builder to JSON and push it into the list. - */ - if (mod.data) { - commands.push(mod.data.toJSON()); + const moduleUrl = pathToFileURL(modulePath).href; + + try { + const mod = await import(moduleUrl); + + if (mod?.data && typeof mod.execute === "function") { + const json = mod.data.toJSON(); + + // 👇 Log exactly what is being registered + logger.info( + { + command: json.name, + group, + file, + }, + "Registering slash command", + ); + + commands.push(json); + } else { + logger.debug( + { file: `${group}/${file}` }, + "Skipping non-command module (missing data or execute)", + ); + } + } catch (err) { + logger.warn( + { err, file: `${group}/${file}` }, + "Failed to import command module", + ); } } } @@ -53,30 +81,28 @@ async function loadCommandData() { /* * Register all slash commands with Discord for the configured application & guild. - * - * This uses REST.put() to completely replace the current slash-command set - * for the guild. This makes command updates instant for testing. */ -async function register() { +async function register(): Promise { const rest = new REST({ version: "10" }).setToken(env.token); const commands = await loadCommandData(); - /* - * Overwrite the guild’s existing slash commands with the updated list. - * This is preferred during development because changes propagate immediately. - */ + logger.info( + { + count: commands.length, + commands: commands.map((c) => c.name), + }, + "Final slash command payload", + ); + await rest.put(Routes.applicationGuildCommands(env.appId, env.guildId), { body: commands, }); - logger.info("Commands registered."); + logger.info("Commands registered successfully."); } -/* - * Wrapper so errors throw clearly and stop the script immediately. - */ register().catch((err) => { logger.error(err, "Failed to register commands"); process.exit(1); -}); +}); \ No newline at end of file diff --git a/src/commands/faq/subcommands/_shared.ts b/src/services/faq/_shared.ts similarity index 96% rename from src/commands/faq/subcommands/_shared.ts rename to src/services/faq/_shared.ts index e609b684..4b25d00e 100644 --- a/src/commands/faq/subcommands/_shared.ts +++ b/src/services/faq/_shared.ts @@ -13,11 +13,11 @@ // - No Discord lifecycle (defer/reply) decisions import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; +import { logger } from "../../utils/logger.js"; -import { canFaqAction } from "../../../services/faq/permissions.js"; -import { MAX_KEY_LEN } from "../../../services/faq/types.js"; -import type { FaqEntry } from "../../../services/faq/types.js"; +import { canFaqAction } from "./permissions.js"; +import { MAX_KEY_LEN } from "./types.js"; +import type { FaqEntry } from "./types.js"; export type FaqAction = "add" | "get" | "list" | "remove"; diff --git a/src/services/faq/services.ts b/src/services/faq/services.ts index 539ba8f8..7d9a74ed 100644 --- a/src/services/faq/services.ts +++ b/src/services/faq/services.ts @@ -11,7 +11,7 @@ // - Discord interaction handling (that stays in commands) // - Low-level file I/O details (that stays in store.ts) -import { loadStore, saveStore } from "../../commands/faq/store.js"; +import { loadStore, saveStore } from "./store.js"; import { logger } from "../../utils/logger.js"; import { MAX_KEY_LEN, @@ -19,12 +19,8 @@ import { MAX_BODY_LEN, MAX_TAGS, MAX_TAG_LEN, -} from "../../commands/faq/types.js"; -import type { - FaqEntry, - CreateFaqInput, - UpdateFaqPatch, -} from "../../commands/faq/types.js"; +} from "./types.js"; +import type { FaqEntry, CreateFaqInput, UpdateFaqPatch } from "./types.js"; /** * Return all FAQ entries (unordered). diff --git a/src/services/faq/types.ts b/src/services/faq/types.ts index 011a7c87..e9f68467 100644 --- a/src/services/faq/types.ts +++ b/src/services/faq/types.ts @@ -16,6 +16,31 @@ */ export const MAX_KEY_LEN = 48; +/** + * Max title length after trimming. + * Keep this short so list views stay readable. + */ +export const MAX_TITLE_LEN = 80; + +/** + * Max body length after trimming. + * 4000 is a reasonable starting point and stays under typical Discord limits + * once you add formatting and headers. + */ +export const MAX_BODY_LEN = 4000; + +/** + * Max number of tags allowed on an entry. + * Prevents spam and keeps list filters usable. + */ +export const MAX_TAGS = 10; + +/** + * Max length per tag after trimming. + * Keeps tags compact for list output. + */ +export const MAX_TAG_LEN = 24; + /* -------------------------------------------------------------------------- */ /* Core Types */ /* -------------------------------------------------------------------------- */ @@ -72,3 +97,14 @@ export type CreateFaqInput = { tags?: string[]; actor: string; // user id or "system" }; + +/** + * Allowed fields when updating an FAQ entry. + * + * Notes: + * - actor is required so updatedBy is always attributable. + * - patch fields are optional and validated by the service layer if present. + */ +export type UpdateFaqPatch = Partial> & { + actor: string; +}; From fa981dee0c4bbf46b54c3961f05b27907dbb6a69 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 30 Dec 2025 11:23:25 -0500 Subject: [PATCH 2/8] style: auto-format with Prettier [skip-precheck] --- src/commands/faq/subcommands/add.ts | 6 +++++- src/commands/faq/subcommands/list.ts | 6 +++++- src/commands/faq/subcommands/remove.ts | 6 +++++- src/registerCommands.ts | 7 ++----- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/commands/faq/subcommands/add.ts b/src/commands/faq/subcommands/add.ts index ac03ca2a..d4e5b1b1 100644 --- a/src/commands/faq/subcommands/add.ts +++ b/src/commands/faq/subcommands/add.ts @@ -16,7 +16,11 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { logger } from "../../../utils/logger.js"; import { create } from "../../../services/faq/services.js"; -import { guardFaqAction, parseTags, handleFaqSubcommandError } from "../../../services/faq/_shared.js"; +import { + guardFaqAction, + parseTags, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/commands/faq/subcommands/list.ts b/src/commands/faq/subcommands/list.ts index e2118d58..f0e83eff 100644 --- a/src/commands/faq/subcommands/list.ts +++ b/src/commands/faq/subcommands/list.ts @@ -17,7 +17,11 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { getAll } from "../../../services/faq/services.js"; -import { guardFaqAction, formatFaqList, handleFaqSubcommandError } from "../../../services/faq/_shared.js"; +import { + guardFaqAction, + formatFaqList, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/commands/faq/subcommands/remove.ts b/src/commands/faq/subcommands/remove.ts index 155f9254..21759673 100644 --- a/src/commands/faq/subcommands/remove.ts +++ b/src/commands/faq/subcommands/remove.ts @@ -17,7 +17,11 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "dis import { logger } from "../../../utils/logger.js"; import { getByKey, remove } from "../../../services/faq/services.js"; -import { guardFaqAction, readRequiredKey, handleFaqSubcommandError } from "../../../services/faq/_shared.js"; +import { + guardFaqAction, + readRequiredKey, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { diff --git a/src/registerCommands.ts b/src/registerCommands.ts index e0446a12..4d898b83 100644 --- a/src/registerCommands.ts +++ b/src/registerCommands.ts @@ -68,10 +68,7 @@ async function loadCommandData(): Promise< ); } } catch (err) { - logger.warn( - { err, file: `${group}/${file}` }, - "Failed to import command module", - ); + logger.warn({ err, file: `${group}/${file}` }, "Failed to import command module"); } } } @@ -105,4 +102,4 @@ async function register(): Promise { register().catch((err) => { logger.error(err, "Failed to register commands"); process.exit(1); -}); \ No newline at end of file +}); From 6830b4c240aeb71c01d96e4f6bdfb312b549a7b5 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 30 Dec 2025 15:48:54 -0500 Subject: [PATCH 3/8] Adding in a slight change --- src/config/env.ts | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index af6dbb56..f1bd5815 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -70,10 +70,7 @@ type SummaryMode = "local" | "llm"; * * - GITHUB_TOKEN * Personal Access Token used for authenticated GitHub REST calls. - * Required for: - * - Issue lookup - * - PR lookup - * - PR announcements + * Required only when GitHub-backed features are enabled. * * - GITHUB_OWNER * Default repository owner for polling PRs (optional). @@ -88,11 +85,12 @@ type SummaryMode = "local" | "llm"; * Polling interval for PR announcements. * Defaults to 60 seconds if not provided. * - * All GitHub announcement fields are optional so the bot can run - * without polling enabled. + * All GitHub-related fields are OPTIONAL so the bot can run + * without any GitHub configuration or tokens. */ const summaryMode = (process.env.SUMMARY_MODE ?? "local") as SummaryMode; +// Fail fast ONLY when LLM summaries are explicitly enabled if (summaryMode === "llm" && !process.env.OPENAI_API_KEY) { throw new Error("OPENAI_API_KEY is required when SUMMARY_MODE=llm"); } @@ -120,16 +118,47 @@ export const env = { /* GitHub */ /* ---------------------------------------------------------------- */ - // Auth token for GitHub REST API + // Auth token for GitHub REST API (optional) githubToken: process.env.GITHUB_TOKEN ?? null, // Default repo configuration for PR polling (optional) githubOwner: process.env.GITHUB_OWNER ?? null, githubRepo: process.env.GITHUB_REPO ?? null, - // Where PR announcements should be posted + // Where PR announcements should be posted (optional) githubAnnounceChannelId: process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null, // Polling interval (ms). Defaults to 60s. githubPollIntervalMs: envInt("GITHUB_POLL_INTERVAL_MS", 60_000), -}; + + /** + * Feature gate: + * + * GitHub announcement polling is enabled ONLY when all required + * configuration is present. This prevents the bot from attempting + * GitHub API calls (or requiring tokens) at startup. + */ + githubAnnouncementsEnabled: + Boolean(process.env.GITHUB_TOKEN) && + Boolean(process.env.GITHUB_OWNER) && + Boolean(process.env.GITHUB_REPO) && + Boolean(process.env.GITHUB_ANNOUNCE_CHANNEL_ID), + + /** + * Helper for GitHub-only code paths. + * + * Call this ONLY inside GitHub feature implementations + * (commands, pollers, handlers). + * + * This ensures: + * - The bot can start without GitHub config + * - GitHub features fail loudly and clearly when misconfigured + */ + requireGithubToken(): string { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN is required for this GitHub feature"); + } + return token; + }, +}; \ No newline at end of file From 9f69fcbf76827ffcd20d4d640165bce38698ff1b Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 30 Dec 2025 15:48:58 -0500 Subject: [PATCH 4/8] style: auto-format with Prettier [skip-precheck] --- src/config/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/env.ts b/src/config/env.ts index f1bd5815..34e33b44 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -161,4 +161,4 @@ export const env = { } return token; }, -}; \ No newline at end of file +}; From 2cb52b49186def67eb7410ac8e9b23bca3966fb0 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 30 Dec 2025 15:50:27 -0500 Subject: [PATCH 5/8] Adding more fixes --- src/services/github/githubClient.ts | 22 ++++---- src/services/github/prFormatter.ts | 82 ++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/src/services/github/githubClient.ts b/src/services/github/githubClient.ts index 5738f897..2608a8b7 100644 --- a/src/services/github/githubClient.ts +++ b/src/services/github/githubClient.ts @@ -28,27 +28,29 @@ export class GitHubApiError extends Error { * Low-level GitHub request helper. * * Responsibilities: - * - Validate auth is configured + * - Validate auth is configured (only when GitHub features are invoked) * - Add auth headers * - Perform fetch * - Handle non-OK responses * - Return typed JSON * * Higher-level logic (issues, PRs, fallback behavior) lives in githubApi.ts. + * + * Note: + * This function SHOULD NOT be called at startup unless GitHub features are enabled. + * The bot must be able to run without GitHub tokens/config. */ export async function githubRequest(path: string): Promise { const url = `${GITHUB_API_BASE}${path}`; - if (!env.githubToken) { - // This should be caught at startup, but keep a defensive check here too. - throw new Error("GITHUB_TOKEN is not set in environment"); - } + // Enforce token only when a GitHub code path is actually executed. + const token = env.requireGithubToken(); try { const res = await fetch(url, { headers: { Accept: "application/vnd.github+json", - Authorization: `Bearer ${env.githubToken}`, + Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "OmegaBot", }, @@ -75,13 +77,9 @@ export async function githubRequest(path: string): Promise { return (await res.json()) as T; } catch (err) { - // If it's already our rich error, bubble it up unchanged. - if (err instanceof GitHubApiError) { - throw err; - } + if (err instanceof GitHubApiError) throw err; - // Network errors, DNS failures, timeouts, etc. logger.error({ err, url, path }, "GitHub API request threw"); throw err; } -} +} \ No newline at end of file diff --git a/src/services/github/prFormatter.ts b/src/services/github/prFormatter.ts index 5ceb2d3c..6223f49b 100644 --- a/src/services/github/prFormatter.ts +++ b/src/services/github/prFormatter.ts @@ -1,23 +1,77 @@ -// src/services/github/prFormatter.ts +// src/services/github/prPoller.ts -import type { GitHubPrSummary } from "./types.js"; +import type { Client } from "discord.js"; +import { listPullRequests } from "./githubApi.js"; +import { getLastSeen, setLastSeenPr } from "./lastSeenStore.js"; +import { formatPullRequest } from "./prFormatter.js"; +import { logger } from "../../utils/logger.js"; /** - * PR formatting helpers for Discord output. - * - * Design goals: - * - Keep output readable in a busy channel - * - Use only fields we already have in the PR summary - * - Make it easy to swap formatting later without touching poller logic + * Minimal shape we need from the GitHub PR list for polling announcements. + * githubApi.listPullRequests must return objects with `updated_at`. */ +type PrForPolling = { + updated_at: string; +}; /** - * 2-line PR output (readable, compact): - * Line 1: "#123 Title (by author)" - * Line 2: URL + * Poll GitHub for new or updated PRs and announce them in a Discord channel. + * + * Design goals: + * - Never throw (background job safety) + * - Log failures once with context + * - Skip quietly when nothing to do */ -export function formatPullRequest(pr: GitHubPrSummary): string { - const author = pr.user?.login ?? "unknown"; +export async function pollPullRequestsOnce(args: { + client: Client; + owner: string; + repo: string; + announceChannelId: string; + limit?: number; +}): Promise { + const { client, owner, repo, announceChannelId, limit = 20 } = args; + + try { + // Pull newest-updated first (based on githubApi query params) + const prs = (await listPullRequests(owner, repo, { + state: "open", + limit, + })) as unknown as (PrForPolling & Parameters[0])[]; + + if (prs.length === 0) return; + + const lastSeen = getLastSeen(owner, repo) ?? 0; + + // Keep only PRs updated after our stored timestamp + const fresh = prs.filter((pr) => { + const ms = Date.parse(pr.updated_at); + return !Number.isNaN(ms) && ms > lastSeen; + }); + + if (fresh.length === 0) return; + + const fetched = await client.channels.fetch(announceChannelId); + if (!fetched || !fetched.isTextBased()) { + logger.warn({ announceChannelId }, "PR poller could not resolve announce channel"); + return; + } + + // Extra runtime guard + if (!("send" in fetched) || typeof fetched.send !== "function") return; + + // Post oldest-first so announcements read naturally + const oldestFirst = [...fresh].sort( + (a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at), + ); + + for (const pr of oldestFirst) { + await fetched.send(formatPullRequest(pr)); + } - return [`#${pr.number} ${pr.title} (by ${author})`, pr.html_url].join("\n"); + // Update last-seen to newest PR we announced + const newest = oldestFirst[oldestFirst.length - 1]; + setLastSeenPr(owner, repo, newest.updated_at); + } catch (err) { + logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed"); + } } From 4167e9455d5187c813f22fffe6961d445f0133e6 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 30 Dec 2025 15:50:31 -0500 Subject: [PATCH 6/8] style: auto-format with Prettier [skip-precheck] --- src/services/github/githubClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/github/githubClient.ts b/src/services/github/githubClient.ts index 2608a8b7..4d62e23d 100644 --- a/src/services/github/githubClient.ts +++ b/src/services/github/githubClient.ts @@ -82,4 +82,4 @@ export async function githubRequest(path: string): Promise { logger.error({ err, url, path }, "GitHub API request threw"); throw err; } -} \ No newline at end of file +} From 5c1421702ec02a341614b2af56d764d079b85350 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 31 Dec 2025 09:26:32 -0500 Subject: [PATCH 7/8] Adding in changes --- src/config/env.ts | 13 +++- src/registerCommands.ts | 25 +++++-- src/services/github/prFormatter.ts | 114 ++++++++++++----------------- 3 files changed, 78 insertions(+), 74 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 34e33b44..2a4c8c03 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -51,7 +51,8 @@ type SummaryMode = "local" | "llm"; * Application ID required for slash command registration. * * - DISCORD_GUILD_ID - * Guild where development commands are registered. + * OPTIONAL: Guild where development commands are registered. + * If not set, commands should be registered globally instead. * * ------------------------------------------------------------------ * Optional (feature flags / enhancements) @@ -102,7 +103,13 @@ export const env = { token: requireEnv("DISCORD_TOKEN"), appId: requireEnv("DISCORD_APP_ID"), - guildId: requireEnv("DISCORD_GUILD_ID"), + + /** + * Optional: + * Used for faster slash-command iteration during development. + * If unset, you can register commands globally instead. + */ + guildId: process.env.DISCORD_GUILD_ID ?? null, /* ---------------------------------------------------------------- */ /* Summaries */ @@ -161,4 +168,4 @@ export const env = { } return token; }, -}; +}; \ No newline at end of file diff --git a/src/registerCommands.ts b/src/registerCommands.ts index 4d898b83..7bb67600 100644 --- a/src/registerCommands.ts +++ b/src/registerCommands.ts @@ -68,7 +68,10 @@ async function loadCommandData(): Promise< ); } } catch (err) { - logger.warn({ err, file: `${group}/${file}` }, "Failed to import command module"); + logger.warn( + { err, file: `${group}/${file}` }, + "Failed to import command module", + ); } } } @@ -77,7 +80,10 @@ async function loadCommandData(): Promise< } /* - * Register all slash commands with Discord for the configured application & guild. + * Register all slash commands with Discord for the configured application. + * + * If DISCORD_GUILD_ID is set, we register to that guild (fast iteration). + * Otherwise we register globally (can take longer to propagate). */ async function register(): Promise { const rest = new REST({ version: "10" }).setToken(env.token); @@ -92,14 +98,23 @@ async function register(): Promise { "Final slash command payload", ); - await rest.put(Routes.applicationGuildCommands(env.appId, env.guildId), { + if (env.guildId) { + await rest.put(Routes.applicationGuildCommands(env.appId, env.guildId), { + body: commands, + }); + + logger.info({ guildId: env.guildId }, "Commands registered to guild."); + return; + } + + await rest.put(Routes.applicationCommands(env.appId), { body: commands, }); - logger.info("Commands registered successfully."); + logger.info("Commands registered globally."); } register().catch((err) => { logger.error(err, "Failed to register commands"); process.exit(1); -}); +}); \ No newline at end of file diff --git a/src/services/github/prFormatter.ts b/src/services/github/prFormatter.ts index 6223f49b..db5ab71c 100644 --- a/src/services/github/prFormatter.ts +++ b/src/services/github/prFormatter.ts @@ -1,77 +1,59 @@ -// src/services/github/prPoller.ts +// src/services/github/prFormatter.ts -import type { Client } from "discord.js"; -import { listPullRequests } from "./githubApi.js"; -import { getLastSeen, setLastSeenPr } from "./lastSeenStore.js"; -import { formatPullRequest } from "./prFormatter.js"; -import { logger } from "../../utils/logger.js"; +import type { GitHubPullRequest } from "./types.js"; -/** - * Minimal shape we need from the GitHub PR list for polling announcements. - * githubApi.listPullRequests must return objects with `updated_at`. - */ -type PrForPolling = { - updated_at: string; -}; +/* ------------------------------------------------------------------ */ +/* Formatting helpers */ +/* ------------------------------------------------------------------ */ /** - * Poll GitHub for new or updated PRs and announce them in a Discord channel. + * Format a single GitHub pull request into a Discord-friendly message. * * Design goals: - * - Never throw (background job safety) - * - Log failures once with context - * - Skip quietly when nothing to do + * - Pure function (no I/O, no Discord client usage) + * - Safe to reuse in commands and background pollers + * - Stable output for future diffing / testing + * + * IMPORTANT: + * This file must NOT import itself or re-export via a barrel, + * otherwise TypeScript will create circular alias errors. */ -export async function pollPullRequestsOnce(args: { - client: Client; - owner: string; - repo: string; - announceChannelId: string; - limit?: number; -}): Promise { - const { client, owner, repo, announceChannelId, limit = 20 } = args; - - try { - // Pull newest-updated first (based on githubApi query params) - const prs = (await listPullRequests(owner, repo, { - state: "open", - limit, - })) as unknown as (PrForPolling & Parameters[0])[]; - - if (prs.length === 0) return; - - const lastSeen = getLastSeen(owner, repo) ?? 0; - - // Keep only PRs updated after our stored timestamp - const fresh = prs.filter((pr) => { - const ms = Date.parse(pr.updated_at); - return !Number.isNaN(ms) && ms > lastSeen; - }); - - if (fresh.length === 0) return; - const fetched = await client.channels.fetch(announceChannelId); - if (!fetched || !fetched.isTextBased()) { - logger.warn({ announceChannelId }, "PR poller could not resolve announce channel"); - return; - } - - // Extra runtime guard - if (!("send" in fetched) || typeof fetched.send !== "function") return; +/** + * Format a pull request announcement message. + */ +export function formatPullRequest(pr: GitHubPullRequest): string { + const author = pr.user?.login ? ` by @${pr.user.login}` : ""; + const state = pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open"; + + const updatedAt = pr.updated_at + ? ` (updated ${formatTimestamp(pr.updated_at)})` + : ""; + + return [ + `**PR #${pr.number}** ${pr.title}`, + `${state}${author}${updatedAt}`, + pr.html_url, + ].join("\n"); +} - // Post oldest-first so announcements read naturally - const oldestFirst = [...fresh].sort( - (a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at), - ); +/* ------------------------------------------------------------------ */ +/* Internal utilities */ +/* ------------------------------------------------------------------ */ - for (const pr of oldestFirst) { - await fetched.send(formatPullRequest(pr)); - } +/** + * Format an ISO timestamp into a short, readable form. + * + * We intentionally avoid locale-specific formatting so output + * is consistent across environments. + */ +function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; - // Update last-seen to newest PR we announced - const newest = oldestFirst[oldestFirst.length - 1]; - setLastSeenPr(owner, repo, newest.updated_at); - } catch (err) { - logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed"); - } -} + return date.toISOString().replace("T", " ").replace("Z", " UTC"); +} \ No newline at end of file From 2dff71c93d4b9fd45d21ee2e6fbf7a767a7a2be3 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 31 Dec 2025 09:26:41 -0500 Subject: [PATCH 8/8] style: auto-format with Prettier [skip-precheck] --- src/config/env.ts | 2 +- src/registerCommands.ts | 7 ++----- src/services/github/prFormatter.ts | 12 +++--------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 2a4c8c03..7fcab019 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -168,4 +168,4 @@ export const env = { } return token; }, -}; \ No newline at end of file +}; diff --git a/src/registerCommands.ts b/src/registerCommands.ts index 7bb67600..e3d8b7a0 100644 --- a/src/registerCommands.ts +++ b/src/registerCommands.ts @@ -68,10 +68,7 @@ async function loadCommandData(): Promise< ); } } catch (err) { - logger.warn( - { err, file: `${group}/${file}` }, - "Failed to import command module", - ); + logger.warn({ err, file: `${group}/${file}` }, "Failed to import command module"); } } } @@ -117,4 +114,4 @@ async function register(): Promise { register().catch((err) => { logger.error(err, "Failed to register commands"); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/services/github/prFormatter.ts b/src/services/github/prFormatter.ts index db5ab71c..a50fc05d 100644 --- a/src/services/github/prFormatter.ts +++ b/src/services/github/prFormatter.ts @@ -24,15 +24,9 @@ import type { GitHubPullRequest } from "./types.js"; */ export function formatPullRequest(pr: GitHubPullRequest): string { const author = pr.user?.login ? ` by @${pr.user.login}` : ""; - const state = pr.merged_at - ? "merged" - : pr.state === "closed" - ? "closed" - : "open"; + const state = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open"; - const updatedAt = pr.updated_at - ? ` (updated ${formatTimestamp(pr.updated_at)})` - : ""; + const updatedAt = pr.updated_at ? ` (updated ${formatTimestamp(pr.updated_at)})` : ""; return [ `**PR #${pr.number}** ${pr.title}`, @@ -56,4 +50,4 @@ function formatTimestamp(iso: string): string { if (Number.isNaN(date.getTime())) return iso; return date.toISOString().replace("T", " ").replace("Z", " UTC"); -} \ No newline at end of file +}