rufloui 0.3.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/'1' +0 -0
- package/.env.example +46 -0
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +287 -0
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/Webhooks) +0 -0
- package/docs/plans/2026-03-11-github-webhooks.md +957 -0
- package/docs/screenshot-swarm-monitor.png +0 -0
- package/frontend +0 -0
- package/index.html +13 -0
- package/package.json +56 -0
- package/public/vite.svg +4 -0
- package/src/backend/__tests__/webhook-github.test.ts +934 -0
- package/src/backend/jsonl-monitor.ts +430 -0
- package/src/backend/server.ts +2972 -0
- package/src/backend/telegram-bot.ts +511 -0
- package/src/backend/webhook-github.ts +350 -0
- package/src/frontend/App.tsx +461 -0
- package/src/frontend/api.ts +281 -0
- package/src/frontend/components/ErrorBoundary.tsx +98 -0
- package/src/frontend/components/Layout.tsx +431 -0
- package/src/frontend/components/ui/Button.tsx +111 -0
- package/src/frontend/components/ui/Card.tsx +51 -0
- package/src/frontend/components/ui/StatusBadge.tsx +60 -0
- package/src/frontend/main.tsx +63 -0
- package/src/frontend/pages/AgentVizPanel.tsx +428 -0
- package/src/frontend/pages/AgentsPanel.tsx +445 -0
- package/src/frontend/pages/ConfigPanel.tsx +661 -0
- package/src/frontend/pages/Dashboard.tsx +482 -0
- package/src/frontend/pages/HiveMindPanel.tsx +355 -0
- package/src/frontend/pages/HooksPanel.tsx +240 -0
- package/src/frontend/pages/LogsPanel.tsx +261 -0
- package/src/frontend/pages/MemoryPanel.tsx +444 -0
- package/src/frontend/pages/NeuralPanel.tsx +301 -0
- package/src/frontend/pages/PerformancePanel.tsx +198 -0
- package/src/frontend/pages/SessionsPanel.tsx +428 -0
- package/src/frontend/pages/SetupWizard.tsx +181 -0
- package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
- package/src/frontend/pages/SwarmPanel.tsx +322 -0
- package/src/frontend/pages/TasksPanel.tsx +535 -0
- package/src/frontend/pages/WebhooksPanel.tsx +335 -0
- package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
- package/src/frontend/store.ts +185 -0
- package/src/frontend/styles/global.css +113 -0
- package/src/frontend/test-setup.ts +1 -0
- package/src/frontend/tour/TourContext.tsx +161 -0
- package/src/frontend/tour/tourSteps.ts +181 -0
- package/src/frontend/tour/tourStyles.css +116 -0
- package/src/frontend/types.ts +239 -0
- package/src/frontend/utils/formatTime.test.ts +83 -0
- package/src/frontend/utils/formatTime.ts +23 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +17 -0
- package/{,+ +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { Router, Request, Response, RequestHandler } from 'express'
|
|
5
|
+
|
|
6
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface GitHubWebhookConfig {
|
|
9
|
+
enabled: boolean
|
|
10
|
+
/** Personal access token (repo scope) for creating branches/PRs */
|
|
11
|
+
githubToken: string
|
|
12
|
+
/** Webhook secret for HMAC-SHA256 validation */
|
|
13
|
+
webhookSecret: string
|
|
14
|
+
/** Repos to monitor — array of "owner/repo" strings */
|
|
15
|
+
repos: string[]
|
|
16
|
+
/** Auto-assign new issue tasks to the active swarm */
|
|
17
|
+
autoAssign: boolean
|
|
18
|
+
/** Custom instructions template for swarm tasks. Placeholders: {{title}}, {{body}}, {{url}}, {{author}}, {{labels}}, {{repo}}, {{number}} */
|
|
19
|
+
taskTemplate: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WebhookEvent {
|
|
23
|
+
id: string
|
|
24
|
+
provider: 'github'
|
|
25
|
+
repo: string
|
|
26
|
+
event: string
|
|
27
|
+
title: string
|
|
28
|
+
body: string
|
|
29
|
+
url: string
|
|
30
|
+
number: number
|
|
31
|
+
author: string
|
|
32
|
+
labels: string[]
|
|
33
|
+
receivedAt: string
|
|
34
|
+
taskId?: string
|
|
35
|
+
status: 'received' | 'processing' | 'completed' | 'failed' | 'ignored'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TEMPLATE = 'Analyze this issue, investigate the codebase, implement a fix, write tests, and prepare a summary of changes.'
|
|
39
|
+
|
|
40
|
+
const DEFAULT_CONFIG: GitHubWebhookConfig = {
|
|
41
|
+
enabled: false,
|
|
42
|
+
githubToken: '',
|
|
43
|
+
webhookSecret: '',
|
|
44
|
+
repos: [],
|
|
45
|
+
autoAssign: true,
|
|
46
|
+
taskTemplate: '',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildTaskDescription(event: WebhookEvent, template: string): string {
|
|
50
|
+
const bodyText = event.body?.slice(0, 2000) || 'No description provided.'
|
|
51
|
+
const instructions = template || DEFAULT_TEMPLATE
|
|
52
|
+
const rendered = instructions
|
|
53
|
+
.replace(/\{\{title\}\}/g, event.title)
|
|
54
|
+
.replace(/\{\{body\}\}/g, bodyText)
|
|
55
|
+
.replace(/\{\{url\}\}/g, event.url)
|
|
56
|
+
.replace(/\{\{author\}\}/g, event.author)
|
|
57
|
+
.replace(/\{\{labels\}\}/g, event.labels.join(', ') || 'none')
|
|
58
|
+
.replace(/\{\{repo\}\}/g, event.repo)
|
|
59
|
+
.replace(/\{\{number\}\}/g, String(event.number))
|
|
60
|
+
|
|
61
|
+
// If the custom template already includes {{body}}, skip the body in the header
|
|
62
|
+
// to avoid duplication
|
|
63
|
+
const templateHasBody = template && /\{\{body\}\}/.test(template)
|
|
64
|
+
|
|
65
|
+
const parts = [
|
|
66
|
+
`GitHub Issue: ${event.url}`,
|
|
67
|
+
`Author: ${event.author}`,
|
|
68
|
+
`Labels: ${event.labels.join(', ') || 'none'}`,
|
|
69
|
+
]
|
|
70
|
+
if (!templateHasBody) {
|
|
71
|
+
parts.push('', bodyText)
|
|
72
|
+
}
|
|
73
|
+
parts.push('', '---', `Instructions: ${rendered}`)
|
|
74
|
+
|
|
75
|
+
return parts.join('\n')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Persistence ────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function configPath(): string {
|
|
81
|
+
const dir = process.env.RUFLO_PERSIST_DIR || '.ruflo'
|
|
82
|
+
return join(dir, 'github-webhook.json')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function loadGitHubWebhookConfig(): GitHubWebhookConfig {
|
|
86
|
+
try {
|
|
87
|
+
if (existsSync(configPath())) {
|
|
88
|
+
const raw = JSON.parse(readFileSync(configPath(), 'utf-8'))
|
|
89
|
+
return { ...DEFAULT_CONFIG, ...raw }
|
|
90
|
+
}
|
|
91
|
+
} catch { /* use defaults */ }
|
|
92
|
+
// Fallback to env vars
|
|
93
|
+
return {
|
|
94
|
+
...DEFAULT_CONFIG,
|
|
95
|
+
enabled: process.env.GITHUB_WEBHOOK_ENABLED === 'true',
|
|
96
|
+
githubToken: process.env.GITHUB_TOKEN || '',
|
|
97
|
+
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET || '',
|
|
98
|
+
repos: process.env.GITHUB_WEBHOOK_REPOS?.split(',').map(r => r.trim()).filter(Boolean) || [],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function saveGitHubWebhookConfig(config: GitHubWebhookConfig): void {
|
|
103
|
+
const dir = process.env.RUFLO_PERSIST_DIR || '.ruflo'
|
|
104
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
105
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf-8')
|
|
106
|
+
try { chmodSync(configPath(), 0o600) } catch { /* Windows */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── HMAC Validation ────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export function verifyGitHubSignature(
|
|
112
|
+
payload: Buffer | string,
|
|
113
|
+
signature: string | undefined,
|
|
114
|
+
secret: string,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (!signature || !secret) return false
|
|
117
|
+
// Strip the 'sha256=' prefix from the header value
|
|
118
|
+
const sig = signature.startsWith('sha256=') ? signature.slice(7) : signature
|
|
119
|
+
const expected = createHmac('sha256', secret).update(payload).digest('hex')
|
|
120
|
+
if (expected.length !== sig.length) return false
|
|
121
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
122
|
+
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── In-memory event store ──────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const MAX_EVENTS = 200
|
|
128
|
+
let webhookEvents: WebhookEvent[] = []
|
|
129
|
+
|
|
130
|
+
function addEvent(event: WebhookEvent): void {
|
|
131
|
+
webhookEvents.unshift(event)
|
|
132
|
+
if (webhookEvents.length > MAX_EVENTS) webhookEvents = webhookEvents.slice(0, MAX_EVENTS)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function updateWebhookEventByTaskId(taskId: string, status: WebhookEvent['status']): void {
|
|
136
|
+
const event = webhookEvents.find(e => e.taskId === taskId)
|
|
137
|
+
if (event) event.status = status
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getWebhookEvents(): WebhookEvent[] {
|
|
141
|
+
return webhookEvents
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Route Factory ──────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export interface WebhookStores {
|
|
147
|
+
createAndAssignTask: (title: string, description: string) => Promise<{ taskId: string; assigned: boolean }>
|
|
148
|
+
broadcast: (type: string, payload: unknown) => void
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function githubWebhookRoutes(
|
|
152
|
+
getConfig: () => GitHubWebhookConfig,
|
|
153
|
+
setConfig: (c: GitHubWebhookConfig) => void,
|
|
154
|
+
stores: WebhookStores,
|
|
155
|
+
): Router {
|
|
156
|
+
const router = Router()
|
|
157
|
+
|
|
158
|
+
const wrap = (fn: (req: Request, res: Response) => Promise<void>): RequestHandler =>
|
|
159
|
+
async (req, res, _next) => {
|
|
160
|
+
try { await fn(req, res) } catch (err: unknown) {
|
|
161
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Webhook receiver ───────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
router.post('/github', wrap(async (req, res) => {
|
|
168
|
+
const config = getConfig()
|
|
169
|
+
if (!config.enabled) {
|
|
170
|
+
res.status(503).json({ error: 'GitHub webhooks disabled' })
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The raw body buffer is attached by the express.raw() middleware in server.ts
|
|
175
|
+
const rawBody: Buffer | undefined = (req as any).rawBody
|
|
176
|
+
const sig = req.headers['x-hub-signature-256'] as string | undefined
|
|
177
|
+
|
|
178
|
+
// Validate HMAC signature
|
|
179
|
+
if (config.webhookSecret) {
|
|
180
|
+
if (!sig) {
|
|
181
|
+
res.status(401).json({ error: 'Missing X-Hub-Signature-256 header' })
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (!rawBody || rawBody.length === 0) {
|
|
185
|
+
res.status(400).json({ error: 'Empty request body' })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (!verifyGitHubSignature(rawBody, sig, config.webhookSecret)) {
|
|
189
|
+
res.status(401).json({ error: 'Invalid signature' })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ghEvent = req.headers['x-github-event'] as string || 'unknown'
|
|
195
|
+
const payload = req.body
|
|
196
|
+
|
|
197
|
+
// Respond to GitHub's initial ping event
|
|
198
|
+
if (ghEvent === 'ping') {
|
|
199
|
+
res.json({ ok: true, action: 'pong', zen: payload.zen || '' })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Only handle issue events for now.
|
|
204
|
+
if (ghEvent !== 'issues') {
|
|
205
|
+
res.json({ ok: true, action: 'ignored', reason: `event type '${ghEvent}' not handled` })
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const action = payload.action
|
|
210
|
+
if (action !== 'opened' && action !== 'reopened') {
|
|
211
|
+
res.json({ ok: true, action: 'ignored', reason: `issues.${action} not handled` })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const issue = payload.issue
|
|
216
|
+
const repo = payload.repository?.full_name || 'unknown/unknown'
|
|
217
|
+
|
|
218
|
+
// Check if this repo is monitored
|
|
219
|
+
if (config.repos.length > 0 && !config.repos.includes(repo)) {
|
|
220
|
+
res.json({ ok: true, action: 'ignored', reason: `repo '${repo}' not monitored` })
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Create webhook event
|
|
225
|
+
const event: WebhookEvent = {
|
|
226
|
+
id: `gh-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
227
|
+
provider: 'github',
|
|
228
|
+
repo,
|
|
229
|
+
event: `issues.${action}`,
|
|
230
|
+
title: issue.title || 'Untitled',
|
|
231
|
+
body: issue.body || '',
|
|
232
|
+
url: issue.html_url || '',
|
|
233
|
+
number: issue.number || 0,
|
|
234
|
+
author: issue.user?.login || 'unknown',
|
|
235
|
+
labels: (issue.labels || []).map((l: { name: string }) => l.name),
|
|
236
|
+
receivedAt: new Date().toISOString(),
|
|
237
|
+
status: 'received',
|
|
238
|
+
}
|
|
239
|
+
addEvent(event)
|
|
240
|
+
stores.broadcast('webhook:received', event)
|
|
241
|
+
|
|
242
|
+
// Auto-create task if enabled
|
|
243
|
+
if (config.autoAssign) {
|
|
244
|
+
event.status = 'processing'
|
|
245
|
+
stores.broadcast('webhook:updated', event)
|
|
246
|
+
|
|
247
|
+
const taskTitle = `[${repo}#${issue.number}] ${issue.title}`
|
|
248
|
+
const taskDesc = buildTaskDescription(event, config.taskTemplate)
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const result = await stores.createAndAssignTask(taskTitle, taskDesc)
|
|
252
|
+
event.taskId = result.taskId
|
|
253
|
+
event.status = result.assigned ? 'processing' : 'received'
|
|
254
|
+
stores.broadcast('webhook:updated', event)
|
|
255
|
+
} catch {
|
|
256
|
+
event.status = 'failed'
|
|
257
|
+
stores.broadcast('webhook:updated', event)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
res.json({ ok: true, action: 'task_created', eventId: event.id, taskId: event.taskId })
|
|
262
|
+
}))
|
|
263
|
+
|
|
264
|
+
// ── Config API ─────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
router.get('/github/config', wrap(async (_req, res) => {
|
|
267
|
+
const config = getConfig()
|
|
268
|
+
res.json({
|
|
269
|
+
enabled: config.enabled,
|
|
270
|
+
hasToken: !!config.githubToken,
|
|
271
|
+
tokenPreview: config.githubToken ? '...' + config.githubToken.slice(-4) : '',
|
|
272
|
+
webhookSecret: config.webhookSecret ? '****' : '',
|
|
273
|
+
hasSecret: !!config.webhookSecret,
|
|
274
|
+
repos: config.repos,
|
|
275
|
+
autoAssign: config.autoAssign,
|
|
276
|
+
taskTemplate: config.taskTemplate || '',
|
|
277
|
+
})
|
|
278
|
+
}))
|
|
279
|
+
|
|
280
|
+
router.put('/github/config', wrap(async (req, res) => {
|
|
281
|
+
const config = getConfig()
|
|
282
|
+
const { enabled, githubToken, webhookSecret, repos, autoAssign, taskTemplate } = req.body
|
|
283
|
+
if (typeof enabled === 'boolean') config.enabled = enabled
|
|
284
|
+
if (typeof githubToken === 'string') config.githubToken = githubToken
|
|
285
|
+
if (typeof webhookSecret === 'string') config.webhookSecret = webhookSecret
|
|
286
|
+
if (Array.isArray(repos)) config.repos = repos.filter((r: unknown) => typeof r === 'string' && (r as string).includes('/'))
|
|
287
|
+
if (typeof autoAssign === 'boolean') config.autoAssign = autoAssign
|
|
288
|
+
if (typeof taskTemplate === 'string') config.taskTemplate = taskTemplate
|
|
289
|
+
setConfig(config)
|
|
290
|
+
saveGitHubWebhookConfig(config)
|
|
291
|
+
res.json({ ok: true })
|
|
292
|
+
}))
|
|
293
|
+
|
|
294
|
+
// ── Test endpoint ───────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
router.post('/github/test', wrap(async (_req, res) => {
|
|
297
|
+
const config = getConfig()
|
|
298
|
+
if (!config.enabled) {
|
|
299
|
+
res.status(503).json({ error: 'GitHub webhooks disabled' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const event: WebhookEvent = {
|
|
304
|
+
id: `gh-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
305
|
+
provider: 'github',
|
|
306
|
+
repo: 'test/webhook-test',
|
|
307
|
+
event: 'issues.opened',
|
|
308
|
+
title: 'Test webhook event',
|
|
309
|
+
body: 'This is a test event sent from the RuFloUI dashboard to verify the webhook pipeline works correctly.',
|
|
310
|
+
url: 'https://github.com/test/webhook-test/issues/0',
|
|
311
|
+
number: 0,
|
|
312
|
+
author: 'rufloui-test',
|
|
313
|
+
labels: ['test'],
|
|
314
|
+
receivedAt: new Date().toISOString(),
|
|
315
|
+
status: 'received',
|
|
316
|
+
}
|
|
317
|
+
addEvent(event)
|
|
318
|
+
stores.broadcast('webhook:received', event)
|
|
319
|
+
|
|
320
|
+
if (config.autoAssign) {
|
|
321
|
+
event.status = 'processing'
|
|
322
|
+
stores.broadcast('webhook:updated', event)
|
|
323
|
+
const taskTitle = `[test/webhook-test#0] Test webhook event`
|
|
324
|
+
const taskDesc = buildTaskDescription(event, config.taskTemplate)
|
|
325
|
+
try {
|
|
326
|
+
const result = await stores.createAndAssignTask(taskTitle, taskDesc)
|
|
327
|
+
event.taskId = result.taskId
|
|
328
|
+
event.status = result.assigned ? 'processing' : 'received'
|
|
329
|
+
stores.broadcast('webhook:updated', event)
|
|
330
|
+
res.json({ ok: true, eventId: event.id, taskId: result.taskId, assigned: result.assigned })
|
|
331
|
+
} catch {
|
|
332
|
+
event.status = 'failed'
|
|
333
|
+
stores.broadcast('webhook:updated', event)
|
|
334
|
+
res.json({ ok: false, error: 'Task creation failed', eventId: event.id })
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
res.json({ ok: true, eventId: event.id, message: 'Test event created (auto-assign disabled)' })
|
|
338
|
+
}
|
|
339
|
+
}))
|
|
340
|
+
|
|
341
|
+
// ── Events API ─────────────────────────────────────────────────────────
|
|
342
|
+
// Pipeline verified: events stored via addEvent(), retrievable here,
|
|
343
|
+
// broadcast via 'webhook:received'/'webhook:updated' for real-time WebSocket updates.
|
|
344
|
+
|
|
345
|
+
router.get('/github/events', wrap(async (_req, res) => {
|
|
346
|
+
res.json(webhookEvents)
|
|
347
|
+
}))
|
|
348
|
+
|
|
349
|
+
return router
|
|
350
|
+
}
|