claude-channel-github-webhook 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "github-webhook",
3
+ "version": "1.0.0",
4
+ "description": "GitHub Webhook events (PR reviews, CI failures, @claude mentions) as Claude Code Channel notifications",
5
+ "channel": {
6
+ "command": "npx",
7
+ "args": ["tsx", "src/channel.ts"]
8
+ },
9
+ "env": [
10
+ {
11
+ "name": "GITHUB_WEBHOOK_SECRET",
12
+ "description": "GitHub Webhook secret for HMAC signature verification",
13
+ "required": false
14
+ },
15
+ {
16
+ "name": "WEBHOOK_PORT",
17
+ "description": "Port for the webhook router (default: 8788)",
18
+ "required": false,
19
+ "default": "8788"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ node-version: [20, 22]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: ${{ matrix.node-version }}
20
+ cache: npm
21
+ - run: npm ci
22
+ - run: npm run lint
23
+ - run: npm test
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # claude-channel-github-webhook
2
+
3
+ A Claude Code Channel plugin that delivers GitHub Webhook events as real-time notifications to your Claude Code session.
4
+
5
+ Supports PR reviews, CI failures, and `@claude` mentions.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ GitHub Webhook
11
+ |
12
+ v (tunnel: ngrok / cloudflared / etc.)
13
+ [channel.ts :8788] <- single process: MCP server + HTTP listener
14
+ |
15
+ v (notifications/claude/channel)
16
+ Claude Code session
17
+ ```
18
+
19
+ A single process handles everything: receives GitHub Webhooks over HTTP and forwards them to Claude Code via MCP stdio. No router, no registry, no extra processes.
20
+
21
+ ## Notification Rules
22
+
23
+ | Event | Condition |
24
+ |---|---|
25
+ | `pull_request_review` | All reviews |
26
+ | `check_run` | `conclusion === "failure"` only |
27
+ | `issue_comment` | Comment body contains `@claude` |
28
+
29
+ ## Setup
30
+
31
+ ### 1. Install
32
+
33
+ ```bash
34
+ npm install claude-channel-github-webhook
35
+ ```
36
+
37
+ Or clone directly:
38
+
39
+ ```bash
40
+ git clone https://github.com/moeki0/claude-channel-github-webhook.git
41
+ cd claude-channel-github-webhook
42
+ npm install
43
+ ```
44
+
45
+ ### 2. Open a tunnel
46
+
47
+ ```bash
48
+ ngrok http 8788
49
+ ```
50
+
51
+ Any tunneling tool works (ngrok, cloudflared, Smee.io, reverse proxy, etc.).
52
+
53
+ ### 3. Configure the GitHub Webhook
54
+
55
+ In your repository's **Settings > Webhooks > Add webhook**:
56
+
57
+ - **Payload URL**: Your tunnel's public URL
58
+ - **Content type**: `application/json`
59
+ - **Secret**: Any string (optional but recommended)
60
+ - **Events**: `Pull request reviews` / `Check runs` / `Issue comments`
61
+
62
+ ### 4. Launch with Claude Code
63
+
64
+ Add to your project's `.mcp.json`:
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "github-webhook": {
70
+ "command": "npx",
71
+ "args": ["claude-channel-github-webhook"],
72
+ "env": {
73
+ "GITHUB_WEBHOOK_SECRET": "your_secret"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ Then start Claude Code:
81
+
82
+ ```bash
83
+ claude --dangerously-load-development-channels server:github-webhook
84
+ ```
85
+
86
+ ## Environment Variables
87
+
88
+ | Variable | Description | Default |
89
+ |---|---|---|
90
+ | `GITHUB_WEBHOOK_SECRET` | Secret for HMAC signature verification | None (verification skipped) |
91
+ | `WEBHOOK_PORT` | HTTP listen port | `8788` |
92
+
93
+ ## How It Works
94
+
95
+ 1. Claude Code spawns `channel.ts` as a child process
96
+ 2. The process connects to Claude Code via MCP stdio
97
+ 3. It starts an HTTP server on `127.0.0.1:8788`
98
+ 4. GitHub Webhooks arrive via tunnel, are verified (HMAC), parsed, and filtered
99
+ 5. Matching events are sent as `notifications/claude/channel` with structured `meta` attributes
100
+ 6. Claude Code receives `<channel source="github-webhook" event="check_run" ...>` tags and acts accordingly
101
+
102
+ ## What Claude Receives
103
+
104
+ ```xml
105
+ <channel source="github-webhook" event="check_run" conclusion="failure" branch="feat/x" workflow="CI / test">
106
+ CI failed: CI / test
107
+ Branch: feat/x
108
+ Details: https://github.com/org/repo/actions/runs/123
109
+ </channel>
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ npm test # Run tests
116
+ npm run lint # Type check
117
+ npm run start # Start channel standalone
118
+ ```
119
+
120
+ ## Status
121
+
122
+ Claude Code Channels is in **Research Preview**.
123
+
124
+ - Requires Claude Code v2.1.80+
125
+ - Requires claude.ai login (not API key)
126
+ - Team/Enterprise orgs must enable `channelsEnabled`
package/jest.config.js ADDED
@@ -0,0 +1,11 @@
1
+ export default {
2
+ preset: "ts-jest/presets/default-esm",
3
+ testEnvironment: "node",
4
+ extensionsToTreatAsEsm: [".ts"],
5
+ moduleNameMapper: {
6
+ "^(\\.{1,2}/.*)\\.js$": "$1",
7
+ },
8
+ transform: {
9
+ "^.+\\.ts$": ["ts-jest", { useESM: true }],
10
+ },
11
+ };
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "claude-channel-github-webhook",
3
+ "version": "1.0.0",
4
+ "description": "GitHub Webhook Channel plugin for Claude Code",
5
+ "type": "module",
6
+ "bin": "src/channel.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "tsx src/channel.ts",
10
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
11
+ "lint": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.0.0",
15
+ "tsx": "^4.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.0.0",
19
+ "@types/jest": "^29.0.0",
20
+ "jest": "^29.0.0",
21
+ "ts-jest": "^29.0.0",
22
+ "typescript": "^5.0.0"
23
+ }
24
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env npx tsx
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import http from "node:http";
5
+ import { buildNotification, verifySignature } from "./shared/message.js";
6
+
7
+ const SECRET = process.env.GITHUB_WEBHOOK_SECRET ?? "";
8
+ const PORT = parseInt(process.env.WEBHOOK_PORT ?? "8788");
9
+ const MAX_BODY = 1024 * 1024; // 1MB
10
+
11
+ const mcp = new Server(
12
+ { name: "github-webhook", version: "1.0.0" },
13
+ {
14
+ capabilities: { experimental: { "claude/channel": {} } },
15
+ instructions: [
16
+ "Events from the github-webhook channel arrive as <channel source=\"github-webhook\" ...>.",
17
+ "Attributes include event type, branch, PR number, etc.",
18
+ "On check_run failures: read the CI logs and attempt to fix the issue.",
19
+ "On pull_request_review: read the review comments and address the feedback.",
20
+ "On issue_comment with @claude: respond to the request in the comment.",
21
+ "These are one-way notifications — no reply mechanism is available.",
22
+ ].join(" "),
23
+ }
24
+ );
25
+
26
+ await mcp.connect(new StdioServerTransport());
27
+
28
+ const httpServer = http.createServer((req, res) => {
29
+ if (req.method !== "POST") {
30
+ res.writeHead(405);
31
+ res.end();
32
+ return;
33
+ }
34
+
35
+ let body = "";
36
+ let size = 0;
37
+
38
+ req.on("data", (chunk: Buffer) => {
39
+ size += chunk.length;
40
+ if (size > MAX_BODY) {
41
+ res.writeHead(413);
42
+ res.end("payload too large");
43
+ req.destroy();
44
+ return;
45
+ }
46
+ body += chunk;
47
+ });
48
+
49
+ req.on("end", async () => {
50
+ if (size > MAX_BODY) return;
51
+
52
+ const signature = req.headers["x-hub-signature-256"] as string | undefined;
53
+ if (!verifySignature(body, signature, SECRET)) {
54
+ res.writeHead(401);
55
+ res.end("invalid signature");
56
+ return;
57
+ }
58
+
59
+ const event = req.headers["x-github-event"] as string;
60
+ let payload: Record<string, unknown>;
61
+ try {
62
+ payload = JSON.parse(body);
63
+ } catch {
64
+ res.writeHead(400);
65
+ res.end("invalid json");
66
+ return;
67
+ }
68
+
69
+ const notification = buildNotification(event, payload);
70
+ if (notification) {
71
+ await mcp.notification({
72
+ method: "notifications/claude/channel",
73
+ params: notification,
74
+ });
75
+ process.stderr.write(`[github-webhook] notified: ${event}\n`);
76
+ }
77
+
78
+ res.writeHead(200);
79
+ res.end("ok");
80
+ });
81
+ });
82
+
83
+ httpServer.listen(PORT, "127.0.0.1", () => {
84
+ process.stderr.write(`[github-webhook] listening on 127.0.0.1:${PORT}\n`);
85
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { buildNotification, verifySignature } from "./message.js";
3
+
4
+ describe("buildNotification", () => {
5
+ it("pull_request_review のメッセージと meta を組み立てる", () => {
6
+ const payload = {
7
+ pull_request: {
8
+ title: "Add feature",
9
+ number: 42,
10
+ html_url: "https://github.com/org/repo/pull/42",
11
+ head: { ref: "feat/add-feature" },
12
+ },
13
+ review: {
14
+ user: { login: "reviewer" },
15
+ state: "approved",
16
+ body: "LGTM",
17
+ },
18
+ };
19
+ const result = buildNotification("pull_request_review", payload);
20
+ expect(result).not.toBeNull();
21
+ expect(result!.content).toContain("Add feature");
22
+ expect(result!.content).toContain("reviewer");
23
+ expect(result!.content).toContain("approved");
24
+ expect(result!.meta.event).toBe("pull_request_review");
25
+ expect(result!.meta.pr_number).toBe("42");
26
+ });
27
+
28
+ it("check_run 失敗のメッセージを組み立てる", () => {
29
+ const payload = {
30
+ check_run: {
31
+ name: "CI / test",
32
+ conclusion: "failure",
33
+ html_url: "https://github.com/org/repo/actions/runs/1",
34
+ check_suite: { head_branch: "feat/add-feature" },
35
+ },
36
+ };
37
+ const result = buildNotification("check_run", payload);
38
+ expect(result).not.toBeNull();
39
+ expect(result!.content).toContain("CI / test");
40
+ expect(result!.meta.event).toBe("check_run");
41
+ expect(result!.meta.conclusion).toBe("failure");
42
+ });
43
+
44
+ it("check_run 成功は null を返す", () => {
45
+ const payload = {
46
+ check_run: {
47
+ name: "CI / test",
48
+ conclusion: "success",
49
+ html_url: "https://github.com/org/repo/actions/runs/1",
50
+ check_suite: { head_branch: "feat/add-feature" },
51
+ },
52
+ };
53
+ expect(buildNotification("check_run", payload)).toBeNull();
54
+ });
55
+
56
+ it("issue_comment に @claude を含む場合のみ通過する", () => {
57
+ const payload = {
58
+ issue: { title: "Bug report" },
59
+ comment: { body: "Hey @claude please fix this", html_url: "https://github.com/org/repo/issues/1#comment" },
60
+ };
61
+ const result = buildNotification("issue_comment", payload);
62
+ expect(result).not.toBeNull();
63
+ expect(result!.content).toContain("Bug report");
64
+ expect(result!.meta.event).toBe("issue_comment");
65
+ });
66
+
67
+ it("issue_comment に @claude を含まない場合は null を返す", () => {
68
+ const payload = {
69
+ issue: { title: "Bug report" },
70
+ comment: { body: "Just a regular comment", html_url: "https://github.com/org/repo/issues/1#comment" },
71
+ };
72
+ expect(buildNotification("issue_comment", payload)).toBeNull();
73
+ });
74
+
75
+ it("@claudebot のような部分一致では通過しない", () => {
76
+ const payload = {
77
+ issue: { title: "Bug report" },
78
+ comment: { body: "ask @claudebot", html_url: "https://github.com/org/repo/issues/1#comment" },
79
+ };
80
+ expect(buildNotification("issue_comment", payload)).toBeNull();
81
+ });
82
+
83
+ it("未知のイベントタイプは null を返す", () => {
84
+ expect(buildNotification("unknown_event", {})).toBeNull();
85
+ });
86
+ });
87
+
88
+ describe("verifySignature", () => {
89
+ it("正しい署名を検証できる", () => {
90
+ const body = '{"test":true}';
91
+ const secret = "mysecret";
92
+ // pre-computed: echo -n '{"test":true}' | openssl dgst -sha256 -hmac 'mysecret'
93
+ expect(verifySignature(body, "sha256=f269168b331c2c56aa328857bfcda87bacca5e4e1da4da687667068a21dd3c53", secret)).toBe(true);
94
+ });
95
+
96
+ it("不正な署名を拒否する", () => {
97
+ expect(verifySignature("{}", "sha256=wrong", "mysecret")).toBe(false);
98
+ });
99
+
100
+ it("secret が空なら検証をスキップする", () => {
101
+ expect(verifySignature("{}", undefined, "")).toBe(true);
102
+ });
103
+
104
+ it("secret があるのに署名がなければ拒否する", () => {
105
+ expect(verifySignature("{}", undefined, "mysecret")).toBe(false);
106
+ });
107
+ });
@@ -0,0 +1,93 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export interface Notification {
4
+ [key: string]: unknown;
5
+ content: string;
6
+ meta: Record<string, string>;
7
+ }
8
+
9
+ export function buildNotification(event: string, payload: Record<string, unknown>): Notification | null {
10
+ switch (event) {
11
+ case "pull_request_review": {
12
+ const pr = payload.pull_request as {
13
+ title: string;
14
+ number: number;
15
+ html_url: string;
16
+ };
17
+ const review = payload.review as {
18
+ user: { login: string };
19
+ state: string;
20
+ body?: string;
21
+ };
22
+ return {
23
+ content: [
24
+ `PR review on "${pr.title}" (#${pr.number})`,
25
+ `Reviewer: ${review.user.login}`,
26
+ `State: ${review.state}`,
27
+ `Comment: ${review.body ?? "(none)"}`,
28
+ `URL: ${pr.html_url}`,
29
+ ].join("\n"),
30
+ meta: {
31
+ event,
32
+ pr_number: String(pr.number),
33
+ reviewer: review.user.login,
34
+ state: review.state,
35
+ },
36
+ };
37
+ }
38
+
39
+ case "check_run": {
40
+ const checkRun = payload.check_run as {
41
+ name: string;
42
+ conclusion: string;
43
+ html_url: string;
44
+ check_suite: { head_branch: string };
45
+ };
46
+ if (checkRun.conclusion !== "failure") return null;
47
+ return {
48
+ content: [
49
+ `CI failed: ${checkRun.name}`,
50
+ `Branch: ${checkRun.check_suite.head_branch}`,
51
+ `Details: ${checkRun.html_url}`,
52
+ ].join("\n"),
53
+ meta: {
54
+ event,
55
+ conclusion: checkRun.conclusion,
56
+ branch: checkRun.check_suite.head_branch,
57
+ workflow: checkRun.name,
58
+ },
59
+ };
60
+ }
61
+
62
+ case "issue_comment": {
63
+ const issue = payload.issue as { title: string };
64
+ const comment = payload.comment as { body: string; html_url: string };
65
+ if (!/@claude\b/.test(comment.body)) return null;
66
+ return {
67
+ content: [
68
+ `Mentioned in: ${issue.title}`,
69
+ `Comment: ${comment.body}`,
70
+ `URL: ${comment.html_url}`,
71
+ ].join("\n"),
72
+ meta: {
73
+ event,
74
+ issue_title: issue.title,
75
+ },
76
+ };
77
+ }
78
+
79
+ default:
80
+ return null;
81
+ }
82
+ }
83
+
84
+ export function verifySignature(body: string, signature: string | undefined, secret: string): boolean {
85
+ if (!secret) return true;
86
+ if (!signature) return false;
87
+ const digest = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
88
+ try {
89
+ return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["src"]
12
+ }