diff --git a/README.md b/README.md index a9a1bafe..a3238dc4 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ OmegaBot is online │ ├── banner.png │ └── omegabot.png ├── CONTRIBUTORS.md +├── data +│ └── timezones.json ├── docs │ ├── commands.md │ ├── dev-notes.md @@ -142,6 +144,9 @@ OmegaBot is online │ ├── commands │ │ ├── general │ │ │ └── ping.ts +│ │ ├── github +│ │ │ ├── gh.ts +│ │ │ └── pr.ts │ │ ├── history │ │ │ └── history.ts │ │ ├── playback @@ -156,6 +161,10 @@ OmegaBot is online │ │ ├── commandLoader.ts │ │ ├── fetchChannelMessages.ts │ │ └── interactionHandler.ts +│ ├── github +│ │ ├── githubApi.ts +│ │ ├── githubClient.ts +│ │ └── types.ts │ ├── summary │ │ ├── llmSummary.ts │ │ ├── localSummary.ts diff --git a/docs/commands.md b/docs/commands.md index 795ec978..09f33bf1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,59 +1,93 @@ -# Commands Overview +# OmegaBot Commands -This document describes all current and planned slash commands in OmegaBot. +This document lists all available slash commands supported by OmegaBot. --- -## /ping +## General Commands -Health check command. +### /ping -Purpose: +Checks whether the bot is online and reports latency. -- Confirms the bot is online -- Useful for verifying permissions and connectivity +**Usage** + +``` +/ping +``` + +--- + +## History & Summary Commands + +### /history + +Fetches recent messages from the current channel and sends them to you via DM. + +**Options** + +- `count` (optional): Number of messages to fetch (default: 50) + +--- + +### /summary + +Generates a summary of recent channel activity and sends it via DM. + +**Notes** + +- Uses local summarization by default +- LLM-based summarization may be enabled via configuration --- -## /history +## GitHub Commands -Chat playback command. +These commands use the GitHub REST API with authenticated requests. -Behavior: +### /gh issue -- Fetches the last N user messages from the current channel -- Filters out bot messages -- Sorts messages oldest → newest -- Sends transcript via DM -- Falls back to a file attachment if over Discord limits +Fetch a single GitHub issue by number. -Options: +**Usage** -- `count` (number): How many messages to fetch +``` +/gh issue owner: repo: number: +``` --- -## /summary +### /gh issues + +List open issues for a repository. -Conversation summarization command. +**Usage** + +``` +/gh issues owner: repo: limit: labels: +``` + +**Notes** + +- Defaults to open issues +- Pull requests are filtered out by default + +--- -Behavior: +### /pr -- Fetches recent messages -- Builds a transcript using the shared transcript builder -- Runs local or LLM summarization (depending on configuration) -- Sends result via DM +Fetch a GitHub pull request by number. -Options: +**Usage** -- `count` (number): How many messages to summarize +``` +/pr owner: repo: number: +``` --- -## Planned / Future Commands +## Notes -- `/faq` — Lookup stored FAQs -- `/github issue` — Fetch GitHub issue details -- `/github pr` — Fetch pull request details -- `/announce pr` — Announce merged PRs -- `/playback` — Paginated chat playback +- GitHub commands require a valid GitHub Personal Access Token +- Results are returned as ephemeral responses by default +- Errors are handled gracefully and reported to the user diff --git a/src/commands/github/gh.ts b/src/commands/github/gh.ts new file mode 100644 index 00000000..219d1b42 --- /dev/null +++ b/src/commands/github/gh.ts @@ -0,0 +1,143 @@ +// src/commands/github/gh.ts + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { GitHubApiError } from "../../services/github/githubClient.js"; +import { + getIssue, + listIssues, + listPullRequests, +} from "../../services/github/githubApi.js"; + +/** + * /gh command + * GitHub helpers (issues + PRs) + */ +export const data = new SlashCommandBuilder() + .setName("gh") + .setDescription("GitHub helpers") + + .addSubcommand((s) => + s + .setName("issue") + .setDescription("Fetch a GitHub issue by number") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user").setRequired(true), + ) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) + .addIntegerOption((o) => + o.setName("number").setDescription("Issue #").setRequired(true), + ), + ) + + .addSubcommand((s) => + s + .setName("issues") + .setDescription("List open issues") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user").setRequired(true), + ) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) + .addIntegerOption((o) => + o + .setName("limit") + .setDescription("How many to show (default 5, max 20)") + .setMinValue(1) + .setMaxValue(20), + ), + ) + + .addSubcommand((s) => + s + .setName("prs") + .setDescription("List pull requests") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user").setRequired(true), + ) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) + .addIntegerOption((o) => + o + .setName("limit") + .setDescription("How many to show (default 5, max 20)") + .setMinValue(1) + .setMaxValue(20), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const owner = interaction.options.getString("owner", true); + const repo = interaction.options.getString("repo", true); + + try { + if (sub === "issue") { + const number = interaction.options.getInteger("number", true); + const issue = await getIssue(owner, repo, number); + + await interaction.editReply( + [ + `**${owner}/${repo}#${issue.number}**`, + issue.title, + `State: ${issue.state}`, + `Author: ${issue.user?.login ?? "unknown"}`, + issue.html_url, + ].join("\n"), + ); + return; + } + + if (sub === "issues") { + const limit = interaction.options.getInteger("limit") ?? 5; + const issues = await listIssues(owner, repo, { state: "open", limit }); + + if (!issues.length) { + await interaction.editReply("No open issues found."); + return; + } + + await interaction.editReply( + [ + `**Open issues for ${owner}/${repo}**`, + "", + ...issues.map( + (i) => `#${i.number} ${i.title} (by ${i.user?.login ?? "unknown"})`, + ), + ].join("\n"), + ); + return; + } + + if (sub === "prs") { + const limit = interaction.options.getInteger("limit") ?? 5; + const prs = await listPullRequests(owner, repo, { state: "open", limit }); + + if (!prs.length) { + await interaction.editReply("No open pull requests found."); + return; + } + + await interaction.editReply( + [ + `**Open PRs for ${owner}/${repo}**`, + "", + ...prs.map((p) => `#${p.number} ${p.title} (by ${p.user?.login ?? "unknown"})`), + ].join("\n"), + ); + return; + } + } catch (err) { + if (err instanceof GitHubApiError) { + await interaction.editReply(`GitHub error (${err.status}): ${err.message}`); + return; + } + + console.error("[gh] command failed", err); + await interaction.editReply("Something went wrong talking to GitHub."); + } +} diff --git a/src/commands/github/pr.ts b/src/commands/github/pr.ts new file mode 100644 index 00000000..595c5e4b --- /dev/null +++ b/src/commands/github/pr.ts @@ -0,0 +1,55 @@ +// src/commands/github/pr.ts + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { GitHubApiError } from "../../services/github/githubClient.js"; +import { getPullRequest } from "../../services/github/githubApi.js"; + +/** + * /pr command + * Fetch a single PR by number. + */ +export const data = new SlashCommandBuilder() + .setName("pr") + .setDescription("GitHub pull request helpers") + .addIntegerOption((o) => o.setName("number").setDescription("PR #").setRequired(true)) + .addStringOption((o) => o.setName("owner").setDescription("Org/user").setRequired(true)) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const owner = interaction.options.getString("owner", true); + const repo = interaction.options.getString("repo", true); + const number = interaction.options.getInteger("number", true); + + try { + const pr = await getPullRequest(owner, repo, number); + + const mergedLabel = pr.merged ? "merged" : "not merged"; + const body = [ + `**${owner}/${repo} PR #${pr.number}**`, + `${pr.title}`, + `State: ${pr.state} (${mergedLabel})`, + `Author: ${pr.user?.login ?? "unknown"}`, + `URL: ${pr.html_url}`, + ].join("\n"); + + await interaction.editReply(body); + } catch (err) { + if (err instanceof GitHubApiError) { + if (err.status === 404) { + await interaction.editReply("PR not found. Check owner/repo and PR number."); + return; + } + await interaction.editReply(`GitHub API error (${err.status}): ${err.message}`); + return; + } + + console.error("[pr] command failed", err); + await interaction.editReply("Something went wrong while talking to GitHub."); + } +} diff --git a/src/config/env.ts b/src/config/env.ts index 96b217dc..7ff4a33d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -40,4 +40,7 @@ export const env = { // Optional, only needed when user chooses LLM summarization. openAIKey: process.env.OPENAI_API_KEY ?? null, + + // GitHub + githubToken: process.env.GITHUB_TOKEN ?? null, }; diff --git a/src/services/github/githubApi.ts b/src/services/github/githubApi.ts new file mode 100644 index 00000000..a87da5db --- /dev/null +++ b/src/services/github/githubApi.ts @@ -0,0 +1,169 @@ +// src/services/github/githubApi.ts + +import { githubRequest, GitHubApiError } from "./githubClient.js"; +import type { + GitHubIssue, + GitHubPullRequest, + GitHubIssueSummary, + GitHubPrSummary, +} from "./types.js"; + +/* ------------------------------------------------------------------ */ +/* Path helpers */ +/* ------------------------------------------------------------------ */ + +function repoBase(owner: string, repo: string): string { + return `/repos/${owner}/${repo}`; +} + +function issuePath(owner: string, repo: string, number: number): string { + return `${repoBase(owner, repo)}/issues/${number}`; +} + +function prPath(owner: string, repo: string, number: number): string { + return `${repoBase(owner, repo)}/pulls/${number}`; +} + +function listIssuesPath( + owner: string, + repo: string, + options?: { + state?: "open" | "closed" | "all"; + limit?: number; + labels?: string[]; + }, +): string { + const params = new URLSearchParams(); + + if (options?.state) params.set("state", options.state); + if (options?.limit) params.set("per_page", String(options.limit)); + if (options?.labels?.length) params.set("labels", options.labels.join(",")); + + params.set("sort", "updated"); + params.set("direction", "desc"); + + const query = params.toString(); + return `${repoBase(owner, repo)}/issues${query ? `?${query}` : ""}`; +} + +function listPullRequestsPath( + owner: string, + repo: string, + options?: { + state?: "open" | "closed" | "all"; + limit?: number; + }, +): string { + const params = new URLSearchParams(); + + if (options?.state) params.set("state", options.state); + if (options?.limit) params.set("per_page", String(options.limit)); + + params.set("sort", "updated"); + params.set("direction", "desc"); + + const query = params.toString(); + return `${repoBase(owner, repo)}/pulls${query ? `?${query}` : ""}`; +} + +/* ------------------------------------------------------------------ */ +/* Error helpers */ +/* ------------------------------------------------------------------ */ + +function isGitHubApiError(err: unknown): err is GitHubApiError { + return err instanceof GitHubApiError; +} + +function is404(err: unknown): err is GitHubApiError { + return isGitHubApiError(err) && err.status === 404; +} + +/* ------------------------------------------------------------------ */ +/* Single-item fetchers */ +/* ------------------------------------------------------------------ */ + +export async function getIssue( + owner: string, + repo: string, + number: number, +): Promise { + return githubRequest(issuePath(owner, repo, number)); +} + +export async function getPullRequest( + owner: string, + repo: string, + number: number, +): Promise { + return githubRequest(prPath(owner, repo, number)); +} + +export async function getIssueOrPr( + owner: string, + repo: string, + number: number, +): Promise { + try { + return await getPullRequest(owner, repo, number); + } catch (err) { + if (is404(err)) return await getIssue(owner, repo, number); + throw err; + } +} + +/* ------------------------------------------------------------------ */ +/* List helpers (command-facing) */ +/* ------------------------------------------------------------------ */ + +/** + * List issues (issues-only; PRs filtered out) + */ +export async function listIssues( + owner: string, + repo: string, + options?: { + state?: "open" | "closed" | "all"; + limit?: number; + labels?: string[]; + }, +): Promise { + const data = await githubRequest(listIssuesPath(owner, repo, options)); + + return data + .filter((i) => !i.pull_request) + .map((i) => ({ + number: i.number, + title: i.title, + html_url: i.html_url, + state: i.state, + user: i.user, + comments: i.comments, + })); +} + +/** + * List pull requests + */ +export async function listPullRequests( + owner: string, + repo: string, + options?: { + state?: "open" | "closed" | "all"; + limit?: number; + }, +): Promise { + const data = await githubRequest( + listPullRequestsPath(owner, repo, options), + ); + + return data.map((pr) => ({ + number: pr.number, + title: pr.title, + html_url: pr.html_url, + state: pr.state, + merged_at: pr.merged_at, + user: pr.user, + comments: pr.comments, + review_comments: pr.review_comments, + })); +} diff --git a/src/services/github/githubClient.ts b/src/services/github/githubClient.ts new file mode 100644 index 00000000..9a907f20 --- /dev/null +++ b/src/services/github/githubClient.ts @@ -0,0 +1,68 @@ +// src/services/github/githubClient.ts + +import { env } from "../../config/env.js"; + +/** + * Base URL for GitHub REST API v3 + */ +const GITHUB_API_BASE = "https://api.github.com"; + +/** + * Error shape thrown when GitHub responds with a non-2xx status. + * Callers can inspect `status` to decide what to do (ex: 404 fallback). + */ +export class GitHubApiError extends Error { + status: number; + url: string; + + constructor(message: string, status: number, url: string) { + super(message); + this.name = "GitHubApiError"; + this.status = status; + this.url = url; + } +} + +/** + * Low-level GitHub request helper. + * + * Responsibilities: + * - Add auth headers + * - Perform fetch + * - Handle non-OK responses + * - Return typed JSON + * + * Higher-level logic (issues, PRs, fallback behavior) lives in githubApi.ts. + */ +export async function githubRequest(path: string): Promise { + const url = `${GITHUB_API_BASE}${path}`; + + if (!env.githubToken) { + throw new Error("GITHUB_TOKEN is not set in environment"); + } + + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${env.githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + let message = res.statusText; + + // GitHub usually returns JSON with { message: "...", ... } + try { + const body = (await res.json()) as { message?: string }; + if (body?.message) message = body.message; + } catch { + // Ignore parse failures, keep statusText + } + + throw new GitHubApiError(message, res.status, url); + } + + return (await res.json()) as T; +} diff --git a/src/services/github/types.ts b/src/services/github/types.ts new file mode 100644 index 00000000..2b215ed8 --- /dev/null +++ b/src/services/github/types.ts @@ -0,0 +1,145 @@ +/** + * GitHub API Types + * + * This file defines the data contracts used by OmegaBot when interacting + * with the GitHub REST API. + * + * Design goals: + * - Keep types explicit and readable + * - Separate "full API shapes" from "command-friendly views" + * - Avoid over-modeling fields we don’t actually use + */ + +/* ------------------------------------------------------------------ */ +/* Shared base types */ +/* ------------------------------------------------------------------ */ + +/** + * GitHub user object + */ +export type GitHubUser = { + login: string; + html_url: string; +}; + +/** + * GitHub issue/PR label + */ +export type GitHubLabel = { + name: string; + color: string; +}; + +/* ------------------------------------------------------------------ */ +/* Full GitHub API response types */ +/* These match what the GitHub REST API actually returns */ +/* ------------------------------------------------------------------ */ + +/** + * GitHub Issue + * + * NOTE: + * - Pull Requests also appear in the `/issues` API. + * - When an issue is a PR, it includes a `pull_request` field. + */ +export type GitHubIssue = { + id: number; + number: number; + title: string; + body: string | null; + state: "open" | "closed"; + html_url: string; + + user: GitHubUser; + labels: GitHubLabel[]; + + comments: number; + + created_at: string; + updated_at: string; + closed_at: string | null; + + /** + * Present only if this issue is actually a Pull Request + * (when fetched from the /issues API) + */ + pull_request?: { + html_url: string; + }; +}; + +/** + * GitHub Pull Request + * + * Returned from the `/pulls` API. + * Contains additional fields not present on issues. + */ +export type GitHubPullRequest = { + id: number; + number: number; + title: string; + body: string | null; + state: "open" | "closed"; + html_url: string; + + user: GitHubUser; + + merged: boolean; + mergeable: boolean | null; + + comments: number; + review_comments: number; + commits: number; + additions: number; + deletions: number; + changed_files: number; + + created_at: string; + updated_at: string; + closed_at: string | null; + merged_at: string | null; +}; + +/** + * Lightweight repository info + * Useful for validation, previews, or summaries later + */ +export type GitHubRepo = { + full_name: string; + description: string | null; + html_url: string; + stargazers_count: number; + forks_count: number; + open_issues_count: number; +}; + +/* ------------------------------------------------------------------ */ +/* Command-friendly / normalized response types */ +/* These are what slash commands should actually consume */ +/* ------------------------------------------------------------------ */ + +/** + * Minimal Issue view used by commands + */ +export type GitHubIssueSummary = { + number: number; + title: string; + html_url: string; + state: "open" | "closed"; + user?: { login: string }; + comments?: number; +}; + +/** + * Minimal PR view used by commands + */ +export type GitHubPrSummary = { + number: number; + title: string; + html_url: string; + state: "open" | "closed"; + merged_at: string | null; + user?: { login: string }; + comments?: number; + review_comments?: number; +};