brainiac-github 0.0.6 → 0.1.0
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 +4 -4
- data/README.md +48 -0
- data/lib/brainiac/plugins/github/app_client.rb +172 -0
- data/lib/brainiac/plugins/github/cli.rb +17 -0
- data/lib/brainiac/plugins/github/config.rb +19 -0
- data/lib/brainiac/plugins/github/handler.rb +100 -27
- data/lib/brainiac/plugins/github/version.rb +1 -1
- data/lib/brainiac/plugins/github.rb +6 -1
- data/templates/github.json.example +5 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ad5b0842a1e9731e535de2a742f69efa1a243e956d34d3b2a3ec8f04813ec96
|
|
4
|
+
data.tar.gz: 4d01970f8f0ca48975fc66fb8197d0b94c16b0797eed937123af4c0639163168
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d637b12f74f47258614b8114efa550baac3ef11073c3dd4320edd637335e761aa5ec50601d4042c119d4ac988cc6ebbe26f34a0905071a2b205725b9749d7a7
|
|
7
|
+
data.tar.gz: 279d75d005d2889eb5373b0161764cd82459ada1aab2e022c1449793a56bd0910592008f71a860909595e6da54bfe66de6349f8323a1510171feacb81d5815aa
|
data/README.md
CHANGED
|
@@ -24,6 +24,11 @@ Config lives at `~/.brainiac/github.json`:
|
|
|
24
24
|
```json
|
|
25
25
|
{
|
|
26
26
|
"webhook_secret": "your-github-webhook-secret",
|
|
27
|
+
"app": {
|
|
28
|
+
"id": "123456",
|
|
29
|
+
"private_key_path": "~/.brainiac/github-app-private-key.pem",
|
|
30
|
+
"installation_id": "78901234"
|
|
31
|
+
},
|
|
27
32
|
"repos": {}
|
|
28
33
|
}
|
|
29
34
|
```
|
|
@@ -34,6 +39,49 @@ Generate a webhook secret:
|
|
|
34
39
|
ruby -rsecurerandom -e 'puts SecureRandom.hex(20)'
|
|
35
40
|
```
|
|
36
41
|
|
|
42
|
+
### GitHub App Setup (Recommended)
|
|
43
|
+
|
|
44
|
+
Using a GitHub App makes PR comments and reactions appear as the app's bot user
|
|
45
|
+
(e.g. "brainiac-bot") instead of your personal account. This gives each agent a
|
|
46
|
+
distinct identity in PR conversations.
|
|
47
|
+
|
|
48
|
+
1. Go to **Settings → Developer settings → GitHub Apps → New GitHub App**
|
|
49
|
+
2. Set the following:
|
|
50
|
+
- **Name**: e.g. `brainiac-bot`
|
|
51
|
+
- **Homepage URL**: your Brainiac instance URL
|
|
52
|
+
- **Webhook URL**: leave blank (webhook is handled by the plugin directly)
|
|
53
|
+
- **Permissions**:
|
|
54
|
+
- Pull requests: Read & Write
|
|
55
|
+
- Issues: Read & Write
|
|
56
|
+
- Contents: Read (for fetching PR diffs)
|
|
57
|
+
- **Events**: uncheck everything (events come via the repo webhook, not the app)
|
|
58
|
+
3. Create the app and note the **App ID** from the app's settings page
|
|
59
|
+
4. Generate a private key (`.pem` file) and save it to `~/.brainiac/github-app-private-key.pem`
|
|
60
|
+
5. Install the app on your org/repos and note the **Installation ID** from the URL:
|
|
61
|
+
`https://github.com/settings/installations/INSTALLATION_ID`
|
|
62
|
+
6. Add the credentials to your `github.json`:
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"app": {
|
|
66
|
+
"id": "123456",
|
|
67
|
+
"private_key_path": "~/.brainiac/github-app-private-key.pem",
|
|
68
|
+
"installation_id": "78901234"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
If app credentials are not configured, the plugin falls back to using the `gh` CLI
|
|
74
|
+
(which authenticates as your personal GitHub account).
|
|
75
|
+
|
|
76
|
+
### Environment Variables
|
|
77
|
+
|
|
78
|
+
As an alternative to config file values, you can set:
|
|
79
|
+
|
|
80
|
+
- `GITHUB_WEBHOOK_SECRET` — webhook signature secret
|
|
81
|
+
- `GITHUB_APP_ID` — GitHub App ID
|
|
82
|
+
- `GITHUB_APP_PRIVATE_KEY_PATH` — path to the `.pem` private key file
|
|
83
|
+
- `GITHUB_APP_INSTALLATION_ID` — installation ID
|
|
84
|
+
|
|
37
85
|
### GitHub Webhook Setup
|
|
38
86
|
|
|
39
87
|
1. Go to your repo → Settings → Webhooks → Add webhook
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "jwt"
|
|
8
|
+
require "time"
|
|
9
|
+
|
|
10
|
+
module Brainiac
|
|
11
|
+
module Plugins
|
|
12
|
+
module Github
|
|
13
|
+
# HTTP client that authenticates as a GitHub App (installation).
|
|
14
|
+
#
|
|
15
|
+
# When app credentials are configured (app_id + private_key_path + installation_id),
|
|
16
|
+
# API calls are made as the App's bot user — so PR comments, reactions, etc.
|
|
17
|
+
# appear with the App's identity rather than a personal user.
|
|
18
|
+
#
|
|
19
|
+
# Falls back to `gh` CLI when app credentials are not configured.
|
|
20
|
+
module AppClient
|
|
21
|
+
GITHUB_API = "https://api.github.com"
|
|
22
|
+
TOKEN_EXPIRY_BUFFER = 60 # refresh token 60s before expiry
|
|
23
|
+
|
|
24
|
+
@token = nil
|
|
25
|
+
@token_expires_at = nil
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Returns true if GitHub App credentials are fully configured.
|
|
30
|
+
def configured?
|
|
31
|
+
!!(Config.app_id && Config.private_key_path && Config.installation_id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# POST a comment on an issue or PR.
|
|
35
|
+
#
|
|
36
|
+
# @param repo [String] "owner/repo"
|
|
37
|
+
# @param pr_number [Integer]
|
|
38
|
+
# @param body [String] comment markdown
|
|
39
|
+
# @return [Hash] parsed response
|
|
40
|
+
def create_comment(repo, pr_number, body)
|
|
41
|
+
post("/repos/#{repo}/issues/#{pr_number}/comments", { body: body })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# POST a reaction on an issue comment.
|
|
45
|
+
#
|
|
46
|
+
# @param repo [String] "owner/repo"
|
|
47
|
+
# @param comment_id [Integer]
|
|
48
|
+
# @param reaction [String] e.g. "eyes", "+1", "rocket"
|
|
49
|
+
# @return [Hash] parsed response
|
|
50
|
+
def create_comment_reaction(repo, comment_id, reaction)
|
|
51
|
+
post("/repos/#{repo}/issues/comments/#{comment_id}/reactions", { content: reaction })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# POST a reaction on a PR review.
|
|
55
|
+
#
|
|
56
|
+
# @param repo [String] "owner/repo"
|
|
57
|
+
# @param review_id [Integer]
|
|
58
|
+
# @param reaction [String]
|
|
59
|
+
# @return [Hash] parsed response
|
|
60
|
+
def create_review_reaction(repo, review_id, reaction)
|
|
61
|
+
post("/repos/#{repo}/pulls/reviews/#{review_id}/reactions", { content: reaction })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# GET request to GitHub API.
|
|
65
|
+
#
|
|
66
|
+
# @param path [String] API path (e.g. "/repos/owner/repo/pulls/1")
|
|
67
|
+
# @return [Hash] parsed response
|
|
68
|
+
def get(path)
|
|
69
|
+
request(:get, path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# POST request to GitHub API.
|
|
73
|
+
#
|
|
74
|
+
# @param path [String] API path
|
|
75
|
+
# @param body [Hash] request body
|
|
76
|
+
# @return [Hash] parsed response
|
|
77
|
+
def post(path, body)
|
|
78
|
+
request(:post, path, body)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reset cached token (useful for testing or when credentials change).
|
|
82
|
+
def reset!
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@token = nil
|
|
85
|
+
@token_expires_at = nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def request(method, path, body = nil)
|
|
92
|
+
token = installation_token
|
|
93
|
+
uri = URI("#{GITHUB_API}#{path}")
|
|
94
|
+
|
|
95
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
96
|
+
http.use_ssl = true
|
|
97
|
+
http.open_timeout = 10
|
|
98
|
+
http.read_timeout = 30
|
|
99
|
+
|
|
100
|
+
req = case method
|
|
101
|
+
when :get
|
|
102
|
+
Net::HTTP::Get.new(uri.request_uri)
|
|
103
|
+
when :post
|
|
104
|
+
Net::HTTP::Post.new(uri.request_uri)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
req["Authorization"] = "Bearer #{token}"
|
|
108
|
+
req["Accept"] = "application/vnd.github+json"
|
|
109
|
+
req["X-GitHub-Api-Version"] = "2022-11-28"
|
|
110
|
+
req["User-Agent"] = "Brainiac-GitHub-App"
|
|
111
|
+
req.body = JSON.generate(body) if body
|
|
112
|
+
req.content_type = "application/json" if body
|
|
113
|
+
|
|
114
|
+
response = http.request(req)
|
|
115
|
+
|
|
116
|
+
raise "GitHub API error #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
117
|
+
|
|
118
|
+
JSON.parse(response.body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Generate a short-lived JWT signed with the App's private key.
|
|
122
|
+
# Used to request an installation access token.
|
|
123
|
+
def generate_jwt
|
|
124
|
+
private_key = OpenSSL::PKey::RSA.new(File.read(Config.private_key_path))
|
|
125
|
+
now = Time.now.to_i
|
|
126
|
+
|
|
127
|
+
payload = {
|
|
128
|
+
iat: now - 60, # issued at (60s clock drift allowance)
|
|
129
|
+
exp: now + (10 * 60), # expires in 10 minutes (max allowed)
|
|
130
|
+
iss: Config.app_id
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
JWT.encode(payload, private_key, "RS256")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fetch or return a cached installation access token.
|
|
137
|
+
# Tokens are valid for 1 hour; we refresh 60s early.
|
|
138
|
+
def installation_token
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
return @token if @token && @token_expires_at && Time.now.to_i < @token_expires_at
|
|
141
|
+
|
|
142
|
+
jwt = generate_jwt
|
|
143
|
+
uri = URI("#{GITHUB_API}/app/installations/#{Config.installation_id}/access_tokens")
|
|
144
|
+
|
|
145
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
146
|
+
http.use_ssl = true
|
|
147
|
+
http.open_timeout = 10
|
|
148
|
+
http.read_timeout = 30
|
|
149
|
+
|
|
150
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
151
|
+
req["Authorization"] = "Bearer #{jwt}"
|
|
152
|
+
req["Accept"] = "application/vnd.github+json"
|
|
153
|
+
req["X-GitHub-Api-Version"] = "2022-11-28"
|
|
154
|
+
req["User-Agent"] = "Brainiac-GitHub-App"
|
|
155
|
+
|
|
156
|
+
response = http.request(req)
|
|
157
|
+
|
|
158
|
+
raise "Failed to get installation token: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
159
|
+
|
|
160
|
+
data = JSON.parse(response.body)
|
|
161
|
+
@token = data["token"]
|
|
162
|
+
# Parse expiry, subtract buffer
|
|
163
|
+
expires_at = Time.parse(data["expires_at"]).to_i
|
|
164
|
+
@token_expires_at = expires_at - TOKEN_EXPIRY_BUFFER
|
|
165
|
+
@token
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -51,10 +51,27 @@ module Brainiac
|
|
|
51
51
|
|
|
52
52
|
config = JSON.parse(File.read(config_file))
|
|
53
53
|
secret = config["webhook_secret"]
|
|
54
|
+
app_config = config["app"] || {}
|
|
55
|
+
|
|
54
56
|
puts "GitHub Configuration:"
|
|
55
57
|
puts " Config file: #{config_file}"
|
|
56
58
|
puts " Webhook secret: #{secret && !secret.empty? ? "#{secret[0..5]}..." : "(not set)"}"
|
|
57
59
|
puts " Repos: #{config.fetch("repos", {}).keys.join(", ").then { |s| s.empty? ? "(none)" : s }}"
|
|
60
|
+
puts ""
|
|
61
|
+
puts " App Authentication:"
|
|
62
|
+
if app_config["id"] && !app_config["id"].to_s.empty?
|
|
63
|
+
puts " App ID: #{app_config["id"]}"
|
|
64
|
+
puts " Private key: #{app_config["private_key_path"] || "(not set)"}"
|
|
65
|
+
puts " Installation ID: #{app_config["installation_id"] || "(not set)"}"
|
|
66
|
+
key_path = app_config["private_key_path"]
|
|
67
|
+
if key_path && File.exist?(File.expand_path(key_path))
|
|
68
|
+
puts " Status: ✅ configured"
|
|
69
|
+
else
|
|
70
|
+
puts " Status: ⚠️ private key file not found"
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
puts " Status: not configured (using gh CLI fallback)"
|
|
74
|
+
end
|
|
58
75
|
end
|
|
59
76
|
|
|
60
77
|
def cmd_status
|
|
@@ -22,6 +22,7 @@ module Brainiac
|
|
|
22
22
|
|
|
23
23
|
@config = load_config
|
|
24
24
|
@last_mtime = File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
|
|
25
|
+
AppClient.reset! if defined?(AppClient)
|
|
25
26
|
LOG.info "[GitHub] Reloaded configuration"
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -29,6 +30,24 @@ module Brainiac
|
|
|
29
30
|
@config["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# GitHub App credentials — all three must be present for App auth to work.
|
|
34
|
+
|
|
35
|
+
def app_id
|
|
36
|
+
@config.dig("app", "id")&.to_s || ENV.fetch("GITHUB_APP_ID", nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def private_key_path
|
|
40
|
+
path = @config.dig("app", "private_key_path") || ENV.fetch("GITHUB_APP_PRIVATE_KEY_PATH", nil)
|
|
41
|
+
return nil unless path
|
|
42
|
+
|
|
43
|
+
expanded = File.expand_path(path)
|
|
44
|
+
File.exist?(expanded) ? expanded : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def installation_id
|
|
48
|
+
@config.dig("app", "installation_id")&.to_s || ENV.fetch("GITHUB_APP_INSTALLATION_ID", nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
private
|
|
33
52
|
|
|
34
53
|
def load_config
|
|
@@ -35,7 +35,7 @@ module Brainiac
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
_internal_id, card_info = result
|
|
38
|
-
card_number = card_info
|
|
38
|
+
card_number = extract_card_number(card_info)
|
|
39
39
|
unless card_number
|
|
40
40
|
LOG.warn "Card has no number — can't comment or move"
|
|
41
41
|
return [200, { status: "ignored", reason: "card has no number" }.to_json]
|
|
@@ -63,7 +63,7 @@ module Brainiac
|
|
|
63
63
|
return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
|
|
64
64
|
|
|
65
65
|
_internal_id, card_info = result
|
|
66
|
-
card_number = card_info
|
|
66
|
+
card_number = extract_card_number(card_info)
|
|
67
67
|
worktree = card_info["worktree"]
|
|
68
68
|
|
|
69
69
|
return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
|
|
@@ -104,17 +104,16 @@ module Brainiac
|
|
|
104
104
|
|
|
105
105
|
if result
|
|
106
106
|
_internal_id, card_info = result
|
|
107
|
-
card_number = card_info
|
|
107
|
+
card_number = extract_card_number(card_info)
|
|
108
108
|
unless card_number
|
|
109
109
|
LOG.warn "Card has no number — can't dispatch review"
|
|
110
110
|
return [200, { status: "ignored", reason: "card has no number" }.to_json]
|
|
111
111
|
end
|
|
112
|
-
card_key = "card-#{card_number}"
|
|
113
112
|
else
|
|
114
113
|
card_info = {}
|
|
115
114
|
card_number = nil
|
|
116
|
-
card_key = "pr-#{repo_name.tr("/", "-")}-#{pr_number}"
|
|
117
115
|
end
|
|
116
|
+
card_key = "pr-review-#{repo_name.tr("/", "-")}-#{pr_number}"
|
|
118
117
|
|
|
119
118
|
return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
|
|
120
119
|
|
|
@@ -151,15 +150,20 @@ module Brainiac
|
|
|
151
150
|
project_key, project_config = project_result
|
|
152
151
|
pr_number = issue["number"]
|
|
153
152
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
if AppClient.configured?
|
|
154
|
+
pr_response = AppClient.get("/repos/#{repo_name}/pulls/#{pr_number}")
|
|
155
|
+
branch = pr_response.dig("head", "ref")
|
|
156
|
+
else
|
|
157
|
+
pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
|
|
158
|
+
chdir: project_config["repo_path"])
|
|
159
|
+
branch = JSON.parse(pr_data)["branch"]
|
|
160
|
+
end
|
|
157
161
|
|
|
158
162
|
result = find_work_item_by_branch(branch)
|
|
159
163
|
|
|
160
164
|
if result
|
|
161
165
|
_, card_info = result
|
|
162
|
-
card_number = card_info
|
|
166
|
+
card_number = extract_card_number(card_info)
|
|
163
167
|
worktree = card_info["worktree"]
|
|
164
168
|
|
|
165
169
|
unless worktree && File.directory?(worktree)
|
|
@@ -167,12 +171,11 @@ module Brainiac
|
|
|
167
171
|
return [200, { status: "ignored", reason: "no active worktree" }.to_json]
|
|
168
172
|
end
|
|
169
173
|
|
|
170
|
-
card_key = "card-#{card_number}"
|
|
171
174
|
else
|
|
172
175
|
card_number = nil
|
|
173
176
|
worktree = project_config["repo_path"]
|
|
174
|
-
card_key = "pr-#{repo_name.tr("/", "-")}-#{pr_number}"
|
|
175
177
|
end
|
|
178
|
+
card_key = "pr-comment-#{repo_name.tr("/", "-")}-#{pr_number}"
|
|
176
179
|
|
|
177
180
|
if session_active?(card_key)
|
|
178
181
|
LOG.info "Skipping PR comment on #{card_key} — agent session already active"
|
|
@@ -245,6 +248,28 @@ module Brainiac
|
|
|
245
248
|
nil
|
|
246
249
|
end
|
|
247
250
|
|
|
251
|
+
# Extract the card number from a work item info hash, supporting both
|
|
252
|
+
# the old flat format ("number") and new source-based format ("sources.fizzy.card_number").
|
|
253
|
+
def extract_card_number(card_info)
|
|
254
|
+
card_info["number"] || card_info.dig("sources", "fizzy", "card_number")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Extract PRs array from a work item, supporting both old flat format and new source-based format.
|
|
258
|
+
def extract_prs(card_info)
|
|
259
|
+
card_info.dig("sources", "github", "prs") || card_info["prs"] || []
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Store PRs in the correct location based on the work item format.
|
|
263
|
+
def store_prs(card_info, prs)
|
|
264
|
+
if card_info.key?("sources")
|
|
265
|
+
card_info["sources"] ||= {}
|
|
266
|
+
card_info["sources"]["github"] ||= {}
|
|
267
|
+
card_info["sources"]["github"]["prs"] = prs
|
|
268
|
+
else
|
|
269
|
+
card_info["prs"] = prs
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
248
273
|
def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
|
|
249
274
|
mark_work_item_merged(card_number)
|
|
250
275
|
cleanup_work_item_worktrees(card_number, repo_path: repo_path,
|
|
@@ -270,23 +295,27 @@ module Brainiac
|
|
|
270
295
|
end
|
|
271
296
|
|
|
272
297
|
internal_id, card_info = result
|
|
273
|
-
prs = card_info
|
|
298
|
+
prs = extract_prs(card_info)
|
|
274
299
|
return if prs.any? { |p| p["number"] == pr_number }
|
|
275
300
|
|
|
276
301
|
prs << { "number" => pr_number, "url" => pr_url }
|
|
277
|
-
card_info
|
|
302
|
+
store_prs(card_info, prs)
|
|
278
303
|
|
|
279
304
|
map = load_work_item_map
|
|
280
305
|
map[internal_id] = card_info
|
|
281
306
|
save_work_item_map(map)
|
|
282
|
-
LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info
|
|
307
|
+
LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{extract_card_number(card_info)} (branch: #{branch})"
|
|
283
308
|
end
|
|
284
309
|
|
|
285
310
|
def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
|
|
286
311
|
repo_name, worktree, project_key, project_config)
|
|
287
312
|
Thread.new do
|
|
288
|
-
|
|
289
|
-
|
|
313
|
+
if AppClient.configured?
|
|
314
|
+
AppClient.create_comment_reaction(repo_name, comment_id, "eyes")
|
|
315
|
+
else
|
|
316
|
+
run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
|
|
317
|
+
"-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
|
|
318
|
+
end
|
|
290
319
|
rescue StandardError => e
|
|
291
320
|
LOG.warn "Could not add reaction to comment: #{e.message}"
|
|
292
321
|
end
|
|
@@ -319,8 +348,14 @@ module Brainiac
|
|
|
319
348
|
repo_name, project_key, project_config, repo_path)
|
|
320
349
|
review_id = review["id"]
|
|
321
350
|
Thread.new do
|
|
322
|
-
|
|
323
|
-
|
|
351
|
+
if AppClient.configured?
|
|
352
|
+
AppClient.create_review_reaction(repo_name, review_id, "eyes")
|
|
353
|
+
else
|
|
354
|
+
run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
|
|
355
|
+
"-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
react_to_review_comments(review_id, pr_number, repo_name, repo_path)
|
|
324
359
|
rescue StandardError => e
|
|
325
360
|
LOG.warn "Could not add reaction to review: #{e.message}"
|
|
326
361
|
end
|
|
@@ -369,24 +404,62 @@ module Brainiac
|
|
|
369
404
|
end
|
|
370
405
|
|
|
371
406
|
def fetch_pr_review_comments(pr_number, repo)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
407
|
+
if AppClient.configured?
|
|
408
|
+
response = AppClient.get("/repos/#{repo}/pulls/#{pr_number}/comments")
|
|
409
|
+
# Response is an array of comment objects
|
|
410
|
+
response.map { |c| { "path" => c["path"], "line" => c["line"], "body" => c["body"], "user" => c.dig("user", "login") } }
|
|
411
|
+
else
|
|
412
|
+
output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments",
|
|
413
|
+
"--jq", ".[] | {path, line, body, user: .user.login}",
|
|
414
|
+
chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
|
|
415
|
+
output.lines.map { |line| JSON.parse(line) }
|
|
416
|
+
end
|
|
376
417
|
rescue StandardError => e
|
|
377
418
|
LOG.warn "Could not fetch PR review comments: #{e.message}"
|
|
378
419
|
[]
|
|
379
420
|
end
|
|
380
421
|
|
|
422
|
+
# React with 👀 to each individual comment in a review submission.
|
|
423
|
+
# This makes reactions visible on line-level file comments, not just the review wrapper.
|
|
424
|
+
def react_to_review_comments(review_id, pr_number, repo_name, repo_path)
|
|
425
|
+
if AppClient.configured?
|
|
426
|
+
comments = AppClient.get("/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments")
|
|
427
|
+
comment_ids = comments.map { |c| c["id"] }
|
|
428
|
+
else
|
|
429
|
+
output = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments",
|
|
430
|
+
"--jq", ".[].id", chdir: repo_path)
|
|
431
|
+
comment_ids = output.lines.map(&:strip).reject(&:empty?)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
comment_ids.each do |comment_id|
|
|
435
|
+
if AppClient.configured?
|
|
436
|
+
AppClient.create_comment_reaction(repo_name, comment_id, "eyes")
|
|
437
|
+
else
|
|
438
|
+
run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/comments/#{comment_id}/reactions",
|
|
439
|
+
"-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
rescue StandardError => e
|
|
443
|
+
LOG.warn "Could not react to review comments: #{e.message}"
|
|
444
|
+
end
|
|
445
|
+
|
|
381
446
|
# Lightweight recent PR comment context for intent classification.
|
|
382
447
|
# Returns "author: message" format (last 5 issue comments on the PR).
|
|
383
448
|
def fetch_pr_intent_context(pr_number, repo_name)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
449
|
+
if AppClient.configured?
|
|
450
|
+
comments = AppClient.get("/repos/#{repo_name}/issues/#{pr_number}/comments")
|
|
451
|
+
entries = comments.last(5).map { |c| "#{c.dig("user", "login")}: #{c["body"]&.slice(0, 200)}" }
|
|
452
|
+
return nil if entries.empty?
|
|
388
453
|
|
|
389
|
-
|
|
454
|
+
entries.join("\n")
|
|
455
|
+
else
|
|
456
|
+
output = run_cmd("gh", "api", "/repos/#{repo_name}/issues/#{pr_number}/comments",
|
|
457
|
+
"--jq", ".[-5:] | .[] | \"\\(.user.login): \\(.body[0:200])\"",
|
|
458
|
+
chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
|
|
459
|
+
return nil if output.strip.empty?
|
|
460
|
+
|
|
461
|
+
output.strip
|
|
462
|
+
end
|
|
390
463
|
rescue StandardError => e
|
|
391
464
|
LOG.warn "[GitHub] Could not fetch intent context for PR ##{pr_number}: #{e.message}" if defined?(LOG)
|
|
392
465
|
nil
|
|
@@ -4,6 +4,7 @@ require_relative "github/version"
|
|
|
4
4
|
require_relative "github/metadata"
|
|
5
5
|
require_relative "github/cli"
|
|
6
6
|
require_relative "github/config"
|
|
7
|
+
require_relative "github/app_client"
|
|
7
8
|
require_relative "github/prompts"
|
|
8
9
|
require_relative "github/notifications"
|
|
9
10
|
require_relative "github/handler"
|
|
@@ -68,7 +69,11 @@ module Brainiac
|
|
|
68
69
|
comment_body = "💥 **#{agent_display} crashed** (exit code #{ctx[:exit_status]})\n\nLog: `#{ctx[:log_file]}`#{snippet_block}"
|
|
69
70
|
|
|
70
71
|
begin
|
|
71
|
-
|
|
72
|
+
if AppClient.configured?
|
|
73
|
+
AppClient.create_comment(repo_name, pr_number, comment_body)
|
|
74
|
+
else
|
|
75
|
+
run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
|
|
76
|
+
end
|
|
72
77
|
LOG.info "[GitHub] Posted crash comment on PR ##{pr_number}"
|
|
73
78
|
rescue StandardError => e
|
|
74
79
|
LOG.error "[GitHub] Failed to post crash comment: #{e.message}"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brainiac-github
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 0.0.14
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: jwt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.9'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.9'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: minitest
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -89,6 +103,7 @@ files:
|
|
|
89
103
|
- LICENSE
|
|
90
104
|
- README.md
|
|
91
105
|
- lib/brainiac/plugins/github.rb
|
|
106
|
+
- lib/brainiac/plugins/github/app_client.rb
|
|
92
107
|
- lib/brainiac/plugins/github/cli.rb
|
|
93
108
|
- lib/brainiac/plugins/github/config.rb
|
|
94
109
|
- lib/brainiac/plugins/github/handler.rb
|