ocak 0.2.0 → 0.4.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.
@@ -1,17 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'failure_reporting'
4
+ require_relative 'merge_orchestration'
3
5
  require_relative 'pipeline_executor'
6
+ require_relative 'process_registry'
7
+ require_relative 'git_utils'
8
+ require_relative 'reready_processor'
4
9
 
5
10
  module Ocak
6
11
  class PipelineRunner
12
+ include FailureReporting
13
+ include MergeOrchestration
14
+
15
+ attr_reader :registry
16
+
7
17
  def initialize(config:, options: {})
8
18
  @config = config
9
19
  @options = options
10
20
  @watch_formatter = options[:watch] ? WatchFormatter.new : nil
11
21
  @shutting_down = false
22
+ @shutdown_count = 0
12
23
  @active_issues = []
24
+ @interrupted_issues = []
13
25
  @active_mutex = Mutex.new
14
- @executor = PipelineExecutor.new(config: config)
26
+ @registry = ProcessRegistry.new
27
+ @executor = PipelineExecutor.new(config: config, shutdown_check: -> { @shutting_down })
15
28
  end
16
29
 
17
30
  def run
@@ -24,18 +37,26 @@ module Ocak
24
37
  end
25
38
 
26
39
  def shutdown!
27
- @shutting_down = true
28
- logger = build_logger
29
- logger.info('Graceful shutdown initiated...')
40
+ count = @active_mutex.synchronize { @shutdown_count += 1 }
30
41
 
31
- issues = IssueFetcher.new(config: @config, logger: logger)
32
- @active_mutex.synchronize do
33
- @active_issues.each do |issue_number|
34
- logger.info("Returning issue ##{issue_number} to ready queue")
35
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
36
- rescue StandardError => e
37
- logger.warn("Failed to reset issue ##{issue_number}: #{e.message}")
38
- end
42
+ if count >= 2
43
+ force_shutdown!
44
+ else
45
+ graceful_shutdown!
46
+ end
47
+ end
48
+
49
+ def shutting_down?
50
+ @shutting_down
51
+ end
52
+
53
+ def print_shutdown_summary
54
+ issues = @active_mutex.synchronize { @interrupted_issues.dup }
55
+ return if issues.empty?
56
+
57
+ warn "\nInterrupted issues:"
58
+ issues.each do |issue_number|
59
+ warn " - Issue ##{issue_number}: ocak resume --issue #{issue_number}"
39
60
  end
40
61
  end
41
62
 
@@ -45,6 +66,8 @@ module Ocak
45
66
  logger = build_logger(issue_number: issue_number)
46
67
  claude = build_claude(logger)
47
68
  issues = IssueFetcher.new(config: @config)
69
+ ensure_labels(issues, logger)
70
+ @executor.issues = issues
48
71
  logger.info("Running single issue mode for ##{issue_number}")
49
72
 
50
73
  if @options[:dry_run]
@@ -53,17 +76,15 @@ module Ocak
53
76
  end
54
77
 
55
78
  issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
56
- result = run_pipeline(issue_number, logger: logger, claude: claude)
79
+ complexity = @options[:fast] ? 'simple' : 'full'
80
+ result = run_pipeline(issue_number, logger: logger, claude: claude, complexity: complexity)
57
81
 
58
- if result[:success]
59
- claude.run_agent('merger', "Create a PR, merge it, and close issue ##{issue_number}",
60
- chdir: @config.project_dir)
61
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
62
- logger.info("Issue ##{issue_number} completed successfully")
82
+ if result[:interrupted]
83
+ handle_interrupted_issue(issue_number, nil, result[:phase], logger: logger, issues: issues)
84
+ elsif result[:success]
85
+ handle_single_success(issue_number, result, logger: logger, claude: claude, issues: issues)
63
86
  else
64
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
65
- issues.comment(issue_number,
66
- "Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
87
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config)
67
88
  logger.error("Issue ##{issue_number} failed at phase: #{result[:phase]}")
68
89
  end
69
90
  end
@@ -71,11 +92,15 @@ module Ocak
71
92
  def run_loop
72
93
  logger = build_logger
73
94
  issues = IssueFetcher.new(config: @config, logger: logger)
95
+ ensure_labels(issues, logger)
96
+ @executor.issues = issues
74
97
  cleanup_stale_worktrees(logger)
75
98
 
76
99
  loop do
77
100
  break if @shutting_down
78
101
 
102
+ process_reready_prs(logger: logger, issues: issues) if @config.manual_review
103
+
79
104
  logger.info("Checking for #{@config.label_ready} issues...")
80
105
  ready = issues.fetch_ready
81
106
 
@@ -89,7 +114,11 @@ module Ocak
89
114
  break if @options[:once]
90
115
 
91
116
  logger.info("Sleeping #{@config.poll_interval}s...")
92
- sleep @config.poll_interval
117
+ @config.poll_interval.times do
118
+ break if @shutting_down
119
+
120
+ sleep 1
121
+ end
93
122
  end
94
123
  end
95
124
 
@@ -116,33 +145,33 @@ module Ocak
116
145
  end
117
146
 
118
147
  def run_batch(batch_issues, logger:, issues:)
119
- worktrees = WorktreeManager.new(config: @config)
148
+ worktrees = WorktreeManager.new(config: @config, logger: logger)
120
149
 
121
150
  threads = batch_issues.map do |issue|
122
151
  Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
123
152
  end
124
153
  results = threads.map(&:value)
125
154
 
126
- results.select { |r| r[:success] }.each do |result|
155
+ unless @shutting_down
127
156
  merger = MergeManager.new(
128
- config: @config, claude: build_claude(logger), logger: logger, watch: @watch_formatter
157
+ config: @config, claude: build_claude(logger), logger: logger, issues: issues, watch: @watch_formatter
129
158
  )
130
- if merger.merge(result[:issue_number], result[:worktree])
131
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_completed)
132
- logger.info("Issue ##{result[:issue_number]} merged successfully")
133
- else
134
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
135
- logger.error("Issue ##{result[:issue_number]} merge failed")
159
+ results.select { |r| r[:success] }.each do |result|
160
+ merge_completed_issue(result, merger: merger, issues: issues, logger: logger)
136
161
  end
137
162
  end
138
163
 
139
164
  results.each do |result|
140
165
  next unless result[:worktree]
166
+ next if result[:interrupted]
141
167
 
142
168
  worktrees.remove(result[:worktree])
143
169
  rescue StandardError => e
144
170
  logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
145
171
  end
172
+
173
+ programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
174
+ raise programming_error if programming_error
146
175
  end
147
176
 
148
177
  def process_one_issue(issue, worktrees:, issues:)
@@ -151,48 +180,116 @@ module Ocak
151
180
  claude = build_claude(logger)
152
181
  worktree = nil
153
182
 
154
- @active_mutex.synchronize { @active_issues << issue_number }
183
+ @active_mutex.synchronize do
184
+ @active_issues << issue_number
185
+ end
155
186
  issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
156
187
  worktree = worktrees.create(issue_number, setup_command: @config.setup_command)
157
188
  logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
158
189
 
190
+ complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
159
191
  result = run_pipeline(issue_number, logger: logger, claude: claude, chdir: worktree.path,
160
- complexity: issue.fetch('complexity', 'full'))
192
+ complexity: complexity)
161
193
 
162
- build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues)
194
+ build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
195
+ logger: logger)
163
196
  rescue StandardError => e
164
- logger.error("Unexpected error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
165
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
166
- { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
197
+ handle_process_error(e, issue_number: issue_number, logger: logger, issues: issues)
198
+ result = { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
199
+ # NameError includes NoMethodError
200
+ result[:programming_error] = e if e.is_a?(NameError) || e.is_a?(TypeError)
201
+ result
167
202
  ensure
168
203
  @active_mutex.synchronize { @active_issues.delete(issue_number) }
169
204
  end
170
205
 
171
- def build_issue_result(result, issue_number:, worktree:, issues:)
172
- if result[:success]
173
- { issue_number: issue_number, success: true, worktree: worktree }
206
+ def build_issue_result(result, issue_number:, worktree:, issues:, logger: nil)
207
+ if result[:interrupted]
208
+ handle_interrupted_issue(issue_number, worktree&.path, result[:phase],
209
+ logger: logger || build_logger(issue_number: issue_number), issues: issues)
210
+ { issue_number: issue_number, success: false, worktree: worktree, interrupted: true }
211
+ elsif result[:success]
212
+ { issue_number: issue_number, success: true, worktree: worktree,
213
+ audit_blocked: result[:audit_blocked], audit_output: result[:audit_output] }
174
214
  else
175
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
176
- issues.comment(issue_number,
177
- "Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
215
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config)
178
216
  { issue_number: issue_number, success: false, worktree: worktree }
179
217
  end
180
218
  end
181
219
 
220
+ def process_reready_prs(logger:, issues:)
221
+ reready = issues.fetch_reready_prs
222
+ return if reready.empty?
223
+
224
+ logger.info("Found #{reready.size} reready PR(s)")
225
+ processor = RereadyProcessor.new(config: @config, logger: logger,
226
+ claude: build_claude(logger), issues: issues,
227
+ watch: @watch_formatter)
228
+ reready.each do |pr|
229
+ break if @shutting_down
230
+
231
+ processor.process(pr)
232
+ end
233
+ end
234
+
182
235
  def cleanup_stale_worktrees(logger)
183
- worktrees = WorktreeManager.new(config: @config)
236
+ worktrees = WorktreeManager.new(config: @config, logger: logger)
184
237
  removed = worktrees.clean_stale
185
238
  removed.each { |path| logger.info("Cleaned stale worktree: #{path}") }
186
239
  rescue StandardError => e
187
240
  logger.warn("Stale worktree cleanup failed: #{e.message}")
188
241
  end
189
242
 
243
+ def ensure_labels(issues, logger)
244
+ issues.ensure_labels(@config.all_labels)
245
+ rescue StandardError => e
246
+ logger.warn("Failed to ensure labels: #{e.message}")
247
+ end
248
+
190
249
  def build_logger(issue_number: nil)
191
- PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
250
+ PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir),
251
+ issue_number: issue_number, log_level: @options.fetch(:log_level, :normal))
192
252
  end
193
253
 
194
254
  def build_claude(logger)
195
- ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter)
255
+ ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter, registry: @registry)
256
+ end
257
+
258
+ def graceful_shutdown!
259
+ @shutting_down = true
260
+ warn "\nGraceful shutdown initiated — finishing current agent step(s)..."
261
+ end
262
+
263
+ def force_shutdown!
264
+ @shutting_down = true
265
+ warn "\nForce shutdown — killing active processes..."
266
+ @registry.kill_all
267
+ end
268
+
269
+ def handle_process_error(error, issue_number:, logger:, issues:)
270
+ logger.error("Unexpected #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
271
+ logger.debug("Full backtrace:\n#{error.backtrace&.join("\n")}")
272
+ issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
273
+ begin
274
+ issues.comment(issue_number, "Unexpected #{error.class}: #{error.message}")
275
+ rescue StandardError
276
+ nil
277
+ end
278
+ end
279
+
280
+ def handle_interrupted_issue(issue_number, worktree_path, step_name, logger:, issues:)
281
+ if worktree_path
282
+ GitUtils.commit_changes(chdir: worktree_path,
283
+ message: "wip: pipeline interrupted after step #{step_name} for issue ##{issue_number}",
284
+ logger: logger)
285
+ end
286
+ issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
287
+ issues&.comment(issue_number,
288
+ "\u{26A0}\u{FE0F} Pipeline interrupted after #{step_name}. " \
289
+ "Resume with `ocak resume --issue #{issue_number}`.")
290
+ @active_mutex.synchronize { @interrupted_issues << issue_number }
291
+ rescue StandardError => e
292
+ logger.warn("Failed to handle interrupted issue ##{issue_number}: #{e.message}")
196
293
  end
197
294
  end
198
295
  end
@@ -5,8 +5,9 @@ require 'fileutils'
5
5
 
6
6
  module Ocak
7
7
  class PipelineState
8
- def initialize(log_dir:)
8
+ def initialize(log_dir:, logger: nil)
9
9
  @log_dir = log_dir
10
+ @logger = logger
10
11
  end
11
12
 
12
13
  def save(issue_number, completed_steps:, worktree_path: nil, branch: nil)
@@ -18,6 +19,10 @@ module Ocak
18
19
  branch: branch,
19
20
  updated_at: Time.now.iso8601
20
21
  }))
22
+ rescue StandardError => e
23
+ @logger&.warn("Pipeline state save failed for issue ##{issue_number}: #{e.message}") ||
24
+ warn("Pipeline state save failed for issue ##{issue_number}: #{e.message}")
25
+ nil
21
26
  end
22
27
 
23
28
  def load(issue_number)
@@ -25,7 +30,7 @@ module Ocak
25
30
  return nil unless File.exist?(path)
26
31
 
27
32
  JSON.parse(File.read(path), symbolize_names: true)
28
- rescue JSON::ParserError => e
33
+ rescue ArgumentError, JSON::ParserError => e
29
34
  warn("Failed to parse pipeline state for issue ##{issue_number}: #{e.message}")
30
35
  nil
31
36
  end
@@ -33,6 +38,8 @@ module Ocak
33
38
  def delete(issue_number)
34
39
  path = state_path(issue_number)
35
40
  FileUtils.rm_f(path)
41
+ rescue ArgumentError
42
+ nil
36
43
  end
37
44
 
38
45
  def list
@@ -47,6 +54,8 @@ module Ocak
47
54
  private
48
55
 
49
56
  def state_path(issue_number)
57
+ raise ArgumentError, "Invalid issue number: #{issue_number}" unless issue_number.to_s.match?(/\A\d+\z/)
58
+
50
59
  File.join(@log_dir, "issue-#{issue_number}-state.json")
51
60
  end
52
61
  end
data/lib/ocak/planner.rb CHANGED
@@ -11,14 +11,12 @@ module Ocak
11
11
  'verify' => 'Review the changes for GitHub issue #%<issue>s. Run: git diff main',
12
12
  'security' => 'Security review changes for GitHub issue #%<issue>s. Run: git diff main',
13
13
  'document' => 'Add documentation for changes in GitHub issue #%<issue>s',
14
- 'audit' => 'Audit the changed files for issue #%<issue>s. Run: git diff main --name-only',
15
- 'merge' => 'Create a PR, merge it, and close issue #%<issue>s',
16
- 'create_pr' => 'Create a PR, merge it, and close issue #%<issue>s'
14
+ 'merge' => 'Create a PR, merge it, and close issue #%<issue>s'
17
15
  }.freeze
18
16
 
19
17
  def build_step_prompt(role, issue_number, review_output)
20
18
  if role == 'fix'
21
- "Fix these review findings for issue ##{issue_number}:\n\n#{review_output}"
19
+ "Fix these review findings for issue ##{issue_number}:\n\n<review_output>\n#{review_output}\n</review_output>"
22
20
  elsif STEP_PROMPTS.key?(role)
23
21
  format(STEP_PROMPTS[role], issue: issue_number)
24
22
  else
@@ -47,7 +45,10 @@ module Ocak
47
45
  json_match = output.match(/\{[\s\S]*"batches"[\s\S]*\}/)
48
46
  if json_match
49
47
  parsed = JSON.parse(json_match[0])
50
- parsed['batches']
48
+ batches = parsed['batches']
49
+ return sequential_batches(issues) unless batches.is_a?(Array)
50
+
51
+ batches
51
52
  else
52
53
  logger.warn('Could not parse planner output, falling back to sequential')
53
54
  sequential_batches(issues)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ class ProcessRegistry
5
+ KILL_WAIT = 2
6
+
7
+ def initialize
8
+ @pids = Set.new
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def register(pid)
13
+ @mutex.synchronize { @pids.add(pid) }
14
+ end
15
+
16
+ def unregister(pid)
17
+ @mutex.synchronize { @pids.delete(pid) }
18
+ end
19
+
20
+ def pids
21
+ @mutex.synchronize { @pids.dup }
22
+ end
23
+
24
+ def kill_all(signal: :TERM, wait: KILL_WAIT)
25
+ snapshot = pids
26
+ return if snapshot.empty?
27
+
28
+ snapshot.each do |pid|
29
+ Process.kill(signal, pid)
30
+ rescue Errno::ESRCH, Errno::EPERM
31
+ nil
32
+ end
33
+
34
+ sleep wait
35
+
36
+ # SIGKILL any survivors; ESRCH means already exited, which is fine
37
+ snapshot.each do |pid|
38
+ Process.kill(:KILL, pid)
39
+ rescue Errno::ESRCH, Errno::EPERM
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -5,15 +5,22 @@ require 'open3'
5
5
  module Ocak
6
6
  # Runs a subprocess with streaming line output and timeout support.
7
7
  module ProcessRunner
8
+ KILL_GRACE_PERIOD = 2
9
+
10
+ FailedStatus = Struct.new(:success?) do
11
+ def self.instance = new(false)
12
+ end
13
+
8
14
  module_function
9
15
 
10
- def run(cmd, chdir:, timeout: nil, on_line: nil)
16
+ def run(cmd, chdir:, timeout: nil, on_line: nil, registry: nil)
11
17
  stdout = +''
12
18
  stderr = +''
13
19
  line_buf = +''
14
20
 
15
21
  Open3.popen3(*cmd, chdir: chdir) do |stdin, out, err, wait_thr|
16
22
  stdin.close
23
+ registry&.register(wait_thr.pid)
17
24
  ctx = {
18
25
  stdout: +'', stderr: +'', line_buf: +'',
19
26
  deadline: timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil,
@@ -23,9 +30,11 @@ module Ocak
23
30
  stdout, stderr, line_buf = read_streams(out, err, ctx)
24
31
  on_line&.call(line_buf.chomp) unless line_buf.empty?
25
32
  [stdout, stderr, wait_thr.value]
33
+ ensure
34
+ registry&.unregister(wait_thr.pid)
26
35
  end
27
36
  rescue Errno::ENOENT => e
28
- ['', e.message, ClaudeRunner::FailedStatus.instance]
37
+ ['', e.message, FailedStatus.instance]
29
38
  end
30
39
 
31
40
  def read_streams(out, err, ctx)
@@ -47,9 +56,9 @@ module Ocak
47
56
 
48
57
  def kill_process(pid)
49
58
  Process.kill('TERM', pid)
50
- sleep 2
59
+ sleep KILL_GRACE_PERIOD
51
60
  Process.kill('KILL', pid)
52
- rescue Errno::ESRCH => e
61
+ rescue Errno::ESRCH, Errno::EPERM => e
53
62
  warn("Process already exited during kill: #{e.message}")
54
63
  nil
55
64
  end
@@ -66,8 +75,7 @@ module Ocak
66
75
  else
67
76
  ctx[:stderr] << chunk
68
77
  end
69
- rescue EOFError => e
70
- warn("Stream EOF for subprocess IO: #{e.message}")
78
+ rescue EOFError
71
79
  readers.delete(io)
72
80
  end
73
81
  end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+ require_relative 'git_utils'
6
+
7
+ module Ocak
8
+ class RereadyProcessor
9
+ def initialize(config:, logger:, claude:, issues:, watch: nil)
10
+ @config = config
11
+ @logger = logger
12
+ @claude = claude
13
+ @issues = issues
14
+ @watch = watch
15
+ end
16
+
17
+ # Main entry point. Returns true on success.
18
+ def process(pull_request)
19
+ pr_number = pull_request['number']
20
+ issue_number = @issues.extract_issue_number_from_pr(pull_request)
21
+ unless issue_number
22
+ @logger.warn("PR ##{pr_number}: could not extract issue number from body")
23
+ return false
24
+ end
25
+
26
+ feedback = gather_feedback(pr_number, issue_number)
27
+ return false unless feedback
28
+
29
+ branch_name = pull_request['headRefName']
30
+ unless GitUtils.safe_branch_name?(branch_name)
31
+ @logger.error("PR ##{pr_number}: unsafe branch name '#{branch_name}'")
32
+ return false
33
+ end
34
+
35
+ unless checkout_pr_branch(branch_name)
36
+ @logger.error("PR ##{pr_number}: failed to checkout branch #{branch_name}")
37
+ cleanup
38
+ return false
39
+ end
40
+
41
+ success = run_feedback_loop(feedback)
42
+ handle_result(pr_number, success)
43
+ ensure
44
+ cleanup
45
+ end
46
+
47
+ private
48
+
49
+ def gather_feedback(pr_number, issue_number)
50
+ comments_data = @issues.fetch_pr_comments(pr_number)
51
+ issue_data = fetch_issue(issue_number)
52
+ return nil unless issue_data
53
+
54
+ { pr_number: pr_number, issue_number: issue_number,
55
+ comments: comments_data[:comments], reviews: comments_data[:reviews],
56
+ issue_title: issue_data['title'], issue_body: issue_data['body'] }
57
+ end
58
+
59
+ def fetch_issue(issue_number)
60
+ @issues.view(issue_number, fields: 'title,body')
61
+ end
62
+
63
+ def checkout_pr_branch(branch_name)
64
+ _, _, fetch_status = Open3.capture3('git', 'fetch', 'origin', branch_name,
65
+ chdir: @config.project_dir)
66
+ return false unless fetch_status.success?
67
+
68
+ _, _, checkout_status = Open3.capture3('git', 'checkout', branch_name,
69
+ chdir: @config.project_dir)
70
+ return false unless checkout_status.success?
71
+
72
+ _, _, pull_status = Open3.capture3('git', 'pull', '--rebase', 'origin', branch_name,
73
+ chdir: @config.project_dir)
74
+ pull_status.success?
75
+ end
76
+
77
+ def run_feedback_loop(feedback)
78
+ prompt = build_feedback_prompt(feedback)
79
+ result = @claude.run_agent('implementer', prompt, chdir: @config.project_dir)
80
+ return false unless result.success?
81
+
82
+ verified = run_verification
83
+ return true if verified
84
+
85
+ # One retry
86
+ retry_result = @claude.run_agent('implementer', "Fix the failing tests and lint errors.\n#{prompt}",
87
+ chdir: @config.project_dir)
88
+ return false unless retry_result.success?
89
+
90
+ run_verification
91
+ end
92
+
93
+ def run_verification
94
+ test_ok = run_optional_cmd(@config.test_command)
95
+ lint_ok = run_optional_cmd(@config.lint_check_command)
96
+ test_ok && lint_ok
97
+ end
98
+
99
+ def run_optional_cmd(cmd)
100
+ return true if cmd.nil? || cmd.empty?
101
+
102
+ _, _, status = Open3.capture3(*Shellwords.shellsplit(cmd), chdir: @config.project_dir)
103
+ status.success?
104
+ rescue ArgumentError => e
105
+ @logger&.warn("Invalid shell command in config: #{cmd.inspect} (#{e.message})")
106
+ false
107
+ end
108
+
109
+ def handle_result(pr_number, success)
110
+ if success
111
+ push_ok = push_updates
112
+ @issues.pr_transition(pr_number, remove_label: @config.label_reready)
113
+ if push_ok
114
+ @issues.pr_comment(pr_number, 'Feedback addressed. Please re-review.')
115
+ else
116
+ @issues.pr_comment(pr_number, 'Failed to push feedback changes. Please check logs.')
117
+ end
118
+ push_ok
119
+ else
120
+ @issues.pr_transition(pr_number, remove_label: @config.label_reready)
121
+ @issues.pr_comment(pr_number, 'Failed to address feedback automatically. Please check logs.')
122
+ false
123
+ end
124
+ end
125
+
126
+ def push_updates
127
+ committed = GitUtils.commit_changes(
128
+ chdir: @config.project_dir,
129
+ message: 'fix: address review feedback',
130
+ logger: @logger
131
+ )
132
+ @logger.warn('Proceeding to push without new commit') unless committed
133
+
134
+ _, _, push_status = Open3.capture3('git', 'push', '--force-with-lease', chdir: @config.project_dir)
135
+ push_status.success?
136
+ end
137
+
138
+ def cleanup
139
+ GitUtils.checkout_main(chdir: @config.project_dir, logger: @logger)
140
+ end
141
+
142
+ def build_feedback_prompt(feedback)
143
+ reviews_text = Array(feedback[:reviews]).map do |r|
144
+ "- #{r.dig('author', 'login')} [#{r['state']}]: #{r['body']}"
145
+ end.join("\n")
146
+
147
+ comments_text = Array(feedback[:comments]).map do |c|
148
+ "- #{c.dig('author', 'login')}: #{c['body']}"
149
+ end.join("\n")
150
+
151
+ <<~PROMPT
152
+ Address the review feedback on PR ##{feedback[:pr_number]} for issue ##{feedback[:issue_number]}.
153
+
154
+ ## Original Issue: #{feedback[:issue_title]}
155
+ <issue_body>
156
+ #{feedback[:issue_body]}
157
+ </issue_body>
158
+
159
+ ## Review Comments
160
+ <review_comments>
161
+ #{reviews_text.empty? ? '(none)' : reviews_text}
162
+ </review_comments>
163
+
164
+ ## PR Comments
165
+ <pr_comments>
166
+ #{comments_text.empty? ? '(none)' : comments_text}
167
+ </pr_comments>
168
+
169
+ ## Instructions
170
+ Read the PR diff with `git diff main` to understand current changes.
171
+ Address each piece of feedback. Do NOT revert changes unless explicitly requested.
172
+ Run tests and lint after making changes.
173
+ PROMPT
174
+ end
175
+ end
176
+ end