brainiac-basecamp 0.0.1
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 +322 -0
- data/lib/brainiac/plugins/basecamp/cli.rb +411 -0
- data/lib/brainiac/plugins/basecamp/client.rb +174 -0
- data/lib/brainiac/plugins/basecamp/config.rb +115 -0
- data/lib/brainiac/plugins/basecamp/epic.rb +200 -0
- data/lib/brainiac/plugins/basecamp/epic_branch.rb +271 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +820 -0
- data/lib/brainiac/plugins/basecamp/metadata.rb +20 -0
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +722 -0
- data/lib/brainiac/plugins/basecamp/prompts.rb +41 -0
- data/lib/brainiac/plugins/basecamp/review_gate.rb +383 -0
- data/lib/brainiac/plugins/basecamp/version.rb +9 -0
- data/lib/brainiac/plugins/basecamp/webhook.rb +189 -0
- data/lib/brainiac/plugins/basecamp.rb +314 -0
- data/lib/brainiac_basecamp.rb +4 -0
- metadata +126 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Basecamp
|
|
6
|
+
# Prompt templates for Basecamp-aware agent sessions.
|
|
7
|
+
module Prompts
|
|
8
|
+
# Injected into agent prompts when working on an epic task.
|
|
9
|
+
# Placeholders use %{name} format (rendered by the orchestrator).
|
|
10
|
+
EPIC_CONTEXT = <<~'PROMPT'
|
|
11
|
+
## Basecamp Epic Context
|
|
12
|
+
|
|
13
|
+
You are working on a task that is part of a larger epic managed in Basecamp.
|
|
14
|
+
The orchestrator will automatically advance to the next task when you complete this one.
|
|
15
|
+
|
|
16
|
+
**Important:**
|
|
17
|
+
- Focus only on this specific card's requirements
|
|
18
|
+
- When done, commit and push as normal — the orchestrator handles sequencing
|
|
19
|
+
- If you discover the task needs changes to the plan (new dependencies, scope change),
|
|
20
|
+
mention it in your Fizzy comment so the human can adjust the Basecamp epic
|
|
21
|
+
|
|
22
|
+
Epic: %{epic_title}
|
|
23
|
+
Progress: %{epic_progress}
|
|
24
|
+
Your task: %{task_title} (Fizzy #%{fizzy_card})
|
|
25
|
+
%{dependencies}
|
|
26
|
+
PROMPT
|
|
27
|
+
|
|
28
|
+
# Template for the Basecamp channel (if agents post directly to Basecamp).
|
|
29
|
+
# Currently unused — agents post to Fizzy/Discord, orchestrator posts to Basecamp.
|
|
30
|
+
CHANNEL = <<~'PROMPT'
|
|
31
|
+
## Basecamp Communication
|
|
32
|
+
|
|
33
|
+
When referencing Basecamp items:
|
|
34
|
+
- Use the basecamp CLI for queries: `basecamp todos list --in <project> --json`
|
|
35
|
+
- Format card references as: Fizzy #NNNN
|
|
36
|
+
- Keep Basecamp comments brief — detailed discussion happens in Fizzy/Discord
|
|
37
|
+
PROMPT
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Review gate system for epic tasks.
|
|
9
|
+
#
|
|
10
|
+
# After an implementation agent completes a task PR, gate agents are
|
|
11
|
+
# dispatched IN PARALLEL to review it. All gates must approve before
|
|
12
|
+
# the PR auto-merges into the epic branch.
|
|
13
|
+
#
|
|
14
|
+
# Gate agents are triggered by:
|
|
15
|
+
# - :agent_completed on the task (initial review)
|
|
16
|
+
# - :pr_synchronized (re-review after fixes)
|
|
17
|
+
#
|
|
18
|
+
# Gate agents do NOT get assigned the Fizzy card — they review the PR
|
|
19
|
+
# directly on GitHub using their bot app identities.
|
|
20
|
+
#
|
|
21
|
+
# Configuration in ~/.brainiac/basecamp.json:
|
|
22
|
+
# "review_gates": ["GLaDOS", "Threepio"]
|
|
23
|
+
#
|
|
24
|
+
# The agent's role is looked up from ~/.brainiac/agents.json and used
|
|
25
|
+
# to determine review focus (test-engineer -> testing, code-reviewer -> quality, etc.)
|
|
26
|
+
#
|
|
27
|
+
# All gates run in parallel by default.
|
|
28
|
+
#
|
|
29
|
+
# Gates can also be triggered by a Fizzy card tag "review-gates" for non-epic PRs.
|
|
30
|
+
module ReviewGate
|
|
31
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Get the configured review gates as normalized hashes.
|
|
35
|
+
# Supports both old format [{agent:, role:}] and new format ["AgentName"]
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<Hash>] Gate configs [{agent:, role:}]
|
|
38
|
+
def gates
|
|
39
|
+
raw = Config.current["review_gates"] || []
|
|
40
|
+
raw.map do |entry|
|
|
41
|
+
if entry.is_a?(Hash)
|
|
42
|
+
# Old format: {"agent": "GLaDOS", "role": "testing"}
|
|
43
|
+
entry
|
|
44
|
+
else
|
|
45
|
+
# New format: just agent name string — look up role from registry
|
|
46
|
+
agent_name = entry.to_s
|
|
47
|
+
role = lookup_agent_role(agent_name)
|
|
48
|
+
{ "agent" => agent_name, "role" => role }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if review gates are configured.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def enabled?
|
|
57
|
+
gates.any?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if all gates have approved for a task.
|
|
61
|
+
#
|
|
62
|
+
# @param task [Hash] Task state from epic
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def all_gates_passed?(task)
|
|
65
|
+
return true unless enabled?
|
|
66
|
+
|
|
67
|
+
approvals = task["gate_approvals"] || []
|
|
68
|
+
required_agents = gates.map { |g| g["agent"].downcase }
|
|
69
|
+
approved_agents = approvals.map { |a| a["agent"].downcase }
|
|
70
|
+
|
|
71
|
+
required_agents.all? { |agent| approved_agents.include?(agent) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Sync gate approvals from GitHub PR reviews (self-healing).
|
|
75
|
+
# Queries actual PR review state and updates task accordingly.
|
|
76
|
+
#
|
|
77
|
+
# @param task [Hash] Task state (mutated in place)
|
|
78
|
+
# @param repo_path [String] Path to repo for gh CLI
|
|
79
|
+
# @return [Hash] Summary of changes made
|
|
80
|
+
def sync_from_github(task, repo_path:)
|
|
81
|
+
return { synced: false, reason: "no PR" } unless task["pr_number"]
|
|
82
|
+
|
|
83
|
+
pr_number = task["pr_number"]
|
|
84
|
+
stdout, _, status = Open3.capture3(
|
|
85
|
+
"gh", "pr", "view", pr_number.to_s,
|
|
86
|
+
"--json", "reviews",
|
|
87
|
+
"--jq", ".reviews[] | [.author.login, .state] | @tsv",
|
|
88
|
+
chdir: repo_path
|
|
89
|
+
)
|
|
90
|
+
return { synced: false, reason: "gh failed" } unless status.success?
|
|
91
|
+
|
|
92
|
+
# Parse reviews into {author => state}
|
|
93
|
+
reviews = {}
|
|
94
|
+
stdout.each_line do |line|
|
|
95
|
+
author, state = line.strip.split("\t")
|
|
96
|
+
reviews[author.downcase] = state.downcase if author && state
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
changes = { approvals_added: [], changes_cleared: [] }
|
|
100
|
+
|
|
101
|
+
# Check each gate agent's review state
|
|
102
|
+
gates.each do |gate|
|
|
103
|
+
agent = gate["agent"]
|
|
104
|
+
role = gate["role"] || "review"
|
|
105
|
+
|
|
106
|
+
# Match agent to GitHub login (agent-brainiac pattern)
|
|
107
|
+
github_login = "#{agent.downcase}-brainiac"
|
|
108
|
+
review_state = reviews[github_login]
|
|
109
|
+
|
|
110
|
+
next unless review_state
|
|
111
|
+
|
|
112
|
+
if review_state == "approved"
|
|
113
|
+
# Record approval if not already recorded
|
|
114
|
+
unless (task["gate_approvals"] || []).any? { |a| a["agent"].downcase == agent.downcase }
|
|
115
|
+
record_approval(task, agent: agent, role: role)
|
|
116
|
+
changes[:approvals_added] << agent
|
|
117
|
+
end
|
|
118
|
+
# Clear from changes_requested if present
|
|
119
|
+
if task["changes_requested_by"]&.include?(agent)
|
|
120
|
+
task["changes_requested_by"].delete(agent)
|
|
121
|
+
changes[:changes_cleared] << agent
|
|
122
|
+
end
|
|
123
|
+
elsif review_state == "changes_requested"
|
|
124
|
+
# Remove any stale approval
|
|
125
|
+
task["gate_approvals"]&.reject! { |a| a["agent"].downcase == agent.downcase }
|
|
126
|
+
# Track changes_requested
|
|
127
|
+
task["changes_requested_by"] ||= []
|
|
128
|
+
task["changes_requested_by"] << agent unless task["changes_requested_by"].include?(agent)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
{ synced: true, changes: changes }
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
{ synced: false, reason: e.message }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Record a gate approval.
|
|
138
|
+
#
|
|
139
|
+
# @param task [Hash] Task state (mutated in place)
|
|
140
|
+
# @param agent [String] Agent that approved
|
|
141
|
+
# @param role [String] Gate role
|
|
142
|
+
def record_approval(task, agent:, role:)
|
|
143
|
+
task["gate_approvals"] ||= []
|
|
144
|
+
# Don't duplicate
|
|
145
|
+
return if task["gate_approvals"].any? { |a| a["agent"].downcase == agent.downcase }
|
|
146
|
+
|
|
147
|
+
task["gate_approvals"] << {
|
|
148
|
+
"agent" => agent,
|
|
149
|
+
"role" => role,
|
|
150
|
+
"approved_at" => Time.now.iso8601
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Reset gate approvals (when changes are requested and code is updated).
|
|
155
|
+
#
|
|
156
|
+
# @param task [Hash] Task state (mutated in place)
|
|
157
|
+
def reset_approvals(task)
|
|
158
|
+
task["gate_approvals"] = []
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Dispatch all gate agents to review a PR in parallel.
|
|
162
|
+
# Uses brainiac-github's app client to post review requests as each bot.
|
|
163
|
+
#
|
|
164
|
+
# @param epic [Hash] Epic state
|
|
165
|
+
# @param task [Hash] Task state
|
|
166
|
+
# @param pr_number [Integer, String] PR number
|
|
167
|
+
# @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
|
|
168
|
+
# @param repo_path [String] Local repo path
|
|
169
|
+
# @return [Array<String>] Agent names dispatched
|
|
170
|
+
def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
|
|
171
|
+
dispatched = []
|
|
172
|
+
|
|
173
|
+
gates.each do |gate|
|
|
174
|
+
agent_name = gate["agent"]
|
|
175
|
+
role = gate["role"] || "review"
|
|
176
|
+
|
|
177
|
+
LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}" if defined?(LOG)
|
|
178
|
+
|
|
179
|
+
# Dispatch the gate agent via brainiac-github's PR review mechanism.
|
|
180
|
+
# The agent gets the PR diff and reviews it using their bot identity.
|
|
181
|
+
Thread.new do
|
|
182
|
+
dispatch_agent_for_review(
|
|
183
|
+
agent_name: agent_name,
|
|
184
|
+
role: role,
|
|
185
|
+
pr_number: pr_number,
|
|
186
|
+
repo_name: repo_name,
|
|
187
|
+
repo_path: repo_path,
|
|
188
|
+
card_number: task["fizzy_card"],
|
|
189
|
+
epic: epic
|
|
190
|
+
)
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
dispatched << agent_name
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Update task state
|
|
199
|
+
task["status"] = "in_review"
|
|
200
|
+
task["gates_dispatched_at"] = Time.now.iso8601
|
|
201
|
+
task["gate_approvals"] ||= []
|
|
202
|
+
|
|
203
|
+
dispatched
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Build the summary comment for Fizzy after all gates pass and merge completes.
|
|
207
|
+
#
|
|
208
|
+
# @param task [Hash] Task state
|
|
209
|
+
# @param pr_url [String] PR URL
|
|
210
|
+
# @return [String] HTML comment for Fizzy
|
|
211
|
+
def build_gate_summary_comment(task, pr_url:)
|
|
212
|
+
approvals = task["gate_approvals"] || []
|
|
213
|
+
lines = []
|
|
214
|
+
lines << "<p>✅ <strong>All review gates passed</strong> — merged into epic branch.</p>"
|
|
215
|
+
lines << "<p><a href=\"#{pr_url}\">PR Link</a></p>"
|
|
216
|
+
lines << "<ul>"
|
|
217
|
+
approvals.each do |approval|
|
|
218
|
+
lines << "<li>#{approval['agent']} (#{approval['role']}): approved</li>"
|
|
219
|
+
end
|
|
220
|
+
lines << "</ul>"
|
|
221
|
+
lines.join("\n")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Check if a Fizzy card has the review-gates tag (for non-epic PRs).
|
|
225
|
+
#
|
|
226
|
+
# @param tags [Array] Fizzy card tags
|
|
227
|
+
# @return [Boolean]
|
|
228
|
+
def tag_triggered?(tags)
|
|
229
|
+
tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
|
|
230
|
+
tag_names.include?("review-gates") || tag_names.include?("qa")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
# Dispatch a single gate agent to review a PR.
|
|
236
|
+
# This creates a review prompt and runs the agent in the repo directory.
|
|
237
|
+
def dispatch_agent_for_review(agent_name:, role:, pr_number:, repo_name:, repo_path:, card_number:, epic:)
|
|
238
|
+
# Build a review-specific prompt for the gate agent
|
|
239
|
+
prompt = build_gate_review_prompt(
|
|
240
|
+
agent_name: agent_name,
|
|
241
|
+
role: role,
|
|
242
|
+
pr_number: pr_number,
|
|
243
|
+
repo_name: repo_name,
|
|
244
|
+
card_number: card_number,
|
|
245
|
+
epic_title: epic["title"]
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Resolve the agent's GitHub token for their bot identity
|
|
249
|
+
agent_env = resolve_agent_github_env(agent_name, repo_name)
|
|
250
|
+
|
|
251
|
+
# Run the agent via the top-level helper method
|
|
252
|
+
# The run_agent method is defined in lib/brainiac/helpers.rb and loaded into main
|
|
253
|
+
pid = nil
|
|
254
|
+
log_file = nil
|
|
255
|
+
card_key = "gate-#{agent_name.downcase}-#{card_number}"
|
|
256
|
+
|
|
257
|
+
begin
|
|
258
|
+
pid, log_file = method(:run_agent).call(
|
|
259
|
+
prompt,
|
|
260
|
+
project_config: resolve_project_config(repo_path),
|
|
261
|
+
chdir: repo_path,
|
|
262
|
+
log_name: "gate-#{role}-#{card_number}",
|
|
263
|
+
agent_name: agent_name,
|
|
264
|
+
source: :github,
|
|
265
|
+
card_number: card_number,
|
|
266
|
+
env: agent_env
|
|
267
|
+
)
|
|
268
|
+
rescue NameError
|
|
269
|
+
# run_agent not available in this context — try calling via Object
|
|
270
|
+
if Object.respond_to?(:run_agent, true)
|
|
271
|
+
pid, log_file = Object.send(:run_agent,
|
|
272
|
+
prompt,
|
|
273
|
+
project_config: resolve_project_config(repo_path),
|
|
274
|
+
chdir: repo_path,
|
|
275
|
+
log_name: "gate-#{role}-#{card_number}",
|
|
276
|
+
agent_name: agent_name,
|
|
277
|
+
source: :github,
|
|
278
|
+
card_number: card_number,
|
|
279
|
+
env: agent_env)
|
|
280
|
+
else
|
|
281
|
+
LOG.warn "[Basecamp:ReviewGate] run_agent not available — gate dispatch skipped" if defined?(LOG)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Register session for waybar visibility
|
|
286
|
+
if pid && defined?(register_session)
|
|
287
|
+
register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
288
|
+
elsif pid && Object.respond_to?(:register_session, true)
|
|
289
|
+
Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Build the prompt for a gate review agent.
|
|
294
|
+
def build_gate_review_prompt(agent_name:, role:, pr_number:, repo_name:, card_number:, epic_title:)
|
|
295
|
+
<<~PROMPT
|
|
296
|
+
You are reviewing PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
|
|
297
|
+
Your role: **#{role}**
|
|
298
|
+
|
|
299
|
+
This is a review gate — the epic cannot proceed until you approve.
|
|
300
|
+
|
|
301
|
+
Review the PR changes with `gh pr diff #{pr_number}` and `gh pr view #{pr_number}`.
|
|
302
|
+
|
|
303
|
+
Based on your role (#{role}):
|
|
304
|
+
#{role_instructions(role)}
|
|
305
|
+
|
|
306
|
+
After your review:
|
|
307
|
+
- If the code meets your standards: `gh pr review #{pr_number} --approve --body "your summary"`
|
|
308
|
+
- If changes are needed: `gh pr review #{pr_number} --request-changes --body "what needs fixing"`
|
|
309
|
+
|
|
310
|
+
Be thorough but pragmatic. This is Fizzy card ##{card_number}.
|
|
311
|
+
|
|
312
|
+
IMPORTANT RESTRICTIONS:
|
|
313
|
+
- Do NOT open new PRs or modify code — you are a reviewer only
|
|
314
|
+
- Do NOT comment on the Fizzy card — your review goes on GitHub only
|
|
315
|
+
- Do NOT use the fizzy CLI at all
|
|
316
|
+
PROMPT
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Role-specific review instructions.
|
|
320
|
+
# Maps agent roles from the registry to review focus areas.
|
|
321
|
+
def role_instructions(role)
|
|
322
|
+
case role.to_s.downcase.gsub(/[-_]/, "")
|
|
323
|
+
# From registry role names
|
|
324
|
+
when "testengineer", "testing", "tests", "qa"
|
|
325
|
+
"- Verify tests exist for new functionality\n- Check test coverage\n- Run the test suite if possible\n- Flag missing edge cases"
|
|
326
|
+
when "codereviewer", "codequality", "quality"
|
|
327
|
+
"- Check code style and conventions\n- Look for code smells, duplication, complexity\n- Verify naming and structure\n- Ensure documentation for public interfaces"
|
|
328
|
+
when "securityengineer", "security"
|
|
329
|
+
"- Check for security vulnerabilities\n- Verify input validation\n- Check for secrets/credentials in code\n- Review auth/authz changes"
|
|
330
|
+
when "architect", "architecture"
|
|
331
|
+
"- Verify design patterns are followed\n- Check for proper separation of concerns\n- Review API design\n- Flag any architectural concerns"
|
|
332
|
+
when "androidengineer", "android"
|
|
333
|
+
"- Check Android-specific patterns and conventions\n- Verify lifecycle handling\n- Review resource usage and memory management"
|
|
334
|
+
when "frontenduxengineer", "frontend", "ux"
|
|
335
|
+
"- Check UI/UX patterns and accessibility\n- Verify responsive design\n- Review user interaction flows"
|
|
336
|
+
else
|
|
337
|
+
"- Review the changes thoroughly\n- Check for correctness and best practices"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Look up an agent's role from the registry.
|
|
342
|
+
#
|
|
343
|
+
# @param agent_name [String] Agent name
|
|
344
|
+
# @return [String] Role name or "reviewer" as default
|
|
345
|
+
def lookup_agent_role(agent_name)
|
|
346
|
+
agents_file = File.join(BRAINIAC_DIR, "agents.json")
|
|
347
|
+
return "reviewer" unless File.exist?(agents_file)
|
|
348
|
+
|
|
349
|
+
agents = JSON.parse(File.read(agents_file))
|
|
350
|
+
agent = agents[agent_name.downcase]
|
|
351
|
+
agent&.dig("role") || "reviewer"
|
|
352
|
+
rescue StandardError
|
|
353
|
+
"reviewer"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Resolve a project config from repo path.
|
|
357
|
+
def resolve_project_config(repo_path)
|
|
358
|
+
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
359
|
+
return {} unless File.exist?(projects_file)
|
|
360
|
+
|
|
361
|
+
projects = JSON.parse(File.read(projects_file))
|
|
362
|
+
projects.find { |_key, config| config["repo_path"] == repo_path }&.last || {}
|
|
363
|
+
rescue StandardError
|
|
364
|
+
{}
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Resolve GitHub env for an agent (GH_TOKEN from their app identity).
|
|
368
|
+
def resolve_agent_github_env(agent_name, repo_name)
|
|
369
|
+
# Try to use brainiac-github's AppClient if available
|
|
370
|
+
if defined?(Brainiac::Plugins::Github::AppClient)
|
|
371
|
+
repo_owner = repo_name.split("/").first
|
|
372
|
+
token = Brainiac::Plugins::Github::AppClient.installation_token_for(agent_name, repo_owner: repo_owner)
|
|
373
|
+
return { "GH_TOKEN" => token } if token
|
|
374
|
+
end
|
|
375
|
+
{}
|
|
376
|
+
rescue StandardError
|
|
377
|
+
{}
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Handles inbound Basecamp webhooks.
|
|
9
|
+
#
|
|
10
|
+
# Webhook types we care about:
|
|
11
|
+
# - todo_assignment_changed: A todo was assigned/unassigned
|
|
12
|
+
# - todo_completed: A todo was marked complete
|
|
13
|
+
# - todolist_created: A new todolist appeared (potential epic)
|
|
14
|
+
#
|
|
15
|
+
# The trigger for epic orchestration:
|
|
16
|
+
# When a todo inside a todolist (with "Epic:" prefix) is assigned to a bot account,
|
|
17
|
+
# OR when any todo in an epic's todolist is assigned to the bot.
|
|
18
|
+
module Webhook
|
|
19
|
+
class << self
|
|
20
|
+
# Process a webhook payload from Basecamp.
|
|
21
|
+
#
|
|
22
|
+
# @param payload [Hash] Parsed JSON webhook payload
|
|
23
|
+
# @return [Array(Integer, String)] HTTP status code and response body
|
|
24
|
+
def handle(payload)
|
|
25
|
+
kind = payload["kind"]
|
|
26
|
+
recording = payload["recording"] || {}
|
|
27
|
+
details = payload["details"] || {}
|
|
28
|
+
|
|
29
|
+
LOG.info "[Basecamp:Webhook] Received event: #{kind}" if defined?(LOG)
|
|
30
|
+
|
|
31
|
+
case kind
|
|
32
|
+
when "todo_assignment_changed"
|
|
33
|
+
handle_todo_assignment(payload, recording, details)
|
|
34
|
+
when "todo_completed"
|
|
35
|
+
handle_todo_completed(payload, recording)
|
|
36
|
+
when "todolist_created"
|
|
37
|
+
handle_todolist_created(payload, recording)
|
|
38
|
+
when "comment_created"
|
|
39
|
+
handle_comment(payload, recording)
|
|
40
|
+
else
|
|
41
|
+
LOG.debug "[Basecamp:Webhook] Ignoring event kind: #{kind}" if defined?(LOG) && LOG.debug?
|
|
42
|
+
[200, { status: "ignored", kind: kind }.to_json]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Handle todo assignment changes.
|
|
49
|
+
#
|
|
50
|
+
# Strategy:
|
|
51
|
+
# - When a todo is assigned to a bot account → start epic orchestration
|
|
52
|
+
# - When a todo is unassigned from a bot account → cancel/reset the epic
|
|
53
|
+
def handle_todo_assignment(payload, recording, details)
|
|
54
|
+
added_person_ids = details["added_person_ids"] || []
|
|
55
|
+
removed_person_ids = details["removed_person_ids"] || []
|
|
56
|
+
title = recording["title"] || ""
|
|
57
|
+
todo_id = recording["id"]
|
|
58
|
+
project_id = recording.dig("bucket", "id")
|
|
59
|
+
parent = recording["parent"] || {}
|
|
60
|
+
parent_type = parent["type"]
|
|
61
|
+
parent_title = parent["title"] || ""
|
|
62
|
+
parent_id = parent["id"]
|
|
63
|
+
|
|
64
|
+
# Handle UNASSIGNMENT — if bot is removed, cancel the epic
|
|
65
|
+
removed_person_ids.each do |person_id|
|
|
66
|
+
bot_account = Config.bot_account_for_person(person_id)
|
|
67
|
+
next unless bot_account
|
|
68
|
+
|
|
69
|
+
todolist_id = parent_type == "Todolist" ? parent_id : nil
|
|
70
|
+
next unless todolist_id
|
|
71
|
+
|
|
72
|
+
epic = Orchestrator.find_epic_by_todolist(todolist_id)
|
|
73
|
+
if epic && epic["status"] == "active"
|
|
74
|
+
epic["status"] = "cancelled"
|
|
75
|
+
epic["cancelled_at"] = Time.now.iso8601
|
|
76
|
+
epic["updated_at"] = Time.now.iso8601
|
|
77
|
+
|
|
78
|
+
# Save the cancellation
|
|
79
|
+
epics_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "basecamp_epics.json")
|
|
80
|
+
all = File.exist?(epics_file) ? (JSON.parse(File.read(epics_file))["epics"] || []) : []
|
|
81
|
+
idx = all.index { |e| e["id"] == epic["id"] }
|
|
82
|
+
all[idx] = epic if idx
|
|
83
|
+
File.write(epics_file, JSON.pretty_generate({ "epics" => all, "updated_at" => Time.now.iso8601 }))
|
|
84
|
+
|
|
85
|
+
LOG.info "[Basecamp:Webhook] Bot unassigned — cancelled epic '#{epic['title']}'" if defined?(LOG)
|
|
86
|
+
return [200, { status: "epic_cancelled", epic_id: epic["id"] }.to_json]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Handle ASSIGNMENT — if bot is added, start the epic
|
|
91
|
+
added_person_ids.each do |person_id|
|
|
92
|
+
bot_account = Config.bot_account_for_person(person_id)
|
|
93
|
+
next unless bot_account
|
|
94
|
+
|
|
95
|
+
agent = bot_account["default_agent"]
|
|
96
|
+
LOG.info "[Basecamp:Webhook] Todo '#{title}' assigned to bot (agent: #{agent})" if defined?(LOG)
|
|
97
|
+
|
|
98
|
+
# Check if the parent todolist is an epic
|
|
99
|
+
epic_title = nil
|
|
100
|
+
todolist_id = nil
|
|
101
|
+
|
|
102
|
+
if parent_type == "Todolist"
|
|
103
|
+
todolist_id = parent_id
|
|
104
|
+
epic_title = parent_title
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# If the todo itself has the epic prefix, it might be a standalone trigger
|
|
108
|
+
# But for Option C, we expect the TODOLIST to have the prefix
|
|
109
|
+
if todolist_id && epic_title&.start_with?(Config.epic_prefix)
|
|
110
|
+
Thread.new do
|
|
111
|
+
Orchestrator.start_epic_from_todo(
|
|
112
|
+
todo_id: todo_id,
|
|
113
|
+
todolist_id: todolist_id,
|
|
114
|
+
project_id: project_id,
|
|
115
|
+
agent: agent,
|
|
116
|
+
title: epic_title
|
|
117
|
+
)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
LOG.error "[Basecamp:Webhook] Epic start failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(LOG)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
return [200, { status: "epic_started", todolist_id: todolist_id, agent: agent }.to_json]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Also support: todo title itself starts with Epic: (single-todo trigger)
|
|
126
|
+
if title.start_with?(Config.epic_prefix)
|
|
127
|
+
LOG.info "[Basecamp:Webhook] Standalone epic todo detected — not a todolist. Ignoring." if defined?(LOG)
|
|
128
|
+
return [200, { status: "ignored", reason: "standalone_epic_todo_not_supported" }.to_json]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
LOG.info "[Basecamp:Webhook] Todo assigned to bot but parent '#{parent_title}' is not an epic" if defined?(LOG)
|
|
132
|
+
return [200, { status: "ignored", reason: "parent_not_epic" }.to_json]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
[200, { status: "ignored", reason: "no_bot_account_matched" }.to_json]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Handle todo completed events.
|
|
139
|
+
# If the todo is part of an active epic but was completed externally (not by the orchestrator),
|
|
140
|
+
# we should still advance the epic state.
|
|
141
|
+
def handle_todo_completed(_payload, recording)
|
|
142
|
+
todo_id = recording["id"]
|
|
143
|
+
title = recording["title"] || ""
|
|
144
|
+
|
|
145
|
+
# Check if this todo is part of an active epic
|
|
146
|
+
active = Orchestrator.active_epics
|
|
147
|
+
active.each do |epic|
|
|
148
|
+
task = epic["tasks"].find { |t| t["todo_id"].to_s == todo_id.to_s }
|
|
149
|
+
next unless task
|
|
150
|
+
next if task["status"] == "complete" # Already handled
|
|
151
|
+
|
|
152
|
+
# Extract the fizzy card from the task
|
|
153
|
+
fizzy_card = task["fizzy_card"]
|
|
154
|
+
if fizzy_card
|
|
155
|
+
LOG.info "[Basecamp:Webhook] Todo '#{title}' completed externally, advancing epic" if defined?(LOG)
|
|
156
|
+
Thread.new { Orchestrator.on_card_completed(fizzy_card) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return [200, { status: "epic_advanced", epic_id: epic["id"] }.to_json]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
[200, { status: "noted" }.to_json]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Handle new todolist creation.
|
|
166
|
+
# If it has the epic prefix, we could optionally auto-detect it.
|
|
167
|
+
# For now, just log it — orchestration starts on assignment.
|
|
168
|
+
def handle_todolist_created(_payload, recording)
|
|
169
|
+
title = recording["title"] || ""
|
|
170
|
+
|
|
171
|
+
if title.start_with?(Config.epic_prefix)
|
|
172
|
+
LOG.info "[Basecamp:Webhook] New epic todolist detected: '#{title}' — waiting for assignment to start" if defined?(LOG)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
[200, { status: "noted" }.to_json]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Handle comments on todos that are part of an epic.
|
|
179
|
+
# Could be used for @bot commands within Basecamp comments.
|
|
180
|
+
def handle_comment(_payload, recording)
|
|
181
|
+
# Future: detect @bot commands in comments
|
|
182
|
+
# e.g., "@Galen pause", "@Galen skip", "@Galen reassign to Sherlock"
|
|
183
|
+
[200, { status: "noted" }.to_json]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|