brainiac-github 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0429a02480efea5f24251aa7dfc818a7140867fd971367523c35125993bdaaca'
4
+ data.tar.gz: a15b23fbce8f026cdc592cce528d7f1ca48fa304e21176a1d6bc6c34cdf54d5f
5
+ SHA512:
6
+ metadata.gz: 60af838671e16cf03b1e0488059c216c08094661a63689ccc203361e7a337dfd9d1bacbad64563bdb2fb7dd1a59432c1ac137b5f0b854fb22ae3973da0723ecc
7
+ data.tar.gz: 6040fb703d23aac121df9f34c4188af9d643f8de9c8cb2d6a807954ebf57d7d56948ce32e62d1dfb58fd01e25c36de35a2eeb67ba7abad9e558a3facec0552ff
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Davis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # brainiac-github
2
+
3
+ GitHub webhook plugin for [Brainiac](https://github.com/stowzilla/brainiac) — the multi-agent orchestration layer.
4
+
5
+ ## What It Does
6
+
7
+ - **PR Reviews** — dispatches agents to address review feedback
8
+ - **PR Comments** — routes PR conversation to the assigned agent
9
+ - **PR Merges** — cleans up worktrees, emits hooks for card management plugins
10
+ - **Workflow Runs** — notifies on CI failures and deploy completions
11
+ - **Issues** — logs new issues for tracking
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ brainiac install github
17
+ brainiac restart
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ Config lives at `~/.brainiac/github.json`:
23
+
24
+ ```json
25
+ {
26
+ "webhook_secret": "your-github-webhook-secret",
27
+ "repos": {}
28
+ }
29
+ ```
30
+
31
+ Generate a webhook secret:
32
+
33
+ ```bash
34
+ ruby -rsecurerandom -e 'puts SecureRandom.hex(20)'
35
+ ```
36
+
37
+ ### GitHub Webhook Setup
38
+
39
+ 1. Go to your repo → Settings → Webhooks → Add webhook
40
+ 2. Payload URL: `https://your-ngrok.ngrok-free.app/github`
41
+ 3. Content type: `application/json`
42
+ 4. Secret: paste your `webhook_secret`
43
+ 5. Events: Pull requests, Pull request reviews, Issue comments, Issues, Workflow runs
44
+
45
+ ## CLI
46
+
47
+ ```bash
48
+ brainiac github setup # Create config file from template
49
+ brainiac github config # Show current configuration
50
+ brainiac github status # Check if webhook endpoint is active
51
+ ```
52
+
53
+ ## Hooks Emitted
54
+
55
+ | Hook | When | Payload |
56
+ |------|------|---------|
57
+ | `:pr_merged` | PR merged to default branch | card_number, pr_url, project_key, ... |
58
+ | `:pr_review_received` | Review submitted | card_number, reviewer, agent_name, ... |
59
+ | `:pr_synchronized` | PR updated (force push) | card_number, worktree, branch, ... |
60
+ | `:production_deployed` | Deploy workflow succeeds | project_key, project_config |
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ git clone https://github.com/stowzilla/brainiac-github.git
66
+ cd brainiac-github
67
+ bundle install
68
+ bundle exec rake test
69
+ bundle exec rubocop
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Github
6
+ module Cli
7
+ class << self
8
+ def run(args)
9
+ command = args.shift
10
+ case command
11
+ when "setup" then cmd_setup
12
+ when "config" then cmd_config
13
+ when "status" then cmd_status
14
+ else print_help
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def cmd_setup
21
+ brainiac_dir = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
22
+ config_file = File.join(brainiac_dir, "github.json")
23
+
24
+ if File.exist?(config_file)
25
+ puts "GitHub config already exists at #{config_file}"
26
+ return
27
+ end
28
+
29
+ template = File.expand_path("../../../../templates/github.json.example", __dir__)
30
+ if File.exist?(template)
31
+ FileUtils.cp(template, config_file)
32
+ puts "Created #{config_file} from template"
33
+ else
34
+ default_config = { "webhook_secret" => "", "repos" => {} }
35
+ File.write(config_file, JSON.pretty_generate(default_config))
36
+ puts "Created #{config_file}"
37
+ end
38
+
39
+ puts "Edit the file to add your GitHub webhook secret."
40
+ puts "Generate one with: ruby -rsecurerandom -e 'puts SecureRandom.hex(20)'"
41
+ end
42
+
43
+ def cmd_config
44
+ brainiac_dir = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
45
+ config_file = File.join(brainiac_dir, "github.json")
46
+
47
+ unless File.exist?(config_file)
48
+ puts "No GitHub config found. Run: brainiac github setup"
49
+ return
50
+ end
51
+
52
+ config = JSON.parse(File.read(config_file))
53
+ secret = config["webhook_secret"]
54
+ puts "GitHub Configuration:"
55
+ puts " Config file: #{config_file}"
56
+ puts " Webhook secret: #{secret && !secret.empty? ? "#{secret[0..5]}..." : "(not set)"}"
57
+ puts " Repos: #{config.fetch("repos", {}).keys.join(", ").then { |s| s.empty? ? "(none)" : s }}"
58
+ end
59
+
60
+ def cmd_status
61
+ require "net/http"
62
+ uri = URI("http://localhost:4567/api/status")
63
+ res = Net::HTTP.get_response(uri)
64
+ if res.is_a?(Net::HTTPSuccess)
65
+ puts "✅ Brainiac server is running — GitHub webhook endpoint active at POST /github"
66
+ else
67
+ puts "⚠️ Server returned #{res.code}"
68
+ end
69
+ rescue Errno::ECONNREFUSED
70
+ puts "❌ Brainiac server is not running"
71
+ end
72
+
73
+ def print_help
74
+ puts "Usage: brainiac github <command>"
75
+ puts ""
76
+ puts "Commands:"
77
+ puts " setup Create GitHub config file (~/.brainiac/github.json)"
78
+ puts " config Show current GitHub configuration"
79
+ puts " status Check if GitHub webhook endpoint is active"
80
+ end
81
+ end
82
+ end
83
+
84
+ # Plugin CLI entry points
85
+ def self.cli(args)
86
+ Cli.run(args)
87
+ end
88
+
89
+ def self.completions
90
+ %w[setup config status]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Github
6
+ module Config
7
+ CONFIG_FILE = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "github.json")
8
+
9
+ @config = {}
10
+ @last_mtime = nil
11
+
12
+ class << self
13
+ attr_reader :config
14
+
15
+ def load!
16
+ @config = load_config
17
+ @last_mtime = File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
18
+ end
19
+
20
+ def reload!
21
+ return unless file_changed?
22
+
23
+ @config = load_config
24
+ @last_mtime = File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
25
+ LOG.info "[GitHub] Reloaded configuration"
26
+ end
27
+
28
+ def webhook_secret
29
+ @config["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
30
+ end
31
+
32
+ private
33
+
34
+ def load_config
35
+ return {} unless File.exist?(CONFIG_FILE)
36
+
37
+ JSON.parse(File.read(CONFIG_FILE))
38
+ rescue JSON::ParserError => e
39
+ LOG.error "[GitHub] Failed to parse config: #{e.message}"
40
+ {}
41
+ end
42
+
43
+ def file_changed?
44
+ return false unless File.exist?(CONFIG_FILE)
45
+
46
+ current_mtime = File.mtime(CONFIG_FILE)
47
+ return false if @last_mtime && current_mtime == @last_mtime
48
+
49
+ @last_mtime = current_mtime
50
+ true
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Github
6
+ module Handler
7
+ class << self
8
+ def handle_pr_merged(payload)
9
+ pr = payload["pull_request"]
10
+ branch = pr.dig("head", "ref")
11
+ base = pr.dig("base", "ref")
12
+ pr_url = pr["html_url"]
13
+ pr_title = pr["title"]
14
+ repo_full_name = payload.dig("repository", "full_name")
15
+
16
+ default_branch = payload.dig("repository", "default_branch") || "main"
17
+ unless base == default_branch
18
+ LOG.info "PR merged into #{base}, not #{default_branch} — ignoring"
19
+ return [200, { status: "ignored", reason: "not merged into #{default_branch}" }.to_json]
20
+ end
21
+
22
+ project_result = identify_project_by_repo(repo_full_name)
23
+ unless project_result
24
+ LOG.info "No project found for GitHub repo #{repo_full_name}"
25
+ return [200, { status: "ignored", reason: "no matching project" }.to_json]
26
+ end
27
+
28
+ project_key, project_config = project_result
29
+ repo_path = project_config["repo_path"]
30
+
31
+ result = find_work_item_by_branch(branch)
32
+ unless result
33
+ LOG.info "No card found for branch #{branch}"
34
+ return [200, { status: "ignored", reason: "no matching card" }.to_json]
35
+ end
36
+
37
+ _internal_id, card_info = result
38
+ card_number = card_info["number"]
39
+ unless card_number
40
+ LOG.warn "Card has no number — can't comment or move"
41
+ return [200, { status: "ignored", reason: "card has no number" }.to_json]
42
+ end
43
+
44
+ LOG.info "PR merged into main for card ##{card_number} (project: #{project_key}): #{pr_url}"
45
+ process_merged_pr(card_info, card_number, branch, pr, pr_url, pr_title, project_key, project_config, repo_path)
46
+
47
+ [200, { status: "processed", card: card_number, pr: pr_url, action: "merged_to_uat", project: project_key }.to_json]
48
+ rescue StandardError => e
49
+ LOG.error "Error handling merged PR: #{e.message}"
50
+ [500, { error: e.message }.to_json]
51
+ end
52
+
53
+ def handle_pr_opened(payload)
54
+ track_pr_in_work_items(payload)
55
+ [200, { status: "processed", action: "pr_tracked" }.to_json]
56
+ end
57
+
58
+ def handle_pr_synchronized(payload)
59
+ pr = payload["pull_request"]
60
+ branch = pr.dig("head", "ref")
61
+
62
+ result = find_work_item_by_branch(branch)
63
+ return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
64
+
65
+ _internal_id, card_info = result
66
+ card_number = card_info["number"]
67
+ worktree = card_info["worktree"]
68
+
69
+ return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
70
+
71
+ results = Brainiac.emit(:pr_synchronized, card_number: card_number, card_info: card_info,
72
+ worktree: worktree, pull_request: pr, branch: branch)
73
+
74
+ if results.any?
75
+ [200, { status: "processed", action: "pr_sync", card: card_number }.to_json]
76
+ else
77
+ [200, { status: "ignored", reason: "no deployment plugin" }.to_json]
78
+ end
79
+ rescue StandardError => e
80
+ LOG.error "[PR Sync] Error: #{e.message}"
81
+ [500, { error: e.message }.to_json]
82
+ end
83
+
84
+ def handle_pr_review_submitted(payload)
85
+ pr = payload["pull_request"]
86
+ review = payload["review"]
87
+ branch = pr.dig("head", "ref")
88
+ pr_number = pr["number"]
89
+ repo_name = payload.dig("repository", "full_name")
90
+ review_state = review["state"]
91
+ reviewer = review.dig("user", "login")
92
+
93
+ unless %w[changes_requested commented].include?(review_state)
94
+ return [200, { status: "ignored", reason: "review state: #{review_state}" }.to_json]
95
+ end
96
+
97
+ project_result = identify_project_by_repo(repo_name)
98
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
99
+
100
+ project_key, project_config = project_result
101
+ repo_path = project_config["repo_path"]
102
+
103
+ result = find_work_item_by_branch(branch)
104
+ return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
105
+
106
+ _internal_id, card_info = result
107
+ card_number = card_info["number"]
108
+ unless card_number
109
+ LOG.warn "Card has no number — can't dispatch review"
110
+ return [200, { status: "ignored", reason: "card has no number" }.to_json]
111
+ end
112
+
113
+ card_key = "card-#{card_number}"
114
+ return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
115
+
116
+ LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
117
+ dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
118
+ repo_name, project_key, project_config, repo_path)
119
+
120
+ [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json]
121
+ rescue StandardError => e
122
+ LOG.error "Error handling PR review: #{e.message}"
123
+ [500, { error: e.message }.to_json]
124
+ end
125
+
126
+ def handle_issue_comment(payload)
127
+ comment = payload["comment"]
128
+ issue = payload["issue"]
129
+ comment_body = comment["body"] || ""
130
+ comment_id = comment["id"]
131
+ comment_user = comment.dig("user", "login")
132
+ repo_name = payload.dig("repository", "full_name")
133
+
134
+ unless issue["pull_request"]
135
+ LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
136
+ return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
137
+ end
138
+
139
+ project_result = identify_project_by_repo(repo_name)
140
+ unless project_result
141
+ LOG.info "No project found for GitHub repo #{repo_name}"
142
+ return [200, { status: "ignored", reason: "no matching project" }.to_json]
143
+ end
144
+
145
+ project_key, project_config = project_result
146
+ pr_number = issue["number"]
147
+
148
+ pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
149
+ chdir: project_config["repo_path"])
150
+ branch = JSON.parse(pr_data)["branch"]
151
+
152
+ result = find_work_item_by_branch(branch)
153
+ unless result
154
+ LOG.info "No card found for PR ##{pr_number} (branch: #{branch})"
155
+ return [200, { status: "ignored", reason: "no matching card" }.to_json]
156
+ end
157
+
158
+ _, card_info = result
159
+ card_number = card_info["number"]
160
+ worktree = card_info["worktree"]
161
+
162
+ unless worktree && File.directory?(worktree)
163
+ LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
164
+ return [200, { status: "ignored", reason: "no active worktree" }.to_json]
165
+ end
166
+
167
+ card_key = "card-#{card_number}"
168
+ if session_active?(card_key)
169
+ LOG.info "Skipping PR comment on card ##{card_number} — agent session already active"
170
+ return [200, { status: "ignored", reason: "session already active" }.to_json]
171
+ end
172
+
173
+ LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
174
+ dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
175
+ repo_name, worktree, project_key, project_config)
176
+
177
+ [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
178
+ rescue StandardError => e
179
+ LOG.error "Error handling PR comment: #{e.message}"
180
+ [500, { error: e.message }.to_json]
181
+ end
182
+
183
+ def handle_issue_opened(payload)
184
+ issue = payload["issue"]
185
+ issue_url = issue["html_url"]
186
+ issue_title = issue["title"]
187
+ issue_number = issue["number"]
188
+ repo_name = payload.dig("repository", "full_name")
189
+
190
+ LOG.info "New GitHub issue ##{issue_number} on #{repo_name}: #{issue_title} (#{issue_url})"
191
+ [200, { status: "logged", issue: issue_number, title: issue_title, url: issue_url }.to_json]
192
+ end
193
+
194
+ def handle_workflow_run(payload)
195
+ workflow = payload["workflow_run"]
196
+ workflow_name = workflow["name"]
197
+ conclusion = workflow["conclusion"]
198
+ repo_full_name = payload.dig("repository", "full_name")
199
+ run_url = workflow["html_url"]
200
+
201
+ if workflow_name == "Deploy to Production" && conclusion == "failure"
202
+ project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
203
+ Notifications.send_workflow_failure(project_key, workflow_name, run_url)
204
+ return [200, { status: "processed", action: "prod_deploy_failure_notified", project: project_key }.to_json]
205
+ end
206
+
207
+ if workflow_name == "Deploy to UAT" && conclusion == "success"
208
+ project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
209
+ Notifications.send_uat_deploy(project_key)
210
+ return [200, { status: "processed", action: "uat_deploy_notified", project: project_key }.to_json]
211
+ end
212
+
213
+ return [200, { status: "ignored", reason: "conclusion: #{conclusion}" }.to_json] unless conclusion == "success"
214
+ return [200, { status: "ignored", reason: "workflow: #{workflow_name}" }.to_json] unless workflow_name == "Deploy to Production"
215
+
216
+ project_result = identify_project_by_repo(repo_full_name)
217
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
218
+
219
+ project_key, project_config = project_result
220
+ close_uat_cards_after_deploy(project_key, project_config)
221
+ rescue StandardError => e
222
+ LOG.error "Error handling workflow run: #{e.message}"
223
+ [500, { error: e.message }.to_json]
224
+ end
225
+
226
+ private
227
+
228
+ def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
229
+ mark_work_item_merged(card_number)
230
+ cleanup_work_item_worktrees(card_number, repo_path: repo_path,
231
+ primary_worktree: card_info["worktree"], primary_branch: branch)
232
+
233
+ Brainiac.emit(:pr_merged,
234
+ card_number: card_number, card_info: card_info,
235
+ branch: branch, pull_request: pull_request,
236
+ pr_url: pr_url, pr_title: pr_title,
237
+ project_key: project_key, project_config: project_config, repo_path: repo_path)
238
+ end
239
+
240
+ def track_pr_in_work_items(payload)
241
+ pr = payload["pull_request"]
242
+ branch = pr.dig("head", "ref")
243
+ pr_number = pr["number"]
244
+ pr_url = pr["html_url"]
245
+
246
+ result = find_work_item_by_branch(branch)
247
+ unless result
248
+ LOG.info "[PR Track] No card found for branch #{branch}"
249
+ return
250
+ end
251
+
252
+ internal_id, card_info = result
253
+ prs = card_info["prs"] || []
254
+ return if prs.any? { |p| p["number"] == pr_number }
255
+
256
+ prs << { "number" => pr_number, "url" => pr_url }
257
+ card_info["prs"] = prs
258
+
259
+ map = load_work_item_map
260
+ map[internal_id] = card_info
261
+ save_work_item_map(map)
262
+ LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
263
+ end
264
+
265
+ def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
266
+ repo_name, worktree, project_key, project_config)
267
+ Thread.new do
268
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
269
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
270
+ rescue StandardError => e
271
+ LOG.warn "Could not add reaction to comment: #{e.message}"
272
+ end
273
+
274
+ agent_name = agent_name_for(project_config)
275
+ prompt = render_prompt(Prompts::PR_COMMENT,
276
+ { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
277
+ "COMMENT_CREATOR" => comment_user, "COMMENT_BODY" => comment_body,
278
+ "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => worktree },
279
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
280
+ project_key: project_key, comment_body: comment_body),
281
+ agent_name: agent_name, channel: :github)
282
+
283
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree,
284
+ log_name: "pr-comment-#{pr_number}",
285
+ model: detect_model(project_config, text: comment_body),
286
+ effort: detect_effort(project_config, text: comment_body),
287
+ agent_name: agent_name, source: :github,
288
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree })
289
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
290
+ end
291
+
292
+ def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
293
+ repo_name, project_key, project_config, repo_path)
294
+ review_id = review["id"]
295
+ Thread.new do
296
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
297
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
298
+ rescue StandardError => e
299
+ LOG.warn "Could not add reaction to review: #{e.message}"
300
+ end
301
+
302
+ agent_name = agent_name_for(project_config)
303
+ Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
304
+ agent_name: agent_name, project_config: project_config, repo_path: repo_path)
305
+
306
+ review_context = build_review_context(reviewer, review, pr_number, repo_name)
307
+ worktree = card_info["worktree"]
308
+ work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
309
+
310
+ prompt = render_prompt(Prompts::PR_REVIEW,
311
+ { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
312
+ "COMMENT_CREATOR" => reviewer, "REVIEW_CONTEXT" => review_context,
313
+ "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => work_dir },
314
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
315
+ project_key: project_key),
316
+ agent_name: agent_name, channel: :github)
317
+
318
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir,
319
+ log_name: "review-#{card_number}", agent_name: agent_name,
320
+ source: :github,
321
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir })
322
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
323
+ end
324
+
325
+ def build_review_context(reviewer, review, pr_number, repo_name)
326
+ context = "GitHub PR Review from @#{reviewer}:\n\n"
327
+ context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
328
+
329
+ review_comments = fetch_pr_review_comments(pr_number, repo_name)
330
+ if review_comments.any?
331
+ context += "Line-specific comments:\n"
332
+ review_comments.each do |comment|
333
+ context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
334
+ end
335
+ end
336
+ context
337
+ end
338
+
339
+ def fetch_pr_review_comments(pr_number, repo)
340
+ output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments",
341
+ "--jq", ".[] | {path, line, body, user: .user.login}",
342
+ chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
343
+ output.lines.map { |line| JSON.parse(line) }
344
+ rescue StandardError => e
345
+ LOG.warn "Could not fetch PR review comments: #{e.message}"
346
+ []
347
+ end
348
+
349
+ def close_uat_cards_after_deploy(project_key, project_config)
350
+ results = Brainiac.emit(:production_deployed, project_key: project_key, project_config: project_config)
351
+ closed_cards = results.flatten.compact
352
+
353
+ if closed_cards.any?
354
+ Notifications.send_deploy(project_key, closed_cards)
355
+ LOG.info "Prod deploy complete — closed #{closed_cards.size} cards"
356
+ else
357
+ LOG.info "Prod deploy processed — no cards closed (plugin may not be installed)"
358
+ end
359
+
360
+ [200, { status: "processed", action: "prod_deploy", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lightweight metadata for brainiac-github.
4
+ # Loaded by `brainiac help` without pulling in the full plugin runtime.
5
+
6
+ require_relative "version"
7
+
8
+ module Brainiac
9
+ module Plugins
10
+ module Github
11
+ # Returns true if GitHub webhook secret is configured.
12
+ def self.configured?
13
+ config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "github.json")
14
+ return false unless File.exist?(config_file)
15
+
16
+ config = JSON.parse(File.read(config_file))
17
+ !config["webhook_secret"].to_s.empty?
18
+ rescue StandardError
19
+ false
20
+ end
21
+
22
+ # Help text shown in `brainiac help` when the plugin is installed.
23
+ def self.help_text
24
+ " brainiac github <command> Manage GitHub webhooks (setup, config, status)"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Github
6
+ module Notifications
7
+ class << self
8
+ def send_deploy(project_key, closed_cards)
9
+ card_lines = closed_cards.map { |c| "• [##{c[:number]} — #{c[:title]}](#{c[:url]})" }.join("\n")
10
+ message = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}"
11
+ send_notification(:deploy, message, metadata_project: project_key)
12
+ end
13
+
14
+ def send_uat_deploy(project_key)
15
+ message = "✅ **#{project_key.capitalize}** deployed to UAT successfully"
16
+ send_notification(:deploy, message, metadata_project: project_key)
17
+ end
18
+
19
+ def send_workflow_failure(project_key, workflow_name, run_url)
20
+ message = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})"
21
+ send_notification(:ci_failure, message, metadata_project: project_key)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Github
6
+ module Prompts
7
+ CHANNEL = <<~PROMPT
8
+ ## GitHub Channel Rules
9
+
10
+ ### Formatting
11
+ Use GitHub-Flavored Markdown for all comments:
12
+ - `## Heading` for sections
13
+ - `**bold**` for emphasis
14
+ - ``` ```language ``` for code blocks
15
+ - `- item` for lists
16
+
17
+ ### Scope
18
+ You are responding to activity on a GitHub PR. Focus on the code changes and review feedback.
19
+ When posting comments, post on the PR unless specifically asked to update the card.
20
+
21
+ PROMPT
22
+
23
+ PR_COMMENT = <<~'PROMPT'
24
+ There's a new comment from @{{COMMENT_CREATOR}} on your PR #{{PR_NUMBER}} for card #{{CARD_NUMBER}}.
25
+
26
+ Comment:
27
+ {{COMMENT_BODY}}
28
+
29
+ Please:
30
+ 1. Read the comment and understand what's being requested
31
+ 2. Make any necessary changes
32
+ 3. Commit and push your updates
33
+ 4. Reply on the PR summarizing what you changed
34
+
35
+ You are in the worktree at {{WORKTREE_PATH}}.
36
+ PROMPT
37
+
38
+ PR_REVIEW = <<~'PROMPT'
39
+ A code review has been submitted on your PR #{{PR_NUMBER}} for card #{{CARD_NUMBER}}.
40
+
41
+ {{REVIEW_CONTEXT}}
42
+
43
+ Please:
44
+ 1. Read the review comments carefully
45
+ 2. Address each piece of feedback
46
+ 3. Make the necessary code changes
47
+ 4. Commit and push your updates
48
+ 5. Post a comment on the PR summarizing the changes
49
+
50
+ You are in the worktree at {{WORKTREE_PATH}}.
51
+ PROMPT
52
+
53
+ UAT = <<~'PROMPT'
54
+ PR #{{PR_NUMBER}} has been merged into main for card #{{CARD_NUMBER}}: "{{CARD_TITLE}}"
55
+
56
+ The card has been moved to the UAT column. The changes are now deployed to the UAT environment.
57
+
58
+ Your job: post a comment on card #{{CARD_NUMBER}} with clear, specific steps for how to manually test this feature in UAT. Include:
59
+ 1. What URL(s) or screen(s) to visit
60
+ 2. Step-by-step actions to verify the feature works
61
+ 3. What the expected behavior should be
62
+ 4. Any edge cases worth checking
63
+ 5. Links to relevant pages if applicable (use the UAT/staging URL, not localhost)
64
+
65
+ Base your testing steps on the card title, the PR diff, and any card context provided. Be specific — "verify it works" is not a testing step.
66
+
67
+ Do NOT make any code changes. This is a read-only review task.
68
+ PROMPT
69
+
70
+ PRE_POST_CHECK = <<~PROMPT
71
+ ## Pre-Post Comment Check (MANDATORY — do this BEFORE posting your comment)
72
+
73
+ Your session may have been running for a while. Before you post your final comment,
74
+ re-check the PR for new comments that arrived while you were working:
75
+
76
+ ```bash
77
+ gh pr view {{PR_NUMBER}} --comments --json comments
78
+ ```
79
+
80
+ If there are **new comments** that weren't in your original context:
81
+
82
+ 1. **Read them carefully** — a reviewer may have added feedback or changed direction
83
+ 2. **Adjust your work or response** to account for the new information
84
+ 3. **Do NOT ignore new comments** — avoid posting a response that's already outdated
85
+
86
+ If no new comments appeared, proceed normally.
87
+
88
+ PROMPT
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Github
6
+ VERSION = "0.0.1"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "github/version"
4
+ require_relative "github/metadata"
5
+ require_relative "github/cli"
6
+ require_relative "github/config"
7
+ require_relative "github/prompts"
8
+ require_relative "github/notifications"
9
+ require_relative "github/handler"
10
+
11
+ module Brainiac
12
+ module Plugins
13
+ module Github
14
+ class << self
15
+ # Called by Brainiac plugin system during server startup.
16
+ #
17
+ # @param app [Sinatra::Application] The running Brainiac server
18
+ def register(app)
19
+ Config.load!
20
+ Brainiac.register_channel_prompt(:github, Prompts::CHANNEL, pre_post_check: Prompts::PRE_POST_CHECK)
21
+ register_crash_handler!
22
+ setup_routes(app)
23
+ LOG.info "[GitHub] Plugin registered (webhook: /github)"
24
+ end
25
+
26
+ # Called after `brainiac install github` — creates config from template.
27
+ def post_install(brainiac_dir)
28
+ config_file = File.join(brainiac_dir, "github.json")
29
+ return if File.exist?(config_file)
30
+
31
+ template = File.expand_path("../../../templates/github.json.example", __dir__)
32
+ if File.exist?(template)
33
+ FileUtils.cp(template, config_file)
34
+ else
35
+ default_config = { "webhook_secret" => "", "repos" => {} }
36
+ File.write(config_file, JSON.pretty_generate(default_config))
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def register_crash_handler!
43
+ Brainiac.on(:agent_crashed) do |ctx|
44
+ next unless ctx[:source] == :github
45
+
46
+ source_context = ctx[:source_context] || {}
47
+ pr_number = source_context[:pr_number]
48
+ repo_name = source_context[:repo_name]
49
+ next unless pr_number && repo_name
50
+
51
+ work_dir = source_context[:work_dir] || Dir.pwd
52
+ agent_display = ctx[:agent_name] || "Agent"
53
+ snippet = ctx[:snippet]
54
+ snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
55
+ comment_body = "💥 **#{agent_display} crashed** (exit code #{ctx[:exit_status]})\n\nLog: `#{ctx[:log_file]}`#{snippet_block}"
56
+
57
+ begin
58
+ run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
59
+ LOG.info "[GitHub] Posted crash comment on PR ##{pr_number}"
60
+ rescue StandardError => e
61
+ LOG.error "[GitHub] Failed to post crash comment: #{e.message}"
62
+ end
63
+
64
+ :github
65
+ end
66
+ end
67
+
68
+ def setup_routes(app)
69
+ app.post "/github" do
70
+ content_type :json
71
+ request.body.rewind
72
+ payload_body = request.body.read
73
+
74
+ Brainiac::Plugins::Github.verify_signature!(request, payload_body)
75
+
76
+ payload = JSON.parse(payload_body)
77
+ event = request.env["HTTP_X_GITHUB_EVENT"]
78
+
79
+ reload_projects!
80
+ reload_agent_registry!
81
+ Brainiac::Plugins::Github::Config.reload!
82
+
83
+ action = payload["action"]
84
+
85
+ case event
86
+ when "pull_request"
87
+ status_code, body = case action
88
+ when "closed"
89
+ if payload.dig("pull_request", "merged")
90
+ Handler.handle_pr_merged(payload)
91
+ else
92
+ [200, { status: "ignored", reason: "PR closed without merge" }.to_json]
93
+ end
94
+ when "opened"
95
+ Handler.handle_pr_opened(payload)
96
+ when "synchronize"
97
+ Handler.handle_pr_synchronized(payload)
98
+ else
99
+ [200, { status: "ignored", reason: "pull_request action: #{action}" }.to_json]
100
+ end
101
+ halt status_code, body
102
+ when "pull_request_review"
103
+ if action == "submitted"
104
+ status_code, body = Handler.handle_pr_review_submitted(payload)
105
+ halt status_code, body
106
+ else
107
+ halt 200, { status: "ignored", reason: "pull_request_review action: #{action}" }.to_json
108
+ end
109
+ when "issue_comment"
110
+ if action == "created"
111
+ status_code, body = Handler.handle_issue_comment(payload)
112
+ halt status_code, body
113
+ else
114
+ halt 200, { status: "ignored", reason: "issue_comment action: #{action}" }.to_json
115
+ end
116
+ when "issues"
117
+ if action == "opened"
118
+ status_code, body = Handler.handle_issue_opened(payload)
119
+ halt status_code, body
120
+ else
121
+ halt 200, { status: "ignored", reason: "issues action: #{action}" }.to_json
122
+ end
123
+ when "workflow_run"
124
+ if action == "completed"
125
+ status_code, body = Handler.handle_workflow_run(payload)
126
+ halt status_code, body
127
+ else
128
+ halt 200, { status: "ignored", reason: "workflow_run action: #{action}" }.to_json
129
+ end
130
+ when "ping"
131
+ halt 200, { status: "pong" }.to_json
132
+ else
133
+ halt 200, { status: "ignored", event: event }.to_json
134
+ end
135
+ rescue JSON::ParserError => e
136
+ LOG.error "Invalid JSON: #{e.message}"
137
+ halt 400, { error: "Invalid JSON" }.to_json
138
+ rescue StandardError => e
139
+ LOG.error "Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
140
+ halt 500, { error: e.message }.to_json
141
+ end
142
+ end
143
+ end
144
+
145
+ # Verify the X-Hub-Signature-256 header (HMAC-SHA256 of the raw body).
146
+ def self.verify_signature!(request, payload_body)
147
+ signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
148
+ halt 403, { error: "Missing GitHub signature" }.to_json unless signature
149
+ secret = Config.webhook_secret
150
+ halt 500, { error: "GitHub webhook secret not configured" }.to_json unless secret
151
+ computed = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)}"
152
+ halt 403, { error: "Invalid GitHub signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "brainiac/plugins/github"
@@ -0,0 +1,4 @@
1
+ {
2
+ "webhook_secret": "your-github-webhook-secret",
3
+ "repos": {}
4
+ }
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: brainiac-github
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Andy Davis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: brainiac
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.9
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.9
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.75'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.75'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-performance
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.25'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.25'
82
+ description: Full GitHub integration for Brainiac — PR reviews, PR comments, PR merges,
83
+ CI workflow notifications, and issue tracking. Uses Brainiac's hook system for lifecycle
84
+ integration (PR merge → work item close, deploy → card close).
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - LICENSE
90
+ - README.md
91
+ - lib/brainiac/plugins/github.rb
92
+ - lib/brainiac/plugins/github/cli.rb
93
+ - lib/brainiac/plugins/github/config.rb
94
+ - lib/brainiac/plugins/github/handler.rb
95
+ - lib/brainiac/plugins/github/metadata.rb
96
+ - lib/brainiac/plugins/github/notifications.rb
97
+ - lib/brainiac/plugins/github/prompts.rb
98
+ - lib/brainiac/plugins/github/version.rb
99
+ - lib/brainiac_github.rb
100
+ - templates/github.json.example
101
+ homepage: https://github.com/stowzilla/brainiac-github
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ rubygems_mfa_required: 'true'
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.4'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.6.9
121
+ specification_version: 4
122
+ summary: GitHub webhook plugin for Brainiac
123
+ test_files: []