brainiac-featureparity 0.1.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 +7 -0
- data/README.md +135 -0
- data/lib/brainiac/plugins/featureparity/cli.rb +136 -0
- data/lib/brainiac/plugins/featureparity/config.rb +146 -0
- data/lib/brainiac/plugins/featureparity/handlers.rb +510 -0
- data/lib/brainiac/plugins/featureparity/metadata.rb +22 -0
- data/lib/brainiac/plugins/featureparity/prompts.rb +242 -0
- data/lib/brainiac/plugins/featureparity/signature.rb +89 -0
- data/lib/brainiac/plugins/featureparity/version.rb +9 -0
- data/lib/brainiac/plugins/featureparity.rb +156 -0
- data/lib/brainiac_featureparity.rb +4 -0
- metadata +121 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Featureparity
|
|
6
|
+
# Prompt templates for FeatureParity agent dispatches.
|
|
7
|
+
module Prompts
|
|
8
|
+
# Channel-specific prompt for FeatureParity interactions.
|
|
9
|
+
# Injected into the agent prompt when dispatching for FP events.
|
|
10
|
+
CHANNEL = <<~PROMPT
|
|
11
|
+
## FeatureParity Channel Rules
|
|
12
|
+
|
|
13
|
+
You're responding to a FeatureParity webhook event. FeatureParity tracks requirements
|
|
14
|
+
and their test coverage across surfaces.
|
|
15
|
+
|
|
16
|
+
### FP Environment
|
|
17
|
+
|
|
18
|
+
This work is coming from a specific FP environment:
|
|
19
|
+
- **API URL:** {{FP_API_URL}}
|
|
20
|
+
- **Environment:** {{FP_ENVIRONMENT}}
|
|
21
|
+
- **Ephemeral:** {{FP_EPHEMERAL}}
|
|
22
|
+
|
|
23
|
+
**CRITICAL:** You MUST use `--api-url {{FP_API_URL}}` with ALL `fp` CLI commands.
|
|
24
|
+
This ensures you're reading from and writing to the correct FP instance (prod vs dev vs ephemeral).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# CORRECT - always include --api-url
|
|
28
|
+
fp show <slug> --project <project> --api-url {{FP_API_URL}}
|
|
29
|
+
fp list --project <project> --api-url {{FP_API_URL}}
|
|
30
|
+
fp propose-change <slug> --project <project> --api-url {{FP_API_URL}} ...
|
|
31
|
+
|
|
32
|
+
# WRONG - will hit production FP even if you're working on dev
|
|
33
|
+
fp show <slug> --project <project>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Requirement Work Workflow
|
|
37
|
+
|
|
38
|
+
When implementing a requirement from FP:
|
|
39
|
+
1. Read the requirement details to understand the spec
|
|
40
|
+
2. Implement the feature/fix in your worktree
|
|
41
|
+
3. Write tests that satisfy the acceptance criteria
|
|
42
|
+
4. Bind your tests to the requirement using `fp:<slug>` comments
|
|
43
|
+
5. Commit and push your changes
|
|
44
|
+
6. Open a PR
|
|
45
|
+
|
|
46
|
+
### Requirement Rejection Workflow
|
|
47
|
+
|
|
48
|
+
A human has rejected a proposed change to a requirement. Your job:
|
|
49
|
+
1. Read the rejection reason to understand what's wrong
|
|
50
|
+
2. Review the requirement's current state in FP
|
|
51
|
+
3. Make the necessary code/test changes to address the feedback
|
|
52
|
+
4. Update the requirement in FP if needed using `fp` CLI commands (with --api-url!)
|
|
53
|
+
5. Push changes and update the PR
|
|
54
|
+
|
|
55
|
+
### FP CLI Commands
|
|
56
|
+
|
|
57
|
+
Remember: ALWAYS include `--api-url {{FP_API_URL}}`
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
fp show <slug> --project <project> --api-url {{FP_API_URL}}
|
|
61
|
+
fp list --project <project> --api-url {{FP_API_URL}}
|
|
62
|
+
fp propose-change <slug> --project <project> --api-url {{FP_API_URL}} \\
|
|
63
|
+
--title "..." --why "..." --acceptance "..."
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Test Binding
|
|
67
|
+
|
|
68
|
+
To link a test to a requirement, add a comment with `fp:<slug>`:
|
|
69
|
+
```ruby
|
|
70
|
+
# fp:user-login-validation
|
|
71
|
+
test "validates email format" do
|
|
72
|
+
# ...
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Important Notes
|
|
77
|
+
|
|
78
|
+
- When implementing: write tests that directly exercise the acceptance criteria
|
|
79
|
+
- When reworking: address the specific rejection reason — don't make unrelated changes
|
|
80
|
+
- Keep your response focused: what you implemented/changed, what's next
|
|
81
|
+
- **Never forget `--api-url`** — using the wrong FP instance corrupts data
|
|
82
|
+
PROMPT
|
|
83
|
+
|
|
84
|
+
# The full prompt template for initial work dispatches (FP-as-source).
|
|
85
|
+
WORK_DISPATCH = <<~PROMPT
|
|
86
|
+
## FeatureParity: Implement Requirement
|
|
87
|
+
|
|
88
|
+
You've been assigned to implement a requirement directly from FeatureParity.
|
|
89
|
+
|
|
90
|
+
### Requirement Details
|
|
91
|
+
- **Slug:** `{{REQUIREMENT_SLUG}}`
|
|
92
|
+
- **Title:** {{REQUIREMENT_TITLE}}
|
|
93
|
+
- **Project:** {{PROJECT_KEY}}
|
|
94
|
+
|
|
95
|
+
### FP Instance
|
|
96
|
+
- **API URL:** {{FP_API_URL}}
|
|
97
|
+
- **Environment:** {{FP_ENVIRONMENT}}
|
|
98
|
+
|
|
99
|
+
**CRITICAL:** All `fp` CLI commands MUST include `--api-url {{FP_API_URL}}`
|
|
100
|
+
|
|
101
|
+
### Description
|
|
102
|
+
{{REQUIREMENT_DESCRIPTION}}
|
|
103
|
+
|
|
104
|
+
### Why This Matters
|
|
105
|
+
{{REQUIREMENT_WHY}}
|
|
106
|
+
|
|
107
|
+
### Acceptance Criteria
|
|
108
|
+
{{ACCEPTANCE_CRITERIA}}
|
|
109
|
+
|
|
110
|
+
### Your Task
|
|
111
|
+
|
|
112
|
+
1. Read and understand the requirement fully
|
|
113
|
+
2. Implement the feature in your worktree
|
|
114
|
+
3. Write tests that exercise the acceptance criteria
|
|
115
|
+
4. Bind your tests to this requirement using `fp:{{REQUIREMENT_SLUG}}` comments
|
|
116
|
+
5. Commit your changes with a clear message referencing the requirement
|
|
117
|
+
6. Push and open a PR
|
|
118
|
+
|
|
119
|
+
Work in the worktree at: {{WORKTREE_PATH}}
|
|
120
|
+
|
|
121
|
+
When you commit, include the requirement slug in the message:
|
|
122
|
+
```
|
|
123
|
+
Implement {{REQUIREMENT_SLUG}}: {{REQUIREMENT_TITLE}}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
To view or update the requirement in FP:
|
|
127
|
+
```bash
|
|
128
|
+
fp show {{REQUIREMENT_SLUG}} --project {{PROJECT_KEY}} --api-url {{FP_API_URL}}
|
|
129
|
+
```
|
|
130
|
+
PROMPT
|
|
131
|
+
|
|
132
|
+
# The full prompt template for requirement rejection dispatches.
|
|
133
|
+
REJECTION_DISPATCH = <<~PROMPT
|
|
134
|
+
## FeatureParity: Requirement Rejection
|
|
135
|
+
|
|
136
|
+
A requirement you proposed has been rejected and needs rework.
|
|
137
|
+
|
|
138
|
+
### Rejection Details
|
|
139
|
+
- **Requirement:** `{{REQUIREMENT_SLUG}}` — {{REQUIREMENT_TITLE}}
|
|
140
|
+
- **Rejected by:** {{ACTOR}}
|
|
141
|
+
- **Rejection note:** {{REJECTION_NOTE}}
|
|
142
|
+
|
|
143
|
+
### FP Instance
|
|
144
|
+
- **API URL:** {{FP_API_URL}}
|
|
145
|
+
- **Environment:** {{FP_ENVIRONMENT}}
|
|
146
|
+
|
|
147
|
+
**CRITICAL:** All `fp` CLI commands MUST include `--api-url {{FP_API_URL}}`
|
|
148
|
+
|
|
149
|
+
### Source Context
|
|
150
|
+
This requirement was proposed from: {{SOURCE_TYPE}} ({{SOURCE_URL}})
|
|
151
|
+
{{SOURCE_CONTEXT}}
|
|
152
|
+
|
|
153
|
+
### What Changed
|
|
154
|
+
{{CHANGE_KIND}}: {{CHANGE_DETAILS}}
|
|
155
|
+
|
|
156
|
+
### Your Task
|
|
157
|
+
|
|
158
|
+
1. Review the rejection note to understand what needs to change
|
|
159
|
+
2. Make the necessary fixes in your worktree
|
|
160
|
+
3. Update the requirement in FP if the wording/scope needs adjustment
|
|
161
|
+
4. Commit and push your changes
|
|
162
|
+
5. Summarize what you fixed and why
|
|
163
|
+
|
|
164
|
+
Work in the existing worktree at: {{WORKTREE_PATH}}
|
|
165
|
+
|
|
166
|
+
To view or update the requirement in FP:
|
|
167
|
+
```bash
|
|
168
|
+
fp show {{REQUIREMENT_SLUG}} --api-url {{FP_API_URL}}
|
|
169
|
+
fp propose-change {{REQUIREMENT_SLUG}} --api-url {{FP_API_URL}} ...
|
|
170
|
+
```
|
|
171
|
+
PROMPT
|
|
172
|
+
|
|
173
|
+
# Writing the tests a newly active requirement is missing (coverage.gap_detected).
|
|
174
|
+
GAP_DISPATCH = <<~PROMPT
|
|
175
|
+
## FeatureParity: Write the Missing Tests
|
|
176
|
+
|
|
177
|
+
A requirement is now active on surfaces that have no tests yet.
|
|
178
|
+
|
|
179
|
+
### Requirement
|
|
180
|
+
- **Slug:** `{{REQUIREMENT_SLUG}}`
|
|
181
|
+
- **Title:** {{REQUIREMENT_TITLE}}
|
|
182
|
+
- **Project:** {{PROJECT_KEY}}
|
|
183
|
+
- **Missing on:** {{MISSING_SURFACES}}
|
|
184
|
+
|
|
185
|
+
### Acceptance Criteria
|
|
186
|
+
{{ACCEPTANCE_CRITERIA}}
|
|
187
|
+
|
|
188
|
+
### FP Instance
|
|
189
|
+
- **API URL:** {{FP_API_URL}}
|
|
190
|
+
- **Environment:** {{FP_ENVIRONMENT}}
|
|
191
|
+
|
|
192
|
+
**CRITICAL:** All `fp` CLI commands MUST include `--api-url {{FP_API_URL}}`
|
|
193
|
+
|
|
194
|
+
### Your Task
|
|
195
|
+
|
|
196
|
+
1. Read the requirement: `fp show {{REQUIREMENT_SLUG}} --project {{PROJECT_KEY}} --api-url {{FP_API_URL}}`
|
|
197
|
+
2. For each missing surface, write a test that exercises the acceptance criteria and mark it
|
|
198
|
+
`fp:{{REQUIREMENT_SLUG}}` (pin the surface, e.g. `fp:{{REQUIREMENT_SLUG}}@web`, when one test file serves several).
|
|
199
|
+
3. If the product doesn't do what the acceptance says, don't bend the test to fit: say so, and
|
|
200
|
+
leave a question on the requirement (`fp question {{REQUIREMENT_SLUG}} "..."`) for a human.
|
|
201
|
+
4. Run the tests, commit, push and open a PR.
|
|
202
|
+
|
|
203
|
+
Work in the worktree at: {{WORKTREE_PATH}}
|
|
204
|
+
PROMPT
|
|
205
|
+
|
|
206
|
+
# Answering a comment that tagged this agent (requirement.agent_mentioned).
|
|
207
|
+
MENTION_DISPATCH = <<~PROMPT
|
|
208
|
+
## FeatureParity: You Were Tagged
|
|
209
|
+
|
|
210
|
+
{{COMMENT_AUTHOR}} tagged you ({{AGENT_NAME}}) in a comment on a requirement.
|
|
211
|
+
|
|
212
|
+
### Requirement
|
|
213
|
+
- **Slug:** `{{REQUIREMENT_SLUG}}`
|
|
214
|
+
- **Title:** {{REQUIREMENT_TITLE}}
|
|
215
|
+
- **Project:** {{PROJECT_KEY}}
|
|
216
|
+
|
|
217
|
+
### The Comment
|
|
218
|
+
{{COMMENT_BODY}}
|
|
219
|
+
|
|
220
|
+
### FP Instance
|
|
221
|
+
- **API URL:** {{FP_API_URL}}
|
|
222
|
+
- **Environment:** {{FP_ENVIRONMENT}}
|
|
223
|
+
|
|
224
|
+
**CRITICAL:** All `fp` CLI commands MUST include `--api-url {{FP_API_URL}}`
|
|
225
|
+
|
|
226
|
+
### Your Task
|
|
227
|
+
|
|
228
|
+
1. Read the requirement and its discussion:
|
|
229
|
+
`fp show {{REQUIREMENT_SLUG}} --project {{PROJECT_KEY}} --api-url {{FP_API_URL}}`
|
|
230
|
+
`fp comments {{REQUIREMENT_SLUG}} --project {{PROJECT_KEY}} --api-url {{FP_API_URL}}`
|
|
231
|
+
2. Do what was asked. If it is to re-check suspect tests, read the tests marked
|
|
232
|
+
`fp:{{REQUIREMENT_SLUG}}` against the current acceptance, fix them if they no longer prove it,
|
|
233
|
+
and re-report the evidence.
|
|
234
|
+
3. Reply on the requirement so they know what you did:
|
|
235
|
+
`fp comments {{REQUIREMENT_SLUG}} --project {{PROJECT_KEY}} --api-url {{FP_API_URL}} --body "..."`
|
|
236
|
+
|
|
237
|
+
Work in: {{WORKTREE_PATH}}
|
|
238
|
+
PROMPT
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Brainiac
|
|
7
|
+
module Plugins
|
|
8
|
+
module Featureparity
|
|
9
|
+
# Verifies a FeatureParity delivery (Standard Webhooks, standardwebhooks.com).
|
|
10
|
+
#
|
|
11
|
+
# FeatureParity signs every delivery with the secret of whatever it was sent to: a
|
|
12
|
+
# project webhook, or an agent key's wake-up address. Each is a "whsec_<base64>"
|
|
13
|
+
# string shown once in FeatureParity. The signature is
|
|
14
|
+
# base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")) where the
|
|
15
|
+
# key is the base64-decoded part after "whsec_", sent as "v1,<signature>".
|
|
16
|
+
module Signature
|
|
17
|
+
# Reject deliveries stamped more than this far from now, so a captured request
|
|
18
|
+
# can't be replayed later.
|
|
19
|
+
TOLERANCE = 300
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# True when `signature` (the webhook-signature header, which may hold several
|
|
24
|
+
# space-separated "v1,..." values) matches the body under any of `secrets`.
|
|
25
|
+
def valid?(secrets:, id:, timestamp:, signature:, body:, now: Time.now.to_i)
|
|
26
|
+
return false if id.to_s.empty? || !fresh?(timestamp, now)
|
|
27
|
+
|
|
28
|
+
offered = offered_signatures(signature)
|
|
29
|
+
Array(secrets).any? do |secret|
|
|
30
|
+
expected = sign(secret, id, timestamp, body)
|
|
31
|
+
offered.any? { |sig| secure_compare(sig, expected) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fresh?(timestamp, now)
|
|
36
|
+
timestamp.to_s.match?(/\A\d+\z/) && (now - timestamp.to_i).abs <= TOLERANCE
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The v1 signatures in a webhook-signature header ("v1,<sig> v1,<sig>").
|
|
40
|
+
def offered_signatures(header)
|
|
41
|
+
header.to_s.split.filter_map { |part| part.split(",", 2).last if part.start_with?("v1,") }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sign(secret, id, timestamp, body)
|
|
45
|
+
key = Base64.decode64(secret.to_s.delete_prefix("whsec_"))
|
|
46
|
+
Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def secure_compare(given, expected)
|
|
50
|
+
return false unless given.bytesize == expected.bytesize
|
|
51
|
+
|
|
52
|
+
OpenSSL.fixed_length_secure_compare(given, expected)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Remembers what was just handled, so a retried delivery (same webhook-id) or the same
|
|
57
|
+
# event arriving twice (a project webhook and an agent's wake-up address both
|
|
58
|
+
# subscribed) dispatches one agent, not two. In memory: a restart forgets, which at
|
|
59
|
+
# worst means one repeat.
|
|
60
|
+
module Dedupe
|
|
61
|
+
TTL = 3600
|
|
62
|
+
MAX = 2000
|
|
63
|
+
@seen = {}
|
|
64
|
+
@lock = Mutex.new
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# Records `key` and returns true if it was already seen within TTL.
|
|
69
|
+
def seen?(key, now: Time.now.to_f)
|
|
70
|
+
return false if key.to_s.empty?
|
|
71
|
+
|
|
72
|
+
@lock.synchronize do
|
|
73
|
+
@seen.delete_if { |_, at| now - at > TTL } if @seen.size >= MAX
|
|
74
|
+
if (at = @seen[key]) && now - at <= TTL
|
|
75
|
+
true
|
|
76
|
+
else
|
|
77
|
+
@seen[key] = now
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reset!
|
|
84
|
+
@lock.synchronize { @seen.clear }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "featureparity/version"
|
|
4
|
+
require_relative "featureparity/metadata"
|
|
5
|
+
require_relative "featureparity/config"
|
|
6
|
+
require_relative "featureparity/prompts"
|
|
7
|
+
require_relative "featureparity/signature"
|
|
8
|
+
require_relative "featureparity/handlers"
|
|
9
|
+
require_relative "featureparity/cli"
|
|
10
|
+
|
|
11
|
+
module Brainiac
|
|
12
|
+
module Plugins
|
|
13
|
+
module Featureparity
|
|
14
|
+
# Supported webhook events from FeatureParity
|
|
15
|
+
SUPPORTED_EVENTS = %w[
|
|
16
|
+
requirement.change_rejected
|
|
17
|
+
requirement.change_approved
|
|
18
|
+
requirement.change_proposed
|
|
19
|
+
requirement.removal_proposed
|
|
20
|
+
requirement.activated
|
|
21
|
+
requirement.archived
|
|
22
|
+
requirement.updated
|
|
23
|
+
requirement.work_started
|
|
24
|
+
requirement.agent_mentioned
|
|
25
|
+
evidence.passing
|
|
26
|
+
evidence.failing
|
|
27
|
+
evidence.suspect
|
|
28
|
+
coverage.gap_detected
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Called by Brainiac plugin system during server startup.
|
|
33
|
+
#
|
|
34
|
+
# @param app [Sinatra::Application] The running Brainiac server
|
|
35
|
+
def register(app)
|
|
36
|
+
Config.load!
|
|
37
|
+
|
|
38
|
+
# Register channel prompt
|
|
39
|
+
Brainiac.register_channel_prompt(:featureparity, Prompts::CHANNEL)
|
|
40
|
+
|
|
41
|
+
setup_routes(app)
|
|
42
|
+
|
|
43
|
+
LOG.info "[FeatureParity] Plugin registered (webhook: /featureparity)"
|
|
44
|
+
LOG.info "[FeatureParity] Accepting webhooks from environments: #{Config.accept_environments.join(', ')}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def setup_routes(app)
|
|
50
|
+
setup_webhook_route(app)
|
|
51
|
+
setup_api_routes(app)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def setup_webhook_route(app)
|
|
55
|
+
app.post "/featureparity" do
|
|
56
|
+
content_type :json
|
|
57
|
+
request.body.rewind
|
|
58
|
+
# self here is the Sinatra request, so reach the plugin by name.
|
|
59
|
+
status_code, body = Brainiac::Plugins::Featureparity.receive(request.body.read, request.env)
|
|
60
|
+
halt status_code, body
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def setup_api_routes(app)
|
|
65
|
+
app.get "/api/featureparity" do
|
|
66
|
+
content_type :json
|
|
67
|
+
{
|
|
68
|
+
enabled: true,
|
|
69
|
+
api_base_url: Brainiac::Plugins::Featureparity::Config.api_base_url,
|
|
70
|
+
environment: Brainiac::Plugins::Featureparity::Config.environment,
|
|
71
|
+
accept_environments: Brainiac::Plugins::Featureparity::Config.accept_environments,
|
|
72
|
+
supported_events: SUPPORTED_EVENTS
|
|
73
|
+
}.to_json
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
public
|
|
78
|
+
|
|
79
|
+
# Handle one delivery: verify it, drop a repeat, check where it came from, and hand
|
|
80
|
+
# it to its event's handler. Returns [status, json body].
|
|
81
|
+
def receive(raw_body, env)
|
|
82
|
+
LOG.debug "[FeatureParity] Webhook received: #{raw_body.length} bytes" if LOG.debug?
|
|
83
|
+
refusal = verification_refusal(raw_body, env)
|
|
84
|
+
return refusal if refusal
|
|
85
|
+
|
|
86
|
+
payload = JSON.parse(raw_body)
|
|
87
|
+
screened = screen(payload)
|
|
88
|
+
return screened if screened
|
|
89
|
+
|
|
90
|
+
reload_projects!
|
|
91
|
+
reload_agent_registry!
|
|
92
|
+
dispatch_event(payload["event"], payload)
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
LOG.error "[FeatureParity] Invalid JSON: #{e.message}"
|
|
95
|
+
[400, { error: "Invalid JSON" }.to_json]
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
LOG.error "[FeatureParity] Unhandled error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
98
|
+
[500, { error: e.message }.to_json]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Signed deliveries (Standard Webhooks): with secrets configured, anything that doesn't
|
|
102
|
+
# verify is refused before it is parsed. FeatureParity retries a delivery under the
|
|
103
|
+
# same webhook-id, so a repeat is acknowledged and dropped.
|
|
104
|
+
def verification_refusal(raw_body, env)
|
|
105
|
+
delivery_id = env["HTTP_WEBHOOK_ID"]
|
|
106
|
+
secrets = Config.signing_secrets
|
|
107
|
+
if secrets.empty?
|
|
108
|
+
LOG.warn "[FeatureParity] Accepting an unverified delivery: set signing_secrets in featureparity.json"
|
|
109
|
+
elsif !Signature.valid?(secrets: secrets, id: delivery_id, timestamp: env["HTTP_WEBHOOK_TIMESTAMP"],
|
|
110
|
+
signature: env["HTTP_WEBHOOK_SIGNATURE"], body: raw_body)
|
|
111
|
+
LOG.warn "[FeatureParity] Refused a delivery whose signature doesn't verify (id: #{delivery_id || 'none'})"
|
|
112
|
+
return [401, { error: "invalid signature" }.to_json]
|
|
113
|
+
end
|
|
114
|
+
return [200, { status: "duplicate" }.to_json] if delivery_id && Dedupe.seen?("delivery:#{delivery_id}")
|
|
115
|
+
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# An event from an environment this instance doesn't serve, or one it doesn't handle.
|
|
120
|
+
def screen(payload)
|
|
121
|
+
event = payload["event"]
|
|
122
|
+
webhook_env = (payload["metadata"] || {})["environment"]
|
|
123
|
+
LOG.info "[FeatureParity] Received event: #{event} from environment: #{webhook_env || 'unspecified'}"
|
|
124
|
+
|
|
125
|
+
unless Config.should_accept_environment?(webhook_env)
|
|
126
|
+
LOG.info "[FeatureParity] Ignoring webhook from environment '#{webhook_env}' " \
|
|
127
|
+
"(accepting: #{Config.accept_environments.join(', ')})"
|
|
128
|
+
return [200, { status: "ignored", reason: "environment mismatch", webhook_environment: webhook_env,
|
|
129
|
+
accepted_environments: Config.accept_environments }.to_json]
|
|
130
|
+
end
|
|
131
|
+
return nil if SUPPORTED_EVENTS.include?(event)
|
|
132
|
+
|
|
133
|
+
LOG.info "[FeatureParity] Ignoring unsupported event: #{event}"
|
|
134
|
+
[200, { status: "ignored", reason: "unsupported event" }.to_json]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The events that start an agent; every other supported event is acknowledged.
|
|
138
|
+
HANDLERS = {
|
|
139
|
+
"requirement.change_rejected" => :handle_change_rejected,
|
|
140
|
+
"requirement.work_started" => :handle_work_started,
|
|
141
|
+
"requirement.agent_mentioned" => :handle_agent_mentioned,
|
|
142
|
+
"coverage.gap_detected" => :handle_gap_detected
|
|
143
|
+
}.freeze
|
|
144
|
+
|
|
145
|
+
# Route an accepted event to its handler. Returns [status, json body].
|
|
146
|
+
def dispatch_event(event, payload)
|
|
147
|
+
handler = HANDLERS[event]
|
|
148
|
+
return Handlers.public_send(handler, payload) if handler
|
|
149
|
+
|
|
150
|
+
LOG.info "[FeatureParity] #{event}: #{payload.dig('data', 'slug')}"
|
|
151
|
+
[200, { status: "acknowledged", event: event }.to_json]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: brainiac-featureparity
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Davis
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: brainiac
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.8
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.8
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.75'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.75'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop-performance
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.25'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.25'
|
|
82
|
+
description: FeatureParity integration for Brainiac — starts the agent picked for
|
|
83
|
+
a requirement, answers comments that tag it, writes missing tests, and reworks rejected
|
|
84
|
+
proposals. Verifies FeatureParity's signed deliveries.
|
|
85
|
+
executables: []
|
|
86
|
+
extensions: []
|
|
87
|
+
extra_rdoc_files: []
|
|
88
|
+
files:
|
|
89
|
+
- README.md
|
|
90
|
+
- lib/brainiac/plugins/featureparity.rb
|
|
91
|
+
- lib/brainiac/plugins/featureparity/cli.rb
|
|
92
|
+
- lib/brainiac/plugins/featureparity/config.rb
|
|
93
|
+
- lib/brainiac/plugins/featureparity/handlers.rb
|
|
94
|
+
- lib/brainiac/plugins/featureparity/metadata.rb
|
|
95
|
+
- lib/brainiac/plugins/featureparity/prompts.rb
|
|
96
|
+
- lib/brainiac/plugins/featureparity/signature.rb
|
|
97
|
+
- lib/brainiac/plugins/featureparity/version.rb
|
|
98
|
+
- lib/brainiac_featureparity.rb
|
|
99
|
+
homepage: https://github.com/stowzilla/brainiac-featureparity
|
|
100
|
+
licenses:
|
|
101
|
+
- MIT
|
|
102
|
+
metadata:
|
|
103
|
+
rubygems_mfa_required: 'true'
|
|
104
|
+
rdoc_options: []
|
|
105
|
+
require_paths:
|
|
106
|
+
- lib
|
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '3.4'
|
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
requirements: []
|
|
118
|
+
rubygems_version: 3.6.9
|
|
119
|
+
specification_version: 4
|
|
120
|
+
summary: FeatureParity plugin for Brainiac
|
|
121
|
+
test_files: []
|