brainiac-github 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ad5b0842a1e9731e535de2a742f69efa1a243e956d34d3b2a3ec8f04813ec96
4
- data.tar.gz: 4d01970f8f0ca48975fc66fb8197d0b94c16b0797eed937123af4c0639163168
3
+ metadata.gz: 15281b0cda1699aa7d7b959380a43bd992b9d7a1a8930eba464047b2ff959d05
4
+ data.tar.gz: a19b302521ae9d8711629e5b1785e3730a21fba35b01933fe7d1cbf78f77dd2c
5
5
  SHA512:
6
- metadata.gz: 4d637b12f74f47258614b8114efa550baac3ef11073c3dd4320edd637335e761aa5ec50601d4042c119d4ac988cc6ebbe26f34a0905071a2b205725b9749d7a7
7
- data.tar.gz: 279d75d005d2889eb5373b0161764cd82459ada1aab2e022c1449793a56bd0910592008f71a860909595e6da54bfe66de6349f8323a1510171feacb81d5815aa
6
+ metadata.gz: 0dce21620964ed25764860b9f697578579b9340f6e8dadbb8dc1751b8e41c70a99d3c0d3f1e4c7f9876ce2d4e415d06f369a38553cdeff368ac722f83acc8e5b
7
+ data.tar.gz: 4e38c8f2504dcd4f5bf0022a64debc513614d4073b846cfc6dab4ea3df249d9049a58a5c9518c1e40938877dd07ee54c7f0526097a97edd1482f6023114d9e30
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,51 @@ 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
 
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)
162
178
  result = find_work_item_by_branch(branch)
163
179
 
164
- if result
180
+ # If a work item exists and has an assigned agent, use that agent
181
+ # instead of the project default. This ensures comments on a PR
182
+ # route to whoever is actually working it (e.g. GLaDOS opened the PR,
183
+ # so GLaDOS should handle follow-up comments — not the project's default agent).
184
+ # Explicit mentions still take priority (already resolved above).
185
+ if result && !mentioned
165
186
  _, card_info = result
166
- card_number = extract_card_number(card_info)
167
- worktree = card_info["worktree"]
168
-
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]
187
+ work_item_agent = card_info["agent"]
188
+ if work_item_agent && work_item_agent.downcase != agent_name.downcase && local_agent_names.include?(work_item_agent)
189
+ LOG.info "[GitHub] Work item agent override: #{work_item_agent} (project default: #{agent_name})"
190
+ agent_name = work_item_agent
172
191
  end
173
-
174
- else
175
- card_number = nil
176
- worktree = project_config["repo_path"]
177
192
  end
178
- card_key = "pr-comment-#{repo_name.tr("/", "-")}-#{pr_number}"
193
+
194
+ card_number, worktree = resolve_comment_worktree(result, mentioned, agent_name, pr_number, project_config)
195
+ return worktree if worktree.is_a?(Array)
196
+
197
+ card_key = "pr-comment-#{repo_name.tr("/", "-")}-#{pr_number}-#{agent_name.downcase}"
179
198
 
180
199
  if session_active?(card_key)
181
200
  LOG.info "Skipping PR comment on #{card_key} — agent session already active"
@@ -185,7 +204,7 @@ module Brainiac
185
204
  card_context = card_number ? " for card ##{card_number}" : ""
186
205
  LOG.info "PR comment from #{comment_user} on PR ##{pr_number}#{card_context} (project: #{project_key})"
187
206
  dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
188
- repo_name, worktree, project_key, project_config)
207
+ repo_name, worktree, project_key, project_config, agent_name: agent_name)
189
208
 
190
209
  [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
191
210
  rescue StandardError => e
@@ -248,12 +267,128 @@ module Brainiac
248
267
  nil
249
268
  end
250
269
 
270
+ # Generate a GH_TOKEN env var for the agent process so `gh` CLI
271
+ # authenticates as the app bot instead of the user's personal account.
272
+ # Falls back to the shared "brainiac" app if the agent's own app isn't configured.
273
+ # Returns empty hash if no app credentials are available at all.
274
+ def github_agent_env(agent_name, repo_name)
275
+ # Try agent's own app first, fall back to shared "brainiac" app
276
+ effective_agent = if AppClient.configured?(agent_name)
277
+ agent_name
278
+ elsif AppClient.configured?("brainiac")
279
+ LOG.info "[GitHub] Agent #{agent_name} has no app configured, falling back to shared brainiac app"
280
+ "brainiac"
281
+ end
282
+ return {} unless effective_agent
283
+
284
+ repo_owner = repo_name.split("/").first
285
+ token = AppClient.installation_token_for(effective_agent, repo_owner: repo_owner)
286
+ return {} unless token
287
+
288
+ { "GH_TOKEN" => token }
289
+ rescue StandardError => e
290
+ LOG.warn "[GitHub] Could not generate agent token: #{e.message}"
291
+ {}
292
+ end
293
+
251
294
  # Extract the card number from a work item info hash, supporting both
252
295
  # the old flat format ("number") and new source-based format ("sources.fizzy.card_number").
253
296
  def extract_card_number(card_info)
254
297
  card_info["number"] || card_info.dig("sources", "fizzy", "card_number")
255
298
  end
256
299
 
300
+ # Resolve the agent name from a work item, falling back to the project default.
301
+ # Work items track which agent is assigned (e.g. GLaDOS opened the PR), so
302
+ # comments/reviews should route to that agent rather than the project default.
303
+ def resolve_work_item_agent(card_info, project_config)
304
+ work_item_agent = card_info["agent"] if card_info
305
+ if work_item_agent && local_agent_names.include?(work_item_agent)
306
+ work_item_agent
307
+ else
308
+ agent_name_for(project_config)
309
+ end
310
+ end
311
+
312
+ # Fetch the head branch name of a PR using the GitHub App or `gh` CLI.
313
+ def fetch_pr_branch(repo_name, pr_number, agent_name, project_config)
314
+ if AppClient.configured?(agent_name)
315
+ pr_response = AppClient.get("/repos/#{repo_name}/pulls/#{pr_number}", agent_key: agent_name)
316
+ pr_response.dig("head", "ref")
317
+ else
318
+ pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
319
+ chdir: project_config["repo_path"])
320
+ JSON.parse(pr_data)["branch"]
321
+ end
322
+ end
323
+
324
+ # Resolve the card number and worktree for a PR comment.
325
+ # Returns [card_number, worktree_path] on success, or a Rack response array
326
+ # (to be returned early) when the comment should be ignored.
327
+ def resolve_comment_worktree(result, mentioned, agent_name, pr_number, project_config)
328
+ return [nil, project_config["repo_path"]] unless result
329
+
330
+ _, card_info = result
331
+ card_number = extract_card_number(card_info)
332
+ worktree = card_info["worktree"]
333
+
334
+ is_mention = mentioned && mentioned.downcase == agent_name.downcase
335
+ if is_mention
336
+ worktree = project_config["repo_path"] unless worktree && File.directory?(worktree)
337
+ else
338
+ unless worktree && File.directory?(worktree)
339
+ LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
340
+ return [200, { status: "ignored", reason: "no active worktree" }.to_json]
341
+ end
342
+ end
343
+
344
+ [card_number, worktree]
345
+ end
346
+
347
+ # Detect an agent mention in a GitHub PR comment using syntax that
348
+ # avoids tagging real GitHub users.
349
+ #
350
+ # Supported patterns (case-insensitive):
351
+ # "Threepio, review this" — agent name + comma at the start of text
352
+ # "/ask Threepio review" — /ask followed by agent name
353
+ # "/Threepio review this" — slash followed by agent name
354
+ # "@threepio-brainiac review" — @name-brainiac (matches bot username pattern)
355
+ #
356
+ # Returns the display name of the matched agent, or nil.
357
+ def detect_github_mention(text)
358
+ return nil if text.nil? || text.strip.empty?
359
+
360
+ downcased = text.strip.downcase
361
+ agent_names = all_agent_names
362
+
363
+ agent_names.each do |name|
364
+ name_lower = name.downcase
365
+
366
+ # Pattern: "Name, ..." at the start (vocative)
367
+ return name if downcased.match?(/\A#{Regexp.escape(name_lower)}\s*,/)
368
+
369
+ # Pattern: "/ask Name ..."
370
+ return name if downcased.match?(%r{\A/ask\s+#{Regexp.escape(name_lower)}\b})
371
+
372
+ # Pattern: "/Name ..."
373
+ return name if downcased.match?(%r{\A/#{Regexp.escape(name_lower)}\b})
374
+
375
+ # Pattern: "@Name-brainiac ..." (matches the bot account naming convention)
376
+ return name if downcased.match?(/@#{Regexp.escape(name_lower)}-brainiac\b/)
377
+ end
378
+
379
+ nil
380
+ end
381
+
382
+ # Check if a bot comment is from the same agent that would handle this PR.
383
+ # e.g. "galen-brainiac[bot]" is a self-trigger for agent "Galen",
384
+ # but "glados-brainiac[bot]" is a cross-agent request and should be processed.
385
+ def own_bot_comment?(bot_login, agent_name)
386
+ # Bot logins are like "galen-brainiac[bot]" — check if it starts with the agent name
387
+ normalized_login = bot_login.to_s.downcase.delete_suffix("[bot]")
388
+ normalized_agent = agent_name.to_s.downcase
389
+ normalized_login.start_with?(normalized_agent)
390
+ end
391
+
257
392
  # Extract PRs array from a work item, supporting both old flat format and new source-based format.
258
393
  def extract_prs(card_info)
259
394
  card_info.dig("sources", "github", "prs") || card_info["prs"] || []
@@ -308,10 +443,12 @@ module Brainiac
308
443
  end
309
444
 
310
445
  def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
311
- repo_name, worktree, project_key, project_config)
446
+ repo_name, worktree, project_key, project_config, agent_name: nil)
447
+ agent_name ||= agent_name_for(project_config)
448
+
312
449
  Thread.new do
313
- if AppClient.configured?
314
- AppClient.create_comment_reaction(repo_name, comment_id, "eyes")
450
+ if AppClient.configured?(agent_name)
451
+ AppClient.create_comment_reaction(repo_name, comment_id, "eyes", agent_key: agent_name)
315
452
  else
316
453
  run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
317
454
  "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
@@ -320,7 +457,6 @@ module Brainiac
320
457
  LOG.warn "Could not add reaction to comment: #{e.message}"
321
458
  end
322
459
 
323
- agent_name = agent_name_for(project_config)
324
460
  prompt = render_prompt(Prompts::PR_COMMENT,
325
461
  { "CARD_NUMBER" => card_number || "PR-#{pr_number}",
326
462
  "CARD_ID" => card_number || "PR-#{pr_number}",
@@ -330,15 +466,18 @@ module Brainiac
330
466
  project_key: project_key, comment_body: comment_body),
331
467
  agent_name: agent_name, channel: :github)
332
468
 
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)
469
+ intent_ctx = fetch_pr_intent_context(pr_number, repo_name, agent_name)
470
+ agent_env = github_agent_env(agent_name, repo_name)
471
+ run_agent_opts = { project_config: project_config, chdir: worktree,
472
+ log_name: "pr-comment-#{pr_number}",
473
+ model: detect_model(project_config, text: comment_body),
474
+ effort: detect_effort(project_config, text: comment_body),
475
+ agent_name: agent_name, source: :github,
476
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree },
477
+ message: comment_body, channel: "GitHub PR comment",
478
+ context: intent_ctx }
479
+ run_agent_opts[:env] = agent_env if method(:run_agent).parameters.flatten.include?(:env)
480
+ pid, log_file = run_agent(prompt, **run_agent_opts)
342
481
  return unless pid
343
482
 
344
483
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
@@ -347,24 +486,25 @@ module Brainiac
347
486
  def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
348
487
  repo_name, project_key, project_config, repo_path)
349
488
  review_id = review["id"]
489
+ agent_name = resolve_work_item_agent(card_info, project_config)
490
+
350
491
  Thread.new do
351
- if AppClient.configured?
352
- AppClient.create_review_reaction(repo_name, review_id, "eyes")
492
+ if AppClient.configured?(agent_name)
493
+ AppClient.create_review_reaction(repo_name, review_id, "eyes", agent_key: agent_name)
353
494
  else
354
495
  run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
355
496
  "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
356
497
  end
357
498
 
358
- react_to_review_comments(review_id, pr_number, repo_name, repo_path)
499
+ react_to_review_comments(review_id, pr_number, repo_name, repo_path, agent_name)
359
500
  rescue StandardError => e
360
501
  LOG.warn "Could not add reaction to review: #{e.message}"
361
502
  end
362
503
 
363
- agent_name = agent_name_for(project_config)
364
504
  Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
365
505
  agent_name: agent_name, project_config: project_config, repo_path: repo_path)
366
506
 
367
- review_context = build_review_context(reviewer, review, pr_number, repo_name)
507
+ review_context = build_review_context(reviewer, review, pr_number, repo_name, agent_name)
368
508
  worktree = card_info["worktree"]
369
509
  work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
370
510
 
@@ -377,23 +517,26 @@ module Brainiac
377
517
  project_key: project_key),
378
518
  agent_name: agent_name, channel: :github)
379
519
 
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))
520
+ agent_env = github_agent_env(agent_name, repo_name)
521
+ run_agent_opts = { project_config: project_config, chdir: work_dir,
522
+ log_name: "review-#{card_number || "pr-#{pr_number}"}",
523
+ agent_name: agent_name,
524
+ source: :github,
525
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir },
526
+ message: review["body"], channel: "GitHub PR review",
527
+ context: fetch_pr_intent_context(pr_number, repo_name, agent_name) }
528
+ run_agent_opts[:env] = agent_env if method(:run_agent).parameters.flatten.include?(:env)
529
+ pid, log_file = run_agent(prompt, **run_agent_opts)
387
530
  return unless pid
388
531
 
389
532
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
390
533
  end
391
534
 
392
- def build_review_context(reviewer, review, pr_number, repo_name)
535
+ def build_review_context(reviewer, review, pr_number, repo_name, agent_key = nil)
393
536
  context = "GitHub PR Review from @#{reviewer}:\n\n"
394
537
  context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
395
538
 
396
- review_comments = fetch_pr_review_comments(pr_number, repo_name)
539
+ review_comments = fetch_pr_review_comments(pr_number, repo_name, agent_key)
397
540
  if review_comments.any?
398
541
  context += "Line-specific comments:\n"
399
542
  review_comments.each do |comment|
@@ -403,9 +546,9 @@ module Brainiac
403
546
  context
404
547
  end
405
548
 
406
- def fetch_pr_review_comments(pr_number, repo)
407
- if AppClient.configured?
408
- response = AppClient.get("/repos/#{repo}/pulls/#{pr_number}/comments")
549
+ def fetch_pr_review_comments(pr_number, repo, agent_key = nil)
550
+ if AppClient.configured?(agent_key)
551
+ response = AppClient.get("/repos/#{repo}/pulls/#{pr_number}/comments", agent_key: agent_key)
409
552
  # Response is an array of comment objects
410
553
  response.map { |c| { "path" => c["path"], "line" => c["line"], "body" => c["body"], "user" => c.dig("user", "login") } }
411
554
  else
@@ -421,9 +564,9 @@ module Brainiac
421
564
 
422
565
  # React with 👀 to each individual comment in a review submission.
423
566
  # 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")
567
+ def react_to_review_comments(review_id, pr_number, repo_name, repo_path, agent_key = nil)
568
+ if AppClient.configured?(agent_key)
569
+ comments = AppClient.get("/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments", agent_key: agent_key)
427
570
  comment_ids = comments.map { |c| c["id"] }
428
571
  else
429
572
  output = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}/reviews/#{review_id}/comments",
@@ -432,8 +575,8 @@ module Brainiac
432
575
  end
433
576
 
434
577
  comment_ids.each do |comment_id|
435
- if AppClient.configured?
436
- AppClient.create_comment_reaction(repo_name, comment_id, "eyes")
578
+ if AppClient.configured?(agent_key)
579
+ AppClient.create_comment_reaction(repo_name, comment_id, "eyes", agent_key: agent_key)
437
580
  else
438
581
  run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/comments/#{comment_id}/reactions",
439
582
  "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
@@ -445,9 +588,9 @@ module Brainiac
445
588
 
446
589
  # Lightweight recent PR comment context for intent classification.
447
590
  # 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")
591
+ def fetch_pr_intent_context(pr_number, repo_name, agent_key = nil)
592
+ if AppClient.configured?(agent_key)
593
+ comments = AppClient.get("/repos/#{repo_name}/issues/#{pr_number}/comments", agent_key: agent_key)
451
594
  entries = comments.last(5).map { |c| "#{c.dig("user", "login")}: #{c["body"]&.slice(0, 200)}" }
452
595
  return nil if entries.empty?
453
596
 
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis