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 +4 -4
- data/README.md +158 -36
- data/lib/brainiac/plugins/github/app_client.rb +66 -32
- data/lib/brainiac/plugins/github/config.rb +64 -7
- data/lib/brainiac/plugins/github/handler.rb +177 -60
- data/lib/brainiac/plugins/github/version.rb +1 -1
- data/lib/brainiac/plugins/github.rb +30 -2
- data/templates/github.json.example +3 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48b4b58174666e2efbd528ac3cf650cad735f25d84eedabffe4d201cdbe2acac
|
|
4
|
+
data.tar.gz: fd097926d92d623f56c7b5c26e7aa88726e952eb6ba21f1392bbecadaaf8f55e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
"
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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**:
|
|
86
|
+
- **Name**: `brainiac` (or your preferred orchestrator name)
|
|
51
87
|
- **Homepage URL**: your Brainiac instance URL
|
|
52
|
-
- **Webhook URL**:
|
|
53
|
-
- **
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- **
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
5.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
25
|
-
@
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
94
|
+
# Reset cached tokens (useful for testing or when credentials change).
|
|
82
95
|
def reset!
|
|
83
96
|
@mutex.synchronize do
|
|
84
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/#{
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@
|
|
165
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
|
@@ -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
|