ocak 0.4.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -21
  3. data/lib/ocak/agent_generator.rb +11 -1
  4. data/lib/ocak/batch_processing.rb +132 -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 +28 -28
  9. data/lib/ocak/commands/init.rb +37 -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 +11 -9
  16. data/lib/ocak/commands/status.rb +29 -12
  17. data/lib/ocak/config.rb +72 -1
  18. data/lib/ocak/conflict_resolution.rb +73 -0
  19. data/lib/ocak/failure_reporting.rb +6 -3
  20. data/lib/ocak/git_utils.rb +18 -11
  21. data/lib/ocak/instance_builders.rb +54 -0
  22. data/lib/ocak/issue_backend.rb +31 -0
  23. data/lib/ocak/issue_fetcher.rb +9 -0
  24. data/lib/ocak/issue_state_machine.rb +36 -0
  25. data/lib/ocak/local_issue_fetcher.rb +165 -0
  26. data/lib/ocak/local_merge_manager.rb +104 -0
  27. data/lib/ocak/merge_manager.rb +30 -103
  28. data/lib/ocak/merge_orchestration.rb +36 -24
  29. data/lib/ocak/merge_verification.rb +40 -0
  30. data/lib/ocak/parallel_execution.rb +36 -0
  31. data/lib/ocak/pipeline_executor.rb +17 -185
  32. data/lib/ocak/pipeline_runner.rb +32 -180
  33. data/lib/ocak/planner.rb +16 -1
  34. data/lib/ocak/project_key.rb +38 -0
  35. data/lib/ocak/reready_processor.rb +11 -11
  36. data/lib/ocak/run_report.rb +5 -2
  37. data/lib/ocak/shutdown_handling.rb +67 -0
  38. data/lib/ocak/state_management.rb +104 -0
  39. data/lib/ocak/step_execution.rb +66 -0
  40. data/lib/ocak/stream_parser.rb +1 -1
  41. data/lib/ocak/target_resolver.rb +41 -0
  42. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  43. data/lib/ocak/templates/agents/implementer.md.erb +35 -8
  44. data/lib/ocak/templates/agents/merger.md.erb +24 -5
  45. data/lib/ocak/templates/agents/pipeline.md.erb +22 -0
  46. data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
  47. data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
  48. data/lib/ocak/templates/gitignore_additions.txt +1 -0
  49. data/lib/ocak/templates/ocak.yml.erb +24 -0
  50. data/lib/ocak/verification.rb +6 -1
  51. data/lib/ocak/worktree_manager.rb +9 -6
  52. data/lib/ocak.rb +1 -1
  53. metadata +21 -1
@@ -35,6 +35,7 @@ module Ocak
35
35
  )
36
36
 
37
37
  generate_files(generator, project_dir, options)
38
+ scaffold_user_config
38
39
  update_settings(project_dir, stack)
39
40
  update_gitignore(project_dir)
40
41
  create_labels(project_dir)
@@ -83,6 +84,36 @@ module Ocak
83
84
  puts ''
84
85
  end
85
86
 
87
+ def scaffold_user_config
88
+ path = Config.user_config_path
89
+ if File.exist?(path)
90
+ puts " #{path} already exists — skipping"
91
+ return
92
+ end
93
+
94
+ FileUtils.mkdir_p(File.dirname(path))
95
+ File.write(path, user_config_template)
96
+ puts " Created #{path}"
97
+ end
98
+
99
+ def user_config_template
100
+ <<~YAML
101
+ # ~/.config/ocak/config.yml
102
+ # Machine-specific Ocak settings (not committed to any repo)
103
+
104
+ # Map repo names to local paths (used with multi_repo: true in project ocak.yml)
105
+ # repos:
106
+ # my-gem: ~/dev/my-gem
107
+ # other-gem: ~/dev/other-gem
108
+
109
+ # Pipeline tuning for this machine
110
+ # pipeline:
111
+ # max_parallel: 3 # default: 5
112
+ # poll_interval: 60 # seconds between polls, default: 60
113
+ # cost_budget: 10.00 # max $ per pipeline run
114
+ YAML
115
+ end
116
+
86
117
  def update_settings(project_dir, stack)
87
118
  settings_path = File.join(project_dir, '.claude', 'settings.json')
88
119
  existing = begin
@@ -183,6 +214,11 @@ module Ocak
183
214
 
184
215
  def create_labels(project_dir)
185
216
  config = Config.load(project_dir)
217
+ if config.local_issues?
218
+ puts ' Skipped label creation (local issue backend)'
219
+ return
220
+ end
221
+
186
222
  fetcher = IssueFetcher.new(config: config)
187
223
  fetcher.ensure_labels(config.all_labels)
188
224
  puts ' Created GitHub labels'
@@ -201,6 +237,7 @@ module Ocak
201
237
  end
202
238
  puts ' .claude/hooks/ — lint + test hooks'
203
239
  puts ' .claude/settings.json — permissions & hooks config'
240
+ puts " #{Config.user_config_path} — machine-specific settings (not committed)"
204
241
  puts ''
205
242
  puts 'Next steps:'
206
243
  puts ' 1. Review ocak.yml and adjust settings'
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../config'
4
+ require_relative '../../local_issue_fetcher'
5
+
6
+ module Ocak
7
+ module Commands
8
+ module Issue
9
+ class Close < Dry::CLI::Command
10
+ desc 'Close a local issue (sets completed label)'
11
+
12
+ argument :issue, type: :integer, required: true, desc: 'Issue number'
13
+
14
+ def call(issue:, **)
15
+ config = Config.load
16
+ fetcher = LocalIssueFetcher.new(config: config)
17
+ issue_number = issue.to_i
18
+
19
+ data = fetcher.view(issue_number)
20
+ unless data
21
+ warn "Issue ##{issue} not found."
22
+ exit 1
23
+ end
24
+
25
+ fetcher.remove_label(issue_number, config.label_ready)
26
+ fetcher.remove_label(issue_number, config.label_in_progress)
27
+ fetcher.add_label(issue_number, config.label_completed)
28
+
29
+ puts "Closed issue ##{issue_number}: #{data['title']}"
30
+ rescue Config::ConfigNotFound => e
31
+ warn "Error: #{e.message}"
32
+ exit 1
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../config'
4
+ require_relative '../../local_issue_fetcher'
5
+
6
+ module Ocak
7
+ module Commands
8
+ module Issue
9
+ class Create < Dry::CLI::Command
10
+ desc 'Create a local issue'
11
+
12
+ argument :title, type: :string, required: true, desc: 'Issue title'
13
+ option :body, type: :string, default: '', desc: 'Issue body (opens $EDITOR if omitted)'
14
+ option :label, type: :array, default: [], desc: 'Labels to add (repeatable)'
15
+ option :complexity, type: :string, default: 'full', desc: 'Issue complexity (full or simple)'
16
+
17
+ def call(title:, **options)
18
+ config = Config.load
19
+ fetcher = LocalIssueFetcher.new(config: config)
20
+
21
+ body = options[:body]
22
+ body = read_from_editor(title) if body.empty?
23
+
24
+ number = fetcher.create(
25
+ title: title,
26
+ body: body,
27
+ labels: options[:label],
28
+ complexity: options[:complexity]
29
+ )
30
+
31
+ path = File.join('.ocak', 'issues', format('%04d.md', number))
32
+ puts "Created issue ##{number} (#{path})"
33
+ rescue Config::ConfigNotFound => e
34
+ warn "Error: #{e.message}"
35
+ exit 1
36
+ end
37
+
38
+ private
39
+
40
+ def read_from_editor(title)
41
+ editor = ENV.fetch('EDITOR', 'vi')
42
+ require 'tempfile'
43
+ file = Tempfile.new(['ocak-issue', '.md'])
44
+ file.write("#{title}\n\n")
45
+ file.close
46
+
47
+ system(editor, file.path)
48
+ content = File.read(file.path)
49
+ # Strip the title line if it's still there
50
+ lines = content.lines
51
+ lines.shift if lines.first&.strip == title
52
+ lines.join.strip
53
+ ensure
54
+ file&.unlink
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../config'
4
+
5
+ module Ocak
6
+ module Commands
7
+ module Issue
8
+ class Edit < Dry::CLI::Command
9
+ desc 'Edit a local issue in $EDITOR'
10
+
11
+ argument :issue, type: :integer, required: true, desc: 'Issue number'
12
+
13
+ def call(issue:, **)
14
+ config = Config.load
15
+ path = File.join(config.project_dir, '.ocak', 'issues', format('%04d.md', issue.to_i))
16
+
17
+ unless File.exist?(path)
18
+ warn "Issue ##{issue} not found at #{path}"
19
+ exit 1
20
+ end
21
+
22
+ editor = ENV.fetch('EDITOR', 'vi')
23
+ system(editor, path)
24
+ rescue Config::ConfigNotFound => e
25
+ warn "Error: #{e.message}"
26
+ exit 1
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../config'
4
+ require_relative '../../local_issue_fetcher'
5
+
6
+ module Ocak
7
+ module Commands
8
+ module Issue
9
+ class List < Dry::CLI::Command
10
+ desc 'List local issues'
11
+
12
+ option :label, type: :string, desc: 'Filter by label'
13
+
14
+ def call(**options)
15
+ config = Config.load
16
+ fetcher = LocalIssueFetcher.new(config: config)
17
+ issues = fetcher.all_issues
18
+
19
+ if options[:label]
20
+ issues = issues.select do |i|
21
+ i['labels']&.any? { |l| l['name'] == options[:label] }
22
+ end
23
+ end
24
+
25
+ if issues.empty?
26
+ puts 'No issues found.'
27
+ return
28
+ end
29
+
30
+ issues.sort_by { |i| i['number'] }.each do |issue|
31
+ labels = (issue['labels'] || []).map { |l| l['name'] }.join(', ')
32
+ label_str = labels.empty? ? '' : " [#{labels}]"
33
+ puts format('#%-4<num>d %<title>s%<labels>s',
34
+ num: issue['number'], title: issue['title'], labels: label_str)
35
+ end
36
+ rescue Config::ConfigNotFound => e
37
+ warn "Error: #{e.message}"
38
+ exit 1
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../config'
4
+ require_relative '../../local_issue_fetcher'
5
+
6
+ module Ocak
7
+ module Commands
8
+ module Issue
9
+ class View < Dry::CLI::Command
10
+ desc 'View a local issue'
11
+
12
+ argument :issue, type: :integer, required: true, desc: 'Issue number'
13
+
14
+ def call(issue:, **)
15
+ config = Config.load
16
+ fetcher = LocalIssueFetcher.new(config: config)
17
+ data = fetcher.view(issue.to_i)
18
+
19
+ unless data
20
+ warn "Issue ##{issue} not found."
21
+ exit 1
22
+ end
23
+
24
+ labels = (data['labels'] || []).map { |l| l['name'] }.join(', ')
25
+ puts "##{data['number']} #{data['title']}"
26
+ puts "Labels: #{labels}" unless labels.empty?
27
+ puts "Complexity: #{data['complexity']}" if data['complexity'] && data['complexity'] != 'full'
28
+ puts ''
29
+ puts data['body'] unless data['body'].to_s.empty?
30
+
31
+ # Show pipeline comments from the raw file
32
+ path = File.join('.ocak', 'issues', format('%04d.md', issue.to_i))
33
+ show_pipeline_comments(path)
34
+ rescue Config::ConfigNotFound => e
35
+ warn "Error: #{e.message}"
36
+ exit 1
37
+ end
38
+
39
+ private
40
+
41
+ def show_pipeline_comments(path)
42
+ return unless File.exist?(path)
43
+
44
+ content = File.read(path)
45
+ sentinel = LocalIssueFetcher::COMMENTS_SENTINEL
46
+ return unless content.include?(sentinel)
47
+
48
+ comments = content.split(sentinel, 2).last.to_s.strip
49
+ return if comments.empty?
50
+
51
+ puts ''
52
+ puts '--- Pipeline Activity ---'
53
+ puts comments
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -3,10 +3,11 @@
3
3
  require_relative '../config'
4
4
  require_relative '../failure_reporting'
5
5
  require_relative '../git_utils'
6
+ require_relative '../issue_state_machine'
6
7
  require_relative '../pipeline_runner'
7
8
  require_relative '../pipeline_state'
8
9
  require_relative '../claude_runner'
9
- require_relative '../issue_fetcher'
10
+ require_relative '../issue_backend'
10
11
  require_relative '../worktree_manager'
11
12
  require_relative '../merge_manager'
12
13
  require_relative '../logger'
@@ -78,9 +79,10 @@ module Ocak
78
79
  logger = PipelineLogger.new(log_dir: log_dir, issue_number: issue_number)
79
80
  watch_formatter = options[:watch] ? WatchFormatter.new : nil
80
81
  claude = ClaudeRunner.new(config: config, logger: logger, watch: watch_formatter)
81
- issues = IssueFetcher.new(config: config, logger: logger)
82
+ issues = IssueBackend.build(config: config, logger: logger)
83
+ state_machine = IssueStateMachine.new(config: config, issues: issues)
82
84
 
83
- issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
85
+ state_machine.mark_resuming(issue_number)
84
86
 
85
87
  runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
86
88
  result = runner.run_pipeline(issue_number,
@@ -88,7 +90,8 @@ module Ocak
88
90
  skip_steps: saved[:completed_steps])
89
91
 
90
92
  ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
91
- issues: issues, claude: claude, logger: logger, watch: watch_formatter }
93
+ issues: issues, state_machine: state_machine, claude: claude, logger: logger,
94
+ watch: watch_formatter }
92
95
  handle_result(result, ctx)
93
96
  end
94
97
 
@@ -96,7 +99,8 @@ module Ocak
96
99
  if result[:success]
97
100
  attempt_merge(ctx)
98
101
  else
99
- report_pipeline_failure(ctx[:issue_number], result, issues: ctx[:issues], config: ctx[:config])
102
+ report_pipeline_failure(ctx[:issue_number], result, issues: ctx[:issues], config: ctx[:config],
103
+ logger: ctx[:logger])
100
104
  warn "Issue ##{ctx[:issue_number]} failed again at phase: #{result[:phase]}"
101
105
  end
102
106
  end
@@ -109,12 +113,10 @@ module Ocak
109
113
  )
110
114
 
111
115
  if merger.merge(ctx[:issue_number], worktree)
112
- ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
113
- to: ctx[:config].label_completed)
116
+ ctx[:state_machine].mark_completed(ctx[:issue_number])
114
117
  puts "Issue ##{ctx[:issue_number]} resumed and merged successfully!"
115
118
  else
116
- ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
117
- to: ctx[:config].label_failed)
119
+ ctx[:state_machine].mark_failed(ctx[:issue_number])
118
120
  warn "Issue ##{ctx[:issue_number]} merge failed after resume"
119
121
  end
120
122
  end
@@ -3,12 +3,16 @@
3
3
  require 'open3'
4
4
  require 'json'
5
5
  require_relative '../config'
6
+ require_relative '../command_runner'
7
+ require_relative '../issue_backend'
6
8
  require_relative '../run_report'
7
9
  require_relative '../worktree_manager'
8
10
 
9
11
  module Ocak
10
12
  module Commands
11
13
  class Status < Dry::CLI::Command
14
+ include CommandRunner
15
+
12
16
  desc 'Show pipeline status'
13
17
 
14
18
  option :report, type: :boolean, default: false, desc: 'Show run reports'
@@ -42,7 +46,26 @@ module Ocak
42
46
 
43
47
  def show_issues(config)
44
48
  puts 'Issues:'
49
+ fetcher = IssueBackend.build(config: config)
45
50
 
51
+ if fetcher.is_a?(LocalIssueFetcher)
52
+ show_local_issues(fetcher, config)
53
+ else
54
+ show_github_issues(config)
55
+ end
56
+ end
57
+
58
+ def show_local_issues(fetcher, config)
59
+ all = fetcher.all_issues
60
+ %w[ready in_progress completed failed].each do |state|
61
+ label = config.send(:"label_#{state}")
62
+ count = all.count { |i| i['labels']&.any? { |l| l['name'] == label } }
63
+ icon = { 'ready' => ' ', 'in_progress' => ' ', 'completed' => ' ', 'failed' => ' ' }[state]
64
+ puts " #{icon} #{state.tr('_', ' ')}: #{count} (label: #{label})"
65
+ end
66
+ end
67
+
68
+ def show_github_issues(config)
46
69
  %w[ready in_progress completed failed].each do |state|
47
70
  label = config.send(:"label_#{state}")
48
71
  count = fetch_issue_count(label, config)
@@ -187,18 +210,12 @@ module Ocak
187
210
  end
188
211
 
189
212
  def fetch_issue_count(label, config)
190
- stdout, _, status = Open3.capture3(
191
- 'gh', 'issue', 'list',
192
- '--label', label,
193
- '--state', 'open',
194
- '--json', 'number',
195
- '--limit', '100',
196
- chdir: config.project_dir
197
- )
198
- return 0 unless status.success?
199
-
200
- JSON.parse(stdout).size
201
- rescue JSON::ParserError, Errno::ENOENT
213
+ result = run_gh('issue', 'list', '--label', label, '--state', 'open',
214
+ '--json', 'number', '--limit', '100', chdir: config.project_dir)
215
+ return 0 unless result.success?
216
+
217
+ JSON.parse(result.stdout).size
218
+ rescue JSON::ParserError
202
219
  0
203
220
  end
204
221
 
data/lib/ocak/config.rb CHANGED
@@ -5,14 +5,28 @@ require 'yaml'
5
5
  module Ocak
6
6
  class Config
7
7
  CONFIG_FILE = 'ocak.yml'
8
+ USER_CONFIG_DIR = 'ocak'
9
+ USER_CONFIG_FILE = 'config.yml'
8
10
 
9
11
  attr_reader :project_dir
10
12
 
13
+ def self.user_config_path
14
+ base = ENV.fetch('XDG_CONFIG_HOME', File.expand_path('~/.config'))
15
+ File.join(base, USER_CONFIG_DIR, USER_CONFIG_FILE)
16
+ end
17
+
11
18
  def self.load(dir = Dir.pwd)
12
19
  path = File.join(dir, CONFIG_FILE)
13
20
  raise ConfigNotFound, "No ocak.yml found in #{dir}. Run `ocak init` first." unless File.exist?(path)
14
21
 
15
- new(YAML.safe_load_file(path, symbolize_names: true), dir)
22
+ project_data = YAML.safe_load_file(path, symbolize_names: true) || {}
23
+ user_data = load_user_config
24
+
25
+ merged = deep_merge(user_data, project_data)
26
+ # repos: is user-only — always take from user config, never project
27
+ merged[:repos] = user_data[:repos] if user_data[:repos]
28
+
29
+ new(merged, dir)
16
30
  end
17
31
 
18
32
  def initialize(data, project_dir = Dir.pwd)
@@ -51,6 +65,10 @@ module Ocak
51
65
  dig(:stack, :security_commands) || []
52
66
  end
53
67
 
68
+ def custom_commands?
69
+ !!(test_command || lint_command || format_command || setup_command || security_commands.any?)
70
+ end
71
+
54
72
  # Pipeline
55
73
  def max_parallel = @overrides[:max_parallel] || dig(:pipeline, :max_parallel) || 5
56
74
  def poll_interval = @overrides[:poll_interval] || dig(:pipeline, :poll_interval) || 60
@@ -72,6 +90,37 @@ module Ocak
72
90
  def require_comment = dig(:safety, :require_comment)
73
91
  def max_issues_per_run = dig(:safety, :max_issues_per_run) || 5
74
92
 
93
+ # Multi-repo
94
+ def repos
95
+ @data[:repos]
96
+ end
97
+
98
+ def multi_repo?
99
+ r = repos
100
+ r.is_a?(Hash) && !r.empty?
101
+ end
102
+
103
+ def target_field
104
+ dig(:target_field) || 'target_repo'
105
+ end
106
+
107
+ def resolve_repo(name)
108
+ raise ConfigError, 'No repos configured in ocak.yml' unless multi_repo?
109
+
110
+ key = name.to_sym
111
+ path_value = repos[key]
112
+ raise ConfigError, "Unknown repo '#{name}'. Known repos: #{repos.keys.join(', ')}" unless path_value
113
+
114
+ expanded = File.expand_path(path_value.to_s)
115
+ raise ConfigError, "Repo path does not exist: #{expanded}" unless Dir.exist?(expanded)
116
+
117
+ { name: name.to_s, path: expanded }
118
+ end
119
+
120
+ # Issues
121
+ def issue_backend = dig(:issues, :backend)
122
+ def local_issues? = issue_backend == 'local'
123
+
75
124
  # Labels
76
125
  def label_ready = dig(:labels, :ready) || 'auto-ready'
77
126
  def label_in_progress = dig(:labels, :in_progress) || 'auto-doing'
@@ -132,5 +181,27 @@ module Ocak
132
181
 
133
182
  class ConfigNotFound < StandardError; end
134
183
  class ConfigError < StandardError; end
184
+
185
+ private_class_method def self.load_user_config
186
+ path = user_config_path
187
+ return {} unless File.exist?(path)
188
+
189
+ data = YAML.safe_load_file(path, symbolize_names: true)
190
+ raise ConfigError, "#{path} must be a YAML hash" unless data.is_a?(Hash) || data.nil?
191
+
192
+ data || {}
193
+ rescue Psych::SyntaxError => e
194
+ raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
195
+ end
196
+
197
+ private_class_method def self.deep_merge(base, override)
198
+ base.merge(override) do |_key, old_val, new_val|
199
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
200
+ deep_merge(old_val, new_val)
201
+ else
202
+ new_val
203
+ end
204
+ end
205
+ end
135
206
  end
136
207
  end
@@ -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,15 +1,18 @@
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
- def report_pipeline_failure(issue_number, result, issues:, config:)
8
- issues.transition(issue_number, from: config.label_in_progress, to: config.label_failed)
9
+ def report_pipeline_failure(issue_number, result, issues:, config:, logger: nil)
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```")
12
- rescue StandardError
14
+ rescue StandardError => e
15
+ logger&.debug("Failure report failed: #{e.message}")
13
16
  nil
14
17
  end
15
18
  end