Module: Brainiac::Plugins::Basecamp::ReviewGate
- Defined in:
- lib/brainiac/plugins/basecamp/review_gate.rb
Overview
Review gate system for epic tasks.
After an implementation agent completes a task PR, gate agents are dispatched IN PARALLEL to review it. All gates must approve before the PR auto-merges into the epic branch.
Gate agents are triggered by:
- :agent_completed on the task (initial review)
- :pr_synchronized (re-review after fixes)
Gate agents do NOT get assigned the Fizzy card — they review the PR directly on GitHub using their bot app identities.
Configuration in ~/.brainiac/basecamp.json:
"review_gates": ["GLaDOS", "Threepio"]
The agent's role is looked up from ~/.brainiac/agents.json and used to determine review focus (test-engineer -> testing, code-reviewer -> quality, etc.)
All gates run in parallel by default.
Gates can also be triggered by a Fizzy card tag "review-gates" for non-epic PRs.
Constant Summary collapse
- BRAINIAC_DIR =
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
- RESPONDED_STATUSES =
%w[approved changes_requested].freeze
Class Method Summary collapse
-
.all_gates_passed?(task) ⇒ Boolean
Check if all gates have approved for a task.
- .all_gates_responded?(task) ⇒ Boolean
-
.build_gate_summary_comment(task, pr_url:) ⇒ String
Build the summary comment for Fizzy after all gates pass and merge completes.
- .changes_requested?(task) ⇒ Boolean
-
.dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:) ⇒ Array<String>
Dispatch all gate agents to review a PR in parallel.
-
.enabled? ⇒ Boolean
Check if review gates are configured.
-
.gate_states(task) ⇒ Object
Each configured gate gets one durable state record, keyed by agent name.
-
.gates ⇒ Array<Hash>
Get the configured review gates as normalized hashes.
-
.record_approval(task, agent:, role:) ⇒ Object
Record a gate approval.
- .record_changes_requested(task, agent:, role:) ⇒ Object
-
.redispatch_stale_gates(epic:, task:, pr_number:, repo_name:, repo_path:) ⇒ Object
Re-dispatch only stale records.
-
.reset_approvals(task) ⇒ Object
Reset gate approvals (when changes are requested and code is updated).
- .responded_count(task) ⇒ Object
-
.sync_from_github(task, repo_path:) ⇒ Hash
Sync gate approvals from GitHub PR reviews (self-healing).
-
.tag_triggered?(tags) ⇒ Boolean
Check if a Fizzy card has the review-gates tag (for non-epic PRs).
Class Method Details
.all_gates_passed?(task) ⇒ Boolean
Check if all gates have approved for a task.
66 67 68 69 70 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 66 def all_gates_passed?(task) return true unless enabled? gate_states(task).values.all? { |state| state["status"] == "approved" } end |
.all_gates_responded?(task) ⇒ Boolean
72 73 74 75 76 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 72 def all_gates_responded?(task) return true unless enabled? gate_states(task).values.all? { |state| RESPONDED_STATUSES.include?(state["status"]) } end |
.build_gate_summary_comment(task, pr_url:) ⇒ String
Build the summary comment for Fizzy after all gates pass and merge completes.
255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 255 def build_gate_summary_comment(task, pr_url:) approvals = gate_states(task).values.select { |state| state["status"] == "approved" } lines = [] lines << "<p>✅ <strong>All review gates passed</strong> — merged into epic branch.</p>" lines << "<p><a href=\"#{pr_url}\">PR Link</a></p>" lines << "<ul>" approvals.each do |approval| lines << "<li>#{approval['agent']} (#{approval['role']}): approved</li>" end lines << "</ul>" lines.join("\n") end |
.changes_requested?(task) ⇒ Boolean
78 79 80 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 78 def changes_requested?(task) gate_states(task).values.any? { |state| state["status"] == "changes_requested" } end |
.dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:) ⇒ Array<String>
Dispatch all gate agents to review a PR in parallel. Uses brainiac-github's app client to post review requests as each bot.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 201 def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:) dispatched = [] gate_states(task).each_value do |state| next unless state["status"] == "pending" agent_name = state["agent"] role = state["role"] || "review" LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}" if defined?(LOG) # Dispatch the gate agent via brainiac-github's PR review mechanism. # The agent gets the PR diff and reviews it using their bot identity. Thread.new do dispatch_agent_for_review( agent_name: agent_name, role: role, pr_number: pr_number, repo_name: repo_name, repo_path: repo_path, card_number: task["fizzy_card"], epic: epic ) rescue StandardError => e LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.}" if defined?(LOG) end dispatched << agent_name state["status"] = "dispatched" state["dispatched_at"] = Time.now.iso8601 state["dispatch_count"] += 1 end # Update task state TaskState.transition!(task, :submit_for_review, triggered_by: "review_gate_dispatch") dispatched end |
.enabled? ⇒ Boolean
Check if review gates are configured.
58 59 60 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 58 def enabled? gates.any? end |
.gate_states(task) ⇒ Object
Each configured gate gets one durable state record, keyed by agent name. This also migrates persisted state from releases that used flat arrays.
NOTE: This method intentionally MUTATES the task hash on read — it lazily initializes gate entries for any newly-configured gates. This means adding a gate to config and calling gate_states on a persisted task will insert a new "pending" entry automatically. Do not refactor into a pure reader.
93 94 95 96 97 98 99 100 101 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 93 def gate_states(task) migrate_legacy_gate_state!(task) task["gate_states"] ||= {} gates.each do |gate| key = gate_key(gate["agent"]) task["gate_states"][key] ||= new_gate_state(gate) end task["gate_states"] end |
.gates ⇒ Array<Hash>
Get the configured review gates as normalized hashes. Supports both old format [role:] and new format ["AgentName"]
40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 40 def gates raw = Config.current["review_gates"] || [] raw.map do |entry| if entry.is_a?(Hash) # Old format: {"agent": "GLaDOS", "role": "testing"} entry else # New format: just agent name string — look up role from registry agent_name = entry.to_s role = lookup_agent_role(agent_name) { "agent" => agent_name, "role" => role } end end end |
.record_approval(task, agent:, role:) ⇒ Object
Record a gate approval.
164 165 166 167 168 169 170 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 164 def record_approval(task, agent:, role:) state = gate_state(task, agent, role) changed = state["status"] != "approved" state["status"] = "approved" state["responded_at"] = Time.now.iso8601 if changed || state["responded_at"].nil? state end |
.record_changes_requested(task, agent:, role:) ⇒ Object
172 173 174 175 176 177 178 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 172 def record_changes_requested(task, agent:, role:) state = gate_state(task, agent, role) changed = state["status"] != "changes_requested" state["status"] = "changes_requested" state["responded_at"] = Time.now.iso8601 if changed || state["responded_at"].nil? state end |
.redispatch_stale_gates(epic:, task:, pr_number:, repo_name:, repo_path:) ⇒ Object
Re-dispatch only stale records. A gate that has used its full retry budget becomes explicitly timed_out instead of silently disappearing.
242 243 244 245 246 247 248 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 242 def redispatch_stale_gates(epic:, task:, pr_number:, repo_name:, repo_path:) stale_gate_states(task).each do |state| state["status"] = state["dispatch_count"] > MAX_GATE_REDISPATCH_RETRIES ? "timed_out" : "pending" end dispatch_gates(epic: epic, task: task, pr_number: pr_number, repo_name: repo_name, repo_path: repo_path) end |
.reset_approvals(task) ⇒ Object
Reset gate approvals (when changes are requested and code is updated).
183 184 185 186 187 188 189 190 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 183 def reset_approvals(task) gate_states(task).each_value do |state| state["status"] = "pending" state["dispatched_at"] = nil state["responded_at"] = nil state["dispatch_count"] = 0 end end |
.responded_count(task) ⇒ Object
82 83 84 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 82 def responded_count(task) gate_states(task).values.count { |state| RESPONDED_STATUSES.include?(state["status"]) } end |
.sync_from_github(task, repo_path:) ⇒ Hash
Sync gate approvals from GitHub PR reviews (self-healing). Queries actual PR review state and updates task accordingly.
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 109 def sync_from_github(task, repo_path:) return { synced: false, reason: "no PR" } unless task["pr_number"] pr_number = task["pr_number"] stdout, _, status = Open3.capture3( "gh", "pr", "view", pr_number.to_s, "--json", "reviews", "--jq", ".reviews[] | [.author.login, .state] | @tsv", chdir: repo_path ) return { synced: false, reason: "gh failed" } unless status.success? # Parse reviews into {author => state} reviews = {} stdout.each_line do |line| , state = line.strip.split("\t") reviews[.downcase] = state.downcase if && state end changes = { approvals_added: [], changes_cleared: [] } # Check each gate agent's review state gates.each do |gate| agent = gate["agent"] role = gate["role"] || "review" # Match agent to GitHub login (agent-brainiac pattern) github_login = "#{agent.downcase}-brainiac" review_state = reviews[github_login] next unless review_state if review_state == "approved" state = gate_state(task, agent, role) was_changes_requested = state["status"] == "changes_requested" unless state["status"] == "approved" record_approval(task, agent: agent, role: role) changes[:approvals_added] << agent end changes[:changes_cleared] << agent if was_changes_requested elsif review_state == "changes_requested" record_changes_requested(task, agent: agent, role: role) end end { synced: true, changes: changes } rescue StandardError => e { synced: false, reason: e. } end |
.tag_triggered?(tags) ⇒ Boolean
Check if a Fizzy card has the review-gates tag (for non-epic PRs).
272 273 274 275 |
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 272 def tag_triggered?() tag_names = .map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase) tag_names.include?("review-gates") || tag_names.include?("qa") end |