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.
- checksums.yaml +4 -4
- data/README.md +101 -21
- data/lib/ocak/agent_generator.rb +11 -1
- data/lib/ocak/batch_processing.rb +132 -0
- data/lib/ocak/claude_runner.rb +12 -8
- data/lib/ocak/cli.rb +13 -0
- data/lib/ocak/command_runner.rb +39 -0
- data/lib/ocak/commands/hiz.rb +28 -28
- data/lib/ocak/commands/init.rb +37 -0
- data/lib/ocak/commands/issue/close.rb +37 -0
- data/lib/ocak/commands/issue/create.rb +59 -0
- data/lib/ocak/commands/issue/edit.rb +31 -0
- data/lib/ocak/commands/issue/list.rb +43 -0
- data/lib/ocak/commands/issue/view.rb +58 -0
- data/lib/ocak/commands/resume.rb +11 -9
- data/lib/ocak/commands/status.rb +29 -12
- data/lib/ocak/config.rb +72 -1
- data/lib/ocak/conflict_resolution.rb +73 -0
- data/lib/ocak/failure_reporting.rb +6 -3
- data/lib/ocak/git_utils.rb +18 -11
- data/lib/ocak/instance_builders.rb +54 -0
- data/lib/ocak/issue_backend.rb +31 -0
- data/lib/ocak/issue_fetcher.rb +9 -0
- data/lib/ocak/issue_state_machine.rb +36 -0
- data/lib/ocak/local_issue_fetcher.rb +165 -0
- data/lib/ocak/local_merge_manager.rb +104 -0
- data/lib/ocak/merge_manager.rb +30 -103
- data/lib/ocak/merge_orchestration.rb +36 -24
- data/lib/ocak/merge_verification.rb +40 -0
- data/lib/ocak/parallel_execution.rb +36 -0
- data/lib/ocak/pipeline_executor.rb +17 -185
- data/lib/ocak/pipeline_runner.rb +32 -180
- data/lib/ocak/planner.rb +16 -1
- data/lib/ocak/project_key.rb +38 -0
- data/lib/ocak/reready_processor.rb +11 -11
- data/lib/ocak/run_report.rb +5 -2
- data/lib/ocak/shutdown_handling.rb +67 -0
- data/lib/ocak/state_management.rb +104 -0
- data/lib/ocak/step_execution.rb +66 -0
- data/lib/ocak/stream_parser.rb +1 -1
- data/lib/ocak/target_resolver.rb +41 -0
- data/lib/ocak/templates/agents/auditor.md.erb +38 -9
- data/lib/ocak/templates/agents/implementer.md.erb +35 -8
- data/lib/ocak/templates/agents/merger.md.erb +24 -5
- data/lib/ocak/templates/agents/pipeline.md.erb +22 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
- data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
- data/lib/ocak/templates/gitignore_additions.txt +1 -0
- data/lib/ocak/templates/ocak.yml.erb +24 -0
- data/lib/ocak/verification.rb +6 -1
- data/lib/ocak/worktree_manager.rb +9 -6
- data/lib/ocak.rb +1 -1
- metadata +21 -1
data/lib/ocak/commands/init.rb
CHANGED
|
@@ -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
|
data/lib/ocak/commands/resume.rb
CHANGED
|
@@ -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 '../
|
|
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 =
|
|
82
|
+
issues = IssueBackend.build(config: config, logger: logger)
|
|
83
|
+
state_machine = IssueStateMachine.new(config: config, issues: issues)
|
|
82
84
|
|
|
83
|
-
|
|
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,
|
|
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[:
|
|
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[:
|
|
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
|
data/lib/ocak/commands/status.rb
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|