ocak 0.5.0 → 0.6.1
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 +75 -22
- data/lib/ocak/agent_generator.rb +9 -0
- data/lib/ocak/batch_processing.rb +35 -5
- data/lib/ocak/commands/hiz.rb +20 -21
- data/lib/ocak/commands/init.rb +32 -0
- data/lib/ocak/commands/resume.rb +7 -6
- data/lib/ocak/commands/status.rb +9 -12
- data/lib/ocak/config.rb +68 -1
- data/lib/ocak/conflict_resolution.rb +73 -0
- data/lib/ocak/failure_reporting.rb +3 -1
- data/lib/ocak/instance_builders.rb +5 -1
- data/lib/ocak/issue_fetcher.rb +9 -0
- data/lib/ocak/issue_state_machine.rb +36 -0
- data/lib/ocak/merge_manager.rb +25 -94
- data/lib/ocak/merge_orchestration.rb +40 -26
- data/lib/ocak/merge_verification.rb +40 -0
- data/lib/ocak/pipeline_executor.rb +9 -2
- data/lib/ocak/pipeline_runner.rb +18 -1
- data/lib/ocak/planner.rb +36 -8
- data/lib/ocak/shutdown_handling.rb +2 -2
- data/lib/ocak/step_execution.rb +14 -6
- data/lib/ocak/stream_parser.rb +1 -1
- data/lib/ocak/target_resolver.rb +41 -0
- data/lib/ocak/templates/agents/implementer.md.erb +4 -1
- data/lib/ocak/templates/agents/merger.md.erb +14 -3
- data/lib/ocak/templates/agents/pipeline.md.erb +18 -0
- data/lib/ocak/templates/gitignore_additions.txt +1 -0
- data/lib/ocak/templates/ocak.yml.erb +9 -0
- data/lib/ocak/worktree_manager.rb +7 -6
- data/lib/ocak.rb +1 -1
- metadata +5 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
# Rebase and conflict resolution logic — rebase_onto_main, resolve_conflicts_via_agent.
|
|
5
|
+
# Extracted from MergeManager to reduce file size.
|
|
6
|
+
module ConflictResolution
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def rebase_onto_main(worktree)
|
|
10
|
+
fetch_result = run_git('fetch', 'origin', 'main', chdir: worktree.path)
|
|
11
|
+
unless fetch_result.success?
|
|
12
|
+
@logger.error("git fetch origin main failed: #{fetch_result.error}")
|
|
13
|
+
return false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
rebase_result = run_git('rebase', 'origin/main', chdir: worktree.path)
|
|
17
|
+
|
|
18
|
+
return true if rebase_result.success?
|
|
19
|
+
|
|
20
|
+
@logger.warn("Rebase conflict, aborting rebase: #{rebase_result.error}")
|
|
21
|
+
abort_result = run_git('rebase', '--abort', chdir: worktree.path)
|
|
22
|
+
@logger.warn("git rebase --abort failed: #{abort_result.error}") unless abort_result.success?
|
|
23
|
+
|
|
24
|
+
# Fall back to merge strategy
|
|
25
|
+
@logger.info('Attempting merge strategy instead...')
|
|
26
|
+
merge_result = run_git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
|
|
27
|
+
|
|
28
|
+
return true if merge_result.success?
|
|
29
|
+
|
|
30
|
+
# Merge also has conflicts — try to resolve via agent
|
|
31
|
+
@logger.warn("Merge conflict, attempting agent resolution: #{merge_result.error}")
|
|
32
|
+
resolve_conflicts_via_agent(worktree)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve_conflicts_via_agent(worktree)
|
|
36
|
+
# Get list of conflicting files
|
|
37
|
+
diff_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
|
|
38
|
+
conflicting = diff_result.stdout.lines.map(&:strip).reject(&:empty?)
|
|
39
|
+
|
|
40
|
+
if conflicting.empty?
|
|
41
|
+
@logger.warn('No conflicting files found, aborting merge')
|
|
42
|
+
run_git('merge', '--abort', chdir: worktree.path)
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result = @claude.run_agent(
|
|
47
|
+
'implementer',
|
|
48
|
+
"Resolve these merge conflicts.\n\n<conflicting_files>\n#{conflicting.join("\n")}\n</conflicting_files>\n\n" \
|
|
49
|
+
'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
|
|
50
|
+
'Then run `git add` on each resolved file.',
|
|
51
|
+
chdir: worktree.path
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if result.success?
|
|
55
|
+
# Check if all conflicts resolved
|
|
56
|
+
remaining_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
|
|
57
|
+
if remaining_result.output.empty?
|
|
58
|
+
commit_result = run_git('commit', '--no-edit', chdir: worktree.path)
|
|
59
|
+
unless commit_result.success?
|
|
60
|
+
@logger.error("Commit after conflict resolution failed: #{commit_result.error}")
|
|
61
|
+
return false
|
|
62
|
+
end
|
|
63
|
+
@logger.info('Merge conflicts resolved by agent')
|
|
64
|
+
return true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@logger.error('Agent could not resolve merge conflicts')
|
|
69
|
+
run_git('merge', '--abort', chdir: worktree.path)
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'issue_state_machine'
|
|
4
|
+
|
|
3
5
|
module Ocak
|
|
4
6
|
# Shared pipeline failure reporting — label transition + comment posting.
|
|
5
7
|
# Included by PipelineRunner and Commands::Resume.
|
|
6
8
|
module FailureReporting
|
|
7
9
|
def report_pipeline_failure(issue_number, result, issues:, config:, logger: nil)
|
|
8
|
-
|
|
10
|
+
IssueStateMachine.new(config: config, issues: issues).mark_failed(issue_number)
|
|
9
11
|
sanitized = result[:output][0..1000].to_s.gsub('```', "'''")
|
|
10
12
|
issues.comment(issue_number,
|
|
11
13
|
"Pipeline failed at phase: #{result[:phase]}\n\n```\n#{sanitized}\n```")
|
|
@@ -17,8 +17,12 @@ module Ocak
|
|
|
17
17
|
ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter, registry: @registry)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def build_state_machine(issues)
|
|
21
|
+
IssueStateMachine.new(config: @config, issues: issues)
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
def build_merge_manager(logger:, issues:)
|
|
21
|
-
if issues.is_a?(LocalIssueFetcher) && !gh_available?
|
|
25
|
+
if issues.is_a?(LocalIssueFetcher) && !@config.multi_repo? && !gh_available?
|
|
22
26
|
LocalMergeManager.new(config: @config, logger: logger, issues: issues)
|
|
23
27
|
else
|
|
24
28
|
MergeManager.new(config: @config, claude: build_claude(logger), logger: logger,
|
data/lib/ocak/issue_fetcher.rb
CHANGED
|
@@ -134,6 +134,15 @@ module Ocak
|
|
|
134
134
|
nil
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
def repo_nwo
|
|
138
|
+
return @repo_nwo if defined?(@repo_nwo_resolved)
|
|
139
|
+
|
|
140
|
+
stdout, _, status = run_gh('repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner')
|
|
141
|
+
@repo_nwo = status.success? ? stdout.strip : nil
|
|
142
|
+
@repo_nwo_resolved = true
|
|
143
|
+
@repo_nwo
|
|
144
|
+
end
|
|
145
|
+
|
|
137
146
|
private
|
|
138
147
|
|
|
139
148
|
def in_progress?(issue)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
# Encapsulates all valid label transitions for pipeline issue processing.
|
|
5
|
+
# Replaces scattered issues.transition calls with named, intention-revealing methods.
|
|
6
|
+
class IssueStateMachine
|
|
7
|
+
def initialize(config:, issues:)
|
|
8
|
+
@config = config
|
|
9
|
+
@issues = issues
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def mark_in_progress(issue_number)
|
|
13
|
+
@issues&.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mark_completed(issue_number)
|
|
17
|
+
@issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mark_failed(issue_number)
|
|
21
|
+
@issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mark_interrupted(issue_number)
|
|
25
|
+
@issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mark_for_review(issue_number)
|
|
29
|
+
@issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_awaiting_review)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mark_resuming(issue_number)
|
|
33
|
+
@issues&.transition(issue_number, from: @config.label_failed, to: @config.label_in_progress)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ocak/merge_manager.rb
CHANGED
|
@@ -4,10 +4,14 @@ require 'open3'
|
|
|
4
4
|
require 'shellwords'
|
|
5
5
|
require_relative 'git_utils'
|
|
6
6
|
require_relative 'command_runner'
|
|
7
|
+
require_relative 'conflict_resolution'
|
|
8
|
+
require_relative 'merge_verification'
|
|
7
9
|
|
|
8
10
|
module Ocak
|
|
9
11
|
class MergeManager
|
|
10
12
|
include CommandRunner
|
|
13
|
+
include ConflictResolution
|
|
14
|
+
include MergeVerification
|
|
11
15
|
|
|
12
16
|
def initialize(config:, claude:, logger:, issues:, watch: nil)
|
|
13
17
|
@config = config
|
|
@@ -40,7 +44,7 @@ module Ocak
|
|
|
40
44
|
|
|
41
45
|
result = @claude.run_agent(
|
|
42
46
|
'merger',
|
|
43
|
-
|
|
47
|
+
merger_prompt(issue_number, worktree),
|
|
44
48
|
chdir: worktree.path
|
|
45
49
|
)
|
|
46
50
|
|
|
@@ -68,6 +72,18 @@ module Ocak
|
|
|
68
72
|
|
|
69
73
|
private
|
|
70
74
|
|
|
75
|
+
def merger_prompt(issue_number, worktree)
|
|
76
|
+
base = if worktree.target_repo
|
|
77
|
+
"Create a PR and merge it for issue ##{issue_number}. Branch: #{worktree.branch}. " \
|
|
78
|
+
'Do NOT close any issues (the issue lives in a different repository).'
|
|
79
|
+
else
|
|
80
|
+
"Create a PR, merge it, and close issue ##{issue_number}. Branch: #{worktree.branch}"
|
|
81
|
+
end
|
|
82
|
+
issue_data = @issues.view(issue_number)
|
|
83
|
+
base += "\n\n<issue_data>\nTitle: #{issue_data['title']}\n\n#{issue_data['body']}\n</issue_data>" if issue_data
|
|
84
|
+
base
|
|
85
|
+
end
|
|
86
|
+
|
|
71
87
|
def log_and_nil(message)
|
|
72
88
|
@logger.error(message)
|
|
73
89
|
nil
|
|
@@ -76,7 +92,14 @@ module Ocak
|
|
|
76
92
|
def open_pull_request(issue_number, worktree)
|
|
77
93
|
issue_title = fetch_issue_title(issue_number)
|
|
78
94
|
pr_title = "Fix ##{issue_number}: #{issue_title}"
|
|
79
|
-
|
|
95
|
+
issue_ref = if worktree.target_repo
|
|
96
|
+
god_nwo = @issues.repo_nwo
|
|
97
|
+
ref = god_nwo ? "#{god_nwo}##{issue_number}" : "##{issue_number}"
|
|
98
|
+
"Related to #{ref}"
|
|
99
|
+
else
|
|
100
|
+
"Closes ##{issue_number}"
|
|
101
|
+
end
|
|
102
|
+
pr_body = "#{issue_ref}\n\n" \
|
|
80
103
|
'_This PR was created in manual review mode. ' \
|
|
81
104
|
'Review and label `auto-reready` to trigger automated fixes based on your feedback._'
|
|
82
105
|
|
|
@@ -114,98 +137,6 @@ module Ocak
|
|
|
114
137
|
)
|
|
115
138
|
end
|
|
116
139
|
|
|
117
|
-
def rebase_onto_main(worktree)
|
|
118
|
-
fetch_result = run_git('fetch', 'origin', 'main', chdir: worktree.path)
|
|
119
|
-
unless fetch_result.success?
|
|
120
|
-
@logger.error("git fetch origin main failed: #{fetch_result.error}")
|
|
121
|
-
return false
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
rebase_result = run_git('rebase', 'origin/main', chdir: worktree.path)
|
|
125
|
-
|
|
126
|
-
return true if rebase_result.success?
|
|
127
|
-
|
|
128
|
-
@logger.warn("Rebase conflict, aborting rebase: #{rebase_result.error}")
|
|
129
|
-
abort_result = run_git('rebase', '--abort', chdir: worktree.path)
|
|
130
|
-
@logger.warn("git rebase --abort failed: #{abort_result.error}") unless abort_result.success?
|
|
131
|
-
|
|
132
|
-
# Fall back to merge strategy
|
|
133
|
-
@logger.info('Attempting merge strategy instead...')
|
|
134
|
-
merge_result = run_git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
|
|
135
|
-
|
|
136
|
-
return true if merge_result.success?
|
|
137
|
-
|
|
138
|
-
# Merge also has conflicts — try to resolve via agent
|
|
139
|
-
@logger.warn("Merge conflict, attempting agent resolution: #{merge_result.error}")
|
|
140
|
-
resolve_conflicts_via_agent(worktree)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def resolve_conflicts_via_agent(worktree)
|
|
144
|
-
# Get list of conflicting files
|
|
145
|
-
diff_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
|
|
146
|
-
conflicting = diff_result.stdout.lines.map(&:strip).reject(&:empty?)
|
|
147
|
-
|
|
148
|
-
if conflicting.empty?
|
|
149
|
-
@logger.warn('No conflicting files found, aborting merge')
|
|
150
|
-
run_git('merge', '--abort', chdir: worktree.path)
|
|
151
|
-
return false
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
result = @claude.run_agent(
|
|
155
|
-
'implementer',
|
|
156
|
-
"Resolve these merge conflicts.\n\n<conflicting_files>\n#{conflicting.join("\n")}\n</conflicting_files>\n\n" \
|
|
157
|
-
'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
|
|
158
|
-
'Then run `git add` on each resolved file.',
|
|
159
|
-
chdir: worktree.path
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
if result.success?
|
|
163
|
-
# Check if all conflicts resolved
|
|
164
|
-
remaining_result = run_git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
|
|
165
|
-
if remaining_result.output.empty?
|
|
166
|
-
commit_result = run_git('commit', '--no-edit', chdir: worktree.path)
|
|
167
|
-
unless commit_result.success?
|
|
168
|
-
@logger.error("Commit after conflict resolution failed: #{commit_result.error}")
|
|
169
|
-
return false
|
|
170
|
-
end
|
|
171
|
-
@logger.info('Merge conflicts resolved by agent')
|
|
172
|
-
return true
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
@logger.error('Agent could not resolve merge conflicts')
|
|
177
|
-
run_git('merge', '--abort', chdir: worktree.path)
|
|
178
|
-
false
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def verify_tests(worktree)
|
|
182
|
-
test_cmd = @config.test_command
|
|
183
|
-
return true unless test_cmd
|
|
184
|
-
|
|
185
|
-
@logger.info('Running tests after rebase...')
|
|
186
|
-
stdout, stderr, status = shell(test_cmd, chdir: worktree.path)
|
|
187
|
-
|
|
188
|
-
if status.success?
|
|
189
|
-
@logger.info('Tests passed after rebase')
|
|
190
|
-
true
|
|
191
|
-
else
|
|
192
|
-
@logger.warn('Tests failed after rebase')
|
|
193
|
-
@logger.debug("Test output:\n#{stdout[0..2000]}\n#{stderr[0..500]}")
|
|
194
|
-
false
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def push_branch(worktree)
|
|
199
|
-
result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
|
|
200
|
-
|
|
201
|
-
unless result.success?
|
|
202
|
-
@logger.error("Push failed: #{result.error}")
|
|
203
|
-
return false
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
true
|
|
207
|
-
end
|
|
208
|
-
|
|
209
140
|
def shell(cmd, chdir:)
|
|
210
141
|
Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
|
|
211
142
|
end
|
|
@@ -14,52 +14,60 @@ module Ocak
|
|
|
14
14
|
elsif @config.manual_review
|
|
15
15
|
handle_batch_manual_review(result, merger: merger, issues: issues, logger: logger)
|
|
16
16
|
elsif merger.merge(result[:issue_number], result[:worktree])
|
|
17
|
-
|
|
17
|
+
@state_machine.mark_completed(result[:issue_number])
|
|
18
18
|
logger.info("Issue ##{result[:issue_number]} merged successfully")
|
|
19
19
|
else
|
|
20
|
-
|
|
20
|
+
@state_machine.mark_failed(result[:issue_number])
|
|
21
21
|
logger.error("Issue ##{result[:issue_number]} merge failed")
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def handle_single_success(issue_number, result, logger:, claude:, issues:)
|
|
26
|
+
target_dir = result[:target_repo]&.dig(:path) || @config.project_dir
|
|
27
|
+
|
|
26
28
|
if result[:audit_blocked]
|
|
27
|
-
handle_single_audit_blocked(issue_number, result, logger: logger, claude: claude, issues: issues
|
|
29
|
+
handle_single_audit_blocked(issue_number, result, logger: logger, claude: claude, issues: issues,
|
|
30
|
+
chdir: target_dir)
|
|
28
31
|
elsif @config.manual_review
|
|
29
|
-
handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
|
|
32
|
+
handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: target_dir)
|
|
30
33
|
else
|
|
31
34
|
unless pipeline_has_merge_step?
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
prompt = if result[:target_repo]
|
|
36
|
+
"Create a PR and merge it for issue ##{issue_number}. " \
|
|
37
|
+
'Do NOT close any issues (the issue lives in a different repository).'
|
|
38
|
+
else
|
|
39
|
+
"Create a PR, merge it, and close issue ##{issue_number}"
|
|
40
|
+
end
|
|
41
|
+
prompt += merger_issue_context(issue_number, issues)
|
|
42
|
+
claude.run_agent('merger', prompt, chdir: target_dir)
|
|
34
43
|
end
|
|
35
|
-
|
|
44
|
+
@state_machine.mark_completed(issue_number)
|
|
36
45
|
logger.info("Issue ##{issue_number} completed successfully")
|
|
37
46
|
end
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
def handle_single_manual_review(issue_number, logger:, claude:, issues:)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
def handle_single_manual_review(issue_number, logger:, claude:, issues: nil, chdir: @config.project_dir)
|
|
50
|
+
prompt = "Create a PR for issue ##{issue_number} but do NOT merge it and do NOT close the issue"
|
|
51
|
+
prompt += merger_issue_context(issue_number, issues)
|
|
52
|
+
claude.run_agent('merger', prompt, chdir: chdir)
|
|
53
|
+
@state_machine.mark_for_review(issue_number)
|
|
45
54
|
logger.info("Issue ##{issue_number} PR created (manual review mode)")
|
|
46
55
|
end
|
|
47
56
|
|
|
48
|
-
def handle_batch_manual_review(result, merger:,
|
|
57
|
+
def handle_batch_manual_review(result, merger:, logger:, issues: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
49
58
|
pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
|
|
50
59
|
if pr_number
|
|
51
|
-
|
|
52
|
-
to: @config.label_awaiting_review)
|
|
60
|
+
@state_machine.mark_for_review(result[:issue_number])
|
|
53
61
|
logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (manual review mode)")
|
|
54
62
|
else
|
|
55
|
-
|
|
63
|
+
@state_machine.mark_failed(result[:issue_number])
|
|
56
64
|
logger.error("Issue ##{result[:issue_number]} PR creation failed")
|
|
57
65
|
end
|
|
58
66
|
end
|
|
59
67
|
|
|
60
|
-
def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:)
|
|
61
|
-
handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
|
|
62
|
-
post_audit_comment_single(result[:audit_output], logger: logger, issues: issues)
|
|
68
|
+
def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:, chdir: @config.project_dir)
|
|
69
|
+
handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: chdir)
|
|
70
|
+
post_audit_comment_single(result[:audit_output], logger: logger, issues: issues, chdir: chdir)
|
|
63
71
|
end
|
|
64
72
|
|
|
65
73
|
def handle_batch_audit(result, merger:, issues:, logger:)
|
|
@@ -70,17 +78,16 @@ module Ocak
|
|
|
70
78
|
pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
|
|
71
79
|
if pr_number
|
|
72
80
|
issues.pr_comment(pr_number, "## Audit Report\n\n#{audit_output}")
|
|
73
|
-
|
|
74
|
-
to: @config.label_awaiting_review)
|
|
81
|
+
@state_machine.mark_for_review(result[:issue_number])
|
|
75
82
|
logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (audit findings)")
|
|
76
83
|
else
|
|
77
|
-
|
|
84
|
+
@state_machine.mark_failed(result[:issue_number])
|
|
78
85
|
logger.error("Issue ##{result[:issue_number]} PR creation failed")
|
|
79
86
|
end
|
|
80
87
|
end
|
|
81
88
|
|
|
82
|
-
def post_audit_comment_single(audit_output, logger:, issues:)
|
|
83
|
-
pr_number = find_pr_for_branch(logger: logger)
|
|
89
|
+
def post_audit_comment_single(audit_output, logger:, issues:, chdir: @config.project_dir)
|
|
90
|
+
pr_number = find_pr_for_branch(logger: logger, chdir: chdir)
|
|
84
91
|
unless pr_number
|
|
85
92
|
logger.warn("Could not find PR to post audit comment — findings were: #{audit_output.to_s[0..200]}")
|
|
86
93
|
return
|
|
@@ -90,12 +97,19 @@ module Ocak
|
|
|
90
97
|
logger.info("Posted audit comment on PR ##{pr_number}")
|
|
91
98
|
end
|
|
92
99
|
|
|
100
|
+
def merger_issue_context(issue_number, issues)
|
|
101
|
+
issue_data = issues&.view(issue_number)
|
|
102
|
+
return '' unless issue_data
|
|
103
|
+
|
|
104
|
+
"\n\n<issue_data>\nTitle: #{issue_data['title']}\n\n#{issue_data['body']}\n</issue_data>"
|
|
105
|
+
end
|
|
106
|
+
|
|
93
107
|
def pipeline_has_merge_step?
|
|
94
108
|
@config.steps.any? { |s| s[:role].to_s == 'merge' || s['role'].to_s == 'merge' }
|
|
95
109
|
end
|
|
96
110
|
|
|
97
|
-
def find_pr_for_branch(logger:)
|
|
98
|
-
stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir:
|
|
111
|
+
def find_pr_for_branch(logger:, chdir: @config.project_dir)
|
|
112
|
+
stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: chdir)
|
|
99
113
|
return nil unless status.success?
|
|
100
114
|
|
|
101
115
|
data = JSON.parse(stdout)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
# Test verification and git push logic — verify_tests, push_branch.
|
|
8
|
+
# Extracted from MergeManager to reduce file size.
|
|
9
|
+
module MergeVerification
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def verify_tests(worktree)
|
|
13
|
+
test_cmd = @config.test_command
|
|
14
|
+
return true unless test_cmd
|
|
15
|
+
|
|
16
|
+
@logger.info('Running tests after rebase...')
|
|
17
|
+
stdout, stderr, status = shell(test_cmd, chdir: worktree.path)
|
|
18
|
+
|
|
19
|
+
if status.success?
|
|
20
|
+
@logger.info('Tests passed after rebase')
|
|
21
|
+
true
|
|
22
|
+
else
|
|
23
|
+
@logger.warn('Tests failed after rebase')
|
|
24
|
+
@logger.debug("Test output:\n#{stdout[0..2000]}\n#{stderr[0..500]}")
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def push_branch(worktree)
|
|
30
|
+
result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
|
|
31
|
+
|
|
32
|
+
unless result.success?
|
|
33
|
+
@logger.error("Push failed: #{result.error}")
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require_relative 'command_runner'
|
|
5
6
|
require_relative 'pipeline_state'
|
|
6
7
|
require_relative 'run_report'
|
|
7
8
|
require_relative 'verification'
|
|
@@ -13,6 +14,7 @@ require_relative 'parallel_execution'
|
|
|
13
14
|
|
|
14
15
|
module Ocak
|
|
15
16
|
class PipelineExecutor
|
|
17
|
+
include CommandRunner
|
|
16
18
|
include Verification
|
|
17
19
|
include Planner
|
|
18
20
|
include StepComments
|
|
@@ -42,6 +44,7 @@ module Ocak
|
|
|
42
44
|
|
|
43
45
|
report = RunReport.new(complexity: complexity)
|
|
44
46
|
state = build_initial_state(complexity, report)
|
|
47
|
+
state[:issue_data] = @issues&.view(issue_number)
|
|
45
48
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
49
|
post_pipeline_start_comment(issue_number, state) if post_start_comment
|
|
47
50
|
|
|
@@ -145,8 +148,12 @@ module Ocak
|
|
|
145
148
|
end
|
|
146
149
|
|
|
147
150
|
def current_branch(chdir, logger: nil)
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
result = run_git('rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
|
|
152
|
+
if result.status.nil?
|
|
153
|
+
logger&.debug("Could not determine current branch: #{result.error}")
|
|
154
|
+
return nil
|
|
155
|
+
end
|
|
156
|
+
result.output
|
|
150
157
|
rescue StandardError => e
|
|
151
158
|
logger&.debug("Could not determine current branch: #{e.message}")
|
|
152
159
|
nil
|
data/lib/ocak/pipeline_runner.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
3
4
|
require_relative 'batch_processing'
|
|
4
5
|
require_relative 'failure_reporting'
|
|
5
6
|
require_relative 'instance_builders'
|
|
7
|
+
require_relative 'issue_state_machine'
|
|
6
8
|
require_relative 'merge_orchestration'
|
|
7
9
|
require_relative 'pipeline_executor'
|
|
8
10
|
require_relative 'process_registry'
|
|
@@ -36,6 +38,7 @@ module Ocak
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def run
|
|
41
|
+
warn_if_untrusted
|
|
39
42
|
@options[:single] ? run_single(@options[:single]) : run_loop
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -52,12 +55,25 @@ module Ocak
|
|
|
52
55
|
|
|
53
56
|
private
|
|
54
57
|
|
|
58
|
+
def warn_if_untrusted
|
|
59
|
+
return unless @config.custom_commands?
|
|
60
|
+
|
|
61
|
+
trusted_path = File.join(@config.project_dir, '.ocak', 'trusted')
|
|
62
|
+
return if File.exist?(trusted_path)
|
|
63
|
+
|
|
64
|
+
warn 'Warning: ocak.yml contains commands that will be executed with your user privileges. ' \
|
|
65
|
+
'Review the config before proceeding.'
|
|
66
|
+
FileUtils.mkdir_p(File.dirname(trusted_path))
|
|
67
|
+
FileUtils.touch(trusted_path)
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
def run_single(issue_number)
|
|
56
71
|
logger = build_logger(issue_number: issue_number)
|
|
57
72
|
claude = build_claude(logger)
|
|
58
73
|
issues = IssueBackend.build(config: @config)
|
|
59
74
|
ensure_labels(issues, logger)
|
|
60
75
|
@executor.issues = issues
|
|
76
|
+
@state_machine = build_state_machine(issues)
|
|
61
77
|
logger.info("Running single issue mode for ##{issue_number}")
|
|
62
78
|
|
|
63
79
|
if @options[:dry_run]
|
|
@@ -65,7 +81,7 @@ module Ocak
|
|
|
65
81
|
return
|
|
66
82
|
end
|
|
67
83
|
|
|
68
|
-
|
|
84
|
+
@state_machine.mark_in_progress(issue_number)
|
|
69
85
|
complexity = @options[:fast] ? 'simple' : 'full'
|
|
70
86
|
result = run_pipeline(issue_number, logger: logger, claude: claude, complexity: complexity)
|
|
71
87
|
|
|
@@ -84,6 +100,7 @@ module Ocak
|
|
|
84
100
|
issues = IssueBackend.build(config: @config, logger: logger)
|
|
85
101
|
ensure_labels(issues, logger)
|
|
86
102
|
@executor.issues = issues
|
|
103
|
+
@state_machine = build_state_machine(issues)
|
|
87
104
|
cleanup_stale_worktrees(logger)
|
|
88
105
|
|
|
89
106
|
loop do
|
data/lib/ocak/planner.rb
CHANGED
|
@@ -14,18 +14,32 @@ module Ocak
|
|
|
14
14
|
'merge' => 'Create a PR, merge it, and close issue #%<issue>s'
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
ISSUE_CONTEXT_ROLES = %w[implement document merge].freeze
|
|
18
|
+
|
|
19
|
+
def build_step_prompt(role, issue_number, review_output, issue_data: nil)
|
|
20
|
+
prompt = if role == 'fix'
|
|
21
|
+
"Fix these review findings for issue ##{issue_number}:\n\n" \
|
|
22
|
+
"<review_output>\n#{review_output}\n</review_output>"
|
|
23
|
+
elsif STEP_PROMPTS.key?(role)
|
|
24
|
+
format(STEP_PROMPTS[role], issue: issue_number)
|
|
25
|
+
else
|
|
26
|
+
"Run #{role} for GitHub issue ##{issue_number}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
prompt += format_issue_context(issue_data) if issue_data && ISSUE_CONTEXT_ROLES.include?(role)
|
|
30
|
+
prompt
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format_issue_context(issue_data)
|
|
34
|
+
parts = []
|
|
35
|
+
parts << "Title: #{issue_data['title']}" if issue_data['title']
|
|
36
|
+
parts << issue_data['body'] if issue_data['body']
|
|
37
|
+
"\n\n<issue_data>\n#{parts.join("\n\n")}\n</issue_data>"
|
|
25
38
|
end
|
|
26
39
|
|
|
27
40
|
def plan_batches(issues, logger:, claude:)
|
|
28
41
|
return sequential_batches(issues) if issues.size <= 1
|
|
42
|
+
return plan_multi_repo_batches(issues) if @config&.multi_repo?
|
|
29
43
|
|
|
30
44
|
issue_json = JSON.generate(issues.map { |i| { number: i['number'], title: i['title'] } })
|
|
31
45
|
result = claude.run_agent(
|
|
@@ -41,6 +55,20 @@ module Ocak
|
|
|
41
55
|
parse_planner_output(result.output, issues, logger)
|
|
42
56
|
end
|
|
43
57
|
|
|
58
|
+
def plan_multi_repo_batches(issues)
|
|
59
|
+
by_repo = issues.group_by { |i| i['_target']&.dig(:name) || '__self__' }
|
|
60
|
+
|
|
61
|
+
# If all issues target different repos, one big parallel batch — no agent call needed
|
|
62
|
+
return [{ 'batch' => 1, 'issues' => issues }] if by_repo.values.all? { |group| group.size == 1 }
|
|
63
|
+
|
|
64
|
+
# Otherwise, issues in the same repo are sequential (by depth), cross-repo are parallel
|
|
65
|
+
max_depth = by_repo.values.map(&:size).max
|
|
66
|
+
(0...max_depth).map do |depth|
|
|
67
|
+
batch_issues = by_repo.values.filter_map { |group| group[depth] }
|
|
68
|
+
{ 'batch' => depth + 1, 'issues' => batch_issues }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
44
72
|
def parse_planner_output(output, issues, logger)
|
|
45
73
|
json_match = output.match(/\{[\s\S]*"batches"[\s\S]*\}/)
|
|
46
74
|
if json_match
|
|
@@ -40,7 +40,7 @@ module Ocak
|
|
|
40
40
|
def handle_process_error(error, issue_number:, logger:, issues:)
|
|
41
41
|
logger.error("Unexpected #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
|
|
42
42
|
logger.debug("Full backtrace:\n#{error.backtrace&.join("\n")}")
|
|
43
|
-
|
|
43
|
+
@state_machine.mark_failed(issue_number)
|
|
44
44
|
begin
|
|
45
45
|
issues.comment(issue_number, "Unexpected #{error.class}: #{error.message}")
|
|
46
46
|
rescue StandardError => e
|
|
@@ -55,7 +55,7 @@ module Ocak
|
|
|
55
55
|
message: "wip: pipeline interrupted after step #{step_name} for issue ##{issue_number}",
|
|
56
56
|
logger: logger)
|
|
57
57
|
end
|
|
58
|
-
|
|
58
|
+
@state_machine&.mark_interrupted(issue_number)
|
|
59
59
|
issues&.comment(issue_number,
|
|
60
60
|
"\u{26A0}\u{FE0F} Pipeline interrupted after #{step_name}. " \
|
|
61
61
|
"Resume with `ocak resume --issue #{issue_number}`.")
|