rufloui 0.3.2 → 0.3.35
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/TESTS.md +91 -0
- package/package.json +2 -2
- package/src/backend/__tests__/e2e-workflows.test.ts +438 -0
- package/src/backend/__tests__/server-integration.test.ts +444 -0
- package/src/backend/__tests__/server-utils.test.ts +200 -0
- package/src/backend/__tests__/webhook-gitlab.test.ts +605 -0
- package/src/backend/server.ts +301 -14
- package/src/backend/webhook-github.ts +0 -1
- package/src/backend/webhook-gitlab.ts +313 -0
- package/src/frontend/__tests__/api.test.ts +375 -0
- package/src/frontend/__tests__/components.test.tsx +195 -0
- package/src/frontend/__tests__/store.test.ts +295 -0
- package/src/frontend/api.ts +8 -1
- package/src/frontend/pages/TasksPanel.tsx +59 -2
- package/src/frontend/pages/WebhooksPanel.tsx +282 -116
- package/src/frontend/types.ts +12 -1
- package/vitest.config.ts +1 -0
- package/frontend +0 -0
- package/release-notes.md +0 -27
- package/{ +0 -0
- package/{, +0 -0
- package/{,+ +0 -0
- /package/{Webhooks) → {}),} +0 -0
package/src/backend/server.ts
CHANGED
|
@@ -10,7 +10,8 @@ import fs from 'fs'
|
|
|
10
10
|
import crypto from 'crypto'
|
|
11
11
|
import { startMonitoring, stopMonitoring, getSessionTree, getAllMonitoredSessions, getNodeLogs } from './jsonl-monitor'
|
|
12
12
|
import { initTelegramBot, TelegramConfig, TelegramHandle } from './telegram-bot'
|
|
13
|
-
import { loadGitHubWebhookConfig, githubWebhookRoutes, updateWebhookEventByTaskId } from './webhook-github'
|
|
13
|
+
import { loadGitHubWebhookConfig, saveGitHubWebhookConfig, githubWebhookRoutes, updateWebhookEventByTaskId } from './webhook-github'
|
|
14
|
+
import { loadGitLabWebhookConfig, saveGitLabWebhookConfig, gitlabWebhookRoutes, updateGitLabEventByTaskId } from './webhook-gitlab'
|
|
14
15
|
|
|
15
16
|
const execAsync = promisify(exec)
|
|
16
17
|
const execFileAsync = promisify(execFile)
|
|
@@ -78,6 +79,198 @@ const ZOMBIE_TIMEOUT = Number(process.env.RUFLO_ZOMBIE_TIMEOUT) || 300_000 // 5
|
|
|
78
79
|
let SKIP_PERMISSIONS = process.env.RUFLOUI_SKIP_PERMISSIONS !== 'false'
|
|
79
80
|
|
|
80
81
|
let githubWebhookConfig = loadGitHubWebhookConfig()
|
|
82
|
+
let gitlabWebhookConfig = loadGitLabWebhookConfig()
|
|
83
|
+
|
|
84
|
+
// ── WEBHOOK REPO MANAGEMENT ─────────────────────────────────────────
|
|
85
|
+
// Clones external repos so agents work on them, not on rufloui itself.
|
|
86
|
+
// After task completion: commits, pushes branch, creates PR/MR, closes issue.
|
|
87
|
+
|
|
88
|
+
interface WebhookMeta {
|
|
89
|
+
provider: 'github' | 'gitlab'
|
|
90
|
+
repo: string // owner/repo or namespace/project
|
|
91
|
+
issueNumber: number
|
|
92
|
+
issueUrl: string
|
|
93
|
+
branchName: string
|
|
94
|
+
host: string // e.g. 'github.com', 'gitlab.com', 'git.proconsi.com'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const REPOS_DIR = path.join(process.env.RUFLO_PERSIST_DIR || '.ruflo', 'repos')
|
|
98
|
+
|
|
99
|
+
async function cloneWebhookRepo(
|
|
100
|
+
provider: 'github' | 'gitlab',
|
|
101
|
+
repo: string,
|
|
102
|
+
token: string,
|
|
103
|
+
issueUrl?: string,
|
|
104
|
+
): Promise<string> {
|
|
105
|
+
const repoDir = path.join(REPOS_DIR, repo.replace(/\//g, path.sep))
|
|
106
|
+
if (!fs.existsSync(REPOS_DIR)) fs.mkdirSync(REPOS_DIR, { recursive: true })
|
|
107
|
+
|
|
108
|
+
// Extract host from the issue URL (supports self-hosted GitLab/GitHub Enterprise)
|
|
109
|
+
let host = provider === 'gitlab' ? 'gitlab.com' : 'github.com'
|
|
110
|
+
if (issueUrl) {
|
|
111
|
+
try { host = new URL(issueUrl).host } catch { /* use default */ }
|
|
112
|
+
}
|
|
113
|
+
console.log(`[webhook-repo] Using host: ${host} for ${repo}`)
|
|
114
|
+
const authUrl = token
|
|
115
|
+
? `https://oauth2:${token}@${host}/${repo}.git`
|
|
116
|
+
: `https://${host}/${repo}.git`
|
|
117
|
+
|
|
118
|
+
if (fs.existsSync(path.join(repoDir, '.git'))) {
|
|
119
|
+
// Repo already cloned — pull latest
|
|
120
|
+
console.log(`[webhook-repo] Pulling latest for ${repo}`)
|
|
121
|
+
await execAsync('git fetch origin', { cwd: repoDir, timeout: 60_000 })
|
|
122
|
+
// Try main, then master — ignore errors from whichever doesn't exist
|
|
123
|
+
await execAsync('git checkout main', { cwd: repoDir }).catch(() =>
|
|
124
|
+
execAsync('git checkout master', { cwd: repoDir }).catch(() => {})
|
|
125
|
+
)
|
|
126
|
+
await execAsync('git pull', { cwd: repoDir, timeout: 60_000 }).catch(() => {})
|
|
127
|
+
// Update remote URL in case token changed
|
|
128
|
+
await execAsync(`git remote set-url origin "${authUrl}"`, { cwd: repoDir }).catch(() => {})
|
|
129
|
+
} else {
|
|
130
|
+
console.log(`[webhook-repo] Cloning ${repo} into ${repoDir}`)
|
|
131
|
+
fs.mkdirSync(repoDir, { recursive: true })
|
|
132
|
+
await execAsync(`git clone "${authUrl}" .`, { cwd: repoDir, timeout: 120_000 })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return repoDir
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function handleWebhookTaskCompletion(taskId: string): Promise<void> {
|
|
139
|
+
const task = taskStore.get(taskId)
|
|
140
|
+
if (!task || !(task as any).webhookMeta || !task.cwd) return
|
|
141
|
+
const meta: WebhookMeta = (task as any).webhookMeta
|
|
142
|
+
const repoDir = task.cwd
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// Check if there are any changes to commit
|
|
146
|
+
const { stdout: statusOut } = await execAsync('git status --porcelain', { cwd: repoDir })
|
|
147
|
+
if (!statusOut.trim()) {
|
|
148
|
+
console.log(`[webhook-repo] No changes to commit for task ${taskId}`)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const branchName = meta.branchName
|
|
153
|
+
console.log(`[webhook-repo] Committing and pushing changes for task ${taskId} on branch ${branchName}`)
|
|
154
|
+
|
|
155
|
+
// Create branch, add, commit, push
|
|
156
|
+
// Create branch or switch to it if it already exists
|
|
157
|
+
await execAsync(`git checkout -b "${branchName}"`, { cwd: repoDir }).catch(() =>
|
|
158
|
+
execAsync(`git checkout "${branchName}"`, { cwd: repoDir })
|
|
159
|
+
)
|
|
160
|
+
await execAsync('git add -A', { cwd: repoDir })
|
|
161
|
+
const commitMsg = `fix: resolve issue #${meta.issueNumber}\n\nAutomated fix by RuFloUI multi-agent pipeline.\nTask: ${taskId}\nIssue: ${meta.issueUrl}`
|
|
162
|
+
await execAsync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { cwd: repoDir })
|
|
163
|
+
await execAsync(`git push -u origin "${branchName}"`, { cwd: repoDir, timeout: 60_000 })
|
|
164
|
+
|
|
165
|
+
// Create PR/MR and close issue via API
|
|
166
|
+
if (meta.provider === 'github') {
|
|
167
|
+
await createGitHubPRAndCloseIssue(meta, branchName)
|
|
168
|
+
} else {
|
|
169
|
+
await createGitLabMRAndCloseIssue(meta, branchName)
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[webhook-repo] Post-completion failed for task ${taskId}:`, err)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function createGitHubPRAndCloseIssue(meta: WebhookMeta, branchName: string): Promise<void> {
|
|
177
|
+
const token = githubWebhookConfig.githubToken
|
|
178
|
+
if (!token) { console.log('[webhook-repo] No GitHub token — skipping PR/close'); return }
|
|
179
|
+
|
|
180
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json' }
|
|
181
|
+
const apiBase = meta.host === 'github.com' ? 'https://api.github.com' : `https://${meta.host}/api/v3`
|
|
182
|
+
|
|
183
|
+
// Create PR
|
|
184
|
+
try {
|
|
185
|
+
const prRes = await fetch(`${apiBase}/repos/${meta.repo}/pulls`, {
|
|
186
|
+
method: 'POST', headers,
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
title: `Fix #${meta.issueNumber}: automated resolution`,
|
|
189
|
+
body: `Automated fix generated by RuFloUI multi-agent pipeline.\n\nCloses #${meta.issueNumber}`,
|
|
190
|
+
head: branchName, base: 'main',
|
|
191
|
+
}),
|
|
192
|
+
})
|
|
193
|
+
if (!prRes.ok) {
|
|
194
|
+
// Try 'master' as base branch
|
|
195
|
+
const prRes2 = await fetch(`${apiBase}/repos/${meta.repo}/pulls`, {
|
|
196
|
+
method: 'POST', headers,
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
title: `Fix #${meta.issueNumber}: automated resolution`,
|
|
199
|
+
body: `Automated fix generated by RuFloUI multi-agent pipeline.\n\nCloses #${meta.issueNumber}`,
|
|
200
|
+
head: branchName, base: 'master',
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
const data = await prRes2.json()
|
|
204
|
+
console.log(`[webhook-repo] GitHub PR created: ${(data as any).html_url || 'failed'}`)
|
|
205
|
+
} else {
|
|
206
|
+
const data = await prRes.json()
|
|
207
|
+
console.log(`[webhook-repo] GitHub PR created: ${(data as any).html_url || 'unknown'}`)
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error('[webhook-repo] GitHub PR creation failed:', err)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Close issue
|
|
214
|
+
try {
|
|
215
|
+
await fetch(`${apiBase}/repos/${meta.repo}/issues/${meta.issueNumber}`, {
|
|
216
|
+
method: 'PATCH', headers,
|
|
217
|
+
body: JSON.stringify({ state: 'closed', state_reason: 'completed' }),
|
|
218
|
+
})
|
|
219
|
+
console.log(`[webhook-repo] GitHub issue #${meta.issueNumber} closed`)
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error('[webhook-repo] GitHub issue close failed:', err)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function createGitLabMRAndCloseIssue(meta: WebhookMeta, branchName: string): Promise<void> {
|
|
226
|
+
const token = gitlabWebhookConfig.gitlabToken
|
|
227
|
+
if (!token) { console.log('[webhook-repo] No GitLab token — skipping MR/close'); return }
|
|
228
|
+
|
|
229
|
+
const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' }
|
|
230
|
+
const apiBase = `https://${meta.host}/api/v4`
|
|
231
|
+
const projectId = encodeURIComponent(meta.repo)
|
|
232
|
+
|
|
233
|
+
// Create MR
|
|
234
|
+
try {
|
|
235
|
+
const mrRes = await fetch(`${apiBase}/projects/${projectId}/merge_requests`, {
|
|
236
|
+
method: 'POST', headers,
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
title: `Fix #${meta.issueNumber}: automated resolution`,
|
|
239
|
+
description: `Automated fix generated by RuFloUI multi-agent pipeline.\n\nCloses #${meta.issueNumber}`,
|
|
240
|
+
source_branch: branchName, target_branch: 'main',
|
|
241
|
+
}),
|
|
242
|
+
})
|
|
243
|
+
if (!mrRes.ok) {
|
|
244
|
+
// Try 'master' as target
|
|
245
|
+
const mrRes2 = await fetch(`${apiBase}/projects/${projectId}/merge_requests`, {
|
|
246
|
+
method: 'POST', headers,
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
title: `Fix #${meta.issueNumber}: automated resolution`,
|
|
249
|
+
description: `Automated fix generated by RuFloUI multi-agent pipeline.\n\nCloses #${meta.issueNumber}`,
|
|
250
|
+
source_branch: branchName, target_branch: 'master',
|
|
251
|
+
}),
|
|
252
|
+
})
|
|
253
|
+
const data = await mrRes2.json()
|
|
254
|
+
console.log(`[webhook-repo] GitLab MR created: ${(data as any).web_url || 'failed'}`)
|
|
255
|
+
} else {
|
|
256
|
+
const data = await mrRes.json()
|
|
257
|
+
console.log(`[webhook-repo] GitLab MR created: ${(data as any).web_url || 'unknown'}`)
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error('[webhook-repo] GitLab MR creation failed:', err)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Close issue
|
|
264
|
+
try {
|
|
265
|
+
await fetch(`${apiBase}/projects/${projectId}/issues/${meta.issueNumber}`, {
|
|
266
|
+
method: 'PUT', headers,
|
|
267
|
+
body: JSON.stringify({ state_event: 'close' }),
|
|
268
|
+
})
|
|
269
|
+
console.log(`[webhook-repo] GitLab issue #${meta.issueNumber} closed`)
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error('[webhook-repo] GitLab issue close failed:', err)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
81
274
|
|
|
82
275
|
// ── PERSISTENCE LAYER ───────────────────────────────────────────────
|
|
83
276
|
// Writes critical in-memory state to .ruflo/ as JSON files so it
|
|
@@ -274,6 +467,12 @@ function broadcast(type: string, payload: unknown) {
|
|
|
274
467
|
const p2 = payload as { id?: string; status?: string }
|
|
275
468
|
if (p2?.id && (p2.status === 'completed' || p2.status === 'failed')) {
|
|
276
469
|
updateWebhookEventByTaskId(p2.id, p2.status as 'completed' | 'failed')
|
|
470
|
+
updateGitLabEventByTaskId(p2.id, p2.status as 'completed' | 'failed')
|
|
471
|
+
// Post-completion: push branch, create PR/MR, close issue
|
|
472
|
+
if (p2.status === 'completed') {
|
|
473
|
+
handleWebhookTaskCompletion(p2.id).catch(err =>
|
|
474
|
+
console.error(`[webhook-repo] Post-completion error for ${p2.id}:`, err))
|
|
475
|
+
}
|
|
277
476
|
}
|
|
278
477
|
}
|
|
279
478
|
}
|
|
@@ -1792,6 +1991,8 @@ interface TaskRecord {
|
|
|
1792
1991
|
sessionUUID?: string; swarmRunId?: string
|
|
1793
1992
|
/** Working directory for claude -p processes */
|
|
1794
1993
|
cwd?: string
|
|
1994
|
+
/** Webhook metadata for post-completion actions (push, PR/MR, close issue) */
|
|
1995
|
+
webhookMeta?: WebhookMeta
|
|
1795
1996
|
}
|
|
1796
1997
|
const taskStore: Map<string, TaskRecord> = new Map()
|
|
1797
1998
|
|
|
@@ -1883,7 +2084,9 @@ function taskRoutes(): Router {
|
|
|
1883
2084
|
const id = String(req.params.id)
|
|
1884
2085
|
const task = taskStore.get(id)
|
|
1885
2086
|
if (task) {
|
|
2087
|
+
// Force cancel regardless of current status (handles stuck tasks)
|
|
1886
2088
|
task.status = 'cancelled'
|
|
2089
|
+
task.completedAt = task.completedAt || new Date().toISOString()
|
|
1887
2090
|
broadcast('task:updated', { ...task, id })
|
|
1888
2091
|
|
|
1889
2092
|
// Kill running processes for this task
|
|
@@ -1908,6 +2111,19 @@ function taskRoutes(): Router {
|
|
|
1908
2111
|
res.json({ id, cancelled: true })
|
|
1909
2112
|
}))
|
|
1910
2113
|
|
|
2114
|
+
// Delete completed/failed/cancelled tasks
|
|
2115
|
+
r.post('/clean-completed', h(async (_req, res) => {
|
|
2116
|
+
let count = 0
|
|
2117
|
+
for (const [id, task] of taskStore.entries()) {
|
|
2118
|
+
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
|
|
2119
|
+
taskStore.delete(id)
|
|
2120
|
+
count++
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
broadcast('task:list', [...taskStore.values()])
|
|
2124
|
+
res.json({ ok: true, deleted: count })
|
|
2125
|
+
}))
|
|
2126
|
+
|
|
1911
2127
|
// Task continuation — create a follow-up task with previous context
|
|
1912
2128
|
r.post('/:id/continue', h(async (req, res) => {
|
|
1913
2129
|
const parentId = String(req.params.id)
|
|
@@ -2800,23 +3016,94 @@ app.use('/api/coordination', coordinationRoutes())
|
|
|
2800
3016
|
app.use('/api/config', configRoutes())
|
|
2801
3017
|
app.use('/api/ai-defence', aiDefenceRoutes())
|
|
2802
3018
|
app.use('/api/swarm-monitor', swarmMonitorRoutes())
|
|
3019
|
+
// Helper: parse "[owner/repo#42] Title" from webhook task titles
|
|
3020
|
+
function parseWebhookTitle(title: string): { repo: string; issueNumber: number } | null {
|
|
3021
|
+
const m = title.match(/^\[([^\]]+)#(\d+)\]/)
|
|
3022
|
+
if (!m) return null
|
|
3023
|
+
return { repo: m[1], issueNumber: Number(m[2]) }
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
// Shared webhook task creator — clones repo, sets cwd, attaches metadata
|
|
3027
|
+
async function createWebhookTask(
|
|
3028
|
+
provider: 'github' | 'gitlab',
|
|
3029
|
+
title: string,
|
|
3030
|
+
description: string,
|
|
3031
|
+
issueUrl: string,
|
|
3032
|
+
): Promise<{ taskId: string; assigned: boolean }> {
|
|
3033
|
+
const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
3034
|
+
const parsed = parseWebhookTitle(title)
|
|
3035
|
+
const task: TaskRecord = {
|
|
3036
|
+
id, title, description, status: 'pending', priority: 'high',
|
|
3037
|
+
createdAt: new Date().toISOString(),
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// Clone the repo and set working directory
|
|
3041
|
+
if (parsed) {
|
|
3042
|
+
const token = provider === 'github'
|
|
3043
|
+
? githubWebhookConfig.githubToken
|
|
3044
|
+
: gitlabWebhookConfig.gitlabToken
|
|
3045
|
+
const branchName = `fix/issue-${parsed.issueNumber}`
|
|
3046
|
+
|
|
3047
|
+
try {
|
|
3048
|
+
const repoDir = await cloneWebhookRepo(provider, parsed.repo, token, issueUrl)
|
|
3049
|
+
task.cwd = repoDir
|
|
3050
|
+
|
|
3051
|
+
// Create the fix branch
|
|
3052
|
+
// Create branch or switch to it if it already exists
|
|
3053
|
+
await execAsync(`git checkout -b "${branchName}"`, { cwd: repoDir }).catch(() =>
|
|
3054
|
+
execAsync(`git checkout "${branchName}"`, { cwd: repoDir })
|
|
3055
|
+
)
|
|
3056
|
+
|
|
3057
|
+
let host = provider === 'gitlab' ? 'gitlab.com' : 'github.com'
|
|
3058
|
+
try { host = new URL(issueUrl).host } catch { /* use default */ }
|
|
3059
|
+
task.webhookMeta = {
|
|
3060
|
+
provider, repo: parsed.repo, issueNumber: parsed.issueNumber,
|
|
3061
|
+
issueUrl, branchName, host,
|
|
3062
|
+
}
|
|
3063
|
+
console.log(`[webhook-repo] Task ${id} will work in ${repoDir} on branch ${branchName}`)
|
|
3064
|
+
} catch (err) {
|
|
3065
|
+
console.error(`[webhook-repo] Clone failed for ${parsed.repo}:`, err)
|
|
3066
|
+
// Do NOT fallback to rufloui cwd — fail the task instead
|
|
3067
|
+
task.status = 'failed'
|
|
3068
|
+
task.result = `Failed to clone repository ${parsed.repo}: ${err instanceof Error ? err.message : String(err)}`
|
|
3069
|
+
taskStore.set(id, task)
|
|
3070
|
+
broadcast('task:added', task)
|
|
3071
|
+
return { taskId: id, assigned: false }
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
taskStore.set(id, task)
|
|
3076
|
+
broadcast('task:added', task)
|
|
3077
|
+
if (!swarmShutdown) {
|
|
3078
|
+
task.status = 'in_progress'
|
|
3079
|
+
task.startedAt = new Date().toISOString()
|
|
3080
|
+
broadcast('task:updated', { ...task, id })
|
|
3081
|
+
launchWorkflowForTask(id, title, description)
|
|
3082
|
+
return { taskId: id, assigned: true }
|
|
3083
|
+
}
|
|
3084
|
+
return { taskId: id, assigned: false }
|
|
3085
|
+
}
|
|
3086
|
+
|
|
2803
3087
|
app.use('/api/webhooks', githubWebhookRoutes(
|
|
2804
3088
|
() => githubWebhookConfig,
|
|
2805
|
-
(c) => { githubWebhookConfig = c },
|
|
3089
|
+
(c) => { githubWebhookConfig = c; saveGitHubWebhookConfig(c) },
|
|
2806
3090
|
{
|
|
2807
3091
|
createAndAssignTask: async (title: string, description: string) => {
|
|
2808
|
-
|
|
2809
|
-
const
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
3092
|
+
// Extract issue URL from description (first line: "GitHub Issue: <url>")
|
|
3093
|
+
const urlMatch = description.match(/GitHub Issue: (https:\/\/\S+)/)
|
|
3094
|
+
return createWebhookTask('github', title, description, urlMatch?.[1] || '')
|
|
3095
|
+
},
|
|
3096
|
+
broadcast,
|
|
3097
|
+
},
|
|
3098
|
+
))
|
|
3099
|
+
|
|
3100
|
+
app.use('/api/webhooks', gitlabWebhookRoutes(
|
|
3101
|
+
() => gitlabWebhookConfig,
|
|
3102
|
+
(c) => { gitlabWebhookConfig = c; saveGitLabWebhookConfig(c) },
|
|
3103
|
+
{
|
|
3104
|
+
createAndAssignTask: async (title: string, description: string) => {
|
|
3105
|
+
const urlMatch = description.match(/GitLab Issue: (https:\/\/\S+)/)
|
|
3106
|
+
return createWebhookTask('gitlab', title, description, urlMatch?.[1] || '')
|
|
2820
3107
|
},
|
|
2821
3108
|
broadcast,
|
|
2822
3109
|
},
|
|
@@ -287,7 +287,6 @@ export function githubWebhookRoutes(
|
|
|
287
287
|
if (typeof autoAssign === 'boolean') config.autoAssign = autoAssign
|
|
288
288
|
if (typeof taskTemplate === 'string') config.taskTemplate = taskTemplate
|
|
289
289
|
setConfig(config)
|
|
290
|
-
saveGitHubWebhookConfig(config)
|
|
291
290
|
res.json({ ok: true })
|
|
292
291
|
}))
|
|
293
292
|
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { Router, Request, Response, RequestHandler } from 'express'
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface GitLabWebhookConfig {
|
|
8
|
+
enabled: boolean
|
|
9
|
+
/** GitLab personal access token (for future API use) */
|
|
10
|
+
gitlabToken: string
|
|
11
|
+
/** Secret token — GitLab sends this as X-Gitlab-Token header (plain comparison) */
|
|
12
|
+
webhookSecret: string
|
|
13
|
+
/** Projects to monitor — array of "namespace/project" strings */
|
|
14
|
+
repos: string[]
|
|
15
|
+
/** Auto-assign new issue tasks to the active swarm */
|
|
16
|
+
autoAssign: boolean
|
|
17
|
+
/** Custom instructions template. Placeholders: {{title}}, {{body}}, {{url}}, {{author}}, {{labels}}, {{repo}}, {{number}} */
|
|
18
|
+
taskTemplate: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GitLabWebhookEvent {
|
|
22
|
+
id: string
|
|
23
|
+
provider: 'gitlab'
|
|
24
|
+
repo: string
|
|
25
|
+
event: string
|
|
26
|
+
title: string
|
|
27
|
+
body: string
|
|
28
|
+
url: string
|
|
29
|
+
number: number
|
|
30
|
+
author: string
|
|
31
|
+
labels: string[]
|
|
32
|
+
receivedAt: string
|
|
33
|
+
taskId?: string
|
|
34
|
+
status: 'received' | 'processing' | 'completed' | 'failed' | 'ignored'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_TEMPLATE = 'Analyze this issue, investigate the codebase, implement a fix, write tests, and prepare a summary of changes.'
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CONFIG: GitLabWebhookConfig = {
|
|
40
|
+
enabled: false,
|
|
41
|
+
gitlabToken: '',
|
|
42
|
+
webhookSecret: '',
|
|
43
|
+
repos: [],
|
|
44
|
+
autoAssign: true,
|
|
45
|
+
taskTemplate: '',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildTaskDescription(event: GitLabWebhookEvent, template: string): string {
|
|
49
|
+
const bodyText = event.body?.slice(0, 2000) || 'No description provided.'
|
|
50
|
+
const instructions = template || DEFAULT_TEMPLATE
|
|
51
|
+
const rendered = instructions
|
|
52
|
+
.replace(/\{\{title\}\}/g, event.title)
|
|
53
|
+
.replace(/\{\{body\}\}/g, bodyText)
|
|
54
|
+
.replace(/\{\{url\}\}/g, event.url)
|
|
55
|
+
.replace(/\{\{author\}\}/g, event.author)
|
|
56
|
+
.replace(/\{\{labels\}\}/g, event.labels.join(', ') || 'none')
|
|
57
|
+
.replace(/\{\{repo\}\}/g, event.repo)
|
|
58
|
+
.replace(/\{\{number\}\}/g, String(event.number))
|
|
59
|
+
|
|
60
|
+
const templateHasBody = template && /\{\{body\}\}/.test(template)
|
|
61
|
+
|
|
62
|
+
const parts = [
|
|
63
|
+
`GitLab Issue: ${event.url}`,
|
|
64
|
+
`Author: ${event.author}`,
|
|
65
|
+
`Labels: ${event.labels.join(', ') || 'none'}`,
|
|
66
|
+
]
|
|
67
|
+
if (!templateHasBody) {
|
|
68
|
+
parts.push('', bodyText)
|
|
69
|
+
}
|
|
70
|
+
parts.push('', '---', `Instructions: ${rendered}`)
|
|
71
|
+
|
|
72
|
+
return parts.join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Persistence ────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function configPath(): string {
|
|
78
|
+
const dir = process.env.RUFLO_PERSIST_DIR || '.ruflo'
|
|
79
|
+
return join(dir, 'gitlab-webhook.json')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadGitLabWebhookConfig(): GitLabWebhookConfig {
|
|
83
|
+
try {
|
|
84
|
+
if (existsSync(configPath())) {
|
|
85
|
+
const raw = JSON.parse(readFileSync(configPath(), 'utf-8'))
|
|
86
|
+
return { ...DEFAULT_CONFIG, ...raw }
|
|
87
|
+
}
|
|
88
|
+
} catch { /* use defaults */ }
|
|
89
|
+
return {
|
|
90
|
+
...DEFAULT_CONFIG,
|
|
91
|
+
enabled: process.env.GITLAB_WEBHOOK_ENABLED === 'true',
|
|
92
|
+
gitlabToken: process.env.GITLAB_TOKEN || '',
|
|
93
|
+
webhookSecret: process.env.GITLAB_WEBHOOK_SECRET || '',
|
|
94
|
+
repos: process.env.GITLAB_WEBHOOK_REPOS?.split(',').map(r => r.trim()).filter(Boolean) || [],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function saveGitLabWebhookConfig(config: GitLabWebhookConfig): void {
|
|
99
|
+
const dir = process.env.RUFLO_PERSIST_DIR || '.ruflo'
|
|
100
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
101
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf-8')
|
|
102
|
+
try { chmodSync(configPath(), 0o600) } catch { /* Windows */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── In-memory event store ──────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const MAX_EVENTS = 200
|
|
108
|
+
let gitlabEvents: GitLabWebhookEvent[] = []
|
|
109
|
+
|
|
110
|
+
function addEvent(event: GitLabWebhookEvent): void {
|
|
111
|
+
gitlabEvents.unshift(event)
|
|
112
|
+
if (gitlabEvents.length > MAX_EVENTS) gitlabEvents = gitlabEvents.slice(0, MAX_EVENTS)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function updateGitLabEventByTaskId(taskId: string, status: GitLabWebhookEvent['status']): void {
|
|
116
|
+
const event = gitlabEvents.find(e => e.taskId === taskId)
|
|
117
|
+
if (event) event.status = status
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getGitLabWebhookEvents(): GitLabWebhookEvent[] {
|
|
121
|
+
return gitlabEvents
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Route Factory ──────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export interface GitLabWebhookStores {
|
|
127
|
+
createAndAssignTask: (title: string, description: string) => Promise<{ taskId: string; assigned: boolean }>
|
|
128
|
+
broadcast: (type: string, payload: unknown) => void
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function gitlabWebhookRoutes(
|
|
132
|
+
getConfig: () => GitLabWebhookConfig,
|
|
133
|
+
setConfig: (c: GitLabWebhookConfig) => void,
|
|
134
|
+
stores: GitLabWebhookStores,
|
|
135
|
+
): Router {
|
|
136
|
+
const router = Router()
|
|
137
|
+
|
|
138
|
+
const wrap = (fn: (req: Request, res: Response) => Promise<void>): RequestHandler =>
|
|
139
|
+
async (req, res, _next) => {
|
|
140
|
+
try { await fn(req, res) } catch (err: unknown) {
|
|
141
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Webhook receiver ───────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
router.post('/gitlab', wrap(async (req, res) => {
|
|
148
|
+
const config = getConfig()
|
|
149
|
+
if (!config.enabled) {
|
|
150
|
+
res.status(503).json({ error: 'GitLab webhooks disabled' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// GitLab uses a simple token comparison (no HMAC)
|
|
155
|
+
if (config.webhookSecret) {
|
|
156
|
+
const token = req.headers['x-gitlab-token'] as string | undefined
|
|
157
|
+
if (!token) {
|
|
158
|
+
res.status(401).json({ error: 'Missing X-Gitlab-Token header' })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (token !== config.webhookSecret) {
|
|
162
|
+
res.status(401).json({ error: 'Invalid token' })
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const glEvent = req.headers['x-gitlab-event'] as string || 'unknown'
|
|
168
|
+
const payload = req.body
|
|
169
|
+
|
|
170
|
+
// Only handle Issue Hook events
|
|
171
|
+
if (glEvent !== 'Issue Hook') {
|
|
172
|
+
res.json({ ok: true, action: 'ignored', reason: `event type '${glEvent}' not handled` })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const attrs = payload.object_attributes || {}
|
|
177
|
+
const action = attrs.action
|
|
178
|
+
if (action !== 'open' && action !== 'reopen') {
|
|
179
|
+
res.json({ ok: true, action: 'ignored', reason: `issue.${action || 'unknown'} not handled` })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const repo = payload.project?.path_with_namespace || 'unknown/unknown'
|
|
184
|
+
|
|
185
|
+
// Check if this repo is monitored
|
|
186
|
+
if (config.repos.length > 0 && !config.repos.includes(repo)) {
|
|
187
|
+
res.json({ ok: true, action: 'ignored', reason: `repo '${repo}' not monitored` })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const event: GitLabWebhookEvent = {
|
|
192
|
+
id: `gl-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
193
|
+
provider: 'gitlab',
|
|
194
|
+
repo,
|
|
195
|
+
event: `issue.${action}`,
|
|
196
|
+
title: attrs.title || 'Untitled',
|
|
197
|
+
body: attrs.description || '',
|
|
198
|
+
url: attrs.url || '',
|
|
199
|
+
number: attrs.iid || 0,
|
|
200
|
+
author: payload.user?.username || 'unknown',
|
|
201
|
+
labels: (payload.labels || []).map((l: { title: string }) => l.title),
|
|
202
|
+
receivedAt: new Date().toISOString(),
|
|
203
|
+
status: 'received',
|
|
204
|
+
}
|
|
205
|
+
addEvent(event)
|
|
206
|
+
stores.broadcast('webhook:received', event)
|
|
207
|
+
|
|
208
|
+
// Auto-create task if enabled
|
|
209
|
+
if (config.autoAssign) {
|
|
210
|
+
event.status = 'processing'
|
|
211
|
+
stores.broadcast('webhook:updated', event)
|
|
212
|
+
|
|
213
|
+
const taskTitle = `[${repo}#${attrs.iid}] ${attrs.title}`
|
|
214
|
+
const taskDesc = buildTaskDescription(event, config.taskTemplate)
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const result = await stores.createAndAssignTask(taskTitle, taskDesc)
|
|
218
|
+
event.taskId = result.taskId
|
|
219
|
+
event.status = result.assigned ? 'processing' : 'received'
|
|
220
|
+
stores.broadcast('webhook:updated', event)
|
|
221
|
+
} catch {
|
|
222
|
+
event.status = 'failed'
|
|
223
|
+
stores.broadcast('webhook:updated', event)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
res.json({ ok: true, action: 'task_created', eventId: event.id, taskId: event.taskId })
|
|
228
|
+
}))
|
|
229
|
+
|
|
230
|
+
// ── Config API ─────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
router.get('/gitlab/config', wrap(async (_req, res) => {
|
|
233
|
+
const config = getConfig()
|
|
234
|
+
res.json({
|
|
235
|
+
enabled: config.enabled,
|
|
236
|
+
hasToken: !!config.gitlabToken,
|
|
237
|
+
tokenPreview: config.gitlabToken ? '...' + config.gitlabToken.slice(-4) : '',
|
|
238
|
+
webhookSecret: config.webhookSecret ? '****' : '',
|
|
239
|
+
hasSecret: !!config.webhookSecret,
|
|
240
|
+
repos: config.repos,
|
|
241
|
+
autoAssign: config.autoAssign,
|
|
242
|
+
taskTemplate: config.taskTemplate || '',
|
|
243
|
+
})
|
|
244
|
+
}))
|
|
245
|
+
|
|
246
|
+
router.put('/gitlab/config', wrap(async (req, res) => {
|
|
247
|
+
const config = getConfig()
|
|
248
|
+
const { enabled, gitlabToken, webhookSecret, repos, autoAssign, taskTemplate } = req.body
|
|
249
|
+
if (typeof enabled === 'boolean') config.enabled = enabled
|
|
250
|
+
if (typeof gitlabToken === 'string') config.gitlabToken = gitlabToken
|
|
251
|
+
if (typeof webhookSecret === 'string') config.webhookSecret = webhookSecret
|
|
252
|
+
if (Array.isArray(repos)) config.repos = repos.filter((r: unknown) => typeof r === 'string' && (r as string).includes('/'))
|
|
253
|
+
if (typeof autoAssign === 'boolean') config.autoAssign = autoAssign
|
|
254
|
+
if (typeof taskTemplate === 'string') config.taskTemplate = taskTemplate
|
|
255
|
+
setConfig(config)
|
|
256
|
+
res.json({ ok: true })
|
|
257
|
+
}))
|
|
258
|
+
|
|
259
|
+
// ── Test endpoint ───────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
router.post('/gitlab/test', wrap(async (_req, res) => {
|
|
262
|
+
const config = getConfig()
|
|
263
|
+
if (!config.enabled) {
|
|
264
|
+
res.status(503).json({ error: 'GitLab webhooks disabled' })
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const event: GitLabWebhookEvent = {
|
|
269
|
+
id: `gl-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
270
|
+
provider: 'gitlab',
|
|
271
|
+
repo: 'test/webhook-test',
|
|
272
|
+
event: 'issue.open',
|
|
273
|
+
title: 'Test GitLab webhook event',
|
|
274
|
+
body: 'This is a test event sent from the RuFloUI dashboard to verify the GitLab webhook pipeline works correctly.',
|
|
275
|
+
url: 'https://gitlab.com/test/webhook-test/-/issues/0',
|
|
276
|
+
number: 0,
|
|
277
|
+
author: 'rufloui-test',
|
|
278
|
+
labels: ['test'],
|
|
279
|
+
receivedAt: new Date().toISOString(),
|
|
280
|
+
status: 'received',
|
|
281
|
+
}
|
|
282
|
+
addEvent(event)
|
|
283
|
+
stores.broadcast('webhook:received', event)
|
|
284
|
+
|
|
285
|
+
if (config.autoAssign) {
|
|
286
|
+
event.status = 'processing'
|
|
287
|
+
stores.broadcast('webhook:updated', event)
|
|
288
|
+
const taskTitle = `[test/webhook-test#0] Test GitLab webhook event`
|
|
289
|
+
const taskDesc = buildTaskDescription(event, config.taskTemplate)
|
|
290
|
+
try {
|
|
291
|
+
const result = await stores.createAndAssignTask(taskTitle, taskDesc)
|
|
292
|
+
event.taskId = result.taskId
|
|
293
|
+
event.status = result.assigned ? 'processing' : 'received'
|
|
294
|
+
stores.broadcast('webhook:updated', event)
|
|
295
|
+
res.json({ ok: true, eventId: event.id, taskId: result.taskId, assigned: result.assigned })
|
|
296
|
+
} catch {
|
|
297
|
+
event.status = 'failed'
|
|
298
|
+
stores.broadcast('webhook:updated', event)
|
|
299
|
+
res.json({ ok: false, error: 'Task creation failed', eventId: event.id })
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
res.json({ ok: true, eventId: event.id, message: 'Test event created (auto-assign disabled)' })
|
|
303
|
+
}
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
// ── Events API ─────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
router.get('/gitlab/events', wrap(async (_req, res) => {
|
|
309
|
+
res.json(gitlabEvents)
|
|
310
|
+
}))
|
|
311
|
+
|
|
312
|
+
return router
|
|
313
|
+
}
|