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,510 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Featureparity
|
|
6
|
+
# Handlers for FeatureParity webhook events.
|
|
7
|
+
#
|
|
8
|
+
# Each `handle_*` returns [status, json body] straight away and does the slow part
|
|
9
|
+
# (fetch, worktree, agent launch) on a thread: FeatureParity waits 5 seconds for an
|
|
10
|
+
# answer and retries a delivery that doesn't get one.
|
|
11
|
+
module Handlers
|
|
12
|
+
# --- Events ------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
# requirement.work_started — someone pressed Start work in FeatureParity, usually for
|
|
15
|
+
# a named agent. The agent gets a worktree for the requirement (reused if one exists)
|
|
16
|
+
# and is asked to implement it.
|
|
17
|
+
def self.handle_work_started(payload)
|
|
18
|
+
context = extract_work_context(payload)
|
|
19
|
+
fp_env = extract_fp_environment(payload)
|
|
20
|
+
agent_name = agent_for_work(payload["data"] || payload)
|
|
21
|
+
return ignored("no local agent for this work (name one, or set default_agent)") unless agent_name
|
|
22
|
+
|
|
23
|
+
project_key, project_config = resolve_project(context[:project_key])
|
|
24
|
+
return ignored("unknown project #{context[:project_key].inspect}") unless project_config
|
|
25
|
+
return duplicate if context[:work_id] && Dedupe.seen?("work:#{context[:work_id]}:#{agent_name}")
|
|
26
|
+
|
|
27
|
+
LOG.info "[FeatureParity] Work started on #{context[:slug]} for #{agent_name} (env: #{fp_env[:environment]})"
|
|
28
|
+
in_background("work on #{context[:slug]}") do
|
|
29
|
+
work_item = ensure_work_item(context, project_key, project_config, agent_name, fp_env)
|
|
30
|
+
next unless work_item
|
|
31
|
+
|
|
32
|
+
dispatch(Prompts::WORK_DISPATCH, work_vars(context, work_item, fp_env),
|
|
33
|
+
work_item: work_item, project_key: project_key, project_config: project_config,
|
|
34
|
+
agent_name: agent_name, slug: context[:slug], kind: "work", title: context[:title])
|
|
35
|
+
end
|
|
36
|
+
accepted(agent_name, context[:slug])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# coverage.gap_detected — a requirement is active on a surface nobody has tested.
|
|
40
|
+
# Only acted on with "dispatch_gaps": true; the gap agent writes the missing tests.
|
|
41
|
+
def self.handle_gap_detected(payload)
|
|
42
|
+
return [200, { status: "acknowledged", reason: "dispatch_gaps is off" }.to_json] unless Config.dispatch_gaps?
|
|
43
|
+
|
|
44
|
+
data = payload["data"] || payload
|
|
45
|
+
context = extract_work_context(payload).merge(surfaces: Array(data["surfaces"]))
|
|
46
|
+
agent_name = local_agent(Config.gap_agent)
|
|
47
|
+
return ignored("no local gap_agent configured") unless agent_name
|
|
48
|
+
|
|
49
|
+
project_key, project_config = resolve_project(context[:project_key])
|
|
50
|
+
return ignored("unknown project #{context[:project_key].inspect}") unless project_config
|
|
51
|
+
return duplicate if Dedupe.seen?("gap:#{context[:slug]}:#{context[:surfaces].sort.join(',')}")
|
|
52
|
+
|
|
53
|
+
fp_env = extract_fp_environment(payload)
|
|
54
|
+
in_background("tests for #{context[:slug]}") do
|
|
55
|
+
work_item = ensure_work_item(context, project_key, project_config, agent_name, fp_env)
|
|
56
|
+
next unless work_item
|
|
57
|
+
|
|
58
|
+
vars = work_vars(context, work_item, fp_env).merge("MISSING_SURFACES" => context[:surfaces].join(", "))
|
|
59
|
+
dispatch(Prompts::GAP_DISPATCH, vars,
|
|
60
|
+
work_item: work_item, project_key: project_key, project_config: project_config,
|
|
61
|
+
agent_name: agent_name, slug: context[:slug], kind: "gap", title: context[:title])
|
|
62
|
+
end
|
|
63
|
+
accepted(agent_name, context[:slug])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# requirement.agent_mentioned — a comment tagged one of the team's agents. If that
|
|
67
|
+
# agent runs here, it reads the comment (in the requirement's worktree when there is
|
|
68
|
+
# one) and answers it on the requirement.
|
|
69
|
+
def self.handle_agent_mentioned(payload)
|
|
70
|
+
data = payload["data"] || payload
|
|
71
|
+
agent_name = resolve_agent(data)
|
|
72
|
+
return ignored("the tagged agent doesn't run here") unless agent_name
|
|
73
|
+
|
|
74
|
+
comment = data["comment"] || {}
|
|
75
|
+
return duplicate if comment["id"] && Dedupe.seen?("mention:#{comment['id']}:#{agent_name}")
|
|
76
|
+
|
|
77
|
+
context = extract_work_context(payload)
|
|
78
|
+
project_key, project_config = resolve_project(context[:project_key])
|
|
79
|
+
return ignored("unknown project #{context[:project_key].inspect}") unless project_config
|
|
80
|
+
|
|
81
|
+
fp_env = extract_fp_environment(payload)
|
|
82
|
+
in_background("mention on #{context[:slug]}") do
|
|
83
|
+
dispatch_mention(data, context, project_key, project_config, agent_name, fp_env)
|
|
84
|
+
end
|
|
85
|
+
accepted(agent_name, context[:slug])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The tagged agent answers in the requirement's worktree when one exists, else the repo.
|
|
89
|
+
def self.dispatch_mention(data, context, project_key, project_config, agent_name, fp_env)
|
|
90
|
+
comment = data["comment"] || {}
|
|
91
|
+
work_item = existing_work_item(context[:slug])
|
|
92
|
+
work_dir = work_item ? work_item["worktree"] : project_config["repo_path"]
|
|
93
|
+
vars = work_vars(context, { "worktree" => work_dir }, fp_env).merge(
|
|
94
|
+
"COMMENT_AUTHOR" => data["actor"].to_s,
|
|
95
|
+
"COMMENT_BODY" => comment["body"].to_s,
|
|
96
|
+
"AGENT_NAME" => agent_name
|
|
97
|
+
)
|
|
98
|
+
dispatch(Prompts::MENTION_DISPATCH, vars,
|
|
99
|
+
work_item: work_item || { "worktree" => work_dir }, project_key: project_key,
|
|
100
|
+
project_config: project_config, agent_name: agent_name, slug: context[:slug],
|
|
101
|
+
kind: "mention", title: context[:title], message: comment["body"].to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# requirement.change_rejected — a human turned down a change an agent proposed. The
|
|
105
|
+
# agent that proposed it, found through the work item the proposal came from, reworks it.
|
|
106
|
+
def self.handle_change_rejected(payload)
|
|
107
|
+
context = extract_rejection_context(payload)
|
|
108
|
+
fp_env = extract_fp_environment(payload)
|
|
109
|
+
LOG.info "[FeatureParity] Change rejected: #{context[:slug]} by #{context[:actor]} (env: #{fp_env[:environment]})"
|
|
110
|
+
|
|
111
|
+
work_item = find_work_item_from_source(context[:source_type], context[:source_url])
|
|
112
|
+
work_item ||= find_work_item_by_fp_requirement(context[:slug])
|
|
113
|
+
return ignored_response("no work item found", context[:slug]) unless work_item
|
|
114
|
+
|
|
115
|
+
validation = validate_work_item(work_item)
|
|
116
|
+
return validation if validation
|
|
117
|
+
|
|
118
|
+
project_config = PROJECTS[work_item["project"]]
|
|
119
|
+
return ignored("project not found") unless project_config
|
|
120
|
+
|
|
121
|
+
update_work_item_fp_env(work_item["id"], fp_env) if fp_env[:api_base_url]
|
|
122
|
+
fp_env = get_fp_env_from_work_item(work_item) if fp_env[:api_base_url].nil?
|
|
123
|
+
agent_name = work_item["agent"]
|
|
124
|
+
|
|
125
|
+
in_background("rework of #{context[:slug]}") do
|
|
126
|
+
dispatch(Prompts::REJECTION_DISPATCH, rejection_vars(context, work_item, fp_env),
|
|
127
|
+
work_item: work_item, project_key: work_item["project"], project_config: project_config,
|
|
128
|
+
agent_name: agent_name, slug: context[:slug], kind: "rejection", title: context[:title])
|
|
129
|
+
end
|
|
130
|
+
accepted(agent_name, context[:slug])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- Who and where -----------------------------------------------------
|
|
134
|
+
|
|
135
|
+
# The local Brainiac agent a payload names: its `agent` (from Start work or a tag)
|
|
136
|
+
# or `assigned_agent`, through the config's `agents` map, then by name. nil when
|
|
137
|
+
# the agent doesn't run on this machine.
|
|
138
|
+
def self.resolve_agent(data)
|
|
139
|
+
agent = data["agent"].is_a?(Hash) ? data["agent"] : {}
|
|
140
|
+
candidates = [agent["key_name"], agent["name"], data["assigned_agent"]].compact.map(&:to_s).reject(&:empty?)
|
|
141
|
+
candidates.each do |candidate|
|
|
142
|
+
mapped = Config.agent_map[candidate.downcase]
|
|
143
|
+
found = local_agent(mapped || candidate)
|
|
144
|
+
return found if found
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Who takes Start work: the agent it names, else the configured default_agent.
|
|
150
|
+
def self.agent_for_work(data)
|
|
151
|
+
resolve_agent(data) || local_agent(Config.default_agent)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# `name` as this machine's agent is spelled, or nil if it isn't local.
|
|
155
|
+
def self.local_agent(name)
|
|
156
|
+
return nil if name.to_s.empty?
|
|
157
|
+
|
|
158
|
+
local_agent_names.find { |local| local.to_s.casecmp?(name.to_s) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# [brainiac project key, config] for a FeatureParity project slug.
|
|
162
|
+
def self.resolve_project(fp_key)
|
|
163
|
+
return [nil, nil] if fp_key.to_s.empty?
|
|
164
|
+
|
|
165
|
+
key = [Config.project_map[fp_key], fp_key].compact.find { |candidate| PROJECTS[candidate] }
|
|
166
|
+
return [key, PROJECTS[key]] if key
|
|
167
|
+
|
|
168
|
+
PROJECTS.find { |_, config| config.is_a?(Hash) && config["featureparity_project"] == fp_key } || [nil, nil]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# --- Work items and worktrees ------------------------------------------
|
|
172
|
+
|
|
173
|
+
# The requirement's work item: the existing one (its worktree still on disk), or a
|
|
174
|
+
# new worktree on an "fp-<slug>" branch registered with FP as the source.
|
|
175
|
+
def self.ensure_work_item(context, project_key, project_config, agent_name, fp_env)
|
|
176
|
+
existing = existing_work_item(context[:slug])
|
|
177
|
+
if existing
|
|
178
|
+
update_work_item_fp_env(existing["id"], fp_env)
|
|
179
|
+
return existing
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
repo_path = project_config["repo_path"]
|
|
183
|
+
unless repo_path && File.directory?(repo_path)
|
|
184
|
+
LOG.warn "[FeatureParity] Project #{project_key} has no repo_path on disk"
|
|
185
|
+
return nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
branch = "fp-#{slugify(context[:slug].to_s)}"
|
|
189
|
+
debounced_repo_fetch(repo_path)
|
|
190
|
+
worktree = create_or_reuse_worktree(repo_path: repo_path, branch: branch)
|
|
191
|
+
id = register_fp_work_item({ branch: branch, worktree: worktree }, context.merge(project_key: project_key),
|
|
192
|
+
agent_name, fp_env)
|
|
193
|
+
find_work_item_by_id(id)&.merge("id" => id)
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
LOG.error "[FeatureParity] Could not prepare a worktree for #{context[:slug]}: #{e.message}"
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The requirement's work item, if its worktree is still on disk.
|
|
200
|
+
def self.existing_work_item(slug)
|
|
201
|
+
item = find_work_item_by_fp_requirement(slug)
|
|
202
|
+
item if item && File.directory?(item["worktree"].to_s)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.register_fp_work_item(worktree_info, context, agent_name, fp_env)
|
|
206
|
+
source_data = { "slug" => context[:slug], "title" => context[:title] }
|
|
207
|
+
if fp_env[:api_base_url]
|
|
208
|
+
source_data["api_base_url"] = fp_env[:api_base_url]
|
|
209
|
+
source_data["environment"] = fp_env[:environment]
|
|
210
|
+
source_data["ephemeral"] = fp_env[:ephemeral]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
register_work_item(
|
|
214
|
+
branch: worktree_info[:branch],
|
|
215
|
+
worktree: worktree_info[:worktree],
|
|
216
|
+
project: context[:project_key],
|
|
217
|
+
agent: agent_name,
|
|
218
|
+
source: "featureparity",
|
|
219
|
+
source_data: source_data
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Keep the FP environment on the work item current (a requirement can move from a
|
|
224
|
+
# preview environment to prod).
|
|
225
|
+
def self.update_work_item_fp_env(work_item_id, fp_env)
|
|
226
|
+
return unless fp_env[:api_base_url] && work_item_id
|
|
227
|
+
|
|
228
|
+
map = load_work_item_map
|
|
229
|
+
fp_source = map.dig(work_item_id, "sources", "featureparity")
|
|
230
|
+
return unless fp_source
|
|
231
|
+
|
|
232
|
+
fp_source["api_base_url"] = fp_env[:api_base_url]
|
|
233
|
+
fp_source["environment"] = fp_env[:environment]
|
|
234
|
+
fp_source["ephemeral"] = fp_env[:ephemeral]
|
|
235
|
+
save_work_item_map(map)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# --- Launching the agent -----------------------------------------------
|
|
239
|
+
|
|
240
|
+
def self.dispatch(template, vars, work_item:, project_key:, project_config:, agent_name:, slug:, kind:,
|
|
241
|
+
title: nil, message: nil)
|
|
242
|
+
prompt = render_prompt(
|
|
243
|
+
template, vars,
|
|
244
|
+
brain_context: build_brain_context(agent_name: agent_name, card_title: title.to_s,
|
|
245
|
+
project_key: project_key, comment_body: message.to_s),
|
|
246
|
+
agent_name: agent_name, channel: :featureparity
|
|
247
|
+
)
|
|
248
|
+
opts = { project_config: project_config, chdir: work_item["worktree"], log_name: "fp-#{kind}-#{slug}",
|
|
249
|
+
agent_name: agent_name, source: :featureparity,
|
|
250
|
+
source_context: { slug: slug, work_item_id: work_item["id"], event: kind } }
|
|
251
|
+
opts[:message] = message if message
|
|
252
|
+
pid, log_file = run_agent(prompt, **opts)
|
|
253
|
+
LOG.info "[FeatureParity] Dispatched #{agent_name} (#{kind}) on #{slug}#{" (pid #{pid})" if pid}"
|
|
254
|
+
register_session("fp-#{kind}-#{slug}", pid, log_file: log_file, agent_name: agent_name) if pid && respond_to?(:register_session, true)
|
|
255
|
+
pid
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Tests set this to run the background work inline.
|
|
259
|
+
class << self
|
|
260
|
+
attr_accessor :run_inline
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def self.in_background(what)
|
|
264
|
+
if run_inline
|
|
265
|
+
yield
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
Thread.new do
|
|
270
|
+
yield
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
LOG.error "[FeatureParity] #{what} failed: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.accepted(agent_name, slug)
|
|
277
|
+
[202, { status: "accepted", agent: agent_name, slug: slug }.to_json]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def self.ignored(reason)
|
|
281
|
+
LOG.info "[FeatureParity] Ignored: #{reason}"
|
|
282
|
+
[200, { status: "ignored", reason: reason }.to_json]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.duplicate
|
|
286
|
+
[200, { status: "duplicate" }.to_json]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# --- Prompt variables --------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def self.work_vars(context, work_item, fp_env)
|
|
292
|
+
{
|
|
293
|
+
"REQUIREMENT_SLUG" => context[:slug],
|
|
294
|
+
"REQUIREMENT_TITLE" => context[:title],
|
|
295
|
+
"REQUIREMENT_DESCRIPTION" => context[:description] || "",
|
|
296
|
+
"REQUIREMENT_WHY" => context[:why] || "",
|
|
297
|
+
"ACCEPTANCE_CRITERIA" => context[:acceptance_criteria] || "",
|
|
298
|
+
"PROJECT_KEY" => context[:project_key],
|
|
299
|
+
"WORKTREE_PATH" => work_item["worktree"],
|
|
300
|
+
"FP_API_URL" => fp_env[:api_base_url] || Config.api_base_url,
|
|
301
|
+
"FP_ENVIRONMENT" => fp_env[:environment] || "prod",
|
|
302
|
+
"FP_EPHEMERAL" => fp_env[:ephemeral] ? "true" : "false"
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def self.rejection_vars(context, work_item, fp_env)
|
|
307
|
+
{
|
|
308
|
+
"REQUIREMENT_SLUG" => context[:slug],
|
|
309
|
+
"REQUIREMENT_TITLE" => context[:title],
|
|
310
|
+
"ACTOR" => context[:actor],
|
|
311
|
+
"REJECTION_NOTE" => context[:note],
|
|
312
|
+
"SOURCE_TYPE" => context[:source_type] || "unknown",
|
|
313
|
+
"SOURCE_URL" => context[:source_url] || "",
|
|
314
|
+
"SOURCE_CONTEXT" => context[:source_context] || "",
|
|
315
|
+
"CHANGE_KIND" => context[:kind],
|
|
316
|
+
"CHANGE_DETAILS" => build_change_details({ "kind" => context[:kind] }),
|
|
317
|
+
"WORKTREE_PATH" => work_item["worktree"],
|
|
318
|
+
"FP_API_URL" => fp_env[:api_base_url] || Config.api_base_url,
|
|
319
|
+
"FP_ENVIRONMENT" => fp_env[:environment] || "prod",
|
|
320
|
+
"FP_EPHEMERAL" => fp_env[:ephemeral] ? "true" : "false"
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# --- Payloads ----------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def self.extract_work_context(payload)
|
|
327
|
+
data = payload["data"] || payload
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
work_id: data["work_id"],
|
|
331
|
+
slug: data["slug"],
|
|
332
|
+
title: data["title"],
|
|
333
|
+
description: data["description"],
|
|
334
|
+
why: data["why"],
|
|
335
|
+
acceptance_criteria: data["acceptance_criteria"] || data.dig("requirement", "acceptance"),
|
|
336
|
+
project_key: data["project_key"] || data["project"],
|
|
337
|
+
assigned_agent: data["assigned_agent"] || data.dig("agent", "name"),
|
|
338
|
+
priority: data["priority"],
|
|
339
|
+
tags: data["tags"] || []
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Which FP instance sent this, so the agent talks to the same one.
|
|
344
|
+
def self.extract_fp_environment(payload)
|
|
345
|
+
metadata = payload["metadata"] || {}
|
|
346
|
+
|
|
347
|
+
{
|
|
348
|
+
environment: metadata["environment"] || "prod",
|
|
349
|
+
api_base_url: metadata["api_base_url"],
|
|
350
|
+
ephemeral: metadata["ephemeral"] || false
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def self.extract_rejection_context(payload)
|
|
355
|
+
data = payload["data"] || payload
|
|
356
|
+
source = data["source"] || {}
|
|
357
|
+
|
|
358
|
+
{
|
|
359
|
+
slug: data["slug"],
|
|
360
|
+
title: data["title"],
|
|
361
|
+
actor: data["actor"],
|
|
362
|
+
note: source["note"] || data["note"] || "No reason provided",
|
|
363
|
+
kind: data["kind"] || "modification",
|
|
364
|
+
source_type: source["type"],
|
|
365
|
+
source_url: source["url"],
|
|
366
|
+
source_context: source["context"]
|
|
367
|
+
}
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A rejection can only be reworked where its agent's worktree still exists.
|
|
371
|
+
def self.validate_work_item(work_item)
|
|
372
|
+
worktree = work_item["worktree"]
|
|
373
|
+
agent_name = work_item["agent"]
|
|
374
|
+
|
|
375
|
+
unless worktree && File.directory?(worktree)
|
|
376
|
+
LOG.warn "[FeatureParity] Worktree not found for work item #{work_item['id']}: #{worktree}"
|
|
377
|
+
return [200, { status: "ignored", reason: "worktree not found" }.to_json]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unless local_agent(agent_name)
|
|
381
|
+
LOG.info "[FeatureParity] Agent '#{agent_name}' is not local, skipping"
|
|
382
|
+
return [200, { status: "ignored", reason: "non-local agent" }.to_json]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def self.get_fp_env_from_work_item(work_item)
|
|
389
|
+
fp_source = work_item.dig("sources", "featureparity") || {}
|
|
390
|
+
{
|
|
391
|
+
environment: fp_source["environment"] || "prod",
|
|
392
|
+
api_base_url: fp_source["api_base_url"],
|
|
393
|
+
ephemeral: fp_source["ephemeral"] || false
|
|
394
|
+
}
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def self.ignored_response(reason, slug)
|
|
398
|
+
LOG.warn "[FeatureParity] Could not find work item for rejection: #{slug}"
|
|
399
|
+
[200, { status: "ignored", reason: reason }.to_json]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# --- Finding work items ------------------------------------------------
|
|
403
|
+
|
|
404
|
+
# A work item by where the rejected requirement came from.
|
|
405
|
+
def self.find_work_item_from_source(source_type, source_url)
|
|
406
|
+
finder = source_finder_for(source_type)
|
|
407
|
+
return nil unless finder
|
|
408
|
+
|
|
409
|
+
finder.call(load_work_item_map, source_url)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def self.source_finder_for(source_type)
|
|
413
|
+
{
|
|
414
|
+
"fizzy" => ->(items, url) { find_by_fizzy_source(items, url) },
|
|
415
|
+
"discord" => ->(items, url) { find_by_discord_source(items, url) },
|
|
416
|
+
"featureparity" => ->(items, url) { find_by_fp_source(items, url) }
|
|
417
|
+
}[source_type]
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def self.find_by_fizzy_source(work_items, url)
|
|
421
|
+
card_number = extract_fizzy_card_number(url)
|
|
422
|
+
return nil unless card_number
|
|
423
|
+
|
|
424
|
+
find_work_item_by_fizzy_card(work_items, card_number)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def self.find_by_discord_source(work_items, url)
|
|
428
|
+
thread_id = extract_discord_thread_id(url)
|
|
429
|
+
return nil unless thread_id
|
|
430
|
+
|
|
431
|
+
find_work_item_by_discord_thread(work_items, thread_id)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def self.find_by_fp_source(work_items, url)
|
|
435
|
+
slug = extract_fp_slug(url)
|
|
436
|
+
return nil unless slug
|
|
437
|
+
|
|
438
|
+
find_work_item_by_fp_requirement_in_items(work_items, slug)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# The requirement slug from an FP URL, or the string itself when it is a bare slug.
|
|
442
|
+
def self.extract_fp_slug(url)
|
|
443
|
+
return nil unless url
|
|
444
|
+
|
|
445
|
+
if url.include?("/requirements/")
|
|
446
|
+
match = url.match(%r{/requirements/([^/\s]+)})
|
|
447
|
+
return match&.[](1)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
url.to_s.strip.empty? ? nil : url.to_s.strip
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def self.find_work_item_by_fp_requirement(slug)
|
|
454
|
+
return nil unless slug
|
|
455
|
+
|
|
456
|
+
find_work_item_by_fp_requirement_in_items(load_work_item_map, slug)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# The work item whose FeatureParity source is this requirement, with its id.
|
|
460
|
+
def self.find_work_item_by_fp_requirement_in_items(work_items, slug)
|
|
461
|
+
found = work_items.find do |_, item|
|
|
462
|
+
fp_source = item.is_a?(Hash) && item.dig("sources", "featureparity")
|
|
463
|
+
fp_source && fp_source["slug"]&.to_s == slug.to_s
|
|
464
|
+
end
|
|
465
|
+
found && { "id" => found[0] }.merge(found[1])
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def self.extract_fizzy_card_number(url)
|
|
469
|
+
return nil unless url
|
|
470
|
+
|
|
471
|
+
match = url.match(%r{/cards/(\d+)})
|
|
472
|
+
match&.[](1)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def self.extract_discord_thread_id(url)
|
|
476
|
+
return nil unless url
|
|
477
|
+
|
|
478
|
+
if url.include?("discord.com")
|
|
479
|
+
match = url.match(%r{/channels/\d+/(\d+)})
|
|
480
|
+
return match&.[](1)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
url.to_s.match?(/^\d+$/) ? url : nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def self.find_work_item_by_fizzy_card(work_items, card_number)
|
|
487
|
+
work_items.values.find do |item|
|
|
488
|
+
fizzy_source = item.dig("sources", "fizzy")
|
|
489
|
+
next unless fizzy_source
|
|
490
|
+
|
|
491
|
+
fizzy_source["card_number"]&.to_s == card_number.to_s
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def self.find_work_item_by_discord_thread(work_items, thread_id)
|
|
496
|
+
work_items.values.find do |item|
|
|
497
|
+
discord_source = item.dig("sources", "discord")
|
|
498
|
+
next unless discord_source
|
|
499
|
+
|
|
500
|
+
discord_source["thread_id"]&.to_s == thread_id.to_s
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def self.build_change_details(data)
|
|
505
|
+
data["kind"] == "removal" ? "Proposed deletion of this requirement" : "Proposed modification to requirement content"
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Featureparity
|
|
6
|
+
# Plugin metadata for brainiac plugin system.
|
|
7
|
+
module Metadata
|
|
8
|
+
def self.name
|
|
9
|
+
"featureparity"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.version
|
|
13
|
+
VERSION
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.description
|
|
17
|
+
"FeatureParity integration — starts the agent you pick on a requirement, and answers its tags, gaps and rejections"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|