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 +7 -0
- data/LICENSE +21 -0
- data/README.md +74 -0
- data/lib/brainiac/plugins/github/cli.rb +94 -0
- data/lib/brainiac/plugins/github/config.rb +56 -0
- data/lib/brainiac/plugins/github/handler.rb +366 -0
- data/lib/brainiac/plugins/github/metadata.rb +28 -0
- data/lib/brainiac/plugins/github/notifications.rb +27 -0
- data/lib/brainiac/plugins/github/prompts.rb +92 -0
- data/lib/brainiac/plugins/github/version.rb +9 -0
- data/lib/brainiac/plugins/github.rb +156 -0
- data/lib/brainiac_github.rb +3 -0
- data/templates/github.json.example +4 -0
- metadata +123 -0
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,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
|
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: []
|