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

Class Method Details

.all_gates_passed?(task) ⇒ Boolean

Check if all gates have approved for a task.

Parameters:

  • task (Hash)

    Task state from epic

Returns:

  • (Boolean)


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

Returns:

  • (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.

Parameters:

  • task (Hash)

    Task state

  • pr_url (String)

    PR URL

Returns:

  • (String)

    HTML comment for Fizzy



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

Returns:

  • (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.

Parameters:

  • epic (Hash)

    Epic state

  • task (Hash)

    Task state

  • pr_number (Integer, String)

    PR number

  • repo_name (String)

    e.g. "stowzilla/brainiac-basecamp"

  • repo_path (String)

    Local repo path

Returns:

  • (Array<String>)

    Agent names dispatched



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.message}" 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.

Returns:

  • (Boolean)


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

.gatesArray<Hash>

Get the configured review gates as normalized hashes. Supports both old format [role:] and new format ["AgentName"]

Returns:

  • (Array<Hash>)

    Gate configs [role:]



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.

Parameters:

  • task (Hash)

    Task state (mutated in place)

  • agent (String)

    Agent that approved

  • role (String)

    Gate role



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

Parameters:

  • task (Hash)

    Task state (mutated in place)



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.

Parameters:

  • task (Hash)

    Task state (mutated in place)

  • repo_path (String)

    Path to repo for gh CLI

Returns:

  • (Hash)

    Summary of changes made



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|
    author, state = line.strip.split("\t")
    reviews[author.downcase] = state.downcase if author && 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)
     = "#{agent.downcase}-brainiac"
    review_state = reviews[]

    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.message }
end

.tag_triggered?(tags) ⇒ Boolean

Check if a Fizzy card has the review-gates tag (for non-epic PRs).

Parameters:

  • tags (Array)

    Fizzy card tags

Returns:

  • (Boolean)


272
273
274
275
# File 'lib/brainiac/plugins/basecamp/review_gate.rb', line 272

def tag_triggered?(tags)
  tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
  tag_names.include?("review-gates") || tag_names.include?("qa")
end