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