From fd558bd07e3f5d3f9b1148a2bd3733477b85d96f Mon Sep 17 00:00:00 2001 From: slacki-ai Date: Tue, 10 Mar 2026 09:45:52 -0400 Subject: [PATCH] Add GitHub issue/PR comments as a second input channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GITHUB_TOKEN is set, Claudex now starts a lightweight HTTP webhook server (default port 8080) alongside the existing Slack Socket Mode connection. Mentioning @claudex in a GitHub issue or PR comment routes the message through the same Claude Code session infrastructure used for Slack — no separate service required. Sessions are keyed by `gh:{owner/repo}#{issue_number}` in the existing store, so context persists across the full issue/PR thread exactly as it does per Slack thread. Responses are posted back as GitHub comments via the REST API. New files: - src/github/handler.ts webhook server, signature verification, session routing - src/github/post.ts post comments back to GitHub Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 ++++++ src/github/handler.ts | 149 ++++++++++++++++++++++++++++++++++++++++++ src/github/post.ts | 25 +++++++ src/index.ts | 11 +++- 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/github/handler.ts create mode 100644 src/github/post.ts diff --git a/README.md b/README.md index f9a8c77..8ad4bc0 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,22 @@ Slack bot that bridges Slack conversations to Claude Code sessions. Each channel SLACK_SIGNING_SECRET=... ANTHROPIC_API_KEY=sk-ant-... OPENAI_API_KEY=sk-... # for Whisper audio transcription + + # Optional — enables GitHub comment mentions (see below) + GITHUB_TOKEN=github_pat_... + GITHUB_WEBHOOK_SECRET=... # shared secret configured in your GitHub App + GITHUB_BOT_HANDLE=@claudex # mention string Claude watches for (default: @claudex) + GITHUB_WEBHOOK_PORT=8080 # HTTP port for the webhook server (default: 8080) ``` 3. **Slack app setup** — import `slack-manifest.json` into your Slack app config. The app needs Socket Mode enabled and the scopes listed in the manifest. +4. **(Optional) GitHub App setup** — to enable `@claudex` mentions in GitHub issues/PRs: + - Create a GitHub App (or use a fine-grained PAT) with `issues: write` permission on the target repos + - Add a webhook pointing at `https://your-host:8080/github/webhook`, subscribed to **Issue comments** events + - Set `GITHUB_TOKEN`, `GITHUB_WEBHOOK_SECRET`, and optionally `GITHUB_BOT_HANDLE` in your `.env` + - The HTTP server starts automatically alongside the Slack Socket Mode connection when `GITHUB_TOKEN` is present + ## Running ### With `manage.sh` (recommended) @@ -52,6 +64,10 @@ npm run start - Attached files are downloaded to disk; audio/voice messages are transcribed via Whisper - Claude has MCP tools for sending messages, uploading files, listing channels, reading history, and searching Slack +### GitHub channel (optional) + +When `GITHUB_TOKEN` is set, Claudex also starts a lightweight HTTP server that receives GitHub webhooks. Mentioning `@claudex` (or whatever `GITHUB_BOT_HANDLE` is set to) in a GitHub issue or PR comment triggers the same Claude Code session infrastructure used for Slack. Sessions are keyed by `gh:{owner/repo}#{issue_number}`, so context persists across the full issue/PR thread — the same way it does per Slack thread. Responses are posted back as GitHub comments. + ## Project structure ``` @@ -64,6 +80,9 @@ src/ messages.ts # post messages, format mrkdwn mcp-server.ts # per-session MCP server with Slack tools tools.ts # Slack MCP tool definitions + github/ + handler.ts # webhook HTTP server, signature verification, session routing + post.ts # post comments back to GitHub via REST API claude/ session.ts # create/resume Claude Code sessions response.ts # consume streaming response diff --git a/src/github/handler.ts b/src/github/handler.ts new file mode 100644 index 0000000..a0b91e5 --- /dev/null +++ b/src/github/handler.ts @@ -0,0 +1,149 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { getSession, saveSession } from "../store/sessions.js"; +import { createSession, resumeSession } from "../claude/session.js"; +import { resolveCwd } from "../util/paths.js"; +import { postGitHubComment } from "./post.js"; + +const BOT_HANDLE = process.env.GITHUB_BOT_HANDLE ?? "@claudex"; + +/** Concurrency guard: set of thread keys currently being processed */ +const activeThreads = new Set(); + +/** Verify the X-Hub-Signature-256 header from GitHub */ +async function verifySignature(req: IncomingMessage, body: string): Promise { + const secret = process.env.GITHUB_WEBHOOK_SECRET; + if (!secret) { + console.warn("[github] GITHUB_WEBHOOK_SECRET not set — skipping signature verification"); + return true; + } + const sig = req.headers["x-hub-signature-256"] as string | undefined; + if (!sig) return false; + const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); + try { + return timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); + } catch { + return false; + } +} + +/** Read the full request body */ +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf-8"); +} + +/** Handle a single webhook HTTP request */ +export async function handleWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (req.method !== "POST" || req.url !== "/github/webhook") { + res.writeHead(404).end(); + return; + } + + const body = await readBody(req); + + if (!(await verifySignature(req, body))) { + console.warn("[github] Webhook signature verification failed"); + res.writeHead(401).end("Unauthorized"); + return; + } + + // Respond immediately so GitHub doesn't time out + res.writeHead(200).end("ok"); + + const event = req.headers["x-github-event"] as string | undefined; + + // Only handle new comments on issues and PRs for now. + // pull_request_review_comment (inline diff comments) can be added later. + if (event !== "issue_comment") return; + + const payload = JSON.parse(body); + if (payload.action !== "created") return; // ignore edits / deletions + + const commentBody: string = payload.comment?.body ?? ""; + if (!commentBody.includes(BOT_HANDLE)) return; + + const repo: string = payload.repository.full_name; // "owner/repo" + const issueNumber: string = String(payload.issue.number); + const commentsUrl: string = payload.issue.comments_url; + const user: string = payload.comment.user.login; + + // Session key: channelId = "gh:owner/repo", threadTs = issue/PR number. + // This slots naturally into the existing session store without any schema changes. + const channelId = `gh:${repo}`; + const threadTs = issueNumber; + const threadKey = `${channelId}:${threadTs}`; + + if (activeThreads.has(threadKey)) { + console.log(`[github] ${threadKey} already processing, skipping`); + return; + } + + activeThreads.add(threadKey); + try { + const existing = getSession(channelId, threadTs); + + // Working directory mirrors the Slack pattern: ~/github/{owner}-{repo}/ + const cwd = resolveCwd("github", repo.replace("/", "-")); + + // Strip the bot mention from the comment text + const text = commentBody.replace(new RegExp(BOT_HANDLE, "g"), "").trim(); + const prompt = `[GitHub ${repo}#${issueNumber}]\n\n${user}: ${text}`; + + console.log(`[github] ${threadKey} sending to Claude: ${prompt.slice(0, 120)}...`); + + const response = existing + ? await resumeSession(prompt, cwd, existing.sessionId) + : await createSession(prompt, cwd); + + await postGitHubComment(commentsUrl, response.text); + + saveSession({ + channelId, + threadTs, + sessionId: response.sessionId, + cwd, + // lastResponseTs isn't used for GitHub sessions (no message aggregation needed), + // but the field is required — store the wall-clock time for debugging. + lastResponseTs: new Date().toISOString(), + createdAt: existing?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + if (response.costUsd > 0) { + console.log(`[github] ${threadKey} cost: $${response.costUsd.toFixed(4)}`); + } + } catch (err) { + console.error(`[github] Error handling ${threadKey}:`, err); + try { + await postGitHubComment( + commentsUrl, + `> ❌ Claudex error: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } catch { + // Best effort — don't let a reply failure mask the original error + } + } finally { + activeThreads.delete(threadKey); + } +} + +/** Start the GitHub webhook HTTP server on the given port */ +export function startGitHubWebhookServer(port: number): void { + const server = createServer(async (req, res) => { + try { + await handleWebhookRequest(req, res); + } catch (err) { + console.error("[github] Unhandled webhook error:", err); + if (!res.headersSent) res.writeHead(500).end("Internal server error"); + } + }); + + server.listen(port, () => { + console.log(`⚡ GitHub webhook server listening on port ${port}`); + }); +} diff --git a/src/github/post.ts b/src/github/post.ts new file mode 100644 index 0000000..0db2038 --- /dev/null +++ b/src/github/post.ts @@ -0,0 +1,25 @@ +/** + * Post a comment back to a GitHub issue or PR via the REST API. + */ +export async function postGitHubComment( + commentsUrl: string, + body: string, +): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error("GITHUB_TOKEN env var not set"); + + const res = await fetch(commentsUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ body }), + }); + + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status} ${await res.text()}`); + } +} diff --git a/src/index.ts b/src/index.ts index 041697d..3c60109 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { createApp } from "./slack/app.js"; import { loadSessions } from "./store/sessions.js"; +import { startGitHubWebhookServer } from "./github/handler.js"; // Validate required env vars const required = ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "ANTHROPIC_API_KEY"]; @@ -13,8 +14,16 @@ for (const key of required) { // Load persisted sessions loadSessions(); -// Start the app +// Start the Slack Socket Mode app const app = createApp(); await app.start(); +// Optionally start the GitHub webhook HTTP server (same process, second I/O channel) +if (process.env.GITHUB_TOKEN) { + const port = parseInt(process.env.GITHUB_WEBHOOK_PORT ?? "8080", 10); + startGitHubWebhookServer(port); +} else { + console.log("GITHUB_TOKEN not set — GitHub webhook server disabled"); +} + console.log("⚡ Claudex is running");