ocak 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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/bin/ocak +7 -0
- data/lib/ocak/agent_generator.rb +171 -0
- data/lib/ocak/claude_runner.rb +169 -0
- data/lib/ocak/cli.rb +28 -0
- data/lib/ocak/commands/audit.rb +25 -0
- data/lib/ocak/commands/clean.rb +30 -0
- data/lib/ocak/commands/debt.rb +21 -0
- data/lib/ocak/commands/design.rb +34 -0
- data/lib/ocak/commands/init.rb +212 -0
- data/lib/ocak/commands/resume.rb +128 -0
- data/lib/ocak/commands/run.rb +60 -0
- data/lib/ocak/commands/status.rb +102 -0
- data/lib/ocak/config.rb +109 -0
- data/lib/ocak/issue_fetcher.rb +137 -0
- data/lib/ocak/logger.rb +192 -0
- data/lib/ocak/merge_manager.rb +158 -0
- data/lib/ocak/pipeline_runner.rb +389 -0
- data/lib/ocak/pipeline_state.rb +51 -0
- data/lib/ocak/planner.rb +68 -0
- data/lib/ocak/process_runner.rb +82 -0
- data/lib/ocak/stack_detector.rb +333 -0
- data/lib/ocak/stream_parser.rb +189 -0
- data/lib/ocak/templates/agents/auditor.md.erb +87 -0
- data/lib/ocak/templates/agents/documenter.md.erb +67 -0
- data/lib/ocak/templates/agents/implementer.md.erb +154 -0
- data/lib/ocak/templates/agents/merger.md.erb +97 -0
- data/lib/ocak/templates/agents/pipeline.md.erb +126 -0
- data/lib/ocak/templates/agents/planner.md.erb +86 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +98 -0
- data/lib/ocak/templates/agents/security_reviewer.md.erb +112 -0
- data/lib/ocak/templates/gitignore_additions.txt +10 -0
- data/lib/ocak/templates/hooks/post_edit_lint.sh.erb +57 -0
- data/lib/ocak/templates/hooks/task_completed_test.sh.erb +34 -0
- data/lib/ocak/templates/ocak.yml.erb +99 -0
- data/lib/ocak/templates/skills/audit/SKILL.md.erb +132 -0
- data/lib/ocak/templates/skills/debt/SKILL.md.erb +128 -0
- data/lib/ocak/templates/skills/design/SKILL.md.erb +131 -0
- data/lib/ocak/templates/skills/scan_file/SKILL.md.erb +113 -0
- data/lib/ocak/verification.rb +83 -0
- data/lib/ocak/worktree_manager.rb +92 -0
- data/lib/ocak.rb +13 -0
- metadata +115 -0
data/lib/ocak/config.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
class Config
|
|
7
|
+
CONFIG_FILE = 'ocak.yml'
|
|
8
|
+
|
|
9
|
+
attr_reader :project_dir
|
|
10
|
+
|
|
11
|
+
def self.load(dir = Dir.pwd)
|
|
12
|
+
path = File.join(dir, CONFIG_FILE)
|
|
13
|
+
raise ConfigNotFound, "No ocak.yml found in #{dir}. Run `ocak init` first." unless File.exist?(path)
|
|
14
|
+
|
|
15
|
+
new(YAML.safe_load_file(path, symbolize_names: true), dir)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(data, project_dir = Dir.pwd)
|
|
19
|
+
@data = data || {}
|
|
20
|
+
@project_dir = project_dir
|
|
21
|
+
@overrides = {}
|
|
22
|
+
validate!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def override(key, value)
|
|
26
|
+
@overrides[key] = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Stack
|
|
30
|
+
def language = dig(:stack, :language) || 'unknown'
|
|
31
|
+
def framework = dig(:stack, :framework)
|
|
32
|
+
def test_command = dig(:stack, :test_command)
|
|
33
|
+
def lint_command = dig(:stack, :lint_command)
|
|
34
|
+
def format_command = dig(:stack, :format_command)
|
|
35
|
+
def setup_command = dig(:stack, :setup_command)
|
|
36
|
+
|
|
37
|
+
# Returns the lint command with auto-fix flags stripped, suitable for check-only verification.
|
|
38
|
+
def lint_check_command
|
|
39
|
+
cmd = lint_command
|
|
40
|
+
return nil unless cmd
|
|
41
|
+
|
|
42
|
+
cmd.gsub(/\s+(?:-A|--fix|--write|--allow-dirty)\b/, '').strip
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def security_commands
|
|
46
|
+
dig(:stack, :security_commands) || []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Pipeline
|
|
50
|
+
def max_parallel = @overrides[:max_parallel] || dig(:pipeline, :max_parallel) || 3
|
|
51
|
+
def poll_interval = @overrides[:poll_interval] || dig(:pipeline, :poll_interval) || 60
|
|
52
|
+
def worktree_dir = dig(:pipeline, :worktree_dir) || '.claude/worktrees'
|
|
53
|
+
def log_dir = dig(:pipeline, :log_dir) || 'logs/pipeline'
|
|
54
|
+
def cost_budget = dig(:pipeline, :cost_budget)
|
|
55
|
+
|
|
56
|
+
# Safety
|
|
57
|
+
def allowed_authors = dig(:safety, :allowed_authors) || []
|
|
58
|
+
def require_comment = dig(:safety, :require_comment)
|
|
59
|
+
def max_issues_per_run = dig(:safety, :max_issues_per_run) || 5
|
|
60
|
+
|
|
61
|
+
# Labels
|
|
62
|
+
def label_ready = dig(:labels, :ready) || 'auto-ready'
|
|
63
|
+
def label_in_progress = dig(:labels, :in_progress) || 'in-progress'
|
|
64
|
+
def label_completed = dig(:labels, :completed) || 'completed'
|
|
65
|
+
def label_failed = dig(:labels, :failed) || 'pipeline-failed'
|
|
66
|
+
|
|
67
|
+
# Steps
|
|
68
|
+
def steps
|
|
69
|
+
@data[:steps] || default_steps
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Agent paths
|
|
73
|
+
def agent_path(name)
|
|
74
|
+
custom = dig(:agents, name.to_sym)
|
|
75
|
+
return File.join(@project_dir, custom) if custom
|
|
76
|
+
|
|
77
|
+
File.join(@project_dir, '.claude', 'agents', "#{name.to_s.tr('_', '-')}.md")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def dig(*keys)
|
|
83
|
+
keys.reduce(@data) { |h, k| h.is_a?(Hash) ? h[k] : nil }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate!
|
|
87
|
+
return if @data.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
raise ConfigError, 'ocak.yml must be a YAML hash'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def default_steps
|
|
93
|
+
[
|
|
94
|
+
{ agent: 'implementer', role: 'implement' },
|
|
95
|
+
{ agent: 'reviewer', role: 'review' },
|
|
96
|
+
{ agent: 'implementer', role: 'fix', condition: 'has_findings' },
|
|
97
|
+
{ agent: 'reviewer', role: 'verify', condition: 'had_fixes' },
|
|
98
|
+
{ agent: 'security_reviewer', role: 'security' },
|
|
99
|
+
{ agent: 'implementer', role: 'fix', condition: 'has_findings', complexity: 'full' },
|
|
100
|
+
{ agent: 'documenter', role: 'document', complexity: 'full' },
|
|
101
|
+
{ agent: 'auditor', role: 'audit', complexity: 'full' },
|
|
102
|
+
{ agent: 'merger', role: 'merge' }
|
|
103
|
+
]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class ConfigNotFound < StandardError; end
|
|
107
|
+
class ConfigError < StandardError; end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
class IssueFetcher
|
|
8
|
+
def initialize(config:, logger: nil)
|
|
9
|
+
@config = config
|
|
10
|
+
@logger = logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch_ready
|
|
14
|
+
stdout, _, status = Open3.capture3(
|
|
15
|
+
'gh', 'issue', 'list',
|
|
16
|
+
'--label', @config.label_ready,
|
|
17
|
+
'--state', 'open',
|
|
18
|
+
'--json', 'number,title,body,labels,author',
|
|
19
|
+
'--limit', '50',
|
|
20
|
+
chdir: @config.project_dir
|
|
21
|
+
)
|
|
22
|
+
return [] unless status.success?
|
|
23
|
+
|
|
24
|
+
issues = JSON.parse(stdout)
|
|
25
|
+
issues.reject! { |i| in_progress?(i) }
|
|
26
|
+
issues.select! { |i| authorized_issue?(i) }
|
|
27
|
+
issues
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_label(issue_number, label)
|
|
33
|
+
run_gh('issue', 'edit', issue_number.to_s, '--add-label', label)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove_label(issue_number, label)
|
|
37
|
+
run_gh('issue', 'edit', issue_number.to_s, '--remove-label', label)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def transition(issue_number, from:, to:)
|
|
41
|
+
remove_label(issue_number, from) if from
|
|
42
|
+
add_label(issue_number, to)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def comment(issue_number, body)
|
|
46
|
+
run_gh('issue', 'comment', issue_number.to_s, '--body', body)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def view(issue_number, fields: 'number,title,body,labels')
|
|
50
|
+
stdout, _, status = Open3.capture3(
|
|
51
|
+
'gh', 'issue', 'view', issue_number.to_s,
|
|
52
|
+
'--json', fields,
|
|
53
|
+
chdir: @config.project_dir
|
|
54
|
+
)
|
|
55
|
+
return nil unless status.success?
|
|
56
|
+
|
|
57
|
+
JSON.parse(stdout)
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def in_progress?(issue)
|
|
65
|
+
issue['labels']&.any? { |l| l['name'] == @config.label_in_progress }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def authorized_issue?(issue)
|
|
69
|
+
authors = allowed_authors
|
|
70
|
+
author_login = issue.dig('author', 'login')
|
|
71
|
+
|
|
72
|
+
return true if authors.empty? && @config.allowed_authors.any?
|
|
73
|
+
|
|
74
|
+
if authors.any? && authors.include?(author_login)
|
|
75
|
+
check_comment_requirement(issue)
|
|
76
|
+
elsif authors.empty?
|
|
77
|
+
# Default: current user only
|
|
78
|
+
if author_login == current_user
|
|
79
|
+
check_comment_requirement(issue)
|
|
80
|
+
else
|
|
81
|
+
@logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
@logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_comment_requirement(issue)
|
|
91
|
+
phrase = @config.require_comment
|
|
92
|
+
return true unless phrase
|
|
93
|
+
|
|
94
|
+
# Check if an allowed author commented the required phrase
|
|
95
|
+
comments = fetch_comments(issue['number'])
|
|
96
|
+
authors = allowed_authors.empty? ? [current_user] : allowed_authors
|
|
97
|
+
|
|
98
|
+
has_approval = comments.any? do |c|
|
|
99
|
+
authors.include?(c.dig('author', 'login')) && c['body']&.strip == phrase
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless has_approval
|
|
103
|
+
@logger&.warn("Skipping issue ##{issue['number']}: missing required '#{phrase}' comment from allowed author")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
has_approval
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fetch_comments(issue_number)
|
|
110
|
+
stdout, _, status = Open3.capture3(
|
|
111
|
+
'gh', 'issue', 'view', issue_number.to_s,
|
|
112
|
+
'--json', 'comments',
|
|
113
|
+
chdir: @config.project_dir
|
|
114
|
+
)
|
|
115
|
+
return [] unless status.success?
|
|
116
|
+
|
|
117
|
+
JSON.parse(stdout).fetch('comments', [])
|
|
118
|
+
rescue JSON::ParserError
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def allowed_authors
|
|
123
|
+
@config.allowed_authors
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def current_user
|
|
127
|
+
@current_user ||= begin
|
|
128
|
+
stdout, _, status = Open3.capture3('gh', 'api', 'user', '--jq', '.login')
|
|
129
|
+
status.success? ? stdout.strip : nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def run_gh(*)
|
|
134
|
+
Open3.capture3('gh', *, chdir: @config.project_dir)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/ocak/logger.rb
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
class PipelineLogger
|
|
8
|
+
COLORS = {
|
|
9
|
+
reset: "\e[0m",
|
|
10
|
+
bold: "\e[1m",
|
|
11
|
+
dim: "\e[2m",
|
|
12
|
+
red: "\e[31m",
|
|
13
|
+
green: "\e[32m",
|
|
14
|
+
yellow: "\e[33m",
|
|
15
|
+
blue: "\e[34m",
|
|
16
|
+
magenta: "\e[35m",
|
|
17
|
+
cyan: "\e[36m",
|
|
18
|
+
white: "\e[37m"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
AGENT_COLORS = {
|
|
22
|
+
'implementer' => :cyan,
|
|
23
|
+
'reviewer' => :magenta,
|
|
24
|
+
'security-reviewer' => :red,
|
|
25
|
+
'auditor' => :yellow,
|
|
26
|
+
'documenter' => :blue,
|
|
27
|
+
'merger' => :green,
|
|
28
|
+
'planner' => :white,
|
|
29
|
+
'pipeline' => :cyan
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(log_dir: nil, issue_number: nil, color: $stderr.tty?)
|
|
33
|
+
@color = color
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@file_logger = setup_file_logger(log_dir, issue_number) if log_dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def info(msg, agent: nil)
|
|
39
|
+
log(:info, msg, agent: agent)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def warn(msg, agent: nil)
|
|
43
|
+
log(:warn, msg, agent: agent, color: :yellow)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def error(msg, agent: nil)
|
|
47
|
+
log(:error, msg, agent: agent, color: :red)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def debug(msg, agent: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
51
|
+
@file_logger&.debug(msg)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :log_file_path
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def log(level, msg, agent: nil, color: nil)
|
|
59
|
+
ts = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
|
60
|
+
plain = "[#{ts}] #{level.to_s.upcase}: #{msg}"
|
|
61
|
+
|
|
62
|
+
@file_logger&.send(level, msg)
|
|
63
|
+
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
output = @color ? colorize(ts, level, msg, agent: agent, color: color) : plain
|
|
66
|
+
$stderr.write("#{output}\n")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def colorize(timestamp, level, msg, agent: nil, color: nil)
|
|
71
|
+
parts = [c(:dim), timestamp, c(:reset), ' ']
|
|
72
|
+
|
|
73
|
+
if agent
|
|
74
|
+
agent_color = AGENT_COLORS.fetch(agent, :white)
|
|
75
|
+
parts.push c(agent_color), c(:bold), "[#{agent}]", c(:reset), ' '
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
msg_color = color || level_color(level)
|
|
79
|
+
parts.push c(msg_color), msg, c(:reset)
|
|
80
|
+
parts.join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def level_color(level)
|
|
84
|
+
case level
|
|
85
|
+
when :error then :red
|
|
86
|
+
when :warn then :yellow
|
|
87
|
+
when :info then :white
|
|
88
|
+
else :dim
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def c(name)
|
|
93
|
+
@color ? COLORS.fetch(name, '') : ''
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def setup_file_logger(log_dir, issue_number)
|
|
97
|
+
FileUtils.mkdir_p(log_dir)
|
|
98
|
+
timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
|
|
99
|
+
suffix = issue_number ? "issue-#{issue_number}" : 'pipeline'
|
|
100
|
+
@log_file_path = File.join(log_dir, "#{timestamp}-#{suffix}.log")
|
|
101
|
+
|
|
102
|
+
logger = ::Logger.new(@log_file_path)
|
|
103
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
104
|
+
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
|
|
105
|
+
end
|
|
106
|
+
logger
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Colorized real-time terminal output for --watch mode.
|
|
111
|
+
class WatchFormatter
|
|
112
|
+
def initialize(io = $stderr)
|
|
113
|
+
@io = io
|
|
114
|
+
@tty = io.respond_to?(:tty?) && io.tty?
|
|
115
|
+
@mutex = Mutex.new
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def emit(agent_name, event)
|
|
119
|
+
return unless event
|
|
120
|
+
|
|
121
|
+
line = format_event(agent_name, event)
|
|
122
|
+
return unless line
|
|
123
|
+
|
|
124
|
+
@mutex.synchronize { @io.puts line }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def format_event(agent_name, event)
|
|
130
|
+
ts = Time.now.strftime('%H:%M:%S')
|
|
131
|
+
agent_color = PipelineLogger::AGENT_COLORS.fetch(agent_name, :white)
|
|
132
|
+
prefix = "#{c(:dim)}#{ts}#{c(:reset)} #{c(agent_color)}#{c(:bold)}[#{agent_name}]#{c(:reset)}"
|
|
133
|
+
|
|
134
|
+
case event[:category]
|
|
135
|
+
when :init then "#{prefix} #{c(:dim)}Session started (model: #{event[:model]})#{c(:reset)}"
|
|
136
|
+
when :tool_call then format_tool_call(prefix, event)
|
|
137
|
+
when :tool_result then format_tool_result(prefix, event)
|
|
138
|
+
when :text then format_text(prefix, event)
|
|
139
|
+
when :result then format_result(prefix, event)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def format_tool_call(prefix, event)
|
|
144
|
+
case event[:tool]
|
|
145
|
+
when 'Edit', 'Write'
|
|
146
|
+
"#{prefix} #{c(:yellow)}[EDIT]#{c(:reset)} #{event[:detail]}"
|
|
147
|
+
when 'Bash'
|
|
148
|
+
"#{prefix} #{c(:blue)}[BASH]#{c(:reset)} #{c(:dim)}#{event[:detail]}#{c(:reset)}"
|
|
149
|
+
when 'Read'
|
|
150
|
+
"#{prefix} #{c(:dim)}[READ] #{event[:detail]}#{c(:reset)}"
|
|
151
|
+
when 'Glob', 'Grep'
|
|
152
|
+
"#{prefix} #{c(:dim)}[#{event[:tool].upcase}] #{event[:detail]}#{c(:reset)}"
|
|
153
|
+
else
|
|
154
|
+
"#{prefix} #{c(:dim)}[#{event[:tool]}]#{c(:reset)}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def format_tool_result(prefix, event)
|
|
159
|
+
return nil unless event[:is_test_result]
|
|
160
|
+
|
|
161
|
+
color = event[:passed] ? :green : :red
|
|
162
|
+
status = event[:passed] ? 'PASS' : 'FAIL'
|
|
163
|
+
"#{prefix} #{c(color)}#{c(:bold)}[TEST #{status}]#{c(:reset)} #{c(:dim)}#{event[:command]}#{c(:reset)}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def format_text(prefix, event)
|
|
167
|
+
return nil unless event[:has_findings]
|
|
168
|
+
|
|
169
|
+
if event[:has_red]
|
|
170
|
+
"#{prefix} #{c(:red)}#{c(:bold)}[REVIEW] BLOCKING#{c(:reset)}"
|
|
171
|
+
elsif event[:has_yellow]
|
|
172
|
+
"#{prefix} #{c(:yellow)}[REVIEW] WARNING#{c(:reset)}"
|
|
173
|
+
else
|
|
174
|
+
"#{prefix} #{c(:green)}[REVIEW] PASS#{c(:reset)}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def format_result(prefix, event)
|
|
179
|
+
cost = event[:cost_usd] ? format('$%.4f', event[:cost_usd]) : 'n/a'
|
|
180
|
+
dur = event[:duration_ms] ? "#{(event[:duration_ms] / 1000.0).round(1)}s" : 'n/a'
|
|
181
|
+
turns = event[:num_turns] ? "#{event[:num_turns]} turns" : ''
|
|
182
|
+
color = event[:subtype] == 'success' ? :green : :red
|
|
183
|
+
done = "#{c(color)}#{c(:bold)}[DONE]#{c(:reset)}"
|
|
184
|
+
detail = "#{c(:dim)}#{event[:subtype]} #{cost} #{dur} #{turns}#{c(:reset)}"
|
|
185
|
+
"#{prefix} #{done} #{detail}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def c(name)
|
|
189
|
+
@tty ? PipelineLogger::COLORS.fetch(name, '') : ''
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
class MergeManager
|
|
8
|
+
def initialize(config:, claude:, logger:, watch: nil)
|
|
9
|
+
@config = config
|
|
10
|
+
@claude = claude
|
|
11
|
+
@logger = logger
|
|
12
|
+
@watch = watch
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Rebase, test, push, then let the merger agent create PR + merge + close issue.
|
|
16
|
+
def merge(issue_number, worktree)
|
|
17
|
+
@logger.info("Starting merge for issue ##{issue_number}")
|
|
18
|
+
|
|
19
|
+
commit_uncommitted_changes(issue_number, worktree)
|
|
20
|
+
|
|
21
|
+
unless rebase_onto_main(worktree)
|
|
22
|
+
@logger.error("Rebase failed for issue ##{issue_number}")
|
|
23
|
+
return false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless verify_tests(worktree)
|
|
27
|
+
@logger.error("Tests failed after rebase for issue ##{issue_number}")
|
|
28
|
+
return false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless push_branch(worktree)
|
|
32
|
+
@logger.error("Push failed for issue ##{issue_number}")
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result = @claude.run_agent(
|
|
37
|
+
'merger',
|
|
38
|
+
"Create a PR, merge it, and close issue ##{issue_number}. Branch: #{worktree.branch}",
|
|
39
|
+
chdir: worktree.path
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if result.success?
|
|
43
|
+
@logger.info("Issue ##{issue_number} merged successfully")
|
|
44
|
+
true
|
|
45
|
+
else
|
|
46
|
+
@logger.error("Merger agent failed for issue ##{issue_number}")
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def commit_uncommitted_changes(issue_number, worktree)
|
|
54
|
+
stdout, = git('status', '--porcelain', chdir: worktree.path)
|
|
55
|
+
return if stdout.strip.empty?
|
|
56
|
+
|
|
57
|
+
@logger.info('Found uncommitted changes, committing before merge...')
|
|
58
|
+
git('add', '-A', chdir: worktree.path)
|
|
59
|
+
_, stderr, status = git('commit', '-m', "chore: uncommitted pipeline changes for issue ##{issue_number}",
|
|
60
|
+
chdir: worktree.path)
|
|
61
|
+
|
|
62
|
+
if status.success?
|
|
63
|
+
@logger.info('Committed uncommitted changes')
|
|
64
|
+
else
|
|
65
|
+
@logger.warn("Commit of uncommitted changes failed: #{stderr[0..200]}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rebase_onto_main(worktree)
|
|
70
|
+
git('fetch', 'origin', 'main', chdir: worktree.path)
|
|
71
|
+
_, stderr, status = git('rebase', 'origin/main', chdir: worktree.path)
|
|
72
|
+
|
|
73
|
+
return true if status.success?
|
|
74
|
+
|
|
75
|
+
@logger.warn("Rebase conflict, aborting rebase: #{stderr}")
|
|
76
|
+
git('rebase', '--abort', chdir: worktree.path)
|
|
77
|
+
|
|
78
|
+
# Fall back to merge strategy
|
|
79
|
+
@logger.info('Attempting merge strategy instead...')
|
|
80
|
+
_, merge_stderr, merge_status = git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
|
|
81
|
+
|
|
82
|
+
return true if merge_status.success?
|
|
83
|
+
|
|
84
|
+
# Merge also has conflicts — try to resolve via agent
|
|
85
|
+
@logger.warn("Merge conflict, attempting agent resolution: #{merge_stderr}")
|
|
86
|
+
resolve_conflicts_via_agent(worktree)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_conflicts_via_agent(worktree)
|
|
90
|
+
# Get list of conflicting files
|
|
91
|
+
stdout, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
|
|
92
|
+
conflicting = stdout.lines.map(&:strip).reject(&:empty?)
|
|
93
|
+
|
|
94
|
+
if conflicting.empty?
|
|
95
|
+
@logger.warn('No conflicting files found, aborting merge')
|
|
96
|
+
git('merge', '--abort', chdir: worktree.path)
|
|
97
|
+
return false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result = @claude.run_agent(
|
|
101
|
+
'implementer',
|
|
102
|
+
"Resolve these merge conflicts. Conflicting files:\n#{conflicting.join("\n")}\n\n" \
|
|
103
|
+
'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
|
|
104
|
+
'Then run `git add` on each resolved file.',
|
|
105
|
+
chdir: worktree.path
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if result.success?
|
|
109
|
+
# Check if all conflicts resolved
|
|
110
|
+
remaining, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
|
|
111
|
+
if remaining.strip.empty?
|
|
112
|
+
git('commit', '--no-edit', chdir: worktree.path)
|
|
113
|
+
@logger.info('Merge conflicts resolved by agent')
|
|
114
|
+
return true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@logger.error('Agent could not resolve merge conflicts')
|
|
119
|
+
git('merge', '--abort', chdir: worktree.path)
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def verify_tests(worktree)
|
|
124
|
+
test_cmd = @config.test_command
|
|
125
|
+
return true unless test_cmd
|
|
126
|
+
|
|
127
|
+
@logger.info('Running tests after rebase...')
|
|
128
|
+
_, _, status = shell(test_cmd, chdir: worktree.path)
|
|
129
|
+
|
|
130
|
+
if status.success?
|
|
131
|
+
@logger.info('Tests passed after rebase')
|
|
132
|
+
true
|
|
133
|
+
else
|
|
134
|
+
@logger.warn('Tests failed after rebase')
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def push_branch(worktree)
|
|
140
|
+
_, stderr, status = git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
|
|
141
|
+
|
|
142
|
+
unless status.success?
|
|
143
|
+
@logger.error("Push failed: #{stderr}")
|
|
144
|
+
return false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def git(*, chdir:)
|
|
151
|
+
Open3.capture3('git', *, chdir: chdir)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def shell(cmd, chdir:)
|
|
155
|
+
Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|