github-webhook-mcp 0.3.0 → 0.6.0-rc.1

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.
package/manifest.json CHANGED
@@ -1,11 +1,21 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "github-webhook-mcp",
4
- "version": "0.3.0",
5
- "description": "Browse pending GitHub webhook events. Pairs with a webhook receiver that writes events.json.",
4
+ "display_name": "GitHub Webhook MCP",
5
+ "version": "1.0.0",
6
+ "description": "Real-time GitHub webhook notifications via Cloudflare Worker + Durable Object.",
7
+ "long_description": "GitHub Webhook MCP bridges GitHub webhook events to Claude via a Cloudflare Worker backend. Events are stored in a Durable Object with SQLite, queried through MCP tools, and optionally pushed in real-time via SSE channel notifications. No local webhook receiver needed — just point your GitHub webhook at the Worker URL.",
6
8
  "author": {
7
- "name": "Liplus Project"
9
+ "name": "Liplus Project",
10
+ "url": "https://github.com/Liplus-Project"
8
11
  },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp.git"
15
+ },
16
+ "homepage": "https://github.com/Liplus-Project/github-webhook-mcp",
17
+ "documentation": "https://github.com/Liplus-Project/github-webhook-mcp#readme",
18
+ "support": "https://github.com/Liplus-Project/github-webhook-mcp/issues",
9
19
  "license": "MIT",
10
20
  "server": {
11
21
  "type": "node",
@@ -16,16 +26,24 @@
16
26
  "${__dirname}/server/index.js"
17
27
  ],
18
28
  "env": {
19
- "EVENTS_JSON_PATH": "${user_config.events_json_path}"
29
+ "WEBHOOK_WORKER_URL": "${user_config.worker_url}",
30
+ "WEBHOOK_CHANNEL": "${user_config.channel_enabled}"
20
31
  }
21
32
  }
22
33
  },
23
34
  "user_config": {
24
- "events_json_path": {
25
- "description": "Absolute path to the events.json file written by the webhook receiver.",
35
+ "worker_url": {
36
+ "description": "URL of the Cloudflare Worker endpoint (e.g. https://github-webhook-mcp.example.workers.dev)",
26
37
  "type": "string",
27
38
  "required": true,
28
- "title": "Events JSON Path"
39
+ "title": "Worker URL"
40
+ },
41
+ "channel_enabled": {
42
+ "description": "Enable SSE channel notifications for Claude Code CLI (set to '0' to disable, '1' to enable)",
43
+ "type": "string",
44
+ "required": false,
45
+ "title": "Channel Notifications",
46
+ "default": "0"
29
47
  }
30
48
  },
31
49
  "tools": [
@@ -51,10 +69,25 @@
51
69
  }
52
70
  ],
53
71
  "compatibility": {
72
+ "claude_desktop": ">=0.11.0",
54
73
  "platforms": [
55
74
  "win32",
56
75
  "darwin",
57
76
  "linux"
58
- ]
59
- }
77
+ ],
78
+ "runtimes": {
79
+ "node": ">=18.0.0"
80
+ }
81
+ },
82
+ "keywords": [
83
+ "github",
84
+ "webhook",
85
+ "notifications",
86
+ "cloudflare",
87
+ "mcp",
88
+ "claude"
89
+ ],
90
+ "privacy_policies": [
91
+ "https://smgjp.com/privacy-policy-github-webhook-mcp/"
92
+ ]
60
93
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "github-webhook-mcp",
3
- "version": "0.3.0",
4
- "description": "MCP server for browsing GitHub webhook events",
3
+ "version": "0.6.0-rc.1",
4
+ "description": "MCP server bridging GitHub webhooks via Cloudflare Worker",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "github-webhook-mcp": "server/index.js"
@@ -13,13 +13,12 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node server/index.js",
16
- "test": "node --test test/",
16
+ "test": "node --check server/index.js",
17
17
  "pack:mcpb": "mcpb pack"
18
18
  },
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.0.0",
21
- "iconv-lite": "^0.6.3",
22
- "zod": "^3.22.0"
21
+ "eventsource": "^2.0.2"
23
22
  },
24
23
  "devDependencies": {
25
24
  "@anthropic-ai/mcpb": "^2.1.0"
@@ -30,7 +29,8 @@
30
29
  "keywords": [
31
30
  "mcp",
32
31
  "github",
33
- "webhook"
32
+ "webhook",
33
+ "cloudflare"
34
34
  ],
35
35
  "license": "MIT",
36
36
  "repository": {
package/server/index.js CHANGED
@@ -1,215 +1,259 @@
1
- #!/usr/bin/env node
2
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import {
5
- ListToolsRequestSchema,
6
- CallToolRequestSchema,
7
- } from "@modelcontextprotocol/sdk/types.js";
8
- import { watch } from "node:fs";
9
- import {
10
- getPendingStatus,
11
- getPendingSummaries,
12
- getEvent,
13
- getPending,
14
- markDone,
15
- summarizeEvent,
16
- dataFilePath,
17
- } from "./event-store.js";
18
-
19
- const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
20
-
21
- const capabilities = {
22
- tools: {},
23
- };
24
- if (CHANNEL_ENABLED) {
25
- capabilities.experimental = { "claude/channel": {} };
26
- }
27
-
28
- const server = new Server(
29
- { name: "github-webhook-mcp", version: "0.3.0" },
30
- {
31
- capabilities,
32
- instructions: CHANNEL_ENABLED
33
- ? "GitHub webhook events arrive as <channel source=\"github-webhook-mcp\" ...>. They are one-way: read them and act, no reply expected."
34
- : undefined,
35
- },
36
- );
37
-
38
- // ── Tools ───────────────────────────────────────────────────────────────────
39
-
40
- const TOOLS = [
41
- {
42
- name: "get_pending_status",
43
- description:
44
- "Get a lightweight snapshot of pending GitHub webhook events. Use this for periodic polling before requesting details.",
45
- inputSchema: { type: "object", properties: {} },
46
- },
47
- {
48
- name: "list_pending_events",
49
- description:
50
- "List lightweight summaries for pending GitHub webhook events. Returns metadata only, without full payloads.",
51
- inputSchema: {
52
- type: "object",
53
- properties: {
54
- limit: {
55
- type: "number",
56
- description: "Maximum number of pending events to return (1-100, default 20)",
57
- },
58
- },
59
- },
60
- },
61
- {
62
- name: "get_event",
63
- description: "Get the full payload for a single webhook event by ID.",
64
- inputSchema: {
65
- type: "object",
66
- properties: {
67
- event_id: { type: "string", description: "The event ID to retrieve" },
68
- },
69
- required: ["event_id"],
70
- },
71
- },
72
- {
73
- name: "get_webhook_events",
74
- description:
75
- "Get pending (unprocessed) GitHub webhook events with full payloads. Prefer get_pending_status or list_pending_events for polling.",
76
- inputSchema: { type: "object", properties: {} },
77
- },
78
- {
79
- name: "mark_processed",
80
- description: "Mark a webhook event as processed so it won't appear again.",
81
- inputSchema: {
82
- type: "object",
83
- properties: {
84
- event_id: {
85
- type: "string",
86
- description: "The event ID to mark as processed",
87
- },
88
- },
89
- required: ["event_id"],
90
- },
91
- },
92
- ];
93
-
94
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
95
-
96
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
97
- const { name, arguments: args } = req.params;
98
-
99
- switch (name) {
100
- case "get_pending_status": {
101
- const status = getPendingStatus();
102
- return {
103
- content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
104
- };
105
- }
106
- case "list_pending_events": {
107
- const limit = Math.max(1, Math.min(100, Number(args?.limit) || 20));
108
- const summaries = getPendingSummaries(limit);
109
- return {
110
- content: [{ type: "text", text: JSON.stringify(summaries, null, 2) }],
111
- };
112
- }
113
- case "get_event": {
114
- const event = getEvent(args.event_id);
115
- if (event === null) {
116
- return {
117
- content: [
118
- {
119
- type: "text",
120
- text: JSON.stringify({ error: "not_found", event_id: args.event_id }),
121
- },
122
- ],
123
- };
124
- }
125
- return {
126
- content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
127
- };
128
- }
129
- case "get_webhook_events": {
130
- const events = getPending();
131
- return {
132
- content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
133
- };
134
- }
135
- case "mark_processed": {
136
- const result = markDone(args.event_id);
137
- return {
138
- content: [
139
- {
140
- type: "text",
141
- text: JSON.stringify({
142
- success: result.success,
143
- event_id: args.event_id,
144
- purged: result.purged,
145
- }),
146
- },
147
- ],
148
- };
149
- }
150
- default:
151
- throw new Error(`unknown tool: ${name}`);
152
- }
153
- });
154
-
155
- // ── Start ───────────────────────────────────────────────────────────────────
156
-
157
- const transport = new StdioServerTransport();
158
- await server.connect(transport);
159
-
160
- // ── File watcher (after connect) ─────────────────────────────────────────────
161
-
162
- if (CHANNEL_ENABLED) {
163
- const seenIds = new Set(getPending().map((e) => e.id));
164
- let debounce = null;
165
-
166
- function onFileChange() {
167
- if (debounce) return;
168
- debounce = setTimeout(() => {
169
- debounce = null;
170
- try {
171
- const pending = getPending();
172
- for (const event of pending) {
173
- if (seenIds.has(event.id)) continue;
174
- seenIds.add(event.id);
175
- const summary = summarizeEvent(event);
176
- const lines = [
177
- `[${summary.type}] ${summary.repo ?? ""}`,
178
- summary.action ? `action: ${summary.action}` : null,
179
- summary.title ? `#${summary.number ?? ""} ${summary.title}` : null,
180
- summary.sender ? `by ${summary.sender}` : null,
181
- summary.url ?? null,
182
- ].filter(Boolean);
183
- server.notification({
184
- method: "notifications/claude/channel",
185
- params: {
186
- content: lines.join("\n"),
187
- meta: {
188
- chat_id: "github",
189
- message_id: event.id,
190
- user: summary.sender ?? "github",
191
- ts: summary.received_at,
192
- },
193
- },
194
- });
195
- }
196
- } catch {
197
- // file may be mid-write; ignore and retry on next change
198
- }
199
- }, 500);
200
- }
201
-
202
- try {
203
- watch(dataFilePath(), onFileChange);
204
- } catch {
205
- // events.json may not exist yet; start polling fallback
206
- const poll = setInterval(() => {
207
- try {
208
- watch(dataFilePath(), onFileChange);
209
- clearInterval(poll);
210
- } catch {
211
- // keep waiting
212
- }
213
- }, 5000);
214
- }
215
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Webhook MCP — Cloudflare Worker bridge
4
+ *
5
+ * Thin stdio MCP server that proxies tool calls to a remote
6
+ * Cloudflare Worker + Durable Object backend via Streamable HTTP.
7
+ * Optionally listens to SSE for real-time channel notifications.
8
+ *
9
+ * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge.
10
+ */
11
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import {
14
+ ListToolsRequestSchema,
15
+ CallToolRequestSchema,
16
+ } from "@modelcontextprotocol/sdk/types.js";
17
+
18
+ const WORKER_URL =
19
+ process.env.WEBHOOK_WORKER_URL ||
20
+ "https://github-webhook-mcp.liplus.workers.dev";
21
+ const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
22
+
23
+ // ── Remote MCP Session (lazy, reused) ────────────────────────────────────────
24
+
25
+ let _sessionId = null;
26
+
27
+ async function getSessionId() {
28
+ if (_sessionId) return _sessionId;
29
+
30
+ const res = await fetch(`${WORKER_URL}/mcp`, {
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ Accept: "application/json, text/event-stream",
35
+ },
36
+ body: JSON.stringify({
37
+ jsonrpc: "2.0",
38
+ method: "initialize",
39
+ params: {
40
+ protocolVersion: "2024-11-05",
41
+ capabilities: {},
42
+ clientInfo: { name: "local-bridge", version: "1.0.0" },
43
+ },
44
+ id: "init",
45
+ }),
46
+ });
47
+
48
+ _sessionId = res.headers.get("mcp-session-id") || "";
49
+ return _sessionId;
50
+ }
51
+
52
+ async function callRemoteTool(name, args) {
53
+ const sessionId = await getSessionId();
54
+
55
+ const res = await fetch(`${WORKER_URL}/mcp`, {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ Accept: "application/json, text/event-stream",
60
+ "mcp-session-id": sessionId,
61
+ },
62
+ body: JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ method: "tools/call",
65
+ params: { name, arguments: args },
66
+ id: crypto.randomUUID(),
67
+ }),
68
+ });
69
+
70
+ const text = await res.text();
71
+
72
+ // Streamable HTTP may return SSE format
73
+ const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
74
+ const json = dataLine ? JSON.parse(dataLine.slice(6)) : JSON.parse(text);
75
+
76
+ if (json.error) {
77
+ // Session expired — retry once with a fresh session
78
+ if (json.error.code === -32600 || json.error.code === -32001) {
79
+ _sessionId = null;
80
+ return callRemoteTool(name, args);
81
+ }
82
+ return { content: [{ type: "text", text: JSON.stringify(json.error) }] };
83
+ }
84
+
85
+ return json.result;
86
+ }
87
+
88
+ // ── MCP Server Setup ─────────────────────────────────────────────────────────
89
+
90
+ const capabilities = { tools: {} };
91
+ if (CHANNEL_ENABLED) {
92
+ capabilities.experimental = { "claude/channel": {} };
93
+ }
94
+
95
+ const server = new Server(
96
+ { name: "github-webhook-mcp", version: "1.0.0" },
97
+ {
98
+ capabilities,
99
+ instructions: CHANNEL_ENABLED
100
+ ? 'GitHub webhook events arrive as <channel source="github-webhook-mcp" ...>. They are one-way: read them and act, no reply expected.'
101
+ : undefined,
102
+ },
103
+ );
104
+
105
+ // ── Tool Definitions ─────────────────────────────────────────────────────────
106
+
107
+ const TOOLS = [
108
+ {
109
+ name: "get_pending_status",
110
+ title: "Get Pending Status",
111
+ description:
112
+ "Get a lightweight snapshot of pending GitHub webhook events. Use this for periodic polling before requesting details.",
113
+ inputSchema: { type: "object", properties: {} },
114
+ annotations: {
115
+ title: "Get Pending Status",
116
+ readOnlyHint: true,
117
+ },
118
+ },
119
+ {
120
+ name: "list_pending_events",
121
+ title: "List Pending Events",
122
+ description:
123
+ "List lightweight summaries for pending GitHub webhook events. Returns metadata only, without full payloads.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ limit: {
128
+ type: "number",
129
+ description:
130
+ "Maximum number of pending events to return (1-100, default 20)",
131
+ },
132
+ },
133
+ },
134
+ annotations: {
135
+ title: "List Pending Events",
136
+ readOnlyHint: true,
137
+ },
138
+ },
139
+ {
140
+ name: "get_event",
141
+ title: "Get Event Payload",
142
+ description: "Get the full payload for a single webhook event by ID.",
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: {
146
+ event_id: { type: "string", description: "The event ID to retrieve" },
147
+ },
148
+ required: ["event_id"],
149
+ },
150
+ annotations: {
151
+ title: "Get Event Payload",
152
+ readOnlyHint: true,
153
+ },
154
+ },
155
+ {
156
+ name: "get_webhook_events",
157
+ title: "Get Webhook Events",
158
+ description:
159
+ "Get pending (unprocessed) GitHub webhook events with full payloads. Prefer get_pending_status or list_pending_events for polling.",
160
+ inputSchema: { type: "object", properties: {} },
161
+ annotations: {
162
+ title: "Get Webhook Events",
163
+ readOnlyHint: true,
164
+ },
165
+ },
166
+ {
167
+ name: "mark_processed",
168
+ title: "Mark Event Processed",
169
+ description:
170
+ "Mark a webhook event as processed so it won't appear again.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ event_id: {
175
+ type: "string",
176
+ description: "The event ID to mark as processed",
177
+ },
178
+ },
179
+ required: ["event_id"],
180
+ },
181
+ annotations: {
182
+ title: "Mark Event Processed",
183
+ destructiveHint: true,
184
+ },
185
+ },
186
+ ];
187
+
188
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
189
+
190
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
191
+ const { name, arguments: args } = req.params;
192
+ try {
193
+ return await callRemoteTool(name, args ?? {});
194
+ } catch (err) {
195
+ return {
196
+ content: [{ type: "text", text: `Failed to reach worker: ${err}` }],
197
+ isError: true,
198
+ };
199
+ }
200
+ });
201
+
202
+ // ── SSE Listener → Channel Notifications ─────────────────────────────────────
203
+
204
+ async function connectSSE() {
205
+ let EventSourceImpl;
206
+ try {
207
+ EventSourceImpl = (await import("eventsource")).default;
208
+ } catch {
209
+ // eventsource not installed — skip SSE
210
+ return;
211
+ }
212
+
213
+ const es = new EventSourceImpl(`${WORKER_URL}/events`);
214
+
215
+ es.onmessage = (event) => {
216
+ try {
217
+ const data = JSON.parse(event.data);
218
+ if ("heartbeat" in data || "status" in data) return;
219
+ if (!data.summary) return;
220
+
221
+ const s = data.summary;
222
+ const lines = [
223
+ `[${s.type}] ${s.repo ?? ""}`,
224
+ s.action ? `action: ${s.action}` : null,
225
+ s.title ? `#${s.number ?? ""} ${s.title}` : null,
226
+ s.sender ? `by ${s.sender}` : null,
227
+ s.url ?? null,
228
+ ].filter(Boolean);
229
+
230
+ server.notification({
231
+ method: "notifications/claude/channel",
232
+ params: {
233
+ content: lines.join("\n"),
234
+ meta: {
235
+ chat_id: "github",
236
+ message_id: s.id,
237
+ user: s.sender ?? "github",
238
+ ts: s.received_at,
239
+ },
240
+ },
241
+ });
242
+ } catch {
243
+ // Ignore parse errors
244
+ }
245
+ };
246
+
247
+ es.onerror = () => {
248
+ // EventSource auto-reconnects
249
+ };
250
+ }
251
+
252
+ // ── Start ────────────────────────────────────────────────────────────────────
253
+
254
+ const transport = new StdioServerTransport();
255
+ await server.connect(transport);
256
+
257
+ if (CHANNEL_ENABLED) {
258
+ connectSSE();
259
+ }
@@ -1,181 +0,0 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { resolve, dirname } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import iconv from "iconv-lite";
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const DEFAULT_DATA_FILE = resolve(__dirname, "..", "..", "events.json");
8
- const PRIMARY_ENCODING = "utf-8";
9
- const LEGACY_ENCODINGS = ["utf-8", "cp932", "shift_jis"];
10
- const DEFAULT_PURGE_DAYS = 1;
11
-
12
- function purgeDays() {
13
- const env = process.env.PURGE_AFTER_DAYS;
14
- if (env !== undefined) {
15
- const n = Number(env);
16
- if (Number.isFinite(n) && n >= 0) return n;
17
- }
18
- return DEFAULT_PURGE_DAYS;
19
- }
20
-
21
- export function dataFilePath() {
22
- return process.env.EVENTS_JSON_PATH || DEFAULT_DATA_FILE;
23
- }
24
-
25
- // ── Load / Save ─────────────────────────────────────────────────────────────
26
-
27
- export function load() {
28
- const filePath = dataFilePath();
29
- if (!existsSync(filePath)) return [];
30
-
31
- const raw = readFileSync(filePath);
32
-
33
- // Try UTF-8 first (with BOM stripping)
34
- try {
35
- let text = raw.toString("utf-8");
36
- if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
37
- const events = JSON.parse(text);
38
- return events;
39
- } catch {
40
- // fall through to legacy encodings
41
- }
42
-
43
- // Try legacy encodings
44
- for (const encoding of LEGACY_ENCODINGS) {
45
- try {
46
- const text = iconv.decode(raw, encoding);
47
- const events = JSON.parse(text);
48
- // Migrate to UTF-8
49
- save(events);
50
- return events;
51
- } catch {
52
- continue;
53
- }
54
- }
55
-
56
- throw new Error(`Unable to decode event store: ${filePath}`);
57
- }
58
-
59
- export function save(events) {
60
- const filePath = dataFilePath();
61
- writeFileSync(filePath, JSON.stringify(events, null, 2), PRIMARY_ENCODING);
62
- }
63
-
64
- // ── Purge ──────────────────────────────────────────────────────────────────
65
-
66
- export function purgeProcessed(events) {
67
- const days = purgeDays();
68
- if (days < 0) return { kept: events, purged: 0 };
69
- const cutoff = Date.now() - days * 86_400_000;
70
- const before = events.length;
71
- const kept = events.filter((e) => {
72
- if (!e.processed) return true;
73
- const ts = Date.parse(e.received_at);
74
- return Number.isNaN(ts) || ts > cutoff;
75
- });
76
- return { kept, purged: before - kept.length };
77
- }
78
-
79
- // ── Query ───────────────────────────────────────────────────────────────────
80
-
81
- export function getPending() {
82
- return load().filter((e) => !e.processed);
83
- }
84
-
85
- export function getEvent(eventId) {
86
- for (const event of load()) {
87
- if (event.id === eventId) return event;
88
- }
89
- return null;
90
- }
91
-
92
- export function getPendingStatus() {
93
- const pending = getPending();
94
- const types = {};
95
- for (const event of pending) {
96
- types[event.type] = (types[event.type] || 0) + 1;
97
- }
98
- return {
99
- pending_count: pending.length,
100
- latest_received_at: pending.length > 0 ? pending[pending.length - 1].received_at : null,
101
- types,
102
- };
103
- }
104
-
105
- // ── Summary ─────────────────────────────────────────────────────────────────
106
-
107
- function eventNumber(payload) {
108
- return (
109
- payload.number ??
110
- payload.issue?.number ??
111
- payload.pull_request?.number ??
112
- null
113
- );
114
- }
115
-
116
- function eventTitle(payload) {
117
- return (
118
- payload.issue?.title ??
119
- payload.pull_request?.title ??
120
- payload.discussion?.title ??
121
- payload.check_run?.name ??
122
- payload.workflow_run?.name ??
123
- payload.workflow_job?.name ??
124
- null
125
- );
126
- }
127
-
128
- function eventUrl(payload) {
129
- return (
130
- payload.issue?.html_url ??
131
- payload.pull_request?.html_url ??
132
- payload.discussion?.html_url ??
133
- payload.check_run?.html_url ??
134
- payload.workflow_run?.html_url ??
135
- null
136
- );
137
- }
138
-
139
- export function summarizeEvent(event) {
140
- const payload = event.payload || {};
141
- return {
142
- id: event.id,
143
- type: event.type,
144
- received_at: event.received_at,
145
- processed: event.processed,
146
- trigger_status: event.trigger_status ?? null,
147
- last_triggered_at: event.last_triggered_at ?? null,
148
- action: payload.action ?? null,
149
- repo: payload.repository?.full_name ?? null,
150
- sender: payload.sender?.login ?? null,
151
- number: eventNumber(payload),
152
- title: eventTitle(payload),
153
- url: eventUrl(payload),
154
- };
155
- }
156
-
157
- export function getPendingSummaries(limit = 20) {
158
- let pending = getPending();
159
- if (limit > 0) {
160
- pending = pending.slice(-limit);
161
- }
162
- return pending.map(summarizeEvent);
163
- }
164
-
165
- // ── Mutation ────────────────────────────────────────────────────────────────
166
-
167
- export function markDone(eventId) {
168
- const events = load();
169
- let found = false;
170
- for (const event of events) {
171
- if (event.id === eventId) {
172
- event.processed = true;
173
- found = true;
174
- break;
175
- }
176
- }
177
- if (!found) return { success: false, purged: 0 };
178
- const { kept, purged } = purgeProcessed(events);
179
- save(kept);
180
- return { success: true, purged };
181
- }