brainiac-github 0.3.2 → 0.3.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 708e7c1fc05829642c2f04b08c5d12eb3350bcf40f6cfbd7b923e94252718d33
4
- data.tar.gz: de33a6acca8074115ea7c8102828acc830eb2f43e453407273156acb6bc6e274
3
+ metadata.gz: 49d9d73cefe9f24ff840f683ea53a4ff2276cbd89495037765dcc65fb3370fae
4
+ data.tar.gz: 9de4f939b32f3f7c6f802d64e5a5280cc2de108fc771212b865bb17b8a12d21e
5
5
  SHA512:
6
- metadata.gz: 707cec265ade4657632ce8ac14a17c30e6658b8fdf61e403b0375d9979d3aae2db05f6c16c0c830dadc257a66839de4f435f70de185ac2446cd3d3ccab331286
7
- data.tar.gz: f645bfe2c9d66169a1a0293920919f7783776a784363c9892264419b87ee88b81ba8243828d256f54bf44e6b9637a51c787496f2eacb217c3fc42558dab34ca3
6
+ metadata.gz: f3b54d02be22dbc9072aa3170a7073ef912b40f9555b5fdfde9a0cea3c4212fcf84c5e797026cdf094b1ef4e220804784a30361e64377e00470a785e6481d2e6
7
+ data.tar.gz: 6cc367893d43b6276e141902e21b17e48c416a84c76e989bc0d34620223ee47b52fd02ec01650684e3adde7410d39ac42e9de8eeece11a895c43aea2e7d1b414
@@ -4,6 +4,17 @@ module Brainiac
4
4
  module Plugins
5
5
  module Github
6
6
  module Handler
7
+ # Marker that starts every crash-notification comment (see register_crash_handler!).
8
+ CRASH_COMMENT_PREFIX = "💥"
9
+
10
+ # Circuit breaker: max comment dispatches per (repo, PR, agent) within the window.
11
+ # Stops any comment -> dispatch -> crash -> comment feedback loop, regardless of
12
+ # who posted the comment or what it says.
13
+ COMMENT_DISPATCH_LIMIT = 4
14
+ COMMENT_DISPATCH_WINDOW = 120 # seconds
15
+ COMMENT_DISPATCH_LOCK = Mutex.new
16
+ COMMENT_DISPATCH_TIMES = Hash.new { |h, k| h[k] = [] }
17
+
7
18
  class << self
8
19
  def handle_pr_merged(payload)
9
20
  pr = payload["pull_request"]
@@ -181,13 +192,14 @@ module Brainiac
181
192
  result = find_work_item_by_branch(branch)
182
193
 
183
194
  if result
184
- _internal_id, card_info = result
195
+ work_item_id, card_info = result
185
196
  card_number = extract_card_number(card_info)
186
197
  unless card_number
187
198
  LOG.warn "Card has no number — can't dispatch review"
188
199
  return [200, { status: "ignored", reason: "card has no number" }.to_json]
189
200
  end
190
201
  else
202
+ work_item_id = nil
191
203
  card_info = {}
192
204
  card_number = nil
193
205
  end
@@ -236,7 +248,7 @@ module Brainiac
236
248
  card_context = card_number ? " for card ##{card_number}" : ""
237
249
  LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number}#{card_context} (project: #{project_key})"
238
250
  dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
239
- repo_name, project_key, project_config, repo_path)
251
+ repo_name, project_key, project_config, repo_path, work_item_id: work_item_id)
240
252
 
241
253
  [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json]
242
254
  rescue StandardError => e
@@ -253,20 +265,8 @@ module Brainiac
253
265
  comment_user_type = comment.dig("user", "type")
254
266
  repo_name = payload.dig("repository", "full_name")
255
267
 
256
- unless issue["pull_request"]
257
- LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
258
- return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
259
- end
260
-
261
- # If the PR was opened by a remote agent bot (not local to this machine),
262
- # skip dispatch — the other machine that owns the agent will handle it.
263
- pr_author = issue.dig("user", "login")
264
- pr_author_type = issue.dig("user", "type")
265
- unless pr_author_owned_locally?(pr_author, pr_author_type)
266
- pr_number = issue["number"]
267
- LOG.info "[GitHub] PR ##{pr_number} owned by remote agent (#{pr_author}) — skipping dispatch"
268
- return [200, { status: "ignored", reason: "PR owned by remote agent" }.to_json]
269
- end
268
+ rejection = issue_comment_rejection(issue, comment_body)
269
+ return rejection if rejection
270
270
 
271
271
  project_result = identify_project_by_repo(repo_name)
272
272
  unless project_result
@@ -300,6 +300,12 @@ module Brainiac
300
300
  bot_ignore_reason = bot_comment_ignore_reason(comment_user, comment_user_type, agent_name, pr_number)
301
301
  return bot_ignore_reason if bot_ignore_reason
302
302
 
303
+ if comment_dispatch_throttled?(repo_name, pr_number, agent_name)
304
+ LOG.warn "[GitHub] PR ##{pr_number}: >#{COMMENT_DISPATCH_LIMIT} dispatches for #{agent_name} in " \
305
+ "#{COMMENT_DISPATCH_WINDOW}s — possible loop, ignoring comment"
306
+ return [200, { status: "ignored", reason: "dispatch rate limit" }.to_json]
307
+ end
308
+
303
309
  branch = fetch_pr_branch(repo_name, pr_number, agent_name, project_config)
304
310
  result = find_work_item_by_branch(branch)
305
311
 
@@ -420,6 +426,42 @@ module Brainiac
420
426
  pr_author_owned_locally?(pr_author, pr_author_type)
421
427
  end
422
428
 
429
+ # Early-exit guards for issue_comment events. Returns a Rack-style response
430
+ # array when the comment should be ignored, or nil to continue dispatch.
431
+ def issue_comment_rejection(issue, comment_body)
432
+ unless issue["pull_request"]
433
+ LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
434
+ return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
435
+ end
436
+
437
+ # If the PR was opened by a remote agent bot (not local to this machine),
438
+ # skip dispatch — the other machine that owns the agent will handle it.
439
+ pr_author = issue.dig("user", "login")
440
+ pr_author_type = issue.dig("user", "type")
441
+ unless pr_author_owned_locally?(pr_author, pr_author_type)
442
+ LOG.info "[GitHub] PR ##{issue["number"]} owned by remote agent (#{pr_author}) — skipping dispatch"
443
+ return [200, { status: "ignored", reason: "PR owned by remote agent" }.to_json]
444
+ end
445
+
446
+ # Crash notifications are posted via `gh` as the human's account when the app
447
+ # client isn't configured, so the bot-login checks below can't catch them.
448
+ # Without this guard each crash comment re-dispatches the agent, which crashes
449
+ # again, forever.
450
+ if crash_notification_comment?(comment_body)
451
+ LOG.info "[GitHub] Ignoring crash notification comment on PR ##{issue["number"]}"
452
+ return [200, { status: "ignored", reason: "crash notification" }.to_json]
453
+ end
454
+
455
+ nil
456
+ end
457
+
458
+ # True when a comment is a crash notification posted by the crash handler.
459
+ # These are posted via `gh` as the human account when no app client is
460
+ # configured, so the bot-login checks can't catch them — match on the prefix.
461
+ def crash_notification_comment?(comment_body)
462
+ comment_body.to_s.lstrip.start_with?(CRASH_COMMENT_PREFIX)
463
+ end
464
+
423
465
  # Check PR ownership given the author login and account type.
424
466
  # Used by both pull_request_review (has full PR object) and issue_comment
425
467
  # (has issue.user which is the PR author on PR-linked issues).
@@ -630,6 +672,20 @@ module Brainiac
630
672
  # 1. Self-triggered: bot matches the dispatching agent (prevents loops)
631
673
  # 2. Remote agent: bot matches a known agent that isn't local (other machine handles it)
632
674
  # Allows: cross-agent LOCAL bot comments (e.g. GLaDOS commenting on Galen's PR)
675
+ # Records a dispatch attempt; true when the (repo, PR, agent) is over the limit.
676
+ def comment_dispatch_throttled?(repo_name, pr_number, agent_name)
677
+ key = [repo_name, pr_number, agent_name.to_s.downcase]
678
+ now = Time.now.to_f
679
+ COMMENT_DISPATCH_LOCK.synchronize do
680
+ times = COMMENT_DISPATCH_TIMES[key]
681
+ times.reject! { |t| now - t > COMMENT_DISPATCH_WINDOW }
682
+ return true if times.size >= COMMENT_DISPATCH_LIMIT
683
+
684
+ times << now
685
+ false
686
+ end
687
+ end
688
+
633
689
  def bot_comment_ignore_reason(comment_user, comment_user_type, agent_name, pr_number)
634
690
  return nil unless comment_user_type == "Bot"
635
691
 
@@ -742,23 +798,15 @@ module Brainiac
742
798
 
743
799
  intent_ctx = fetch_pr_intent_context(pr_number, repo_name, agent_name)
744
800
  agent_env = github_agent_env(agent_name, repo_name)
745
- # detect_model_explicit is newer core; fall back to detect_model on older builds.
746
- if respond_to?(:detect_model_explicit)
747
- comment_model, comment_model_explicit = detect_model_explicit(project_config, text: comment_body)
748
- else
749
- comment_model = detect_model(project_config, text: comment_body)
750
- comment_model_explicit = nil
751
- end
752
801
  run_agent_opts = { project_config: project_config, chdir: worktree,
753
802
  log_name: "pr-comment-#{pr_number}",
754
- model: comment_model,
755
- effort: detect_effort(project_config, text: comment_body),
756
803
  agent_name: agent_name, source: :github,
757
804
  source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree },
758
805
  message: comment_body, channel: "GitHub PR comment",
759
806
  context: intent_ctx }
807
+ work_item_id = find_work_item_by_worktree(worktree)&.first if respond_to?(:find_work_item_by_worktree, true)
808
+ run_agent_opts.merge!(dispatch_override_opts(project_config, comment_body, agent_name, work_item_id))
760
809
  run_agent_opts[:env] = agent_env if method(:run_agent).parameters.flatten.include?(:env)
761
- run_agent_opts[:explicit_model] = comment_model_explicit if method(:run_agent).parameters.flatten.include?(:explicit_model)
762
810
  pid, log_file = run_agent(prompt, **run_agent_opts)
763
811
  return unless pid
764
812
 
@@ -766,7 +814,7 @@ module Brainiac
766
814
  end
767
815
 
768
816
  def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer,
769
- repo_name, project_key, project_config, repo_path)
817
+ repo_name, project_key, project_config, repo_path, work_item_id: nil)
770
818
  review_id = review["id"]
771
819
  agent_name = resolve_work_item_agent(card_info, project_config)
772
820
 
@@ -809,6 +857,7 @@ module Brainiac
809
857
  source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir },
810
858
  message: review["body"], channel: "GitHub PR review",
811
859
  context: fetch_pr_intent_context(pr_number, repo_name, agent_name) }
860
+ run_agent_opts.merge!(dispatch_override_opts(project_config, review["body"].to_s, agent_name, work_item_id))
812
861
  run_agent_opts[:env] = agent_env if method(:run_agent).parameters.flatten.include?(:env)
813
862
  pid, log_file = run_agent(prompt, **run_agent_opts)
814
863
  return unless pid
@@ -816,6 +865,36 @@ module Brainiac
816
865
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
817
866
  end
818
867
 
868
+ # run_agent options for the CLI provider, model, effort and profile of a dispatch.
869
+ # Core's resolve_dispatch_overrides parses the [cli:X]/[model]/[effort:X]/[p:X] tags,
870
+ # falls back to the work item's stored overrides (so a card switched to claude in
871
+ # Fizzy stays on claude for PR comments), resolves the model through the provider
872
+ # that will actually run, and persists new tags. Older core builds lack it, so fall
873
+ # back to the per-tag detectors there.
874
+ def dispatch_override_opts(project_config, text, agent_name, work_item_id)
875
+ supported = method(:run_agent).parameters.flatten
876
+ opts = if respond_to?(:resolve_dispatch_overrides, true)
877
+ overrides = resolve_dispatch_overrides(project_config, text: text, agent_name: agent_name,
878
+ work_item_id: work_item_id)
879
+ { cli_provider: overrides[:cli_provider], model: overrides[:model], effort: overrides[:effort],
880
+ profile: overrides[:profile], explicit_model: overrides[:explicit_model] }
881
+ else
882
+ legacy_override_opts(project_config, text)
883
+ end
884
+ opts.compact.slice(*supported)
885
+ end
886
+
887
+ def legacy_override_opts(project_config, text)
888
+ cli_provider = respond_to?(:detect_cli_provider, true) ? detect_cli_provider(text: text) : nil
889
+ if respond_to?(:detect_model_explicit, true)
890
+ model, explicit = detect_model_explicit(project_config, text: text, cli_provider_override: cli_provider)
891
+ else
892
+ model = detect_model(project_config, text: text)
893
+ end
894
+ { cli_provider: cli_provider, model: model, explicit_model: explicit,
895
+ effort: detect_effort(project_config, text: text) }
896
+ end
897
+
819
898
  def build_review_context(reviewer, review, pr_number, repo_name, agent_key = nil)
820
899
  context = "GitHub PR Review from @#{reviewer}:\n\n"
821
900
  context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Github
6
- VERSION = "0.3.2"
6
+ VERSION = "0.3.4"
7
7
  end
8
8
  end
9
9
  end
@@ -67,7 +67,8 @@ module Brainiac
67
67
  agent_key = ctx[:agent_key]
68
68
  snippet = ctx[:snippet]
69
69
  snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
70
- comment_body = "💥 **#{agent_display} crashed** (exit code #{ctx[:exit_status]})\n\nLog: `#{ctx[:log_file]}`#{snippet_block}"
70
+ comment_body = "#{Handler::CRASH_COMMENT_PREFIX} **#{agent_display} crashed** " \
71
+ "(exit code #{ctx[:exit_status]})\n\nLog: `#{ctx[:log_file]}`#{snippet_block}"
71
72
 
72
73
  begin
73
74
  if AppClient.configured?(agent_key)
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.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis