brainiac-github 0.1.0 → 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: 7ad5b0842a1e9731e535de2a742f69efa1a243e956d34d3b2a3ec8f04813ec96
4
- data.tar.gz: 4d01970f8f0ca48975fc66fb8197d0b94c16b0797eed937123af4c0639163168
3
+ metadata.gz: 48b4b58174666e2efbd528ac3cf650cad735f25d84eedabffe4d201cdbe2acac
4
+ data.tar.gz: fd097926d92d623f56c7b5c26e7aa88726e952eb6ba21f1392bbecadaaf8f55e
5
5
  SHA512:
6
- metadata.gz: 4d637b12f74f47258614b8114efa550baac3ef11073c3dd4320edd637335e761aa5ec50601d4042c119d4ac988cc6ebbe26f34a0905071a2b205725b9749d7a7
7
- data.tar.gz: 279d75d005d2889eb5373b0161764cd82459ada1aab2e022c1449793a56bd0910592008f71a860909595e6da54bfe66de6349f8323a1510171feacb81d5815aa
6
+ metadata.gz: 828c3c2dfe74d72c92a717b68b1d79e4d9fddf1a73539f912b874895020b5254b8c64d630d391494099630d943cf3f6d47ca8b1726b6d783c8de509180096889
7
+ data.tar.gz: f0d3527e61e1b1b3bcf0f8f1e3da2a9ff63d39711238bb5c134f3826875949035256ed945a953335369c5db612255cee7703a8b302f51487dd1b97da8589de75
data/README.md CHANGED
@@ -24,71 +24,193 @@ 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"
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
+ }
31
45
  },
32
46
  "repos": {}
33
47
  }
34
48
  ```
35
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
+
36
55
  Generate a webhook secret:
37
56
 
38
57
  ```bash
39
58
  ruby -rsecurerandom -e 'puts SecureRandom.hex(20)'
40
59
  ```
41
60
 
42
- ### GitHub App Setup (Recommended)
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
+ ```
43
78
 
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.
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)
47
83
 
48
84
  1. Go to **Settings → Developer settings → GitHub Apps → New GitHub App**
49
85
  2. Set the following:
50
- - **Name**: e.g. `brainiac-bot`
86
+ - **Name**: `brainiac` (or your preferred orchestrator name)
51
87
  - **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
- }
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
71
185
  ```
72
186
 
73
- If app credentials are not configured, the plugin falls back to using the `gh` CLI
74
- (which authenticates as your personal GitHub account).
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).
75
194
 
76
195
  ### Environment Variables
77
196
 
78
- As an alternative to config file values, you can set:
197
+ As an alternative to config file values, you can set (applies to single-app mode only):
79
198
 
80
199
  - `GITHUB_WEBHOOK_SECRET` — webhook signature secret
81
200
  - `GITHUB_APP_ID` — GitHub App ID
82
201
  - `GITHUB_APP_PRIVATE_KEY_PATH` — path to the `.pem` private key file
83
202
  - `GITHUB_APP_INSTALLATION_ID` — installation ID
84
203
 
204
+ For per-agent apps, use the config file — env vars don't support multiple apps.
205
+
85
206
  ### GitHub Webhook Setup
86
207
 
87
- 1. Go to your repo Settings Webhooks Add webhook
88
- 2. Payload URL: `https://your-ngrok.ngrok-free.app/github`
89
- 3. Content type: `application/json`
90
- 4. Secret: paste your `webhook_secret`
91
- 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.
92
214
 
93
215
  ## CLI
94
216
 
@@ -16,19 +16,27 @@ module Brainiac
16
16
  # API calls are made as the App's bot user — so PR comments, reactions, etc.
17
17
  # appear with the App's identity rather than a personal user.
18
18
  #
19
- # Falls back to `gh` CLI when app credentials are not configured.
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.
20
22
  module AppClient
21
23
  GITHUB_API = "https://api.github.com"
22
24
  TOKEN_EXPIRY_BUFFER = 60 # refresh token 60s before expiry
23
25
 
24
- @token = nil
25
- @token_expires_at = nil
26
+ # Per-agent token cache: { agent_key_or_nil => { token:, expires_at: } }
27
+ @tokens = {}
26
28
  @mutex = Mutex.new
27
29
 
28
30
  class << self
29
31
  # Returns true if GitHub App credentials are fully configured.
30
- def configured?
31
- !!(Config.app_id && Config.private_key_path && Config.installation_id)
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?)
32
40
  end
33
41
 
34
42
  # POST a comment on an issue or PR.
@@ -36,9 +44,10 @@ module Brainiac
36
44
  # @param repo [String] "owner/repo"
37
45
  # @param pr_number [Integer]
38
46
  # @param body [String] comment markdown
47
+ # @param agent_key [String, nil] agent key for per-agent app identity
39
48
  # @return [Hash] parsed response
40
- def create_comment(repo, pr_number, body)
41
- post("/repos/#{repo}/issues/#{pr_number}/comments", { body: body })
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)
42
51
  end
43
52
 
44
53
  # POST a reaction on an issue comment.
@@ -46,9 +55,10 @@ module Brainiac
46
55
  # @param repo [String] "owner/repo"
47
56
  # @param comment_id [Integer]
48
57
  # @param reaction [String] e.g. "eyes", "+1", "rocket"
58
+ # @param agent_key [String, nil] agent key for per-agent app identity
49
59
  # @return [Hash] parsed response
50
- def create_comment_reaction(repo, comment_id, reaction)
51
- post("/repos/#{repo}/issues/comments/#{comment_id}/reactions", { content: reaction })
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)
52
62
  end
53
63
 
54
64
  # POST a reaction on a PR review.
@@ -56,40 +66,51 @@ module Brainiac
56
66
  # @param repo [String] "owner/repo"
57
67
  # @param review_id [Integer]
58
68
  # @param reaction [String]
69
+ # @param agent_key [String, nil] agent key for per-agent app identity
59
70
  # @return [Hash] parsed response
60
- def create_review_reaction(repo, review_id, reaction)
61
- post("/repos/#{repo}/pulls/reviews/#{review_id}/reactions", { content: reaction })
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)
62
73
  end
63
74
 
64
75
  # GET request to GitHub API.
65
76
  #
66
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
67
79
  # @return [Hash] parsed response
68
- def get(path)
69
- request(:get, path)
80
+ def get(path, agent_key: nil)
81
+ request(:get, path, agent_key: agent_key)
70
82
  end
71
83
 
72
84
  # POST request to GitHub API.
73
85
  #
74
86
  # @param path [String] API path
75
87
  # @param body [Hash] request body
88
+ # @param agent_key [String, nil] agent key for per-agent app identity
76
89
  # @return [Hash] parsed response
77
- def post(path, body)
78
- request(:post, path, body)
90
+ def post(path, body, agent_key: nil)
91
+ request(:post, path, body, agent_key: agent_key)
79
92
  end
80
93
 
81
- # Reset cached token (useful for testing or when credentials change).
94
+ # Reset cached tokens (useful for testing or when credentials change).
82
95
  def reset!
83
96
  @mutex.synchronize do
84
- @token = nil
85
- @token_expires_at = nil
97
+ @tokens = {}
86
98
  end
87
99
  end
88
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
+
89
109
  private
90
110
 
91
- def request(method, path, body = nil)
92
- token = installation_token
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)
93
114
  uri = URI("#{GITHUB_API}#{path}")
94
115
 
95
116
  http = Net::HTTP.new(uri.host, uri.port)
@@ -118,16 +139,22 @@ module Brainiac
118
139
  JSON.parse(response.body)
119
140
  end
120
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
+
121
148
  # Generate a short-lived JWT signed with the App's private key.
122
149
  # Used to request an installation access token.
123
- def generate_jwt
124
- private_key = OpenSSL::PKey::RSA.new(File.read(Config.private_key_path))
150
+ def generate_jwt(agent_key = nil)
151
+ private_key = OpenSSL::PKey::RSA.new(File.read(Config.private_key_path(agent_key)))
125
152
  now = Time.now.to_i
126
153
 
127
154
  payload = {
128
155
  iat: now - 60, # issued at (60s clock drift allowance)
129
156
  exp: now + (10 * 60), # expires in 10 minutes (max allowed)
130
- iss: Config.app_id
157
+ iss: Config.app_id(agent_key)
131
158
  }
132
159
 
133
160
  JWT.encode(payload, private_key, "RS256")
@@ -135,12 +162,19 @@ module Brainiac
135
162
 
136
163
  # Fetch or return a cached installation access token.
137
164
  # Tokens are valid for 1 hour; we refresh 60s early.
138
- def installation_token
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
+
139
172
  @mutex.synchronize do
140
- return @token if @token && @token_expires_at && Time.now.to_i < @token_expires_at
173
+ cached = @tokens[cache_key]
174
+ return cached[:token] if cached && Time.now.to_i < cached[:expires_at]
141
175
 
142
- jwt = generate_jwt
143
- uri = URI("#{GITHUB_API}/app/installations/#{Config.installation_id}/access_tokens")
176
+ jwt = generate_jwt(agent_key)
177
+ uri = URI("#{GITHUB_API}/app/installations/#{inst_id}/access_tokens")
144
178
 
145
179
  http = Net::HTTP.new(uri.host, uri.port)
146
180
  http.use_ssl = true
@@ -158,11 +192,11 @@ module Brainiac
158
192
  raise "Failed to get installation token: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
159
193
 
160
194
  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
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
166
200
  end
167
201
  end
168
202
  end
@@ -30,26 +30,83 @@ module Brainiac
30
30
  @config["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
31
31
  end
32
32
 
33
- # GitHub App credentials all three must be present for App auth to work.
33
+ # Per-agent app credentials from the "apps" hash.
34
+ # Falls back to the shared "app" config if no per-agent entry exists.
34
35
 
35
- def app_id
36
- @config.dig("app", "id")&.to_s || ENV.fetch("GITHUB_APP_ID", nil)
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)
37
40
  end
38
41
 
39
- def private_key_path
40
- path = @config.dig("app", "private_key_path") || ENV.fetch("GITHUB_APP_PRIVATE_KEY_PATH", nil)
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)
41
46
  return nil unless path
42
47
 
43
48
  expanded = File.expand_path(path)
44
49
  File.exist?(expanded) ? expanded : nil
45
50
  end
46
51
 
47
- def installation_id
48
- @config.dig("app", "installation_id")&.to_s || ENV.fetch("GITHUB_APP_INSTALLATION_ID", nil)
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)
49
98
  end
50
99
 
51
100
  private
52
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
+
53
110
  def load_config
54
111
  return {} unless File.exist?(CONFIG_FILE)
55
112
 
@@ -134,6 +134,7 @@ module Brainiac
134
134
  comment_body = comment["body"] || ""
135
135
  comment_id = comment["id"]
136
136
  comment_user = comment.dig("user", "login")
137
+ comment_user_type = comment.dig("user", "type")
137
138
  repo_name = payload.dig("repository", "full_name")
138
139
 
139
140
  unless issue["pull_request"]
@@ -149,33 +150,37 @@ module Brainiac
149
150
 
150
151
  project_key, project_config = project_result
151
152
  pr_number = issue["number"]
153
+ agent_name = agent_name_for(project_config)
152
154
 
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"]
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
160
168
  end
161
169
 
162
- result = find_work_item_by_branch(branch)
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
163
176
 
164
- if result
165
- _, card_info = result
166
- card_number = extract_card_number(card_info)
167
- worktree = card_info["worktree"]
177
+ branch = fetch_pr_branch(repo_name, pr_number, agent_name, project_config)
178
+ result = find_work_item_by_branch(branch)
168
179
 
169
- unless worktree && File.directory?(worktree)
170
- LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
171
- return [200, { status: "ignored", reason: "no active worktree" }.to_json]
172
- end
180
+ card_number, worktree = resolve_comment_worktree(result, mentioned, agent_name, pr_number, project_config)
181
+ return worktree if worktree.is_a?(Array)
173
182
 
174
- else
175
- card_number = nil
176
- worktree = project_config["repo_path"]
177
- end
178
- card_key = "pr-comment-#{repo_name.tr("/", "-")}-#{pr_number}"
183
+ card_key = "pr-comment-#{repo_name.tr("/", "-")}-#{pr_number}-#{agent_name.downcase}"
179
184
 
180
185
  if session_active?(card_key)
181
186
  LOG.info "Skipping PR comment on #{card_key} — agent session already active"
@@ -185,7 +190,7 @@ module Brainiac
185
190
  card_context = card_number ? " for card ##{card_number}" : ""
186
191
  LOG.info "PR comment from #{comment_user} on PR ##{pr_number}#{card_context} (project: #{project_key})"
187
192
  dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
188
- repo_name, worktree, project_key, project_config)
193
+ repo_name, worktree, project_key, project_config, agent_name: agent_name)
189
194
 
190
195
  [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
191
196
  rescue StandardError => e
@@ -248,12 +253,116 @@ module Brainiac
248
253
  nil
249
254
  end
250
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
+
251
280
  # Extract the card number from a work item info hash, supporting both
252
281
  # the old flat format ("number") and new source-based format ("sources.fizzy.card_number").
253
282
  def extract_card_number(card_info)
254
283
  card_info["number"] || card_info.dig("sources", "fizzy", "card_number")
255
284
  end
256
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
+
257
366
  # Extract PRs array from a work item, supporting both old flat format and new source-based format.
258
367
  def extract_prs(card_info)
259
368
  card_info.dig("sources", "github", "prs") || card_info["prs"] || []
@@ -308,10 +417,12 @@ module Brainiac
308
417
  end
309
418
 
310
419
  def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
311
- 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
+
312
423
  Thread.new do
313
- if AppClient.configured?
314
- AppClient.create_comment_reaction(repo_name, comment_id, "eyes")
424
+ if AppClient.configured?(agent_name)
425
+ AppClient.create_comment_reaction(repo_name, comment_id, "eyes", agent_key: agent_name)
315
426
  else
316
427
  run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
317
428
  "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
@@ -320,7 +431,6 @@ module Brainiac
320
431
  LOG.warn "Could not add reaction to comment: #{e.message}"
321
432
  end
322
433
 
323
- agent_name = agent_name_for(project_config)
324
434
  prompt = render_prompt(Prompts::PR_COMMENT,
325
435
  { "CARD_NUMBER" => card_number || "PR-#{pr_number}",
326
436
  "CARD_ID" => card_number || "PR-#{pr_number}",
@@ -330,15 +440,18 @@ module Brainiac
330
440
  project_key: project_key, comment_body: comment_body),
331
441
  agent_name: agent_name, channel: :github)
332
442
 
333
- intent_ctx = fetch_pr_intent_context(pr_number, repo_name)
334
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree,
335
- log_name: "pr-comment-#{pr_number}",
336
- model: detect_model(project_config, text: comment_body),
337
- effort: detect_effort(project_config, text: comment_body),
338
- agent_name: agent_name, source: :github,
339
- source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree },
340
- message: comment_body, channel: "GitHub PR comment",
341
- 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)
342
455
  return unless pid
343
456
 
344
457
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
@@ -347,24 +460,25 @@ module Brainiac
347
460
  def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
348
461
  repo_name, project_key, project_config, repo_path)
349
462
  review_id = review["id"]
463
+ agent_name = agent_name_for(project_config)
464
+
350
465
  Thread.new do
351
- if AppClient.configured?
352
- AppClient.create_review_reaction(repo_name, review_id, "eyes")
466
+ if AppClient.configured?(agent_name)
467
+ AppClient.create_review_reaction(repo_name, review_id, "eyes", agent_key: agent_name)
353
468
  else
354
469
  run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
355
470
  "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
356
471
  end
357
472
 
358
- react_to_review_comments(review_id, pr_number, repo_name, repo_path)
473
+ react_to_review_comments(review_id, pr_number, repo_name, repo_path, agent_name)
359
474
  rescue StandardError => e
360
475
  LOG.warn "Could not add reaction to review: #{e.message}"
361
476
  end
362
477
 
363
- agent_name = agent_name_for(project_config)
364
478
  Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
365
479
  agent_name: agent_name, project_config: project_config, repo_path: repo_path)
366
480
 
367
- 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)
368
482
  worktree = card_info["worktree"]
369
483
  work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
370
484
 
@@ -377,23 +491,26 @@ module Brainiac
377
491
  project_key: project_key),
378
492
  agent_name: agent_name, channel: :github)
379
493
 
380
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir,
381
- log_name: "review-#{card_number || "pr-#{pr_number}"}",
382
- agent_name: agent_name,
383
- source: :github,
384
- source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir },
385
- message: review["body"], channel: "GitHub PR review",
386
- 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)
387
504
  return unless pid
388
505
 
389
506
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
390
507
  end
391
508
 
392
- def build_review_context(reviewer, review, pr_number, repo_name)
509
+ def build_review_context(reviewer, review, pr_number, repo_name, agent_key = nil)
393
510
  context = "GitHub PR Review from @#{reviewer}:\n\n"
394
511
  context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
395
512
 
396
- review_comments = fetch_pr_review_comments(pr_number, repo_name)
513
+ review_comments = fetch_pr_review_comments(pr_number, repo_name, agent_key)
397
514
  if review_comments.any?
398
515
  context += "Line-specific comments:\n"
399
516
  review_comments.each do |comment|
@@ -403,9 +520,9 @@ module Brainiac
403
520
  context
404
521
  end
405
522
 
406
- def fetch_pr_review_comments(pr_number, repo)
407
- if AppClient.configured?
408
- response = AppClient.get("/repos/#{repo}/pulls/#{pr_number}/comments")
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)
409
526
  # Response is an array of comment objects
410
527
  response.map { |c| { "path" => c["path"], "line" => c["line"], "body" => c["body"], "user" => c.dig("user", "login") } }
411
528
  else
@@ -421,9 +538,9 @@ module Brainiac
421
538
 
422
539
  # React with 👀 to each individual comment in a review submission.
423
540
  # 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")
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)
427
544
  comment_ids = comments.map { |c| c["id"] }
428
545
  else
429
546
  output = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments",
@@ -432,8 +549,8 @@ module Brainiac
432
549
  end
433
550
 
434
551
  comment_ids.each do |comment_id|
435
- if AppClient.configured?
436
- AppClient.create_comment_reaction(repo_name, comment_id, "eyes")
552
+ if AppClient.configured?(agent_key)
553
+ AppClient.create_comment_reaction(repo_name, comment_id, "eyes", agent_key: agent_key)
437
554
  else
438
555
  run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/comments/#{comment_id}/reactions",
439
556
  "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
@@ -445,9 +562,9 @@ module Brainiac
445
562
 
446
563
  # Lightweight recent PR comment context for intent classification.
447
564
  # Returns "author: message" format (last 5 issue comments on the PR).
448
- def fetch_pr_intent_context(pr_number, repo_name)
449
- if AppClient.configured?
450
- comments = AppClient.get("/repos/#{repo_name}/issues/#{pr_number}/comments")
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)
451
568
  entries = comments.last(5).map { |c| "#{c.dig("user", "login")}: #{c["body"]&.slice(0, 200)}" }
452
569
  return nil if entries.empty?
453
570
 
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Github
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
@@ -64,13 +64,14 @@ module Brainiac
64
64
 
65
65
  work_dir = source_context[:work_dir] || Dir.pwd
66
66
  agent_display = ctx[:agent_name] || "Agent"
67
+ agent_key = ctx[:agent_key]
67
68
  snippet = ctx[:snippet]
68
69
  snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
69
70
  comment_body = "💥 **#{agent_display} crashed** (exit code #{ctx[:exit_status]})\n\nLog: `#{ctx[:log_file]}`#{snippet_block}"
70
71
 
71
72
  begin
72
- if AppClient.configured?
73
- AppClient.create_comment(repo_name, pr_number, comment_body)
73
+ if AppClient.configured?(agent_key)
74
+ AppClient.create_comment(repo_name, pr_number, comment_body, agent_key: agent_key)
74
75
  else
75
76
  run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
76
77
  end
@@ -89,11 +90,23 @@ module Brainiac
89
90
  request.body.rewind
90
91
  payload_body = request.body.read
91
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
+
92
100
  Brainiac::Plugins::Github.verify_signature!(request, payload_body)
93
101
 
94
102
  payload = JSON.parse(payload_body)
95
103
  event = request.env["HTTP_X_GITHUB_EVENT"]
96
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
+
97
110
  reload_projects!
98
111
  reload_agent_registry!
99
112
  Brainiac::Plugins::Github::Config.reload!
@@ -169,6 +182,21 @@ module Brainiac
169
182
  computed = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)}"
170
183
  halt 403, { error: "Invalid GitHub signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
171
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
172
200
  end
173
201
  end
174
202
  end
@@ -1,5 +1,7 @@
1
1
  {
2
- "webhook_secret": "your-github-webhook-secret",
2
+ "webhook_secret": "",
3
+ "allowed_owners": [],
4
+ "apps": {},
3
5
  "app": {
4
6
  "id": "",
5
7
  "private_key_path": "~/.brainiac/github-app-private-key.pem",
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis