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.
- checksums.yaml +4 -4
- data/README.md +27 -0
- data/lib/ocak/agent_generator.rb +2 -1
- data/lib/ocak/batch_processing.rb +102 -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 +8 -7
- data/lib/ocak/commands/init.rb +5 -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 +4 -3
- data/lib/ocak/commands/status.rb +20 -0
- data/lib/ocak/config.rb +4 -0
- data/lib/ocak/failure_reporting.rb +3 -2
- data/lib/ocak/git_utils.rb +18 -11
- data/lib/ocak/instance_builders.rb +50 -0
- data/lib/ocak/issue_backend.rb +31 -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 +31 -32
- data/lib/ocak/merge_orchestration.rb +8 -2
- data/lib/ocak/parallel_execution.rb +36 -0
- data/lib/ocak/pipeline_executor.rb +9 -183
- data/lib/ocak/pipeline_runner.rb +15 -180
- data/lib/ocak/planner.rb +1 -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/templates/agents/auditor.md.erb +38 -9
- data/lib/ocak/templates/agents/implementer.md.erb +32 -8
- data/lib/ocak/templates/agents/merger.md.erb +12 -5
- data/lib/ocak/templates/agents/pipeline.md.erb +4 -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/ocak.yml.erb +15 -0
- data/lib/ocak/verification.rb +6 -1
- data/lib/ocak/worktree_manager.rb +2 -0
- data/lib/ocak.rb +1 -1
- metadata +17 -1
data/lib/ocak/git_utils.rb
CHANGED
|
@@ -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
|
-
|
|
21
|
-
unless
|
|
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
|
|
28
|
+
return false if result.output.empty?
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
unless
|
|
29
|
-
logger&.warn("git add failed: #{
|
|
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
|
-
|
|
34
|
-
unless
|
|
35
|
-
logger&.warn("git commit failed: #{
|
|
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
|
-
|
|
46
|
-
|
|
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
|
data/lib/ocak/merge_manager.rb
CHANGED
|
@@ -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
|
-
|
|
81
|
-
'
|
|
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
|
|
89
|
-
@logger.error("PR creation failed: #{
|
|
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
|
-
|
|
116
|
-
unless
|
|
117
|
-
@logger.error("git fetch origin main failed: #{
|
|
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
|
-
|
|
124
|
+
rebase_result = run_git('rebase', 'origin/main', chdir: worktree.path)
|
|
122
125
|
|
|
123
|
-
return true if
|
|
126
|
+
return true if rebase_result.success?
|
|
124
127
|
|
|
125
|
-
@logger.warn("Rebase conflict, aborting rebase: #{
|
|
126
|
-
|
|
127
|
-
@logger.warn("git rebase --abort failed: #{
|
|
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
|
-
|
|
134
|
+
merge_result = run_git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
|
|
132
135
|
|
|
133
|
-
return true if
|
|
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: #{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
unless
|
|
165
|
-
@logger.error("Commit after conflict resolution failed: #{
|
|
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
|
-
|
|
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
|
-
|
|
199
|
+
result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
|
|
197
200
|
|
|
198
|
-
unless
|
|
199
|
-
@logger.error("Push failed: #{
|
|
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
|
-
|
|
32
|
-
|
|
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
|