ocak 0.5.0 → 0.6.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.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ # Rebase and conflict resolution logic — rebase_onto_main, resolve_conflicts_via_agent.
5
+ # Extracted from MergeManager to reduce file size.
6
+ module ConflictResolution
7
+ private
8
+
9
+ def rebase_onto_main(worktree)
10
+ fetch_result = run_git('fetch', 'origin', 'main', chdir: worktree.path)
11
+ unless fetch_result.success?
12
+ @logger.error("git fetch origin main failed: #{fetch_result.error}")
13
+ return false
14
+ end
15
+
16
+ rebase_result = run_git('rebase', 'origin/main', chdir: worktree.path)
17
+
18
+ return true if rebase_result.success?
19
+
20
+ @logger.warn("Rebase conflict, aborting rebase: #{rebase_result.error}")
21
+ abort_result = run_git('rebase', '--abort', chdir: worktree.path)
22
+ @logger.warn("git rebase --abort failed: #{abort_result.error}") unless abort_result.success?
23
+
24
+ # Fall back to merge strategy
25
+ @logger.info('Attempting merge strategy instead...')
26
+ merge_result = run_git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
27
+
28
+ return true if merge_result.success?
29
+
30
+ # Merge also has conflicts — try to resolve via agent
31
+ @logger.warn("Merge conflict, attempting agent resolution: #{merge_result.error}")
32
+ resolve_conflicts_via_agent(worktree)
33
+ end
34
+
35
+ def resolve_conflicts_via_agent(worktree)
36
+ # Get list of conflicting files
37
+ diff_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
38
+ conflicting = diff_result.stdout.lines.map(&:strip).reject(&:empty?)
39
+
40
+ if conflicting.empty?
41
+ @logger.warn('No conflicting files found, aborting merge')
42
+ run_git('merge', '--abort', chdir: worktree.path)
43
+ return false
44
+ end
45
+
46
+ result = @claude.run_agent(
47
+ 'implementer',
48
+ "Resolve these merge conflicts.\n\n<conflicting_files>\n#{conflicting.join("\n")}\n</conflicting_files>\n\n" \
49
+ 'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
50
+ 'Then run `git add` on each resolved file.',
51
+ chdir: worktree.path
52
+ )
53
+
54
+ if result.success?
55
+ # Check if all conflicts resolved
56
+ remaining_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
57
+ if remaining_result.output.empty?
58
+ commit_result = run_git('commit', '--no-edit', chdir: worktree.path)
59
+ unless commit_result.success?
60
+ @logger.error("Commit after conflict resolution failed: #{commit_result.error}")
61
+ return false
62
+ end
63
+ @logger.info('Merge conflicts resolved by agent')
64
+ return true
65
+ end
66
+ end
67
+
68
+ @logger.error('Agent could not resolve merge conflicts')
69
+ run_git('merge', '--abort', chdir: worktree.path)
70
+ false
71
+ end
72
+ end
73
+ end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'issue_state_machine'
4
+
3
5
  module Ocak
4
6
  # Shared pipeline failure reporting — label transition + comment posting.
5
7
  # Included by PipelineRunner and Commands::Resume.
6
8
  module FailureReporting
7
9
  def report_pipeline_failure(issue_number, result, issues:, config:, logger: nil)
8
- issues.transition(issue_number, from: config.label_in_progress, to: config.label_failed)
10
+ IssueStateMachine.new(config: config, issues: issues).mark_failed(issue_number)
9
11
  sanitized = result[:output][0..1000].to_s.gsub('```', "'''")
10
12
  issues.comment(issue_number,
11
13
  "Pipeline failed at phase: #{result[:phase]}\n\n```\n#{sanitized}\n```")
@@ -17,8 +17,12 @@ module Ocak
17
17
  ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter, registry: @registry)
18
18
  end
19
19
 
20
+ def build_state_machine(issues)
21
+ IssueStateMachine.new(config: @config, issues: issues)
22
+ end
23
+
20
24
  def build_merge_manager(logger:, issues:)
21
- if issues.is_a?(LocalIssueFetcher) && !gh_available?
25
+ if issues.is_a?(LocalIssueFetcher) && !@config.multi_repo? && !gh_available?
22
26
  LocalMergeManager.new(config: @config, logger: logger, issues: issues)
23
27
  else
24
28
  MergeManager.new(config: @config, claude: build_claude(logger), logger: logger,
@@ -134,6 +134,15 @@ module Ocak
134
134
  nil
135
135
  end
136
136
 
137
+ def repo_nwo
138
+ return @repo_nwo if defined?(@repo_nwo_resolved)
139
+
140
+ stdout, _, status = run_gh('repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner')
141
+ @repo_nwo = status.success? ? stdout.strip : nil
142
+ @repo_nwo_resolved = true
143
+ @repo_nwo
144
+ end
145
+
137
146
  private
138
147
 
139
148
  def in_progress?(issue)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ # Encapsulates all valid label transitions for pipeline issue processing.
5
+ # Replaces scattered issues.transition calls with named, intention-revealing methods.
6
+ class IssueStateMachine
7
+ def initialize(config:, issues:)
8
+ @config = config
9
+ @issues = issues
10
+ end
11
+
12
+ def mark_in_progress(issue_number)
13
+ @issues&.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
14
+ end
15
+
16
+ def mark_completed(issue_number)
17
+ @issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
18
+ end
19
+
20
+ def mark_failed(issue_number)
21
+ @issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
22
+ end
23
+
24
+ def mark_interrupted(issue_number)
25
+ @issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
26
+ end
27
+
28
+ def mark_for_review(issue_number)
29
+ @issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_awaiting_review)
30
+ end
31
+
32
+ def mark_resuming(issue_number)
33
+ @issues&.transition(issue_number, from: @config.label_failed, to: @config.label_in_progress)
34
+ end
35
+ end
36
+ end
@@ -4,10 +4,14 @@ require 'open3'
4
4
  require 'shellwords'
5
5
  require_relative 'git_utils'
6
6
  require_relative 'command_runner'
7
+ require_relative 'conflict_resolution'
8
+ require_relative 'merge_verification'
7
9
 
8
10
  module Ocak
9
11
  class MergeManager
10
12
  include CommandRunner
13
+ include ConflictResolution
14
+ include MergeVerification
11
15
 
12
16
  def initialize(config:, claude:, logger:, issues:, watch: nil)
13
17
  @config = config
@@ -40,7 +44,7 @@ module Ocak
40
44
 
41
45
  result = @claude.run_agent(
42
46
  'merger',
43
- "Create a PR, merge it, and close issue ##{issue_number}. Branch: #{worktree.branch}",
47
+ merger_prompt(issue_number, worktree),
44
48
  chdir: worktree.path
45
49
  )
46
50
 
@@ -68,6 +72,18 @@ module Ocak
68
72
 
69
73
  private
70
74
 
75
+ def merger_prompt(issue_number, worktree)
76
+ base = if worktree.target_repo
77
+ "Create a PR and merge it for issue ##{issue_number}. Branch: #{worktree.branch}. " \
78
+ 'Do NOT close any issues (the issue lives in a different repository).'
79
+ else
80
+ "Create a PR, merge it, and close issue ##{issue_number}. Branch: #{worktree.branch}"
81
+ end
82
+ issue_data = @issues.view(issue_number)
83
+ base += "\n\n<issue_data>\nTitle: #{issue_data['title']}\n\n#{issue_data['body']}\n</issue_data>" if issue_data
84
+ base
85
+ end
86
+
71
87
  def log_and_nil(message)
72
88
  @logger.error(message)
73
89
  nil
@@ -76,7 +92,14 @@ module Ocak
76
92
  def open_pull_request(issue_number, worktree)
77
93
  issue_title = fetch_issue_title(issue_number)
78
94
  pr_title = "Fix ##{issue_number}: #{issue_title}"
79
- pr_body = "Closes ##{issue_number}\n\n" \
95
+ issue_ref = if worktree.target_repo
96
+ god_nwo = @issues.repo_nwo
97
+ ref = god_nwo ? "#{god_nwo}##{issue_number}" : "##{issue_number}"
98
+ "Related to #{ref}"
99
+ else
100
+ "Closes ##{issue_number}"
101
+ end
102
+ pr_body = "#{issue_ref}\n\n" \
80
103
  '_This PR was created in manual review mode. ' \
81
104
  'Review and label `auto-reready` to trigger automated fixes based on your feedback._'
82
105
 
@@ -114,98 +137,6 @@ module Ocak
114
137
  )
115
138
  end
116
139
 
117
- def rebase_onto_main(worktree)
118
- fetch_result = run_git('fetch', 'origin', 'main', chdir: worktree.path)
119
- unless fetch_result.success?
120
- @logger.error("git fetch origin main failed: #{fetch_result.error}")
121
- return false
122
- end
123
-
124
- rebase_result = run_git('rebase', 'origin/main', chdir: worktree.path)
125
-
126
- return true if rebase_result.success?
127
-
128
- @logger.warn("Rebase conflict, aborting rebase: #{rebase_result.error}")
129
- abort_result = run_git('rebase', '--abort', chdir: worktree.path)
130
- @logger.warn("git rebase --abort failed: #{abort_result.error}") unless abort_result.success?
131
-
132
- # Fall back to merge strategy
133
- @logger.info('Attempting merge strategy instead...')
134
- merge_result = run_git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
135
-
136
- return true if merge_result.success?
137
-
138
- # Merge also has conflicts — try to resolve via agent
139
- @logger.warn("Merge conflict, attempting agent resolution: #{merge_result.error}")
140
- resolve_conflicts_via_agent(worktree)
141
- end
142
-
143
- def resolve_conflicts_via_agent(worktree)
144
- # Get list of conflicting files
145
- diff_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
146
- conflicting = diff_result.stdout.lines.map(&:strip).reject(&:empty?)
147
-
148
- if conflicting.empty?
149
- @logger.warn('No conflicting files found, aborting merge')
150
- run_git('merge', '--abort', chdir: worktree.path)
151
- return false
152
- end
153
-
154
- result = @claude.run_agent(
155
- 'implementer',
156
- "Resolve these merge conflicts.\n\n<conflicting_files>\n#{conflicting.join("\n")}\n</conflicting_files>\n\n" \
157
- 'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
158
- 'Then run `git add` on each resolved file.',
159
- chdir: worktree.path
160
- )
161
-
162
- if result.success?
163
- # Check if all conflicts resolved
164
- remaining_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
165
- if remaining_result.output.empty?
166
- commit_result = run_git('commit', '--no-edit', chdir: worktree.path)
167
- unless commit_result.success?
168
- @logger.error("Commit after conflict resolution failed: #{commit_result.error}")
169
- return false
170
- end
171
- @logger.info('Merge conflicts resolved by agent')
172
- return true
173
- end
174
- end
175
-
176
- @logger.error('Agent could not resolve merge conflicts')
177
- run_git('merge', '--abort', chdir: worktree.path)
178
- false
179
- end
180
-
181
- def verify_tests(worktree)
182
- test_cmd = @config.test_command
183
- return true unless test_cmd
184
-
185
- @logger.info('Running tests after rebase...')
186
- stdout, stderr, status = shell(test_cmd, chdir: worktree.path)
187
-
188
- if status.success?
189
- @logger.info('Tests passed after rebase')
190
- true
191
- else
192
- @logger.warn('Tests failed after rebase')
193
- @logger.debug("Test output:\n#{stdout[0..2000]}\n#{stderr[0..500]}")
194
- false
195
- end
196
- end
197
-
198
- def push_branch(worktree)
199
- result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
200
-
201
- unless result.success?
202
- @logger.error("Push failed: #{result.error}")
203
- return false
204
- end
205
-
206
- true
207
- end
208
-
209
140
  def shell(cmd, chdir:)
210
141
  Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
211
142
  end
@@ -14,52 +14,60 @@ module Ocak
14
14
  elsif @config.manual_review
15
15
  handle_batch_manual_review(result, merger: merger, issues: issues, logger: logger)
16
16
  elsif merger.merge(result[:issue_number], result[:worktree])
17
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_completed)
17
+ @state_machine.mark_completed(result[:issue_number])
18
18
  logger.info("Issue ##{result[:issue_number]} merged successfully")
19
19
  else
20
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
20
+ @state_machine.mark_failed(result[:issue_number])
21
21
  logger.error("Issue ##{result[:issue_number]} merge failed")
22
22
  end
23
23
  end
24
24
 
25
25
  def handle_single_success(issue_number, result, logger:, claude:, issues:)
26
+ target_dir = result[:target_repo]&.dig(:path) || @config.project_dir
27
+
26
28
  if result[:audit_blocked]
27
- handle_single_audit_blocked(issue_number, result, logger: logger, claude: claude, issues: issues)
29
+ handle_single_audit_blocked(issue_number, result, logger: logger, claude: claude, issues: issues,
30
+ chdir: target_dir)
28
31
  elsif @config.manual_review
29
- handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
32
+ handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: target_dir)
30
33
  else
31
34
  unless pipeline_has_merge_step?
32
- claude.run_agent('merger', "Create a PR, merge it, and close issue ##{issue_number}",
33
- chdir: @config.project_dir)
35
+ prompt = if result[:target_repo]
36
+ "Create a PR and merge it for issue ##{issue_number}. " \
37
+ 'Do NOT close any issues (the issue lives in a different repository).'
38
+ else
39
+ "Create a PR, merge it, and close issue ##{issue_number}"
40
+ end
41
+ prompt += merger_issue_context(issue_number, issues)
42
+ claude.run_agent('merger', prompt, chdir: target_dir)
34
43
  end
35
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
44
+ @state_machine.mark_completed(issue_number)
36
45
  logger.info("Issue ##{issue_number} completed successfully")
37
46
  end
38
47
  end
39
48
 
40
- def handle_single_manual_review(issue_number, logger:, claude:, issues:)
41
- claude.run_agent('merger',
42
- "Create a PR for issue ##{issue_number} but do NOT merge it and do NOT close the issue",
43
- chdir: @config.project_dir)
44
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_awaiting_review)
49
+ def handle_single_manual_review(issue_number, logger:, claude:, issues: nil, chdir: @config.project_dir)
50
+ prompt = "Create a PR for issue ##{issue_number} but do NOT merge it and do NOT close the issue"
51
+ prompt += merger_issue_context(issue_number, issues)
52
+ claude.run_agent('merger', prompt, chdir: chdir)
53
+ @state_machine.mark_for_review(issue_number)
45
54
  logger.info("Issue ##{issue_number} PR created (manual review mode)")
46
55
  end
47
56
 
48
- def handle_batch_manual_review(result, merger:, issues:, logger:)
57
+ def handle_batch_manual_review(result, merger:, logger:, issues: nil) # rubocop:disable Lint/UnusedMethodArgument
49
58
  pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
50
59
  if pr_number
51
- issues.transition(result[:issue_number], from: @config.label_in_progress,
52
- to: @config.label_awaiting_review)
60
+ @state_machine.mark_for_review(result[:issue_number])
53
61
  logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (manual review mode)")
54
62
  else
55
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
63
+ @state_machine.mark_failed(result[:issue_number])
56
64
  logger.error("Issue ##{result[:issue_number]} PR creation failed")
57
65
  end
58
66
  end
59
67
 
60
- def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:)
61
- handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
62
- post_audit_comment_single(result[:audit_output], logger: logger, issues: issues)
68
+ def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:, chdir: @config.project_dir)
69
+ handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: chdir)
70
+ post_audit_comment_single(result[:audit_output], logger: logger, issues: issues, chdir: chdir)
63
71
  end
64
72
 
65
73
  def handle_batch_audit(result, merger:, issues:, logger:)
@@ -70,17 +78,16 @@ module Ocak
70
78
  pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
71
79
  if pr_number
72
80
  issues.pr_comment(pr_number, "## Audit Report\n\n#{audit_output}")
73
- issues.transition(result[:issue_number], from: @config.label_in_progress,
74
- to: @config.label_awaiting_review)
81
+ @state_machine.mark_for_review(result[:issue_number])
75
82
  logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (audit findings)")
76
83
  else
77
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
84
+ @state_machine.mark_failed(result[:issue_number])
78
85
  logger.error("Issue ##{result[:issue_number]} PR creation failed")
79
86
  end
80
87
  end
81
88
 
82
- def post_audit_comment_single(audit_output, logger:, issues:)
83
- pr_number = find_pr_for_branch(logger: logger)
89
+ def post_audit_comment_single(audit_output, logger:, issues:, chdir: @config.project_dir)
90
+ pr_number = find_pr_for_branch(logger: logger, chdir: chdir)
84
91
  unless pr_number
85
92
  logger.warn("Could not find PR to post audit comment — findings were: #{audit_output.to_s[0..200]}")
86
93
  return
@@ -90,12 +97,19 @@ module Ocak
90
97
  logger.info("Posted audit comment on PR ##{pr_number}")
91
98
  end
92
99
 
100
+ def merger_issue_context(issue_number, issues)
101
+ issue_data = issues&.view(issue_number)
102
+ return '' unless issue_data
103
+
104
+ "\n\n<issue_data>\nTitle: #{issue_data['title']}\n\n#{issue_data['body']}\n</issue_data>"
105
+ end
106
+
93
107
  def pipeline_has_merge_step?
94
108
  @config.steps.any? { |s| s[:role].to_s == 'merge' || s['role'].to_s == 'merge' }
95
109
  end
96
110
 
97
- def find_pr_for_branch(logger:)
98
- stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: @config.project_dir)
111
+ def find_pr_for_branch(logger:, chdir: @config.project_dir)
112
+ stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: chdir)
99
113
  return nil unless status.success?
100
114
 
101
115
  data = JSON.parse(stdout)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module Ocak
7
+ # Test verification and git push logic — verify_tests, push_branch.
8
+ # Extracted from MergeManager to reduce file size.
9
+ module MergeVerification
10
+ private
11
+
12
+ def verify_tests(worktree)
13
+ test_cmd = @config.test_command
14
+ return true unless test_cmd
15
+
16
+ @logger.info('Running tests after rebase...')
17
+ stdout, stderr, status = shell(test_cmd, chdir: worktree.path)
18
+
19
+ if status.success?
20
+ @logger.info('Tests passed after rebase')
21
+ true
22
+ else
23
+ @logger.warn('Tests failed after rebase')
24
+ @logger.debug("Test output:\n#{stdout[0..2000]}\n#{stderr[0..500]}")
25
+ false
26
+ end
27
+ end
28
+
29
+ def push_branch(worktree)
30
+ result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
31
+
32
+ unless result.success?
33
+ @logger.error("Push failed: #{result.error}")
34
+ return false
35
+ end
36
+
37
+ true
38
+ end
39
+ end
40
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'fileutils'
5
+ require_relative 'command_runner'
5
6
  require_relative 'pipeline_state'
6
7
  require_relative 'run_report'
7
8
  require_relative 'verification'
@@ -13,6 +14,7 @@ require_relative 'parallel_execution'
13
14
 
14
15
  module Ocak
15
16
  class PipelineExecutor
17
+ include CommandRunner
16
18
  include Verification
17
19
  include Planner
18
20
  include StepComments
@@ -42,6 +44,7 @@ module Ocak
42
44
 
43
45
  report = RunReport.new(complexity: complexity)
44
46
  state = build_initial_state(complexity, report)
47
+ state[:issue_data] = @issues&.view(issue_number)
45
48
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
49
  post_pipeline_start_comment(issue_number, state) if post_start_comment
47
50
 
@@ -145,8 +148,12 @@ module Ocak
145
148
  end
146
149
 
147
150
  def current_branch(chdir, logger: nil)
148
- stdout, = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
149
- stdout.strip
151
+ result = run_git('rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
152
+ if result.status.nil?
153
+ logger&.debug("Could not determine current branch: #{result.error}")
154
+ return nil
155
+ end
156
+ result.output
150
157
  rescue StandardError => e
151
158
  logger&.debug("Could not determine current branch: #{e.message}")
152
159
  nil
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require_relative 'batch_processing'
4
5
  require_relative 'failure_reporting'
5
6
  require_relative 'instance_builders'
7
+ require_relative 'issue_state_machine'
6
8
  require_relative 'merge_orchestration'
7
9
  require_relative 'pipeline_executor'
8
10
  require_relative 'process_registry'
@@ -36,6 +38,7 @@ module Ocak
36
38
  end
37
39
 
38
40
  def run
41
+ warn_if_untrusted
39
42
  @options[:single] ? run_single(@options[:single]) : run_loop
40
43
  end
41
44
 
@@ -52,12 +55,25 @@ module Ocak
52
55
 
53
56
  private
54
57
 
58
+ def warn_if_untrusted
59
+ return unless @config.custom_commands?
60
+
61
+ trusted_path = File.join(@config.project_dir, '.ocak', 'trusted')
62
+ return if File.exist?(trusted_path)
63
+
64
+ warn 'Warning: ocak.yml contains commands that will be executed with your user privileges. ' \
65
+ 'Review the config before proceeding.'
66
+ FileUtils.mkdir_p(File.dirname(trusted_path))
67
+ FileUtils.touch(trusted_path)
68
+ end
69
+
55
70
  def run_single(issue_number)
56
71
  logger = build_logger(issue_number: issue_number)
57
72
  claude = build_claude(logger)
58
73
  issues = IssueBackend.build(config: @config)
59
74
  ensure_labels(issues, logger)
60
75
  @executor.issues = issues
76
+ @state_machine = build_state_machine(issues)
61
77
  logger.info("Running single issue mode for ##{issue_number}")
62
78
 
63
79
  if @options[:dry_run]
@@ -65,7 +81,7 @@ module Ocak
65
81
  return
66
82
  end
67
83
 
68
- issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
84
+ @state_machine.mark_in_progress(issue_number)
69
85
  complexity = @options[:fast] ? 'simple' : 'full'
70
86
  result = run_pipeline(issue_number, logger: logger, claude: claude, complexity: complexity)
71
87
 
@@ -84,6 +100,7 @@ module Ocak
84
100
  issues = IssueBackend.build(config: @config, logger: logger)
85
101
  ensure_labels(issues, logger)
86
102
  @executor.issues = issues
103
+ @state_machine = build_state_machine(issues)
87
104
  cleanup_stale_worktrees(logger)
88
105
 
89
106
  loop do
data/lib/ocak/planner.rb CHANGED
@@ -14,18 +14,32 @@ module Ocak
14
14
  'merge' => 'Create a PR, merge it, and close issue #%<issue>s'
15
15
  }.freeze
16
16
 
17
- def build_step_prompt(role, issue_number, review_output)
18
- if role == 'fix'
19
- "Fix these review findings for issue ##{issue_number}:\n\n<review_output>\n#{review_output}\n</review_output>"
20
- elsif STEP_PROMPTS.key?(role)
21
- format(STEP_PROMPTS[role], issue: issue_number)
22
- else
23
- "Run #{role} for GitHub issue ##{issue_number}"
24
- end
17
+ ISSUE_CONTEXT_ROLES = %w[implement document merge].freeze
18
+
19
+ def build_step_prompt(role, issue_number, review_output, issue_data: nil)
20
+ prompt = if role == 'fix'
21
+ "Fix these review findings for issue ##{issue_number}:\n\n" \
22
+ "<review_output>\n#{review_output}\n</review_output>"
23
+ elsif STEP_PROMPTS.key?(role)
24
+ format(STEP_PROMPTS[role], issue: issue_number)
25
+ else
26
+ "Run #{role} for GitHub issue ##{issue_number}"
27
+ end
28
+
29
+ prompt += format_issue_context(issue_data) if issue_data && ISSUE_CONTEXT_ROLES.include?(role)
30
+ prompt
31
+ end
32
+
33
+ def format_issue_context(issue_data)
34
+ parts = []
35
+ parts << "Title: #{issue_data['title']}" if issue_data['title']
36
+ parts << issue_data['body'] if issue_data['body']
37
+ "\n\n<issue_data>\n#{parts.join("\n\n")}\n</issue_data>"
25
38
  end
26
39
 
27
40
  def plan_batches(issues, logger:, claude:)
28
41
  return sequential_batches(issues) if issues.size <= 1
42
+ return plan_multi_repo_batches(issues) if @config&.multi_repo?
29
43
 
30
44
  issue_json = JSON.generate(issues.map { |i| { number: i['number'], title: i['title'] } })
31
45
  result = claude.run_agent(
@@ -41,6 +55,20 @@ module Ocak
41
55
  parse_planner_output(result.output, issues, logger)
42
56
  end
43
57
 
58
+ def plan_multi_repo_batches(issues)
59
+ by_repo = issues.group_by { |i| i['_target']&.dig(:name) || '__self__' }
60
+
61
+ # If all issues target different repos, one big parallel batch — no agent call needed
62
+ return [{ 'batch' => 1, 'issues' => issues }] if by_repo.values.all? { |group| group.size == 1 }
63
+
64
+ # Otherwise, issues in the same repo are sequential (by depth), cross-repo are parallel
65
+ max_depth = by_repo.values.map(&:size).max
66
+ (0...max_depth).map do |depth|
67
+ batch_issues = by_repo.values.filter_map { |group| group[depth] }
68
+ { 'batch' => depth + 1, 'issues' => batch_issues }
69
+ end
70
+ end
71
+
44
72
  def parse_planner_output(output, issues, logger)
45
73
  json_match = output.match(/\{[\s\S]*"batches"[\s\S]*\}/)
46
74
  if json_match
@@ -40,7 +40,7 @@ module Ocak
40
40
  def handle_process_error(error, issue_number:, logger:, issues:)
41
41
  logger.error("Unexpected #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
42
42
  logger.debug("Full backtrace:\n#{error.backtrace&.join("\n")}")
43
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
43
+ @state_machine.mark_failed(issue_number)
44
44
  begin
45
45
  issues.comment(issue_number, "Unexpected #{error.class}: #{error.message}")
46
46
  rescue StandardError => e
@@ -55,7 +55,7 @@ module Ocak
55
55
  message: "wip: pipeline interrupted after step #{step_name} for issue ##{issue_number}",
56
56
  logger: logger)
57
57
  end
58
- issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
58
+ @state_machine&.mark_interrupted(issue_number)
59
59
  issues&.comment(issue_number,
60
60
  "\u{26A0}\u{FE0F} Pipeline interrupted after #{step_name}. " \
61
61
  "Resume with `ocak resume --issue #{issue_number}`.")