brainiac 0.0.11 → 0.0.12

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.
@@ -1,390 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # GitHub webhook handlers: PR merges, reviews, and issue comments.
4
-
5
- # Fallback column ID for backwards compatibility when no board config exists
6
- DEFAULT_UAT_COLUMN_ID = "03fsmglsr6az06ppyotawsti8"
7
-
8
- # Find a work item by matching the PR's head branch to a branch in the card map.
9
- def find_work_item_by_branch(branch)
10
- map = load_work_item_map
11
- map.each do |internal_id, info|
12
- next unless info["branch"] == branch
13
-
14
- return [internal_id, info]
15
- end
16
- nil
17
- end
18
-
19
- # Track a newly opened PR in the work item map by matching its branch.
20
- def track_pr_in_work_items(payload)
21
- pr = payload["pull_request"]
22
- branch = pr.dig("head", "ref")
23
- pr_number = pr["number"]
24
- pr_url = pr["html_url"]
25
-
26
- result = find_work_item_by_branch(branch)
27
- unless result
28
- LOG.info "[PR Track] No card found for branch #{branch}"
29
- return
30
- end
31
-
32
- internal_id, card_info = result
33
- prs = card_info["prs"] || []
34
- return if prs.any? { |p| p["number"] == pr_number }
35
-
36
- prs << { "number" => pr_number, "url" => pr_url }
37
- card_info["prs"] = prs
38
-
39
- map = load_work_item_map
40
- map[internal_id] = card_info
41
- save_work_item_map(map)
42
- LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
43
- end
44
-
45
- # Fetch review comments from a PR using GitHub CLI
46
- def fetch_pr_review_comments(pr_number, repo)
47
- output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments", "--jq", ".[] | {path, line, body, user: .user.login}")
48
- output.lines.map { |line| JSON.parse(line) }
49
- rescue StandardError => e
50
- LOG.warn "Could not fetch PR review comments: #{e.message}"
51
- []
52
- end
53
-
54
- # Check if a PR link is already present in the card's comments.
55
-
56
- def handle_github_pr_merged(payload)
57
- pr = payload["pull_request"]
58
- branch = pr.dig("head", "ref")
59
- base = pr.dig("base", "ref")
60
- pr_url = pr["html_url"]
61
- pr_title = pr["title"]
62
- repo_full_name = payload.dig("repository", "full_name")
63
-
64
- default_branch = payload.dig("repository", "default_branch") || "main"
65
- unless base == default_branch
66
- LOG.info "PR merged into #{base}, not #{default_branch} — ignoring"
67
- return [200, { status: "ignored", reason: "not merged into #{default_branch}" }.to_json]
68
- end
69
-
70
- project_result = identify_project_by_repo(repo_full_name)
71
- unless project_result
72
- LOG.info "No project found for GitHub repo #{repo_full_name}"
73
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
74
- end
75
-
76
- project_key, project_config = project_result
77
- repo_path = project_config["repo_path"]
78
-
79
- result = find_work_item_by_branch(branch)
80
- unless result
81
- LOG.info "No card found for branch #{branch}"
82
- return [200, { status: "ignored", reason: "no matching card" }.to_json]
83
- end
84
-
85
- internal_id, card_info = result
86
- card_number = card_info["number"]
87
- unless card_number
88
- LOG.warn "Card #{internal_id} has no number — can't comment or move"
89
- return [200, { status: "ignored", reason: "card has no number" }.to_json]
90
- end
91
-
92
- LOG.info "PR merged into main for card ##{card_number} (project: #{project_key}): #{pr_url}"
93
- process_merged_pr(card_info, card_number, branch, pr, pr_url, pr_title, project_key, project_config, repo_path)
94
-
95
- [200, { status: "processed", card: card_number, pr: pr_url, action: "merged_to_uat", project: project_key }.to_json]
96
- rescue StandardError => e
97
- LOG.error "Error handling merged PR: #{e.message}"
98
- [500, { error: e.message }.to_json]
99
- end
100
-
101
- def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
102
- mark_work_item_merged(card_number)
103
- cleanup_work_item_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch)
104
-
105
- # Emit hook — plugins handle their own post-merge actions
106
- # (e.g., card plugin posts PR link, moves card column, dispatches UAT agent)
107
- Brainiac.emit(:pr_merged,
108
- card_number: card_number,
109
- card_info: card_info,
110
- branch: branch,
111
- pull_request: pull_request,
112
- pr_url: pr_url,
113
- pr_title: pr_title,
114
- project_key: project_key,
115
- project_config: project_config,
116
- repo_path: repo_path)
117
- end
118
-
119
- def handle_github_issue_comment(payload)
120
- comment = payload["comment"]
121
- issue = payload["issue"]
122
- comment_body = comment["body"] || ""
123
- comment_id = comment["id"]
124
- comment_user = comment.dig("user", "login")
125
- repo_name = payload.dig("repository", "full_name")
126
-
127
- unless issue["pull_request"]
128
- LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
129
- return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
130
- end
131
-
132
- project_result = identify_project_by_repo(repo_name)
133
- unless project_result
134
- LOG.info "No project found for GitHub repo #{repo_name}"
135
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
136
- end
137
-
138
- project_key, project_config = project_result
139
- pr_number = issue["number"]
140
-
141
- pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
142
- chdir: project_config["repo_path"])
143
- branch = JSON.parse(pr_data)["branch"]
144
-
145
- result = find_work_item_by_branch(branch)
146
- unless result
147
- LOG.info "No card found for PR ##{pr_number} (branch: #{branch})"
148
- return [200, { status: "ignored", reason: "no matching card" }.to_json]
149
- end
150
-
151
- _, card_info = result
152
- card_number = card_info["number"]
153
- worktree = card_info["worktree"]
154
-
155
- unless worktree && File.directory?(worktree)
156
- LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
157
- return [200, { status: "ignored", reason: "no active worktree" }.to_json]
158
- end
159
-
160
- card_key = "card-#{card_number}"
161
- if session_active?(card_key)
162
- LOG.info "Skipping PR comment on card ##{card_number} — agent session already active"
163
- return [200, { status: "ignored", reason: "session already active" }.to_json]
164
- end
165
-
166
- LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
167
- dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
168
- repo_name, worktree, project_key, project_config)
169
-
170
- [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
171
- rescue StandardError => e
172
- LOG.error "Error handling PR comment: #{e.message}"
173
- [500, { error: e.message }.to_json]
174
- end
175
-
176
- def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
177
- repo_name, worktree, project_key, project_config)
178
- Thread.new do
179
- run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
180
- "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
181
- rescue StandardError => e
182
- LOG.warn "Could not add reaction to comment: #{e.message}"
183
- end
184
-
185
- agent_name = agent_name_for(project_config)
186
- prompt = render_prompt(PROMPT_GITHUB_PR_COMMENT,
187
- { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
188
- "COMMENT_CREATOR" => comment_user, "COMMENT_BODY" => comment_body,
189
- "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => worktree },
190
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
191
- project_key: project_key, comment_body: comment_body),
192
- agent_name: agent_name, channel: :github)
193
-
194
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree,
195
- log_name: "pr-comment-#{pr_number}",
196
- model: detect_model(project_config, text: comment_body),
197
- effort: detect_effort(project_config, text: comment_body),
198
- agent_name: agent_name, source: :github,
199
- source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree })
200
- register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
201
- end
202
-
203
- def handle_github_workflow_run(payload)
204
- workflow = payload["workflow_run"]
205
- workflow_name = workflow["name"]
206
- conclusion = workflow["conclusion"]
207
- repo_full_name = payload.dig("repository", "full_name")
208
- run_url = workflow["html_url"]
209
-
210
- if workflow_name == "Deploy to Production" && conclusion == "failure"
211
- project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
212
- send_workflow_failure_notification(project_key, workflow_name, run_url)
213
- return [200, { status: "processed", action: "prod_deploy_failure_notified", project: project_key }.to_json]
214
- end
215
-
216
- if workflow_name == "Deploy to UAT" && conclusion == "success"
217
- project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
218
- send_uat_deploy_notification(project_key)
219
- return [200, { status: "processed", action: "uat_deploy_notified", project: project_key }.to_json]
220
- end
221
-
222
- return [200, { status: "ignored", reason: "conclusion: #{conclusion}" }.to_json] unless conclusion == "success"
223
-
224
- return [200, { status: "ignored", reason: "workflow: #{workflow_name}" }.to_json] unless workflow_name == "Deploy to Production"
225
-
226
- project_result = identify_project_by_repo(repo_full_name)
227
- return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
228
-
229
- project_key, project_config = project_result
230
- close_uat_cards_after_deploy(project_key, project_config)
231
- rescue StandardError => e
232
- LOG.error "Error handling workflow run: #{e.message}"
233
- [500, { error: e.message }.to_json]
234
- end
235
-
236
- def close_uat_cards_after_deploy(project_key, project_config)
237
- # Emit hook — work item plugins handle closing cards after production deploy
238
- results = Brainiac.emit(:production_deployed, project_key: project_key, project_config: project_config)
239
- closed_cards = results.flatten.compact
240
-
241
- if closed_cards.any?
242
- send_deploy_notification(project_key, closed_cards)
243
- LOG.info "Prod deploy complete — closed #{closed_cards.size} cards"
244
- else
245
- LOG.info "Prod deploy processed — no cards closed (plugin may not be installed)"
246
- end
247
-
248
- [200, { status: "processed", action: "prod_deploy", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
249
- end
250
-
251
- def send_deploy_notification(project_key, closed_cards)
252
- card_lines = closed_cards.map { |c| "• [##{c[:number]} — #{c[:title]}](#{c[:url]})" }.join("\n")
253
- message = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}"
254
- send_notification(:deploy, message, metadata_project: project_key)
255
- end
256
-
257
- def send_uat_deploy_notification(project_key)
258
- message = "✅ **#{project_key.capitalize}** deployed to UAT successfully"
259
- send_notification(:deploy, message, metadata_project: project_key)
260
- end
261
-
262
- def send_workflow_failure_notification(project_key, workflow_name, run_url)
263
- message = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})"
264
- send_notification(:ci_failure, message, metadata_project: project_key)
265
- end
266
-
267
- def handle_github_issue_opened(payload)
268
- issue = payload["issue"]
269
- issue_url = issue["html_url"]
270
- issue_title = issue["title"]
271
- issue_number = issue["number"]
272
- repo_name = payload.dig("repository", "full_name")
273
-
274
- LOG.info "New GitHub issue ##{issue_number} on #{repo_name}: #{issue_title} (#{issue_url})"
275
-
276
- [200, { status: "logged", issue: issue_number, title: issue_title, url: issue_url }.to_json]
277
- end
278
-
279
- def handle_github_pr_review_submitted(payload)
280
- pr = payload["pull_request"]
281
- review = payload["review"]
282
- branch = pr.dig("head", "ref")
283
- pr_number = pr["number"]
284
- repo_name = payload.dig("repository", "full_name")
285
- review_state = review["state"]
286
- reviewer = review.dig("user", "login")
287
-
288
- return [200, { status: "ignored", reason: "review state: #{review_state}" }.to_json] unless %w[changes_requested commented].include?(review_state)
289
-
290
- project_result = identify_project_by_repo(repo_name)
291
- return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
292
-
293
- project_key, project_config = project_result
294
- repo_path = project_config["repo_path"]
295
-
296
- result = find_work_item_by_branch(branch)
297
- return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
298
-
299
- internal_id, card_info = result
300
- card_number = card_info["number"]
301
- unless card_number
302
- LOG.warn "Card #{internal_id} has no number — can't comment"
303
- return [200, { status: "ignored", reason: "card has no number" }.to_json]
304
- end
305
-
306
- card_key = "card-#{card_number}"
307
- return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
308
-
309
- LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
310
- dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer, repo_name, project_key, project_config, repo_path)
311
-
312
- [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json]
313
- rescue StandardError => e
314
- LOG.error "Error handling PR review: #{e.message}"
315
- [500, { error: e.message }.to_json]
316
- end
317
-
318
- def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer, repo_name, project_key, project_config, repo_path)
319
- review_id = review["id"]
320
- Thread.new do
321
- run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
322
- "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
323
- rescue StandardError => e
324
- LOG.warn "Could not add reaction to review: #{e.message}"
325
- end
326
-
327
- agent_name = agent_name_for(project_config)
328
- # Notify work item system that a review was received (plugin posts status comment)
329
- Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
330
- agent_name: agent_name, project_config: project_config, repo_path: repo_path)
331
-
332
- review_context = build_review_context(reviewer, review, pr_number, repo_name)
333
- worktree = card_info["worktree"]
334
- work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
335
-
336
- prompt = render_prompt(PROMPT_GITHUB_PR_REVIEW,
337
- { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
338
- "COMMENT_CREATOR" => reviewer, "REVIEW_CONTEXT" => review_context,
339
- "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => work_dir },
340
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
341
- project_key: project_key),
342
- agent_name: agent_name, channel: :github)
343
-
344
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir,
345
- log_name: "review-#{card_number}", agent_name: agent_name,
346
- source: :github,
347
- source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir })
348
- register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
349
- end
350
-
351
- def build_review_context(reviewer, review, pr_number, repo_name)
352
- context = "GitHub PR Review from @#{reviewer}:\n\n"
353
- context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
354
-
355
- review_comments = fetch_pr_review_comments(pr_number, repo_name)
356
- if review_comments.any?
357
- context += "Line-specific comments:\n"
358
- review_comments.each do |comment|
359
- context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
360
- end
361
- end
362
- context
363
- end
364
-
365
- def handle_github_pr_synchronized(payload)
366
- pr = payload["pull_request"]
367
- branch = pr.dig("head", "ref")
368
-
369
- result = find_work_item_by_branch(branch)
370
- return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
371
-
372
- _internal_id, card_info = result
373
- card_number = card_info["number"]
374
- worktree = card_info["worktree"]
375
-
376
- return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
377
-
378
- # Emit hook — deployment plugin handles sync-deploy logic
379
- results = Brainiac.emit(:pr_synchronized, card_number: card_number, card_info: card_info,
380
- worktree: worktree, pull_request: pr, branch: branch)
381
-
382
- if results.any?
383
- [200, { status: "processed", action: "pr_sync", card: card_number }.to_json]
384
- else
385
- [200, { status: "ignored", reason: "no deployment plugin" }.to_json]
386
- end
387
- rescue StandardError => e
388
- LOG.error "[PR Sync] Error: #{e.message}"
389
- [500, { error: e.message }.to_json]
390
- end