ocak 0.4.0 → 0.5.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -0
  3. data/lib/ocak/agent_generator.rb +2 -1
  4. data/lib/ocak/batch_processing.rb +102 -0
  5. data/lib/ocak/claude_runner.rb +12 -8
  6. data/lib/ocak/cli.rb +13 -0
  7. data/lib/ocak/command_runner.rb +39 -0
  8. data/lib/ocak/commands/hiz.rb +8 -7
  9. data/lib/ocak/commands/init.rb +5 -0
  10. data/lib/ocak/commands/issue/close.rb +37 -0
  11. data/lib/ocak/commands/issue/create.rb +59 -0
  12. data/lib/ocak/commands/issue/edit.rb +31 -0
  13. data/lib/ocak/commands/issue/list.rb +43 -0
  14. data/lib/ocak/commands/issue/view.rb +58 -0
  15. data/lib/ocak/commands/resume.rb +4 -3
  16. data/lib/ocak/commands/status.rb +20 -0
  17. data/lib/ocak/config.rb +4 -0
  18. data/lib/ocak/failure_reporting.rb +3 -2
  19. data/lib/ocak/git_utils.rb +18 -11
  20. data/lib/ocak/instance_builders.rb +50 -0
  21. data/lib/ocak/issue_backend.rb +31 -0
  22. data/lib/ocak/local_issue_fetcher.rb +165 -0
  23. data/lib/ocak/local_merge_manager.rb +104 -0
  24. data/lib/ocak/merge_manager.rb +31 -32
  25. data/lib/ocak/merge_orchestration.rb +8 -2
  26. data/lib/ocak/parallel_execution.rb +36 -0
  27. data/lib/ocak/pipeline_executor.rb +9 -183
  28. data/lib/ocak/pipeline_runner.rb +15 -180
  29. data/lib/ocak/planner.rb +1 -1
  30. data/lib/ocak/project_key.rb +38 -0
  31. data/lib/ocak/reready_processor.rb +11 -11
  32. data/lib/ocak/run_report.rb +5 -2
  33. data/lib/ocak/shutdown_handling.rb +67 -0
  34. data/lib/ocak/state_management.rb +104 -0
  35. data/lib/ocak/step_execution.rb +66 -0
  36. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  37. data/lib/ocak/templates/agents/implementer.md.erb +32 -8
  38. data/lib/ocak/templates/agents/merger.md.erb +12 -5
  39. data/lib/ocak/templates/agents/pipeline.md.erb +4 -0
  40. data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
  41. data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
  42. data/lib/ocak/templates/ocak.yml.erb +15 -0
  43. data/lib/ocak/verification.rb +6 -1
  44. data/lib/ocak/worktree_manager.rb +2 -0
  45. data/lib/ocak.rb +1 -1
  46. metadata +17 -1
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require_relative 'command_runner'
4
5
 
5
6
  module Ocak
6
7
  module GitUtils
8
+ extend CommandRunner
9
+
7
10
  # Validates that a branch name is safe to pass to git commands.
8
11
  # Rejects names that could be interpreted as flags (starting with -)
9
12
  # or cause unexpected git behavior (containing ..).
@@ -17,22 +20,22 @@ module Ocak
17
20
  # Returns true if changes were committed, false if no changes or on failure.
18
21
  # Logs warnings via logger on failure rather than raising.
19
22
  def self.commit_changes(chdir:, message:, logger: nil)
20
- stdout, _, status = Open3.capture3('git', 'status', '--porcelain', chdir: chdir)
21
- unless status.success?
23
+ result = run_git('status', '--porcelain', chdir: chdir)
24
+ unless result.success?
22
25
  logger&.warn('git status --porcelain failed')
23
26
  return false
24
27
  end
25
- return false if stdout.strip.empty?
28
+ return false if result.output.empty?
26
29
 
27
- _, stderr, add_status = Open3.capture3('git', 'add', '-A', chdir: chdir)
28
- unless add_status.success?
29
- logger&.warn("git add failed: #{stderr[0..200]}")
30
+ add_result = run_git('add', '-A', chdir: chdir)
31
+ unless add_result.success?
32
+ logger&.warn("git add failed: #{add_result.error}")
30
33
  return false
31
34
  end
32
35
 
33
- _, stderr, commit_status = Open3.capture3('git', 'commit', '-m', message, chdir: chdir)
34
- unless commit_status.success?
35
- logger&.warn("git commit failed: #{stderr[0..200]}")
36
+ commit_result = run_git('commit', '-m', message, chdir: chdir)
37
+ unless commit_result.success?
38
+ logger&.warn("git commit failed: #{commit_result.error}")
36
39
  return false
37
40
  end
38
41
 
@@ -42,8 +45,12 @@ module Ocak
42
45
  # Checks out the main branch. Intended for cleanup/ensure blocks.
43
46
  # Rescues all errors so it never crashes the caller.
44
47
  def self.checkout_main(chdir:, logger: nil)
45
- _, stderr, status = Open3.capture3('git', 'checkout', 'main', chdir: chdir)
46
- logger&.warn("Cleanup checkout to main failed: #{stderr}") unless status.success?
48
+ result = run_git('checkout', 'main', chdir: chdir)
49
+ unless result.success?
50
+ # Use "error" prefix when command not found (status is nil), otherwise "failed"
51
+ prefix = result.status.nil? ? 'Cleanup checkout to main error:' : 'Cleanup checkout to main failed:'
52
+ logger&.warn("#{prefix} #{result.error}")
53
+ end
47
54
  rescue StandardError => e
48
55
  logger&.warn("Cleanup checkout to main error: #{e.message}")
49
56
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Ocak
6
+ # Factory methods for building logger, claude, merge manager instances, plus setup helpers.
7
+ # Extracted from PipelineRunner to reduce file size.
8
+ module InstanceBuilders
9
+ private
10
+
11
+ def build_logger(issue_number: nil)
12
+ PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir),
13
+ issue_number: issue_number, log_level: @options.fetch(:log_level, :normal))
14
+ end
15
+
16
+ def build_claude(logger)
17
+ ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter, registry: @registry)
18
+ end
19
+
20
+ def build_merge_manager(logger:, issues:)
21
+ if issues.is_a?(LocalIssueFetcher) && !gh_available?
22
+ LocalMergeManager.new(config: @config, logger: logger, issues: issues)
23
+ else
24
+ MergeManager.new(config: @config, claude: build_claude(logger), logger: logger,
25
+ issues: issues, watch: @watch_formatter)
26
+ end
27
+ end
28
+
29
+ def gh_available?
30
+ _, _, status = Open3.capture3('gh', 'repo', 'view', '--json', 'name', chdir: @config.project_dir)
31
+ status.success?
32
+ rescue Errno::ENOENT
33
+ false
34
+ end
35
+
36
+ def cleanup_stale_worktrees(logger)
37
+ worktrees = WorktreeManager.new(config: @config, logger: logger)
38
+ removed = worktrees.clean_stale
39
+ removed.each { |path| logger.info("Cleaned stale worktree: #{path}") }
40
+ rescue StandardError => e
41
+ logger.warn("Stale worktree cleanup failed: #{e.message}")
42
+ end
43
+
44
+ def ensure_labels(issues, logger)
45
+ issues.ensure_labels(@config.all_labels)
46
+ rescue StandardError => e
47
+ logger.warn("Failed to ensure labels: #{e.message}")
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'issue_fetcher'
4
+ require_relative 'local_issue_fetcher'
5
+
6
+ module Ocak
7
+ module IssueBackend
8
+ def self.build(config:, logger: nil)
9
+ case config.issue_backend
10
+ when 'local'
11
+ LocalIssueFetcher.new(config: config, logger: logger)
12
+ when 'github'
13
+ IssueFetcher.new(config: config, logger: logger)
14
+ else
15
+ auto_detect(config: config, logger: logger)
16
+ end
17
+ end
18
+
19
+ def self.auto_detect(config:, logger: nil)
20
+ store_dir = File.join(config.project_dir, '.ocak', 'issues')
21
+ has_local = Dir.exist?(store_dir) && Dir.glob(File.join(store_dir, '*.md')).any?
22
+
23
+ if has_local
24
+ logger&.info('Auto-detected local issue store in .ocak/issues/')
25
+ LocalIssueFetcher.new(config: config, logger: logger)
26
+ else
27
+ IssueFetcher.new(config: config, logger: logger)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require_relative 'issue_fetcher'
7
+
8
+ module Ocak
9
+ class LocalIssueFetcher < IssueFetcher
10
+ COMMENTS_SENTINEL = '<!-- pipeline-comments -->'
11
+
12
+ def initialize(config:, logger: nil)
13
+ super
14
+ @store_dir = File.join(config.project_dir, '.ocak', 'issues')
15
+ end
16
+
17
+ # --- Issue operations (overrides) ---
18
+
19
+ def fetch_ready
20
+ all_issues.select do |issue|
21
+ labels = label_names(issue)
22
+ labels.include?(@config.label_ready) && !labels.include?(@config.label_in_progress)
23
+ end
24
+ rescue StandardError => e
25
+ @logger&.warn("LocalIssueFetcher#fetch_ready failed: #{e.message}")
26
+ []
27
+ end
28
+
29
+ def view(issue_number, fields: 'number,title,body,labels') # rubocop:disable Lint/UnusedMethodArgument
30
+ read_issue(issue_number.to_i)
31
+ rescue StandardError => e
32
+ @logger&.warn("LocalIssueFetcher#view failed for ##{issue_number}: #{e.message}")
33
+ nil
34
+ end
35
+
36
+ def add_label(issue_number, label)
37
+ update_frontmatter(issue_number.to_i) do |fm|
38
+ fm['labels'] = ((fm['labels'] || []) | [label])
39
+ end
40
+ end
41
+
42
+ def remove_label(issue_number, label)
43
+ update_frontmatter(issue_number.to_i) do |fm|
44
+ fm['labels'] = (fm['labels'] || []) - [label]
45
+ end
46
+ end
47
+
48
+ def comment(issue_number, body)
49
+ path = issue_path(issue_number.to_i)
50
+ return unless path && File.exist?(path)
51
+
52
+ content = File.read(path)
53
+ timestamp = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
54
+
55
+ unless content.include?(COMMENTS_SENTINEL)
56
+ File.write(path, "#{content.chomp}\n\n#{COMMENTS_SENTINEL}\n#{timestamp} — #{body}\n")
57
+ return
58
+ end
59
+
60
+ File.open(path, 'a') { |f| f.write("#{timestamp} — #{body}\n") }
61
+ rescue StandardError => e
62
+ @logger&.warn("LocalIssueFetcher#comment failed for ##{issue_number}: #{e.message}")
63
+ nil
64
+ end
65
+
66
+ def ensure_label(_label) = nil
67
+ def ensure_labels(_labels) = nil
68
+
69
+ # --- Issue creation (CLI only) ---
70
+
71
+ def create(title:, body: '', labels: [], complexity: 'full')
72
+ FileUtils.mkdir_p(@store_dir)
73
+ number = next_issue_number
74
+ fm = {
75
+ 'number' => number,
76
+ 'title' => title,
77
+ 'labels' => labels,
78
+ 'complexity' => complexity,
79
+ 'created_at' => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
80
+ }
81
+ File.write(issue_path(number), build_file(fm, body))
82
+ number
83
+ end
84
+
85
+ # --- Queries ---
86
+
87
+ def all_issues
88
+ return [] unless Dir.exist?(@store_dir)
89
+
90
+ Dir.glob(File.join(@store_dir, '*.md'))
91
+ .filter_map { |f| parse_issue_file(f) }
92
+ end
93
+
94
+ private
95
+
96
+ def read_issue(issue_number)
97
+ path = issue_path(issue_number)
98
+ return nil unless File.exist?(path)
99
+
100
+ parse_issue_file(path)
101
+ end
102
+
103
+ def parse_issue_file(path)
104
+ content = File.read(path)
105
+ parts = content.split(/^---\s*$/, 3)
106
+ return nil unless parts.size >= 3
107
+
108
+ fm = YAML.safe_load(parts[1]) || {}
109
+ raw_body = parts[2]
110
+ body = raw_body.split(COMMENTS_SENTINEL, 2).first.to_s.strip
111
+
112
+ {
113
+ 'number' => fm['number'],
114
+ 'title' => fm['title'],
115
+ 'body' => body,
116
+ 'labels' => (fm['labels'] || []).map { |l| { 'name' => l } },
117
+ 'author' => { 'login' => 'local' },
118
+ 'complexity' => fm['complexity'] || 'full'
119
+ }
120
+ rescue StandardError => e
121
+ @logger&.warn("Failed to parse issue file #{path}: #{e.message}")
122
+ nil
123
+ end
124
+
125
+ def update_frontmatter(issue_number)
126
+ path = issue_path(issue_number)
127
+ return unless File.exist?(path)
128
+
129
+ content = File.read(path)
130
+ parts = content.split(/^---\s*$/, 3)
131
+ return unless parts.size >= 3
132
+
133
+ fm = YAML.safe_load(parts[1]) || {}
134
+ yield fm
135
+
136
+ File.write(path, "---\n#{dump_frontmatter(fm)}---\n#{parts[2]}")
137
+ rescue StandardError => e
138
+ @logger&.warn("LocalIssueFetcher#update_frontmatter failed for ##{issue_number}: #{e.message}")
139
+ nil
140
+ end
141
+
142
+ def issue_path(number)
143
+ File.join(@store_dir, format('%04d.md', number))
144
+ end
145
+
146
+ def next_issue_number
147
+ existing = Dir.glob(File.join(@store_dir, '*.md'))
148
+ .filter_map { |f| File.basename(f, '.md').to_i }
149
+ existing.empty? ? 1 : existing.max + 1
150
+ end
151
+
152
+ def label_names(issue)
153
+ (issue['labels'] || []).map { |l| l['name'] }
154
+ end
155
+
156
+ def dump_frontmatter(hash)
157
+ # YAML.dump prepends "---\n"; we manage our own delimiters
158
+ YAML.dump(hash).delete_prefix("---\n")
159
+ end
160
+
161
+ def build_file(frontmatter, body)
162
+ "---\n#{dump_frontmatter(frontmatter)}---\n\n#{body}\n"
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+ require_relative 'git_utils'
6
+
7
+ module Ocak
8
+ class LocalMergeManager
9
+ def initialize(config:, logger:, issues:, **_opts)
10
+ @config = config
11
+ @logger = logger
12
+ @issues = issues
13
+ end
14
+
15
+ def merge(issue_number, worktree)
16
+ @logger.info("Starting local merge for issue ##{issue_number}")
17
+
18
+ commit_uncommitted_changes(issue_number, worktree)
19
+
20
+ unless rebase_onto_main(worktree)
21
+ @logger.error("Rebase failed for issue ##{issue_number}")
22
+ return false
23
+ end
24
+
25
+ unless verify_tests(worktree)
26
+ @logger.error("Tests failed after rebase for issue ##{issue_number}")
27
+ return false
28
+ end
29
+
30
+ unless merge_to_main(worktree)
31
+ @logger.error("Merge to main failed for issue ##{issue_number}")
32
+ return false
33
+ end
34
+
35
+ delete_branch(worktree.branch)
36
+ @logger.info("Issue ##{issue_number} merged to main (local)")
37
+ true
38
+ end
39
+
40
+ def create_pr_only(issue_number, _worktree)
41
+ @logger.warn("manual_review is not supported in fully local mode for issue ##{issue_number}")
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ def commit_uncommitted_changes(issue_number, worktree)
48
+ GitUtils.commit_changes(
49
+ chdir: worktree.path,
50
+ message: "chore: uncommitted pipeline changes for issue ##{issue_number}",
51
+ logger: @logger
52
+ )
53
+ end
54
+
55
+ def rebase_onto_main(worktree)
56
+ _, stderr, status = git('rebase', 'main', chdir: worktree.path)
57
+ return true if status.success?
58
+
59
+ @logger.warn("Rebase failed: #{stderr[0..200]}")
60
+ git('rebase', '--abort', chdir: worktree.path)
61
+ false
62
+ end
63
+
64
+ def verify_tests(worktree)
65
+ test_cmd = @config.test_command
66
+ return true unless test_cmd
67
+
68
+ @logger.info('Running tests after rebase...')
69
+ _, _, status = Open3.capture3(*Shellwords.shellsplit(test_cmd), chdir: worktree.path)
70
+
71
+ if status.success?
72
+ @logger.info('Tests passed after rebase')
73
+ true
74
+ else
75
+ @logger.warn('Tests failed after rebase')
76
+ false
77
+ end
78
+ end
79
+
80
+ def merge_to_main(worktree)
81
+ _, stderr, status = git('checkout', 'main', chdir: @config.project_dir)
82
+ unless status.success?
83
+ @logger.error("Checkout main failed: #{stderr}")
84
+ return false
85
+ end
86
+
87
+ _, stderr, status = git('merge', worktree.branch, '--ff-only', chdir: @config.project_dir)
88
+ return true if status.success?
89
+
90
+ @logger.warn("Fast-forward merge failed: #{stderr[0..200]}")
91
+ false
92
+ end
93
+
94
+ def delete_branch(branch)
95
+ git('branch', '-d', branch, chdir: @config.project_dir)
96
+ rescue StandardError => e
97
+ @logger.warn("Failed to delete branch #{branch}: #{e.message}")
98
+ end
99
+
100
+ def git(*, chdir:)
101
+ Open3.capture3('git', *, chdir: chdir)
102
+ end
103
+ end
104
+ end
@@ -3,9 +3,12 @@
3
3
  require 'open3'
4
4
  require 'shellwords'
5
5
  require_relative 'git_utils'
6
+ require_relative 'command_runner'
6
7
 
7
8
  module Ocak
8
9
  class MergeManager
10
+ include CommandRunner
11
+
9
12
  def initialize(config:, claude:, logger:, issues:, watch: nil)
10
13
  @config = config
11
14
  @claude = claude
@@ -77,20 +80,20 @@ module Ocak
77
80
  '_This PR was created in manual review mode. ' \
78
81
  'Review and label `auto-reready` to trigger automated fixes based on your feedback._'
79
82
 
80
- stdout, stderr, status = Open3.capture3(
81
- 'gh', 'pr', 'create',
83
+ result = run_gh(
84
+ 'pr', 'create',
82
85
  '--title', pr_title,
83
86
  '--body', pr_body,
84
87
  '--head', worktree.branch,
85
88
  chdir: worktree.path
86
89
  )
87
90
 
88
- unless status.success?
89
- @logger.error("PR creation failed: #{stderr}")
91
+ unless result.success?
92
+ @logger.error("PR creation failed: #{result.error}")
90
93
  return nil
91
94
  end
92
95
 
93
- extract_pr_number(stdout)
96
+ extract_pr_number(result.stdout)
94
97
  end
95
98
 
96
99
  def fetch_issue_title(issue_number)
@@ -112,39 +115,39 @@ module Ocak
112
115
  end
113
116
 
114
117
  def rebase_onto_main(worktree)
115
- _, fetch_stderr, fetch_status = git('fetch', 'origin', 'main', chdir: worktree.path)
116
- unless fetch_status.success?
117
- @logger.error("git fetch origin main failed: #{fetch_stderr[0..200]}")
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}")
118
121
  return false
119
122
  end
120
123
 
121
- _, stderr, status = git('rebase', 'origin/main', chdir: worktree.path)
124
+ rebase_result = run_git('rebase', 'origin/main', chdir: worktree.path)
122
125
 
123
- return true if status.success?
126
+ return true if rebase_result.success?
124
127
 
125
- @logger.warn("Rebase conflict, aborting rebase: #{stderr}")
126
- _, abort_stderr, abort_status = git('rebase', '--abort', chdir: worktree.path)
127
- @logger.warn("git rebase --abort failed: #{abort_stderr}") unless abort_status.success?
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?
128
131
 
129
132
  # Fall back to merge strategy
130
133
  @logger.info('Attempting merge strategy instead...')
131
- _, merge_stderr, merge_status = git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
134
+ merge_result = run_git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
132
135
 
133
- return true if merge_status.success?
136
+ return true if merge_result.success?
134
137
 
135
138
  # Merge also has conflicts — try to resolve via agent
136
- @logger.warn("Merge conflict, attempting agent resolution: #{merge_stderr}")
139
+ @logger.warn("Merge conflict, attempting agent resolution: #{merge_result.error}")
137
140
  resolve_conflicts_via_agent(worktree)
138
141
  end
139
142
 
140
143
  def resolve_conflicts_via_agent(worktree)
141
144
  # Get list of conflicting files
142
- stdout, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
143
- conflicting = stdout.lines.map(&:strip).reject(&:empty?)
145
+ diff_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
146
+ conflicting = diff_result.stdout.lines.map(&:strip).reject(&:empty?)
144
147
 
145
148
  if conflicting.empty?
146
149
  @logger.warn('No conflicting files found, aborting merge')
147
- git('merge', '--abort', chdir: worktree.path)
150
+ run_git('merge', '--abort', chdir: worktree.path)
148
151
  return false
149
152
  end
150
153
 
@@ -158,11 +161,11 @@ module Ocak
158
161
 
159
162
  if result.success?
160
163
  # Check if all conflicts resolved
161
- remaining, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
162
- if remaining.strip.empty?
163
- _, commit_stderr, commit_status = git('commit', '--no-edit', chdir: worktree.path)
164
- unless commit_status.success?
165
- @logger.error("Commit after conflict resolution failed: #{commit_stderr}")
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}")
166
169
  return false
167
170
  end
168
171
  @logger.info('Merge conflicts resolved by agent')
@@ -171,7 +174,7 @@ module Ocak
171
174
  end
172
175
 
173
176
  @logger.error('Agent could not resolve merge conflicts')
174
- git('merge', '--abort', chdir: worktree.path)
177
+ run_git('merge', '--abort', chdir: worktree.path)
175
178
  false
176
179
  end
177
180
 
@@ -193,20 +196,16 @@ module Ocak
193
196
  end
194
197
 
195
198
  def push_branch(worktree)
196
- _, stderr, status = git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
199
+ result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
197
200
 
198
- unless status.success?
199
- @logger.error("Push failed: #{stderr}")
201
+ unless result.success?
202
+ @logger.error("Push failed: #{result.error}")
200
203
  return false
201
204
  end
202
205
 
203
206
  true
204
207
  end
205
208
 
206
- def git(*, chdir:)
207
- Open3.capture3('git', *, chdir: chdir)
208
- end
209
-
210
209
  def shell(cmd, chdir:)
211
210
  Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
212
211
  end
@@ -28,8 +28,10 @@ module Ocak
28
28
  elsif @config.manual_review
29
29
  handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
30
30
  else
31
- claude.run_agent('merger', "Create a PR, merge it, and close issue ##{issue_number}",
32
- chdir: @config.project_dir)
31
+ 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)
34
+ end
33
35
  issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
34
36
  logger.info("Issue ##{issue_number} completed successfully")
35
37
  end
@@ -88,6 +90,10 @@ module Ocak
88
90
  logger.info("Posted audit comment on PR ##{pr_number}")
89
91
  end
90
92
 
93
+ def pipeline_has_merge_step?
94
+ @config.steps.any? { |s| s[:role].to_s == 'merge' || s['role'].to_s == 'merge' }
95
+ end
96
+
91
97
  def find_pr_for_branch(logger:)
92
98
  stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: @config.project_dir)
93
99
  return nil unless status.success?
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ # Parallel group execution logic extracted from PipelineExecutor.
5
+ # Includers must provide run_single_step method and symbolize helper.
6
+ module ParallelExecution
7
+ def collect_parallel_group(steps, start_idx)
8
+ group = []
9
+ idx = start_idx
10
+ while idx < steps.size
11
+ step = symbolize(steps[idx])
12
+ break unless step[:parallel]
13
+
14
+ group << [step, idx]
15
+ idx += 1
16
+ end
17
+ group
18
+ end
19
+
20
+ def run_parallel_group(group, issue_number, state, logger:, claude:, chdir:)
21
+ mutex = Mutex.new
22
+ threads = group.map do |step, idx|
23
+ Thread.new do
24
+ run_single_step(step, idx, issue_number, state, logger: logger, claude: claude,
25
+ chdir: chdir, mutex: mutex)
26
+ rescue StandardError => e
27
+ logger.error("#{step[:role]} thread failed: #{e.message}")
28
+ { success: false, phase: step[:role].to_s, output: "Thread error: #{e.message}" }
29
+ end
30
+ end
31
+
32
+ results = threads.map(&:value)
33
+ results.compact.find { |r| r.is_a?(Hash) && !r[:success] }
34
+ end
35
+ end
36
+ end