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.
@@ -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