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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4606ec0c07107e122e51570364e05d20db3d0717e7a6679cdff85471f38e0b5
4
- data.tar.gz: 73ad18ee4563e0e55d56a1cab2f8f8714484d3fee3bbc1c3763ec1e2886221eb
3
+ metadata.gz: 7ad5b0842a1e9731e535de2a742f69efa1a243e956d34d3b2a3ec8f04813ec96
4
+ data.tar.gz: 4d01970f8f0ca48975fc66fb8197d0b94c16b0797eed937123af4c0639163168
5
5
  SHA512:
6
- metadata.gz: 9c7240c8f7a3a1b57e72bc6245a50d7089c82736e6e0259c2944ca623fbb6839f564064b75788c3b8fc60489393b0fdc65ff932410da878b2bb19bc16327c801
7
- data.tar.gz: 05405f8388fe9c817d4c2b59edde95f5ac6819112fa1f30f24d486a35a37c2dd417aad3f0c17f760e237f1d54681508e436cd1820f6b56eea5a1fc6ec77d1e96
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["number"]
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["number"]
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["number"]
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
- pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
155
- chdir: project_config["repo_path"])
156
- branch = JSON.parse(pr_data)["branch"]
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["number"]
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["prs"] || []
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["prs"] = prs
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["number"]} (branch: #{branch})"
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
- run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
289
- "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
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
- run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
323
- "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
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
- output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments",
373
- "--jq", ".[] | {path, line, body, user: .user.login}",
374
- chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
375
- output.lines.map { |line| JSON.parse(line) }
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
- output = run_cmd("gh", "api", "/repos/#{repo_name}/issues/#{pr_number}/comments",
385
- "--jq", ".[-5:] | .[] | \"\\(.user.login): \\(.body[0:200])\"",
386
- chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
387
- return nil if output.strip.empty?
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
- output.strip
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
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Github
6
- VERSION = "0.0.6"
6
+ VERSION = "0.1.0"
7
7
  end
8
8
  end
9
9
  end
@@ -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
- run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
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}"
@@ -1,4 +1,9 @@
1
1
  {
2
2
  "webhook_secret": "your-github-webhook-secret",
3
+ "app": {
4
+ "id": "",
5
+ "private_key_path": "~/.brainiac/github-app-private-key.pem",
6
+ "installation_id": ""
7
+ },
3
8
  "repos": {}
4
9
  }
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.6
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