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.
@@ -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
- const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
2809
- const task = { id, title, description, status: 'pending', priority: 'high', createdAt: new Date().toISOString() } as any
2810
- taskStore.set(id, task)
2811
- broadcast('task:added', task)
2812
- if (!swarmShutdown) {
2813
- task.status = 'in_progress'
2814
- task.startedAt = new Date().toISOString()
2815
- broadcast('task:updated', { ...task, id })
2816
- launchWorkflowForTask(id, title, description)
2817
- return { taskId: id, assigned: true }
2818
- }
2819
- return { taskId: id, assigned: false }
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
+ }