brainiac-github 0.0.6 → 0.2.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: 48b4b58174666e2efbd528ac3cf650cad735f25d84eedabffe4d201cdbe2acac
4
+ data.tar.gz: fd097926d92d623f56c7b5c26e7aa88726e952eb6ba21f1392bbecadaaf8f55e
5
5
  SHA512:
6
- metadata.gz: 9c7240c8f7a3a1b57e72bc6245a50d7089c82736e6e0259c2944ca623fbb6839f564064b75788c3b8fc60489393b0fdc65ff932410da878b2bb19bc16327c801
7
- data.tar.gz: 05405f8388fe9c817d4c2b59edde95f5ac6819112fa1f30f24d486a35a37c2dd417aad3f0c17f760e237f1d54681508e436cd1820f6b56eea5a1fc6ec77d1e96
6
+ metadata.gz: 828c3c2dfe74d72c92a717b68b1d79e4d9fddf1a73539f912b874895020b5254b8c64d630d391494099630d943cf3f6d47ca8b1726b6d783c8de509180096889
7
+ data.tar.gz: f0d3527e61e1b1b3bcf0f8f1e3da2a9ff63d39711238bb5c134f3826875949035256ed945a953335369c5db612255cee7703a8b302f51487dd1b97da8589de75
data/README.md CHANGED
@@ -24,23 +24,193 @@ Config lives at `~/.brainiac/github.json`:
24
24
  ```json
25
25
  {
26
26
  "webhook_secret": "your-github-webhook-secret",
27
+ "allowed_owners": ["stowzilla", "ardavis"],
28
+ "apps": {
29
+ "brainiac": {
30
+ "id": "100000",
31
+ "private_key_path": "~/.brainiac/brainiac.pem",
32
+ "installations": {
33
+ "stowzilla": "11111111",
34
+ "ardavis": "22222222"
35
+ }
36
+ },
37
+ "galen": {
38
+ "id": "200000",
39
+ "private_key_path": "~/.brainiac/galen-brainiac.pem",
40
+ "installations": {
41
+ "stowzilla": "33333333",
42
+ "ardavis": "44444444"
43
+ }
44
+ }
45
+ },
27
46
  "repos": {}
28
47
  }
29
48
  ```
30
49
 
50
+ The `allowed_owners` array restricts which GitHub accounts/orgs can trigger your
51
+ agents. Events from repos not owned by a listed account are rejected with a 403
52
+ before any processing occurs. If omitted, all owners are accepted (filtered
53
+ downstream by project matching instead).
54
+
31
55
  Generate a webhook secret:
32
56
 
33
57
  ```bash
34
58
  ruby -rsecurerandom -e 'puts SecureRandom.hex(20)'
35
59
  ```
36
60
 
61
+ ### GitHub App Setup
62
+
63
+ The plugin uses GitHub Apps for two purposes:
64
+
65
+ 1. **Inbound events** — a single "Brainiac" app receives webhook events from all repos
66
+ 2. **Outbound identity** — per-agent apps post comments/reactions with distinct avatars
67
+
68
+ This mirrors how Discord works: one gateway delivers all messages, each bot responds
69
+ with its own token and avatar.
70
+
71
+ #### Architecture
72
+
73
+ ```
74
+ brainiac (app) → webhook ACTIVE, receives all events
75
+ galen-brainiac (app) → webhook DISABLED, Galen's posting identity
76
+ glados-brainiac (app)→ webhook DISABLED, GLaDOS's posting identity
77
+ ```
78
+
79
+ The "brainiac" agent in the registry also uses the brainiac app as its posting
80
+ identity — so the webhook-receiver app doubles as the brainiac agent's identity.
81
+
82
+ #### Step 1: Create the Brainiac App (Webhook Receiver)
83
+
84
+ 1. Go to **Settings → Developer settings → GitHub Apps → New GitHub App**
85
+ 2. Set the following:
86
+ - **Name**: `brainiac` (or your preferred orchestrator name)
87
+ - **Homepage URL**: your Brainiac instance URL
88
+ - **Webhook URL**: `https://your-ngrok.ngrok-free.app/github`
89
+ - **Webhook secret**: paste the `webhook_secret` from your `github.json`
90
+ 3. Under **"Where can this GitHub App be installed?"**, select **"Any account"**
91
+ (allows installation on both personal accounts and organizations)
92
+ 4. Set **Permissions** (under "Repository permissions"):
93
+ - **Contents**: Read-only
94
+ - **Issues**: Read & Write
95
+ - **Pull requests**: Read & Write
96
+ 5. **Subscribe to events**:
97
+ - Issue comment
98
+ - Issues
99
+ - Pull request
100
+ - Pull request review
101
+ - Workflow run
102
+ 6. Create the app and note the **App ID**
103
+ 7. Generate a private key (see [Private Key Details](#private-key-details) below)
104
+ 8. Upload an avatar for the app
105
+ 9. **Install the app** on each account/org where you have repos:
106
+ - Go to the app's settings → "Install App" tab
107
+ - Install on your personal account (e.g. `ardavis`) — note the Installation ID
108
+ from the URL: `https://github.com/settings/installations/INSTALLATION_ID`
109
+ - Install on your org (e.g. `stowzilla`) — note that Installation ID too
110
+
111
+ #### Step 2: Create Agent Identity Apps (One Per Agent)
112
+
113
+ For each agent that should have its own avatar on PR comments:
114
+
115
+ 1. Go to **Settings → Developer settings → GitHub Apps → New GitHub App**
116
+ 2. Set the following:
117
+ - **Name**: `galen-brainiac`, `glados-brainiac`, etc.
118
+ - **Homepage URL**: your Brainiac instance URL
119
+ - **Webhook**: uncheck **"Active"** (these apps don't receive events)
120
+ 3. Under **"Where can this GitHub App be installed?"**, select **"Any account"**
121
+ 4. Set **Permissions** (under "Repository permissions"):
122
+ - **Contents**: Read-only
123
+ - **Issues**: Read & Write
124
+ - **Pull requests**: Read & Write
125
+ 5. **Do NOT subscribe to any events** (webhook is disabled)
126
+ 6. Create the app and note the **App ID**
127
+ 7. Generate a private key
128
+ 8. Upload the agent's **avatar**
129
+ 9. **Install the app** on each account/org (same as the brainiac app)
130
+ 10. Repeat for each agent
131
+
132
+ #### Step 3: Configure `github.json`
133
+
134
+ ```json
135
+ {
136
+ "webhook_secret": "your-github-webhook-secret",
137
+ "allowed_owners": ["stowzilla", "ardavis"],
138
+ "apps": {
139
+ "brainiac": {
140
+ "id": "100000",
141
+ "private_key_path": "~/.brainiac/brainiac.pem",
142
+ "installations": {
143
+ "stowzilla": "11111111",
144
+ "ardavis": "22222222"
145
+ }
146
+ },
147
+ "galen": {
148
+ "id": "200000",
149
+ "private_key_path": "~/.brainiac/galen-brainiac.pem",
150
+ "installations": {
151
+ "stowzilla": "33333333",
152
+ "ardavis": "44444444"
153
+ }
154
+ },
155
+ "glados": {
156
+ "id": "300000",
157
+ "private_key_path": "~/.brainiac/glados-brainiac.pem",
158
+ "installations": {
159
+ "stowzilla": "55555555",
160
+ "ardavis": "66666666"
161
+ }
162
+ }
163
+ },
164
+ "repos": {}
165
+ }
166
+ ```
167
+
168
+ Each key in `apps` matches the agent's registry key (lowercase). The
169
+ `installations` hash maps account/org names to their Installation IDs.
170
+
171
+ #### Private Key Details
172
+
173
+ The `.pem` file is generated by GitHub — you don't create it yourself. On your
174
+ app's settings page (Settings → Developer settings → GitHub Apps → your app):
175
+
176
+ 1. Scroll to the **"Private keys"** section
177
+ 2. Click **"Generate a private key"**
178
+ 3. GitHub generates an RSA key pair, keeps the public half, and your browser
179
+ downloads the private half as a `.pem` file (named like
180
+ `your-app-name.2026-08-06.private-key.pem`)
181
+ 4. Move it to your brainiac config directory and lock down permissions:
182
+ ```bash
183
+ mv ~/Downloads/your-app-name.*.private-key.pem ~/.brainiac/galen-brainiac.pem
184
+ chmod 600 ~/.brainiac/galen-brainiac.pem
185
+ ```
186
+
187
+ The plugin uses this key to sign JWTs for GitHub API authentication. Never
188
+ commit `.pem` files to version control.
189
+
190
+ #### Fallback
191
+
192
+ If no app credentials are configured for an agent, the plugin falls back to
193
+ using the `gh` CLI (which authenticates as your personal GitHub account).
194
+
195
+ ### Environment Variables
196
+
197
+ As an alternative to config file values, you can set (applies to single-app mode only):
198
+
199
+ - `GITHUB_WEBHOOK_SECRET` — webhook signature secret
200
+ - `GITHUB_APP_ID` — GitHub App ID
201
+ - `GITHUB_APP_PRIVATE_KEY_PATH` — path to the `.pem` private key file
202
+ - `GITHUB_APP_INSTALLATION_ID` — installation ID
203
+
204
+ For per-agent apps, use the config file — env vars don't support multiple apps.
205
+
37
206
  ### GitHub Webhook Setup
38
207
 
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
208
+ The webhook is configured on the Brainiac app itself (Step 1 above) — no
209
+ per-repo webhook setup is needed. The app-level webhook automatically receives
210
+ events from all repos where the app is installed.
211
+
212
+ If you have legacy per-repo webhooks pointing to `/github`, remove them to
213
+ avoid duplicate event deliveries.
44
214
 
45
215
  ## CLI
46
216
 
@@ -0,0 +1,206 @@
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
+ # Supports per-agent apps: if "apps" hash in config has an entry for the agent,
20
+ # that agent's app credentials are used (separate avatar/identity per agent).
21
+ # Falls back to shared "app" config, then to `gh` CLI.
22
+ module AppClient
23
+ GITHUB_API = "https://api.github.com"
24
+ TOKEN_EXPIRY_BUFFER = 60 # refresh token 60s before expiry
25
+
26
+ # Per-agent token cache: { agent_key_or_nil => { token:, expires_at: } }
27
+ @tokens = {}
28
+ @mutex = Mutex.new
29
+
30
+ class << self
31
+ # Returns true if GitHub App credentials are fully configured.
32
+ # Checks per-agent first, then shared app config.
33
+ # Guards against empty-string values (common in template configs).
34
+ def configured?(agent_key = nil)
35
+ id = Config.app_id(agent_key)
36
+ key_path = Config.private_key_path(agent_key)
37
+ inst_id = Config.installation_id(agent_key)
38
+
39
+ !!(id && !id.empty? && key_path && inst_id && !inst_id.empty?)
40
+ end
41
+
42
+ # POST a comment on an issue or PR.
43
+ #
44
+ # @param repo [String] "owner/repo"
45
+ # @param pr_number [Integer]
46
+ # @param body [String] comment markdown
47
+ # @param agent_key [String, nil] agent key for per-agent app identity
48
+ # @return [Hash] parsed response
49
+ def create_comment(repo, pr_number, body, agent_key: nil)
50
+ post("/repos/#{repo}/issues/#{pr_number}/comments", { body: body }, agent_key: agent_key)
51
+ end
52
+
53
+ # POST a reaction on an issue comment.
54
+ #
55
+ # @param repo [String] "owner/repo"
56
+ # @param comment_id [Integer]
57
+ # @param reaction [String] e.g. "eyes", "+1", "rocket"
58
+ # @param agent_key [String, nil] agent key for per-agent app identity
59
+ # @return [Hash] parsed response
60
+ def create_comment_reaction(repo, comment_id, reaction, agent_key: nil)
61
+ post("/repos/#{repo}/issues/comments/#{comment_id}/reactions", { content: reaction }, agent_key: agent_key)
62
+ end
63
+
64
+ # POST a reaction on a PR review.
65
+ #
66
+ # @param repo [String] "owner/repo"
67
+ # @param review_id [Integer]
68
+ # @param reaction [String]
69
+ # @param agent_key [String, nil] agent key for per-agent app identity
70
+ # @return [Hash] parsed response
71
+ def create_review_reaction(repo, review_id, reaction, agent_key: nil)
72
+ post("/repos/#{repo}/pulls/reviews/#{review_id}/reactions", { content: reaction }, agent_key: agent_key)
73
+ end
74
+
75
+ # GET request to GitHub API.
76
+ #
77
+ # @param path [String] API path (e.g. "/repos/owner/repo/pulls/1")
78
+ # @param agent_key [String, nil] agent key for per-agent app identity
79
+ # @return [Hash] parsed response
80
+ def get(path, agent_key: nil)
81
+ request(:get, path, agent_key: agent_key)
82
+ end
83
+
84
+ # POST request to GitHub API.
85
+ #
86
+ # @param path [String] API path
87
+ # @param body [Hash] request body
88
+ # @param agent_key [String, nil] agent key for per-agent app identity
89
+ # @return [Hash] parsed response
90
+ def post(path, body, agent_key: nil)
91
+ request(:post, path, body, agent_key: agent_key)
92
+ end
93
+
94
+ # Reset cached tokens (useful for testing or when credentials change).
95
+ def reset!
96
+ @mutex.synchronize do
97
+ @tokens = {}
98
+ end
99
+ end
100
+
101
+ # Public accessor for installation tokens (used to inject GH_TOKEN into agent env).
102
+ # Returns the raw token string, or nil on failure.
103
+ def installation_token_for(agent_key = nil, repo_owner: nil)
104
+ installation_token(agent_key, repo_owner: repo_owner)
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ def request(method, path, body = nil, agent_key: nil)
112
+ repo_owner = extract_repo_owner(path)
113
+ token = installation_token(agent_key, repo_owner: repo_owner)
114
+ uri = URI("#{GITHUB_API}#{path}")
115
+
116
+ http = Net::HTTP.new(uri.host, uri.port)
117
+ http.use_ssl = true
118
+ http.open_timeout = 10
119
+ http.read_timeout = 30
120
+
121
+ req = case method
122
+ when :get
123
+ Net::HTTP::Get.new(uri.request_uri)
124
+ when :post
125
+ Net::HTTP::Post.new(uri.request_uri)
126
+ end
127
+
128
+ req["Authorization"] = "Bearer #{token}"
129
+ req["Accept"] = "application/vnd.github+json"
130
+ req["X-GitHub-Api-Version"] = "2022-11-28"
131
+ req["User-Agent"] = "Brainiac-GitHub-App"
132
+ req.body = JSON.generate(body) if body
133
+ req.content_type = "application/json" if body
134
+
135
+ response = http.request(req)
136
+
137
+ raise "GitHub API error #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
138
+
139
+ JSON.parse(response.body)
140
+ end
141
+
142
+ # Extract the repo owner from an API path like "/repos/stowzilla/brainiac/pulls/1"
143
+ def extract_repo_owner(path)
144
+ match = path.match(%r{^/repos/([^/]+)/})
145
+ match&.[](1)
146
+ end
147
+
148
+ # Generate a short-lived JWT signed with the App's private key.
149
+ # Used to request an installation access token.
150
+ def generate_jwt(agent_key = nil)
151
+ private_key = OpenSSL::PKey::RSA.new(File.read(Config.private_key_path(agent_key)))
152
+ now = Time.now.to_i
153
+
154
+ payload = {
155
+ iat: now - 60, # issued at (60s clock drift allowance)
156
+ exp: now + (10 * 60), # expires in 10 minutes (max allowed)
157
+ iss: Config.app_id(agent_key)
158
+ }
159
+
160
+ JWT.encode(payload, private_key, "RS256")
161
+ end
162
+
163
+ # Fetch or return a cached installation access token.
164
+ # Tokens are valid for 1 hour; we refresh 60s early.
165
+ # Cache key includes agent_key and repo_owner for proper scoping.
166
+ def installation_token(agent_key = nil, repo_owner: nil)
167
+ inst_id = Config.installation_id(agent_key, repo_owner: repo_owner)
168
+ raise "No installation ID configured#{" for #{repo_owner}" if repo_owner}" unless inst_id
169
+
170
+ cache_key = "#{agent_key || "shared"}-#{inst_id}"
171
+
172
+ @mutex.synchronize do
173
+ cached = @tokens[cache_key]
174
+ return cached[:token] if cached && Time.now.to_i < cached[:expires_at]
175
+
176
+ jwt = generate_jwt(agent_key)
177
+ uri = URI("#{GITHUB_API}/app/installations/#{inst_id}/access_tokens")
178
+
179
+ http = Net::HTTP.new(uri.host, uri.port)
180
+ http.use_ssl = true
181
+ http.open_timeout = 10
182
+ http.read_timeout = 30
183
+
184
+ req = Net::HTTP::Post.new(uri.request_uri)
185
+ req["Authorization"] = "Bearer #{jwt}"
186
+ req["Accept"] = "application/vnd.github+json"
187
+ req["X-GitHub-Api-Version"] = "2022-11-28"
188
+ req["User-Agent"] = "Brainiac-GitHub-App"
189
+
190
+ response = http.request(req)
191
+
192
+ raise "Failed to get installation token: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
193
+
194
+ data = JSON.parse(response.body)
195
+ token = data["token"]
196
+ expires_at = Time.parse(data["expires_at"]).to_i - TOKEN_EXPIRY_BUFFER
197
+
198
+ @tokens[cache_key] = { token: token, expires_at: expires_at }
199
+ token
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ 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,8 +30,83 @@ module Brainiac
29
30
  @config["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
30
31
  end
31
32
 
33
+ # Per-agent app credentials from the "apps" hash.
34
+ # Falls back to the shared "app" config if no per-agent entry exists.
35
+
36
+ def app_id(agent_key = nil)
37
+ per_agent_value(agent_key, "id") ||
38
+ @config.dig("app", "id")&.to_s ||
39
+ ENV.fetch("GITHUB_APP_ID", nil)
40
+ end
41
+
42
+ def private_key_path(agent_key = nil)
43
+ path = per_agent_value(agent_key, "private_key_path") ||
44
+ @config.dig("app", "private_key_path") ||
45
+ ENV.fetch("GITHUB_APP_PRIVATE_KEY_PATH", nil)
46
+ return nil unless path
47
+
48
+ expanded = File.expand_path(path)
49
+ File.exist?(expanded) ? expanded : nil
50
+ end
51
+
52
+ def installation_id(agent_key = nil, repo_owner: nil)
53
+ # Check per-agent config first
54
+ if agent_key
55
+ normalized = agent_key.to_s.downcase
56
+ agent_conf = @config.dig("apps", normalized)
57
+ if agent_conf
58
+ # Per-agent may have multiple installations keyed by owner
59
+ if repo_owner && agent_conf["installations"]
60
+ return agent_conf.dig("installations", repo_owner)&.to_s || agent_conf["installation_id"]&.to_s
61
+ end
62
+
63
+ # Flat installation_id
64
+ return agent_conf["installation_id"]&.to_s if agent_conf["installation_id"]
65
+
66
+ # No repo_owner specified but installations hash exists — return first available
67
+ # (used by configured? check where repo_owner isn't known yet)
68
+ return agent_conf["installations"].values.first&.to_s if agent_conf["installations"]&.any?
69
+ end
70
+ end
71
+
72
+ # Shared app config — check installations hash first, then flat installation_id
73
+ if repo_owner && @config.dig("app", "installations")
74
+ found = @config.dig("app", "installations", repo_owner)&.to_s
75
+ return found if found
76
+ end
77
+
78
+ @config.dig("app", "installation_id")&.to_s ||
79
+ ENV.fetch("GITHUB_APP_INSTALLATION_ID", nil)
80
+ end
81
+
82
+ # Returns the agent key that should be used for a given context.
83
+ # If per-agent apps are configured and the agent has an entry, returns that key.
84
+ # Otherwise returns nil (use shared app).
85
+ def agent_app_configured?(agent_key)
86
+ return false unless agent_key
87
+
88
+ !!@config.dig("apps", agent_key.to_s.downcase)
89
+ end
90
+
91
+ # Check if a repo owner is in the allowed_owners list.
92
+ # If allowed_owners is not configured (empty/missing), all owners are allowed.
93
+ def owner_allowed?(owner)
94
+ allowed = @config["allowed_owners"]
95
+ return true unless allowed.is_a?(Array) && !allowed.empty?
96
+
97
+ allowed.include?(owner)
98
+ end
99
+
32
100
  private
33
101
 
102
+ def per_agent_value(agent_key, field)
103
+ return nil unless agent_key
104
+
105
+ # Agent names come in as display names ("Galen") but config keys are lowercase ("galen")
106
+ normalized = agent_key.to_s.downcase
107
+ @config.dig("apps", normalized, field)&.to_s
108
+ end
109
+
34
110
  def load_config
35
111
  return {} unless File.exist?(CONFIG_FILE)
36
112
 
@@ -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
 
@@ -135,6 +134,7 @@ module Brainiac
135
134
  comment_body = comment["body"] || ""
136
135
  comment_id = comment["id"]
137
136
  comment_user = comment.dig("user", "login")
137
+ comment_user_type = comment.dig("user", "type")
138
138
  repo_name = payload.dig("repository", "full_name")
139
139
 
140
140
  unless issue["pull_request"]
@@ -150,29 +150,37 @@ module Brainiac
150
150
 
151
151
  project_key, project_config = project_result
152
152
  pr_number = issue["number"]
153
+ agent_name = agent_name_for(project_config)
153
154
 
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"]
155
+ # Mention detection: if the comment mentions a specific agent, dispatch
156
+ # that agent instead of the project's default. This enables cross-agent
157
+ # reviews on PRs.
158
+ #
159
+ # Supported syntax (to avoid tagging real GitHub users with @):
160
+ # "Threepio, review this" — name + comma at the start
161
+ # "/ask Threepio review" — /ask Name
162
+ # "/Threepio review this" — /Name
163
+ # "@Threepio review this" — @Name (legacy, works but tags real users)
164
+ mentioned = detect_github_mention(comment_body)
165
+ if mentioned && mentioned.downcase != agent_name.downcase && local_agent_names.include?(mentioned)
166
+ LOG.info "[GitHub] Mention detected: #{mentioned} in PR comment (default agent: #{agent_name})"
167
+ agent_name = mentioned
168
+ end
157
169
 
170
+ # Ignore comments from this project's own bot to prevent self-triggering.
171
+ # Cross-agent bot comments (e.g. GLaDOS commenting on Galen's PR) are still processed.
172
+ if comment_user_type == "Bot" && own_bot_comment?(comment_user, agent_name)
173
+ LOG.info "Ignoring self-triggered bot comment from #{comment_user} (agent: #{agent_name})"
174
+ return [200, { status: "ignored", reason: "self-triggered bot comment" }.to_json]
175
+ end
176
+
177
+ branch = fetch_pr_branch(repo_name, pr_number, agent_name, project_config)
158
178
  result = find_work_item_by_branch(branch)
159
179
 
160
- if result
161
- _, card_info = result
162
- card_number = card_info["number"]
163
- worktree = card_info["worktree"]
180
+ card_number, worktree = resolve_comment_worktree(result, mentioned, agent_name, pr_number, project_config)
181
+ return worktree if worktree.is_a?(Array)
164
182
 
165
- unless worktree && File.directory?(worktree)
166
- LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
167
- return [200, { status: "ignored", reason: "no active worktree" }.to_json]
168
- end
169
-
170
- card_key = "card-#{card_number}"
171
- else
172
- card_number = nil
173
- worktree = project_config["repo_path"]
174
- card_key = "pr-#{repo_name.tr("/", "-")}-#{pr_number}"
175
- end
183
+ card_key = "pr-comment-#{repo_name.tr("/", "-")}-#{pr_number}-#{agent_name.downcase}"
176
184
 
177
185
  if session_active?(card_key)
178
186
  LOG.info "Skipping PR comment on #{card_key} — agent session already active"
@@ -182,7 +190,7 @@ module Brainiac
182
190
  card_context = card_number ? " for card ##{card_number}" : ""
183
191
  LOG.info "PR comment from #{comment_user} on PR ##{pr_number}#{card_context} (project: #{project_key})"
184
192
  dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
185
- repo_name, worktree, project_key, project_config)
193
+ repo_name, worktree, project_key, project_config, agent_name: agent_name)
186
194
 
187
195
  [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
188
196
  rescue StandardError => e
@@ -245,6 +253,132 @@ module Brainiac
245
253
  nil
246
254
  end
247
255
 
256
+ # Generate a GH_TOKEN env var for the agent process so `gh` CLI
257
+ # authenticates as the app bot instead of the user's personal account.
258
+ # Falls back to the shared "brainiac" app if the agent's own app isn't configured.
259
+ # Returns empty hash if no app credentials are available at all.
260
+ def github_agent_env(agent_name, repo_name)
261
+ # Try agent's own app first, fall back to shared "brainiac" app
262
+ effective_agent = if AppClient.configured?(agent_name)
263
+ agent_name
264
+ elsif AppClient.configured?("brainiac")
265
+ LOG.info "[GitHub] Agent #{agent_name} has no app configured, falling back to shared brainiac app"
266
+ "brainiac"
267
+ end
268
+ return {} unless effective_agent
269
+
270
+ repo_owner = repo_name.split("/").first
271
+ token = AppClient.installation_token_for(effective_agent, repo_owner: repo_owner)
272
+ return {} unless token
273
+
274
+ { "GH_TOKEN" => token }
275
+ rescue StandardError => e
276
+ LOG.warn "[GitHub] Could not generate agent token: #{e.message}"
277
+ {}
278
+ end
279
+
280
+ # Extract the card number from a work item info hash, supporting both
281
+ # the old flat format ("number") and new source-based format ("sources.fizzy.card_number").
282
+ def extract_card_number(card_info)
283
+ card_info["number"] || card_info.dig("sources", "fizzy", "card_number")
284
+ end
285
+
286
+ # Fetch the head branch name of a PR using the GitHub App or `gh` CLI.
287
+ def fetch_pr_branch(repo_name, pr_number, agent_name, project_config)
288
+ if AppClient.configured?(agent_name)
289
+ pr_response = AppClient.get("/repos/#{repo_name}/pulls/#{pr_number}", agent_key: agent_name)
290
+ pr_response.dig("head", "ref")
291
+ else
292
+ pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
293
+ chdir: project_config["repo_path"])
294
+ JSON.parse(pr_data)["branch"]
295
+ end
296
+ end
297
+
298
+ # Resolve the card number and worktree for a PR comment.
299
+ # Returns [card_number, worktree_path] on success, or a Rack response array
300
+ # (to be returned early) when the comment should be ignored.
301
+ def resolve_comment_worktree(result, mentioned, agent_name, pr_number, project_config)
302
+ return [nil, project_config["repo_path"]] unless result
303
+
304
+ _, card_info = result
305
+ card_number = extract_card_number(card_info)
306
+ worktree = card_info["worktree"]
307
+
308
+ is_mention = mentioned && mentioned.downcase == agent_name.downcase
309
+ if is_mention
310
+ worktree = project_config["repo_path"] unless worktree && File.directory?(worktree)
311
+ else
312
+ unless worktree && File.directory?(worktree)
313
+ LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
314
+ return [200, { status: "ignored", reason: "no active worktree" }.to_json]
315
+ end
316
+ end
317
+
318
+ [card_number, worktree]
319
+ end
320
+
321
+ # Detect an agent mention in a GitHub PR comment using syntax that
322
+ # avoids tagging real GitHub users.
323
+ #
324
+ # Supported patterns (case-insensitive):
325
+ # "Threepio, review this" — agent name + comma at the start of text
326
+ # "/ask Threepio review" — /ask followed by agent name
327
+ # "/Threepio review this" — slash followed by agent name
328
+ # "@threepio-brainiac review" — @name-brainiac (matches bot username pattern)
329
+ #
330
+ # Returns the display name of the matched agent, or nil.
331
+ def detect_github_mention(text)
332
+ return nil if text.nil? || text.strip.empty?
333
+
334
+ downcased = text.strip.downcase
335
+ agent_names = all_agent_names
336
+
337
+ agent_names.each do |name|
338
+ name_lower = name.downcase
339
+
340
+ # Pattern: "Name, ..." at the start (vocative)
341
+ return name if downcased.match?(/\A#{Regexp.escape(name_lower)}\s*,/)
342
+
343
+ # Pattern: "/ask Name ..."
344
+ return name if downcased.match?(%r{\A/ask\s+#{Regexp.escape(name_lower)}\b})
345
+
346
+ # Pattern: "/Name ..."
347
+ return name if downcased.match?(%r{\A/#{Regexp.escape(name_lower)}\b})
348
+
349
+ # Pattern: "@Name-brainiac ..." (matches the bot account naming convention)
350
+ return name if downcased.match?(/@#{Regexp.escape(name_lower)}-brainiac\b/)
351
+ end
352
+
353
+ nil
354
+ end
355
+
356
+ # Check if a bot comment is from the same agent that would handle this PR.
357
+ # e.g. "galen-brainiac[bot]" is a self-trigger for agent "Galen",
358
+ # but "glados-brainiac[bot]" is a cross-agent request and should be processed.
359
+ def own_bot_comment?(bot_login, agent_name)
360
+ # Bot logins are like "galen-brainiac[bot]" — check if it starts with the agent name
361
+ normalized_login = bot_login.to_s.downcase.delete_suffix("[bot]")
362
+ normalized_agent = agent_name.to_s.downcase
363
+ normalized_login.start_with?(normalized_agent)
364
+ end
365
+
366
+ # Extract PRs array from a work item, supporting both old flat format and new source-based format.
367
+ def extract_prs(card_info)
368
+ card_info.dig("sources", "github", "prs") || card_info["prs"] || []
369
+ end
370
+
371
+ # Store PRs in the correct location based on the work item format.
372
+ def store_prs(card_info, prs)
373
+ if card_info.key?("sources")
374
+ card_info["sources"] ||= {}
375
+ card_info["sources"]["github"] ||= {}
376
+ card_info["sources"]["github"]["prs"] = prs
377
+ else
378
+ card_info["prs"] = prs
379
+ end
380
+ end
381
+
248
382
  def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
249
383
  mark_work_item_merged(card_number)
250
384
  cleanup_work_item_worktrees(card_number, repo_path: repo_path,
@@ -270,28 +404,33 @@ module Brainiac
270
404
  end
271
405
 
272
406
  internal_id, card_info = result
273
- prs = card_info["prs"] || []
407
+ prs = extract_prs(card_info)
274
408
  return if prs.any? { |p| p["number"] == pr_number }
275
409
 
276
410
  prs << { "number" => pr_number, "url" => pr_url }
277
- card_info["prs"] = prs
411
+ store_prs(card_info, prs)
278
412
 
279
413
  map = load_work_item_map
280
414
  map[internal_id] = card_info
281
415
  save_work_item_map(map)
282
- LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
416
+ LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{extract_card_number(card_info)} (branch: #{branch})"
283
417
  end
284
418
 
285
419
  def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
286
- repo_name, worktree, project_key, project_config)
420
+ repo_name, worktree, project_key, project_config, agent_name: nil)
421
+ agent_name ||= agent_name_for(project_config)
422
+
287
423
  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)
424
+ if AppClient.configured?(agent_name)
425
+ AppClient.create_comment_reaction(repo_name, comment_id, "eyes", agent_key: agent_name)
426
+ else
427
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
428
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
429
+ end
290
430
  rescue StandardError => e
291
431
  LOG.warn "Could not add reaction to comment: #{e.message}"
292
432
  end
293
433
 
294
- agent_name = agent_name_for(project_config)
295
434
  prompt = render_prompt(Prompts::PR_COMMENT,
296
435
  { "CARD_NUMBER" => card_number || "PR-#{pr_number}",
297
436
  "CARD_ID" => card_number || "PR-#{pr_number}",
@@ -301,15 +440,18 @@ module Brainiac
301
440
  project_key: project_key, comment_body: comment_body),
302
441
  agent_name: agent_name, channel: :github)
303
442
 
304
- intent_ctx = fetch_pr_intent_context(pr_number, repo_name)
305
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree,
306
- log_name: "pr-comment-#{pr_number}",
307
- model: detect_model(project_config, text: comment_body),
308
- effort: detect_effort(project_config, text: comment_body),
309
- agent_name: agent_name, source: :github,
310
- source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree },
311
- message: comment_body, channel: "GitHub PR comment",
312
- context: intent_ctx)
443
+ intent_ctx = fetch_pr_intent_context(pr_number, repo_name, agent_name)
444
+ agent_env = github_agent_env(agent_name, repo_name)
445
+ run_agent_opts = { project_config: project_config, chdir: worktree,
446
+ log_name: "pr-comment-#{pr_number}",
447
+ model: detect_model(project_config, text: comment_body),
448
+ effort: detect_effort(project_config, text: comment_body),
449
+ agent_name: agent_name, source: :github,
450
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree },
451
+ message: comment_body, channel: "GitHub PR comment",
452
+ context: intent_ctx }
453
+ run_agent_opts[:env] = agent_env if method(:run_agent).parameters.flatten.include?(:env)
454
+ pid, log_file = run_agent(prompt, **run_agent_opts)
313
455
  return unless pid
314
456
 
315
457
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
@@ -318,18 +460,25 @@ module Brainiac
318
460
  def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
319
461
  repo_name, project_key, project_config, repo_path)
320
462
  review_id = review["id"]
463
+ agent_name = agent_name_for(project_config)
464
+
321
465
  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)
466
+ if AppClient.configured?(agent_name)
467
+ AppClient.create_review_reaction(repo_name, review_id, "eyes", agent_key: agent_name)
468
+ else
469
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
470
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
471
+ end
472
+
473
+ react_to_review_comments(review_id, pr_number, repo_name, repo_path, agent_name)
324
474
  rescue StandardError => e
325
475
  LOG.warn "Could not add reaction to review: #{e.message}"
326
476
  end
327
477
 
328
- agent_name = agent_name_for(project_config)
329
478
  Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
330
479
  agent_name: agent_name, project_config: project_config, repo_path: repo_path)
331
480
 
332
- review_context = build_review_context(reviewer, review, pr_number, repo_name)
481
+ review_context = build_review_context(reviewer, review, pr_number, repo_name, agent_name)
333
482
  worktree = card_info["worktree"]
334
483
  work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
335
484
 
@@ -342,23 +491,26 @@ module Brainiac
342
491
  project_key: project_key),
343
492
  agent_name: agent_name, channel: :github)
344
493
 
345
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir,
346
- log_name: "review-#{card_number || "pr-#{pr_number}"}",
347
- agent_name: agent_name,
348
- source: :github,
349
- source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir },
350
- message: review["body"], channel: "GitHub PR review",
351
- context: fetch_pr_intent_context(pr_number, repo_name))
494
+ agent_env = github_agent_env(agent_name, repo_name)
495
+ run_agent_opts = { project_config: project_config, chdir: work_dir,
496
+ log_name: "review-#{card_number || "pr-#{pr_number}"}",
497
+ agent_name: agent_name,
498
+ source: :github,
499
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir },
500
+ message: review["body"], channel: "GitHub PR review",
501
+ context: fetch_pr_intent_context(pr_number, repo_name, agent_name) }
502
+ run_agent_opts[:env] = agent_env if method(:run_agent).parameters.flatten.include?(:env)
503
+ pid, log_file = run_agent(prompt, **run_agent_opts)
352
504
  return unless pid
353
505
 
354
506
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
355
507
  end
356
508
 
357
- def build_review_context(reviewer, review, pr_number, repo_name)
509
+ def build_review_context(reviewer, review, pr_number, repo_name, agent_key = nil)
358
510
  context = "GitHub PR Review from @#{reviewer}:\n\n"
359
511
  context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
360
512
 
361
- review_comments = fetch_pr_review_comments(pr_number, repo_name)
513
+ review_comments = fetch_pr_review_comments(pr_number, repo_name, agent_key)
362
514
  if review_comments.any?
363
515
  context += "Line-specific comments:\n"
364
516
  review_comments.each do |comment|
@@ -368,25 +520,63 @@ module Brainiac
368
520
  context
369
521
  end
370
522
 
371
- 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) }
523
+ def fetch_pr_review_comments(pr_number, repo, agent_key = nil)
524
+ if AppClient.configured?(agent_key)
525
+ response = AppClient.get("/repos/#{repo}/pulls/#{pr_number}/comments", agent_key: agent_key)
526
+ # Response is an array of comment objects
527
+ response.map { |c| { "path" => c["path"], "line" => c["line"], "body" => c["body"], "user" => c.dig("user", "login") } }
528
+ else
529
+ output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments",
530
+ "--jq", ".[] | {path, line, body, user: .user.login}",
531
+ chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
532
+ output.lines.map { |line| JSON.parse(line) }
533
+ end
376
534
  rescue StandardError => e
377
535
  LOG.warn "Could not fetch PR review comments: #{e.message}"
378
536
  []
379
537
  end
380
538
 
539
+ # React with 👀 to each individual comment in a review submission.
540
+ # This makes reactions visible on line-level file comments, not just the review wrapper.
541
+ def react_to_review_comments(review_id, pr_number, repo_name, repo_path, agent_key = nil)
542
+ if AppClient.configured?(agent_key)
543
+ comments = AppClient.get("/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments", agent_key: agent_key)
544
+ comment_ids = comments.map { |c| c["id"] }
545
+ else
546
+ output = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments",
547
+ "--jq", ".[].id", chdir: repo_path)
548
+ comment_ids = output.lines.map(&:strip).reject(&:empty?)
549
+ end
550
+
551
+ comment_ids.each do |comment_id|
552
+ if AppClient.configured?(agent_key)
553
+ AppClient.create_comment_reaction(repo_name, comment_id, "eyes", agent_key: agent_key)
554
+ else
555
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/comments/#{comment_id}/reactions",
556
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
557
+ end
558
+ end
559
+ rescue StandardError => e
560
+ LOG.warn "Could not react to review comments: #{e.message}"
561
+ end
562
+
381
563
  # Lightweight recent PR comment context for intent classification.
382
564
  # Returns "author: message" format (last 5 issue comments on the PR).
383
- 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?
565
+ def fetch_pr_intent_context(pr_number, repo_name, agent_key = nil)
566
+ if AppClient.configured?(agent_key)
567
+ comments = AppClient.get("/repos/#{repo_name}/issues/#{pr_number}/comments", agent_key: agent_key)
568
+ entries = comments.last(5).map { |c| "#{c.dig("user", "login")}: #{c["body"]&.slice(0, 200)}" }
569
+ return nil if entries.empty?
570
+
571
+ entries.join("\n")
572
+ else
573
+ output = run_cmd("gh", "api", "/repos/#{repo_name}/issues/#{pr_number}/comments",
574
+ "--jq", ".[-5:] | .[] | \"\\(.user.login): \\(.body[0:200])\"",
575
+ chdir: PROJECTS.values.first&.dig("repo_path") || Dir.pwd)
576
+ return nil if output.strip.empty?
388
577
 
389
- output.strip
578
+ output.strip
579
+ end
390
580
  rescue StandardError => e
391
581
  LOG.warn "[GitHub] Could not fetch intent context for PR ##{pr_number}: #{e.message}" if defined?(LOG)
392
582
  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.2.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"
@@ -63,12 +64,17 @@ module Brainiac
63
64
 
64
65
  work_dir = source_context[:work_dir] || Dir.pwd
65
66
  agent_display = ctx[:agent_name] || "Agent"
67
+ agent_key = ctx[:agent_key]
66
68
  snippet = ctx[:snippet]
67
69
  snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
68
70
  comment_body = "💥 **#{agent_display} crashed** (exit code #{ctx[:exit_status]})\n\nLog: `#{ctx[:log_file]}`#{snippet_block}"
69
71
 
70
72
  begin
71
- run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
73
+ if AppClient.configured?(agent_key)
74
+ AppClient.create_comment(repo_name, pr_number, comment_body, agent_key: agent_key)
75
+ else
76
+ run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
77
+ end
72
78
  LOG.info "[GitHub] Posted crash comment on PR ##{pr_number}"
73
79
  rescue StandardError => e
74
80
  LOG.error "[GitHub] Failed to post crash comment: #{e.message}"
@@ -84,11 +90,23 @@ module Brainiac
84
90
  request.body.rewind
85
91
  payload_body = request.body.read
86
92
 
93
+ # Dedup: GitHub sends X-GitHub-Delivery as a unique ID per webhook delivery.
94
+ # Drop duplicates (can occur due to threading or retries).
95
+ delivery_id = request.env["HTTP_X_GITHUB_DELIVERY"]
96
+ if delivery_id && Brainiac::Plugins::Github.already_delivered?(delivery_id)
97
+ halt 200, { status: "ignored", reason: "duplicate delivery" }.to_json
98
+ end
99
+
87
100
  Brainiac::Plugins::Github.verify_signature!(request, payload_body)
88
101
 
89
102
  payload = JSON.parse(payload_body)
90
103
  event = request.env["HTTP_X_GITHUB_EVENT"]
91
104
 
105
+ repo_owner = payload.dig("repository", "owner", "login")
106
+ if repo_owner && !Brainiac::Plugins::Github::Config.owner_allowed?(repo_owner)
107
+ halt 403, { error: "Repository owner not in allowed_owners", owner: repo_owner }.to_json
108
+ end
109
+
92
110
  reload_projects!
93
111
  reload_agent_registry!
94
112
  Brainiac::Plugins::Github::Config.reload!
@@ -164,6 +182,21 @@ module Brainiac
164
182
  computed = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)}"
165
183
  halt 403, { error: "Invalid GitHub signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
166
184
  end
185
+
186
+ # Track recent delivery IDs to prevent duplicate processing.
187
+ # Thread-safe via Mutex. Keeps last 100 IDs.
188
+ @recent_deliveries = []
189
+ @delivery_mutex = Mutex.new
190
+
191
+ def self.already_delivered?(delivery_id)
192
+ @delivery_mutex.synchronize do
193
+ return true if @recent_deliveries.include?(delivery_id)
194
+
195
+ @recent_deliveries << delivery_id
196
+ @recent_deliveries.shift if @recent_deliveries.size > 100
197
+ false
198
+ end
199
+ end
167
200
  end
168
201
  end
169
202
  end
@@ -1,4 +1,11 @@
1
1
  {
2
- "webhook_secret": "your-github-webhook-secret",
2
+ "webhook_secret": "",
3
+ "allowed_owners": [],
4
+ "apps": {},
5
+ "app": {
6
+ "id": "",
7
+ "private_key_path": "~/.brainiac/github-app-private-key.pem",
8
+ "installation_id": ""
9
+ },
3
10
  "repos": {}
4
11
  }
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.2.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