ocak 0.5.0 → 0.6.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.
@@ -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,6 +17,10 @@ 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
25
  if issues.is_a?(LocalIssueFetcher) && !gh_available?
22
26
  LocalMergeManager.new(config: @config, logger: logger, issues: issues)
@@ -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,15 @@ module Ocak
68
72
 
69
73
  private
70
74
 
75
+ def merger_prompt(issue_number, worktree)
76
+ 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
+ end
83
+
71
84
  def log_and_nil(message)
72
85
  @logger.error(message)
73
86
  nil
@@ -76,7 +89,14 @@ module Ocak
76
89
  def open_pull_request(issue_number, worktree)
77
90
  issue_title = fetch_issue_title(issue_number)
78
91
  pr_title = "Fix ##{issue_number}: #{issue_title}"
79
- pr_body = "Closes ##{issue_number}\n\n" \
92
+ issue_ref = if worktree.target_repo
93
+ god_nwo = @issues.repo_nwo
94
+ ref = god_nwo ? "#{god_nwo}##{issue_number}" : "##{issue_number}"
95
+ "Related to #{ref}"
96
+ else
97
+ "Closes ##{issue_number}"
98
+ end
99
+ pr_body = "#{issue_ref}\n\n" \
80
100
  '_This PR was created in manual review mode. ' \
81
101
  'Review and label `auto-reready` to trigger automated fixes based on your feedback._'
82
102
 
@@ -114,98 +134,6 @@ module Ocak
114
134
  )
115
135
  end
116
136
 
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
137
  def shell(cmd, chdir:)
210
138
  Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
211
139
  end
@@ -14,52 +14,59 @@ 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
+ claude.run_agent('merger', prompt, chdir: target_dir)
34
42
  end
35
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
43
+ @state_machine.mark_completed(issue_number)
36
44
  logger.info("Issue ##{issue_number} completed successfully")
37
45
  end
38
46
  end
39
47
 
40
- def handle_single_manual_review(issue_number, logger:, claude:, issues:)
48
+ def handle_single_manual_review(issue_number, logger:, claude:, issues: nil, chdir: @config.project_dir) # rubocop:disable Lint/UnusedMethodArgument
41
49
  claude.run_agent('merger',
42
50
  "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)
51
+ chdir: chdir)
52
+ @state_machine.mark_for_review(issue_number)
45
53
  logger.info("Issue ##{issue_number} PR created (manual review mode)")
46
54
  end
47
55
 
48
- def handle_batch_manual_review(result, merger:, issues:, logger:)
56
+ def handle_batch_manual_review(result, merger:, logger:, issues: nil) # rubocop:disable Lint/UnusedMethodArgument
49
57
  pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
50
58
  if pr_number
51
- issues.transition(result[:issue_number], from: @config.label_in_progress,
52
- to: @config.label_awaiting_review)
59
+ @state_machine.mark_for_review(result[:issue_number])
53
60
  logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (manual review mode)")
54
61
  else
55
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
62
+ @state_machine.mark_failed(result[:issue_number])
56
63
  logger.error("Issue ##{result[:issue_number]} PR creation failed")
57
64
  end
58
65
  end
59
66
 
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)
67
+ def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:, chdir: @config.project_dir)
68
+ handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: chdir)
69
+ post_audit_comment_single(result[:audit_output], logger: logger, issues: issues, chdir: chdir)
63
70
  end
64
71
 
65
72
  def handle_batch_audit(result, merger:, issues:, logger:)
@@ -70,17 +77,16 @@ module Ocak
70
77
  pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
71
78
  if pr_number
72
79
  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)
80
+ @state_machine.mark_for_review(result[:issue_number])
75
81
  logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (audit findings)")
76
82
  else
77
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
83
+ @state_machine.mark_failed(result[:issue_number])
78
84
  logger.error("Issue ##{result[:issue_number]} PR creation failed")
79
85
  end
80
86
  end
81
87
 
82
- def post_audit_comment_single(audit_output, logger:, issues:)
83
- pr_number = find_pr_for_branch(logger: logger)
88
+ def post_audit_comment_single(audit_output, logger:, issues:, chdir: @config.project_dir)
89
+ pr_number = find_pr_for_branch(logger: logger, chdir: chdir)
84
90
  unless pr_number
85
91
  logger.warn("Could not find PR to post audit comment — findings were: #{audit_output.to_s[0..200]}")
86
92
  return
@@ -94,8 +100,8 @@ module Ocak
94
100
  @config.steps.any? { |s| s[:role].to_s == 'merge' || s['role'].to_s == 'merge' }
95
101
  end
96
102
 
97
- def find_pr_for_branch(logger:)
98
- stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: @config.project_dir)
103
+ def find_pr_for_branch(logger:, chdir: @config.project_dir)
104
+ stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: chdir)
99
105
  return nil unless status.success?
100
106
 
101
107
  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
@@ -145,8 +147,12 @@ module Ocak
145
147
  end
146
148
 
147
149
  def current_branch(chdir, logger: nil)
148
- stdout, = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
149
- stdout.strip
150
+ result = run_git('rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
151
+ if result.status.nil?
152
+ logger&.debug("Could not determine current branch: #{result.error}")
153
+ return nil
154
+ end
155
+ result.output
150
156
  rescue StandardError => e
151
157
  logger&.debug("Could not determine current branch: #{e.message}")
152
158
  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
@@ -26,6 +26,7 @@ module Ocak
26
26
 
27
27
  def plan_batches(issues, logger:, claude:)
28
28
  return sequential_batches(issues) if issues.size <= 1
29
+ return plan_multi_repo_batches(issues) if @config&.multi_repo?
29
30
 
30
31
  issue_json = JSON.generate(issues.map { |i| { number: i['number'], title: i['title'] } })
31
32
  result = claude.run_agent(
@@ -41,6 +42,20 @@ module Ocak
41
42
  parse_planner_output(result.output, issues, logger)
42
43
  end
43
44
 
45
+ def plan_multi_repo_batches(issues)
46
+ by_repo = issues.group_by { |i| i['_target']&.dig(:name) || '__self__' }
47
+
48
+ # If all issues target different repos, one big parallel batch — no agent call needed
49
+ return [{ 'batch' => 1, 'issues' => issues }] if by_repo.values.all? { |group| group.size == 1 }
50
+
51
+ # Otherwise, issues in the same repo are sequential (by depth), cross-repo are parallel
52
+ max_depth = by_repo.values.map(&:size).max
53
+ (0...max_depth).map do |depth|
54
+ batch_issues = by_repo.values.filter_map { |group| group[depth] }
55
+ { 'batch' => depth + 1, 'issues' => batch_issues }
56
+ end
57
+ end
58
+
44
59
  def parse_planner_output(output, issues, logger)
45
60
  json_match = output.match(/\{[\s\S]*"batches"[\s\S]*\}/)
46
61
  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}`.")
@@ -191,7 +191,7 @@ module Ocak
191
191
  end
192
192
 
193
193
  def detect_test_pass(output)
194
- return true if output.match?(/0 failures,\s*0 errors/)
194
+ return true if output.match?(/\b0 failures\b/)
195
195
  return true if output.match?(/no offenses detected/i)
196
196
  return true if output.match?(/test result: ok/i) # cargo test
197
197
  return false if output.match?(/[1-9]\d* failures?/) || output.match?(/[1-9]\d* errors?/)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Ocak
6
+ module TargetResolver
7
+ # Resolves the target repo for an issue.
8
+ # Returns { name:, path: } hash or nil (single-repo mode).
9
+ # Raises TargetResolutionError on missing/invalid target in multi-repo mode.
10
+ def self.resolve(issue, config:)
11
+ return nil unless config.multi_repo?
12
+
13
+ body = issue['body'].to_s
14
+ repo_name = extract_target_name(body, field: config.target_field)
15
+
16
+ unless repo_name
17
+ raise TargetResolutionError,
18
+ "Issue ##{issue['number']} is missing required '#{config.target_field}' field in body. " \
19
+ "Known repos: #{config.repos.keys.join(', ')}"
20
+ end
21
+
22
+ config.resolve_repo(repo_name)
23
+ end
24
+
25
+ # Extract target name from YAML front-matter.
26
+ # Returns the target name string, or nil if not found.
27
+ def self.extract_target_name(body, field:)
28
+ match = body.match(/\A---\s*\n(.*?)\n---/m)
29
+ return nil unless match
30
+
31
+ frontmatter = YAML.safe_load(match[1])
32
+ return nil unless frontmatter.is_a?(Hash)
33
+
34
+ frontmatter[field]&.to_s&.strip
35
+ rescue Psych::SyntaxError
36
+ nil
37
+ end
38
+
39
+ class TargetResolutionError < StandardError; end
40
+ end
41
+ end
@@ -98,6 +98,9 @@ You implement GitHub issues. The issue is your single source of truth — everyt
98
98
  <%- end -%>
99
99
  <%- end -%>
100
100
 
101
+ ### Working Directory Context
102
+ If a CLAUDE.md file exists in the current working directory, read it for project-specific conventions, patterns, and architecture. This is especially important when working in a repository you haven't seen before.
103
+
101
104
  ### General Rules
102
105
 
103
106
  - Do NOT add features, refactoring, or improvements beyond what the issue specifies