ocak 0.5.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 +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 +4 -0
- data/lib/ocak/issue_fetcher.rb +9 -0
- data/lib/ocak/issue_state_machine.rb +36 -0
- data/lib/ocak/merge_manager.rb +22 -94
- data/lib/ocak/merge_orchestration.rb +30 -24
- data/lib/ocak/merge_verification.rb +40 -0
- data/lib/ocak/pipeline_executor.rb +8 -2
- data/lib/ocak/pipeline_runner.rb +18 -1
- data/lib/ocak/planner.rb +15 -0
- data/lib/ocak/shutdown_handling.rb +2 -2
- data/lib/ocak/stream_parser.rb +1 -1
- data/lib/ocak/target_resolver.rb +41 -0
- data/lib/ocak/templates/agents/implementer.md.erb +3 -0
- data/lib/ocak/templates/agents/merger.md.erb +12 -0
- 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,6 +17,10 @@ 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
25
|
if issues.is_a?(LocalIssueFetcher) && !gh_available?
|
|
22
26
|
LocalMergeManager.new(config: @config, logger: logger, issues: issues)
|
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,15 @@ module Ocak
|
|
|
68
72
|
|
|
69
73
|
private
|
|
70
74
|
|
|
75
|
+
def merger_prompt(issue_number, worktree)
|
|
76
|
+
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
|
+
end
|
|
83
|
+
|
|
71
84
|
def log_and_nil(message)
|
|
72
85
|
@logger.error(message)
|
|
73
86
|
nil
|
|
@@ -76,7 +89,14 @@ module Ocak
|
|
|
76
89
|
def open_pull_request(issue_number, worktree)
|
|
77
90
|
issue_title = fetch_issue_title(issue_number)
|
|
78
91
|
pr_title = "Fix ##{issue_number}: #{issue_title}"
|
|
79
|
-
|
|
92
|
+
issue_ref = if worktree.target_repo
|
|
93
|
+
god_nwo = @issues.repo_nwo
|
|
94
|
+
ref = god_nwo ? "#{god_nwo}##{issue_number}" : "##{issue_number}"
|
|
95
|
+
"Related to #{ref}"
|
|
96
|
+
else
|
|
97
|
+
"Closes ##{issue_number}"
|
|
98
|
+
end
|
|
99
|
+
pr_body = "#{issue_ref}\n\n" \
|
|
80
100
|
'_This PR was created in manual review mode. ' \
|
|
81
101
|
'Review and label `auto-reready` to trigger automated fixes based on your feedback._'
|
|
82
102
|
|
|
@@ -114,98 +134,6 @@ module Ocak
|
|
|
114
134
|
)
|
|
115
135
|
end
|
|
116
136
|
|
|
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
137
|
def shell(cmd, chdir:)
|
|
210
138
|
Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
|
|
211
139
|
end
|
|
@@ -14,52 +14,59 @@ 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
|
+
claude.run_agent('merger', prompt, chdir: target_dir)
|
|
34
42
|
end
|
|
35
|
-
|
|
43
|
+
@state_machine.mark_completed(issue_number)
|
|
36
44
|
logger.info("Issue ##{issue_number} completed successfully")
|
|
37
45
|
end
|
|
38
46
|
end
|
|
39
47
|
|
|
40
|
-
def handle_single_manual_review(issue_number, logger:, claude:, issues:)
|
|
48
|
+
def handle_single_manual_review(issue_number, logger:, claude:, issues: nil, chdir: @config.project_dir) # rubocop:disable Lint/UnusedMethodArgument
|
|
41
49
|
claude.run_agent('merger',
|
|
42
50
|
"Create a PR for issue ##{issue_number} but do NOT merge it and do NOT close the issue",
|
|
43
|
-
chdir:
|
|
44
|
-
|
|
51
|
+
chdir: chdir)
|
|
52
|
+
@state_machine.mark_for_review(issue_number)
|
|
45
53
|
logger.info("Issue ##{issue_number} PR created (manual review mode)")
|
|
46
54
|
end
|
|
47
55
|
|
|
48
|
-
def handle_batch_manual_review(result, merger:,
|
|
56
|
+
def handle_batch_manual_review(result, merger:, logger:, issues: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
49
57
|
pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
|
|
50
58
|
if pr_number
|
|
51
|
-
|
|
52
|
-
to: @config.label_awaiting_review)
|
|
59
|
+
@state_machine.mark_for_review(result[:issue_number])
|
|
53
60
|
logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (manual review mode)")
|
|
54
61
|
else
|
|
55
|
-
|
|
62
|
+
@state_machine.mark_failed(result[:issue_number])
|
|
56
63
|
logger.error("Issue ##{result[:issue_number]} PR creation failed")
|
|
57
64
|
end
|
|
58
65
|
end
|
|
59
66
|
|
|
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)
|
|
67
|
+
def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:, chdir: @config.project_dir)
|
|
68
|
+
handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: chdir)
|
|
69
|
+
post_audit_comment_single(result[:audit_output], logger: logger, issues: issues, chdir: chdir)
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
def handle_batch_audit(result, merger:, issues:, logger:)
|
|
@@ -70,17 +77,16 @@ module Ocak
|
|
|
70
77
|
pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
|
|
71
78
|
if pr_number
|
|
72
79
|
issues.pr_comment(pr_number, "## Audit Report\n\n#{audit_output}")
|
|
73
|
-
|
|
74
|
-
to: @config.label_awaiting_review)
|
|
80
|
+
@state_machine.mark_for_review(result[:issue_number])
|
|
75
81
|
logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (audit findings)")
|
|
76
82
|
else
|
|
77
|
-
|
|
83
|
+
@state_machine.mark_failed(result[:issue_number])
|
|
78
84
|
logger.error("Issue ##{result[:issue_number]} PR creation failed")
|
|
79
85
|
end
|
|
80
86
|
end
|
|
81
87
|
|
|
82
|
-
def post_audit_comment_single(audit_output, logger:, issues:)
|
|
83
|
-
pr_number = find_pr_for_branch(logger: logger)
|
|
88
|
+
def post_audit_comment_single(audit_output, logger:, issues:, chdir: @config.project_dir)
|
|
89
|
+
pr_number = find_pr_for_branch(logger: logger, chdir: chdir)
|
|
84
90
|
unless pr_number
|
|
85
91
|
logger.warn("Could not find PR to post audit comment — findings were: #{audit_output.to_s[0..200]}")
|
|
86
92
|
return
|
|
@@ -94,8 +100,8 @@ module Ocak
|
|
|
94
100
|
@config.steps.any? { |s| s[:role].to_s == 'merge' || s['role'].to_s == 'merge' }
|
|
95
101
|
end
|
|
96
102
|
|
|
97
|
-
def find_pr_for_branch(logger:)
|
|
98
|
-
stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir:
|
|
103
|
+
def find_pr_for_branch(logger:, chdir: @config.project_dir)
|
|
104
|
+
stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: chdir)
|
|
99
105
|
return nil unless status.success?
|
|
100
106
|
|
|
101
107
|
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
|
|
@@ -145,8 +147,12 @@ module Ocak
|
|
|
145
147
|
end
|
|
146
148
|
|
|
147
149
|
def current_branch(chdir, logger: nil)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
result = run_git('rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
|
|
151
|
+
if result.status.nil?
|
|
152
|
+
logger&.debug("Could not determine current branch: #{result.error}")
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
result.output
|
|
150
156
|
rescue StandardError => e
|
|
151
157
|
logger&.debug("Could not determine current branch: #{e.message}")
|
|
152
158
|
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
|
@@ -26,6 +26,7 @@ module Ocak
|
|
|
26
26
|
|
|
27
27
|
def plan_batches(issues, logger:, claude:)
|
|
28
28
|
return sequential_batches(issues) if issues.size <= 1
|
|
29
|
+
return plan_multi_repo_batches(issues) if @config&.multi_repo?
|
|
29
30
|
|
|
30
31
|
issue_json = JSON.generate(issues.map { |i| { number: i['number'], title: i['title'] } })
|
|
31
32
|
result = claude.run_agent(
|
|
@@ -41,6 +42,20 @@ module Ocak
|
|
|
41
42
|
parse_planner_output(result.output, issues, logger)
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def plan_multi_repo_batches(issues)
|
|
46
|
+
by_repo = issues.group_by { |i| i['_target']&.dig(:name) || '__self__' }
|
|
47
|
+
|
|
48
|
+
# If all issues target different repos, one big parallel batch — no agent call needed
|
|
49
|
+
return [{ 'batch' => 1, 'issues' => issues }] if by_repo.values.all? { |group| group.size == 1 }
|
|
50
|
+
|
|
51
|
+
# Otherwise, issues in the same repo are sequential (by depth), cross-repo are parallel
|
|
52
|
+
max_depth = by_repo.values.map(&:size).max
|
|
53
|
+
(0...max_depth).map do |depth|
|
|
54
|
+
batch_issues = by_repo.values.filter_map { |group| group[depth] }
|
|
55
|
+
{ 'batch' => depth + 1, 'issues' => batch_issues }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
44
59
|
def parse_planner_output(output, issues, logger)
|
|
45
60
|
json_match = output.match(/\{[\s\S]*"batches"[\s\S]*\}/)
|
|
46
61
|
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}`.")
|
data/lib/ocak/stream_parser.rb
CHANGED
|
@@ -191,7 +191,7 @@ module Ocak
|
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
def detect_test_pass(output)
|
|
194
|
-
return true if output.match?(
|
|
194
|
+
return true if output.match?(/\b0 failures\b/)
|
|
195
195
|
return true if output.match?(/no offenses detected/i)
|
|
196
196
|
return true if output.match?(/test result: ok/i) # cargo test
|
|
197
197
|
return false if output.match?(/[1-9]\d* failures?/) || output.match?(/[1-9]\d* errors?/)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
module TargetResolver
|
|
7
|
+
# Resolves the target repo for an issue.
|
|
8
|
+
# Returns { name:, path: } hash or nil (single-repo mode).
|
|
9
|
+
# Raises TargetResolutionError on missing/invalid target in multi-repo mode.
|
|
10
|
+
def self.resolve(issue, config:)
|
|
11
|
+
return nil unless config.multi_repo?
|
|
12
|
+
|
|
13
|
+
body = issue['body'].to_s
|
|
14
|
+
repo_name = extract_target_name(body, field: config.target_field)
|
|
15
|
+
|
|
16
|
+
unless repo_name
|
|
17
|
+
raise TargetResolutionError,
|
|
18
|
+
"Issue ##{issue['number']} is missing required '#{config.target_field}' field in body. " \
|
|
19
|
+
"Known repos: #{config.repos.keys.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
config.resolve_repo(repo_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Extract target name from YAML front-matter.
|
|
26
|
+
# Returns the target name string, or nil if not found.
|
|
27
|
+
def self.extract_target_name(body, field:)
|
|
28
|
+
match = body.match(/\A---\s*\n(.*?)\n---/m)
|
|
29
|
+
return nil unless match
|
|
30
|
+
|
|
31
|
+
frontmatter = YAML.safe_load(match[1])
|
|
32
|
+
return nil unless frontmatter.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
frontmatter[field]&.to_s&.strip
|
|
35
|
+
rescue Psych::SyntaxError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class TargetResolutionError < StandardError; end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -98,6 +98,9 @@ You implement GitHub issues. The issue is your single source of truth — everyt
|
|
|
98
98
|
<%- end -%>
|
|
99
99
|
<%- end -%>
|
|
100
100
|
|
|
101
|
+
### Working Directory Context
|
|
102
|
+
If a CLAUDE.md file exists in the current working directory, read it for project-specific conventions, patterns, and architecture. This is especially important when working in a repository you haven't seen before.
|
|
103
|
+
|
|
101
104
|
### General Rules
|
|
102
105
|
|
|
103
106
|
- Do NOT add features, refactoring, or improvements beyond what the issue specifies
|