ocak 0.3.0 → 0.4.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/lib/ocak/agent_generator.rb +2 -2
- data/lib/ocak/commands/hiz.rb +70 -138
- data/lib/ocak/commands/init.rb +7 -6
- data/lib/ocak/commands/resume.rb +4 -4
- data/lib/ocak/config.rb +24 -5
- data/lib/ocak/failure_reporting.rb +16 -0
- data/lib/ocak/git_utils.rb +9 -0
- data/lib/ocak/issue_fetcher.rb +40 -47
- data/lib/ocak/logger.rb +5 -2
- data/lib/ocak/merge_manager.rb +10 -4
- data/lib/ocak/pipeline_executor.rb +119 -63
- data/lib/ocak/pipeline_runner.rb +30 -12
- data/lib/ocak/pipeline_state.rb +11 -2
- data/lib/ocak/planner.rb +1 -2
- data/lib/ocak/process_runner.rb +4 -2
- data/lib/ocak/reready_processor.rb +4 -2
- data/lib/ocak/run_report.rb +2 -0
- data/lib/ocak/step_comments.rb +12 -7
- data/lib/ocak/stream_parser.rb +7 -2
- data/lib/ocak/templates/ocak.yml.erb +1 -0
- data/lib/ocak/verification.rb +37 -2
- data/lib/ocak/worktree_manager.rb +5 -3
- data/lib/ocak.rb +1 -1
- metadata +2 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f09506ff0c348c7f0822da37268c614ea209b9ca8edb643b7a4619df96113a3
|
|
4
|
+
data.tar.gz: 90a52276424ab7f2dbaa19643ca22ffde525460089d7f2b3f97670c638b43f2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 04fd822b0fc96ae9f845d97c431a40552c081d56475925069c2158685d126fb816ef87ddd9946b041c2bf624e9e6676a92c2ab30da2b8d10128eebb7a133a57b
|
|
7
|
+
data.tar.gz: 111a2c5c925cbad3919f65a92ed3d0d16c88acfd934d0e8d1baf97b21d966a53822129255f28f1941b692bd03eb455eff8ca186555d9e78b2cce189085f53853
|
data/lib/ocak/agent_generator.rb
CHANGED
|
@@ -109,7 +109,7 @@ module Ocak
|
|
|
109
109
|
agent_path = File.join(output_dir, "#{output_name}.md")
|
|
110
110
|
current_content = File.read(agent_path)
|
|
111
111
|
|
|
112
|
-
prompt = build_enhancement_prompt(
|
|
112
|
+
prompt = build_enhancement_prompt(current_content, context)
|
|
113
113
|
result = run_claude_prompt(prompt)
|
|
114
114
|
|
|
115
115
|
if result && !result.strip.empty? && result.include?('---')
|
|
@@ -131,7 +131,7 @@ module Ocak
|
|
|
131
131
|
parts.join("\n\n")
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
-
def build_enhancement_prompt(
|
|
134
|
+
def build_enhancement_prompt(template_content, context)
|
|
135
135
|
<<~PROMPT
|
|
136
136
|
You are customizing a Claude Code agent for a specific project.
|
|
137
137
|
|
data/lib/ocak/commands/hiz.rb
CHANGED
|
@@ -6,16 +6,13 @@ require_relative '../config'
|
|
|
6
6
|
require_relative '../claude_runner'
|
|
7
7
|
require_relative '../git_utils'
|
|
8
8
|
require_relative '../issue_fetcher'
|
|
9
|
-
require_relative '../
|
|
10
|
-
require_relative '../planner'
|
|
9
|
+
require_relative '../pipeline_executor'
|
|
11
10
|
require_relative '../step_comments'
|
|
12
11
|
require_relative '../logger'
|
|
13
12
|
|
|
14
13
|
module Ocak
|
|
15
14
|
module Commands
|
|
16
15
|
class Hiz < Dry::CLI::Command
|
|
17
|
-
include Verification
|
|
18
|
-
include Planner
|
|
19
16
|
include StepComments
|
|
20
17
|
|
|
21
18
|
desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
|
|
@@ -26,17 +23,10 @@ module Ocak
|
|
|
26
23
|
option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
|
|
27
24
|
option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
'implementer'
|
|
31
|
-
'reviewer'
|
|
32
|
-
'security-reviewer'
|
|
33
|
-
}.freeze
|
|
34
|
-
|
|
35
|
-
IMPLEMENT_STEP = { agent: 'implementer', role: 'implement' }.freeze
|
|
36
|
-
|
|
37
|
-
REVIEW_STEPS = [
|
|
38
|
-
{ agent: 'reviewer', role: 'review' },
|
|
39
|
-
{ agent: 'security-reviewer', role: 'security' }
|
|
26
|
+
HIZ_STEPS = [
|
|
27
|
+
{ agent: 'implementer', role: 'implement', model: 'sonnet' },
|
|
28
|
+
{ agent: 'reviewer', role: 'review', model: 'haiku', parallel: true },
|
|
29
|
+
{ agent: 'security-reviewer', role: 'security', model: 'sonnet', parallel: true }
|
|
40
30
|
].freeze
|
|
41
31
|
|
|
42
32
|
HizState = Struct.new(:issues, :total_cost, :steps_run, :review_results)
|
|
@@ -50,7 +40,7 @@ module Ocak
|
|
|
50
40
|
return
|
|
51
41
|
end
|
|
52
42
|
|
|
53
|
-
logger = build_logger(issue_number)
|
|
43
|
+
@logger = logger = build_logger(issue_number)
|
|
54
44
|
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
55
45
|
claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
|
|
56
46
|
issues = IssueFetcher.new(config: @config, logger: logger)
|
|
@@ -78,130 +68,70 @@ module Ocak
|
|
|
78
68
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
79
69
|
chdir = @config.project_dir
|
|
80
70
|
|
|
71
|
+
issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
|
|
81
72
|
post_hiz_start_comment(issue_number, state: state)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
post_hiz_summary_comment(issue_number, duration, success: false, failed_phase: failure[:phase],
|
|
88
|
-
state: state)
|
|
89
|
-
handle_failure(issue_number, failure[:phase], failure[:output], issues: state.issues, logger: logger)
|
|
73
|
+
begin
|
|
74
|
+
branch = create_branch(issue_number, chdir)
|
|
75
|
+
rescue RuntimeError => e
|
|
76
|
+
fail_pipeline(issue_number, 'create-branch', e.message,
|
|
77
|
+
start_time: start_time, state: state, logger: logger)
|
|
90
78
|
return
|
|
91
79
|
end
|
|
92
80
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
81
|
+
executor = PipelineExecutor.new(config: @config, issues: issues)
|
|
82
|
+
result = executor.run_pipeline(
|
|
83
|
+
issue_number, logger: logger, claude: claude, chdir: chdir,
|
|
84
|
+
steps: HIZ_STEPS, verification_model: 'sonnet',
|
|
85
|
+
post_start_comment: false, post_summary_comment: false
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
state.total_cost = result[:total_cost] || 0.0
|
|
89
|
+
state.steps_run = result[:steps_run] || 0
|
|
90
|
+
state.steps_run += 1 if verification_ran?(result)
|
|
91
|
+
|
|
92
|
+
unless result[:success]
|
|
93
|
+
fail_pipeline(issue_number, result[:phase], result[:output],
|
|
94
|
+
start_time: start_time, state: state, logger: logger, branch: branch)
|
|
101
95
|
return
|
|
102
96
|
end
|
|
103
97
|
|
|
98
|
+
state.review_results = extract_review_results(result[:step_results])
|
|
99
|
+
|
|
104
100
|
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
105
101
|
post_hiz_summary_comment(issue_number, duration, success: true, state: state)
|
|
106
102
|
push_and_create_pr(issue_number, branch, logger: logger, chdir: chdir, state: state)
|
|
107
103
|
end
|
|
108
104
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
unless result.success?
|
|
114
|
-
logger.error("Implementation failed for issue ##{issue_number}")
|
|
115
|
-
return { phase: 'implement', output: result.output }
|
|
116
|
-
end
|
|
105
|
+
def verification_ran?(result)
|
|
106
|
+
(@config.test_command || @config.lint_check_command) &&
|
|
107
|
+
(result[:success] || result[:phase] == 'final-verify')
|
|
108
|
+
end
|
|
117
109
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
110
|
+
def extract_review_results(step_results)
|
|
111
|
+
return {} unless step_results
|
|
112
|
+
|
|
113
|
+
step_results.select do |_role, r|
|
|
114
|
+
r&.blocking_findings? || r&.warnings?
|
|
115
|
+
end
|
|
121
116
|
end
|
|
122
117
|
|
|
123
118
|
def create_branch(issue_number, chdir)
|
|
124
119
|
branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
|
|
120
|
+
raise "Unsafe branch name: #{branch}" unless GitUtils.safe_branch_name?(branch)
|
|
121
|
+
|
|
125
122
|
_, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
|
|
126
123
|
raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
|
|
127
124
|
|
|
128
125
|
branch
|
|
129
126
|
end
|
|
130
127
|
|
|
131
|
-
def run_reviews_in_parallel(issue_number, claude:, logger:, chdir:, state:)
|
|
132
|
-
threads = REVIEW_STEPS.map do |step|
|
|
133
|
-
Thread.new do
|
|
134
|
-
run_step(step, issue_number, claude: claude, logger: logger, chdir: chdir, state: state)
|
|
135
|
-
rescue StandardError => e
|
|
136
|
-
logger.error("#{step[:role]} thread failed: #{e.message}")
|
|
137
|
-
nil
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
results = {}
|
|
142
|
-
threads.each_with_index do |thread, i|
|
|
143
|
-
result = thread.value
|
|
144
|
-
step = REVIEW_STEPS[i]
|
|
145
|
-
if result
|
|
146
|
-
state.steps_run += 1
|
|
147
|
-
state.total_cost += result.cost_usd.to_f
|
|
148
|
-
results[step[:role]] = result if result.blocking_findings? || result.warnings?
|
|
149
|
-
end
|
|
150
|
-
next if result.nil? || result.success?
|
|
151
|
-
|
|
152
|
-
logger.warn("#{step[:role]} reported issues but continuing")
|
|
153
|
-
end
|
|
154
|
-
results
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def run_step(step, issue_number, claude:, logger:, chdir:, state:)
|
|
158
|
-
agent = step[:agent]
|
|
159
|
-
role = step[:role]
|
|
160
|
-
model = STEP_MODELS[agent]
|
|
161
|
-
logger.info("--- Phase: #{role} (#{agent}) [#{model}] ---")
|
|
162
|
-
post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})", state: state)
|
|
163
|
-
prompt = build_step_prompt(role, issue_number, nil)
|
|
164
|
-
result = claude.run_agent(agent, prompt, chdir: chdir, model: model)
|
|
165
|
-
post_step_completion_comment(issue_number, role, result, state: state)
|
|
166
|
-
result
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def run_final_verification_step(issue_number, claude:, logger:, chdir:, state:)
|
|
170
|
-
return nil unless @config.test_command || @config.lint_check_command
|
|
171
|
-
|
|
172
|
-
logger.info('--- Final verification ---')
|
|
173
|
-
post_step_comment(issue_number, "\u{1F504} **Phase: final-verify** (verification)", state: state)
|
|
174
|
-
result = run_final_checks(logger, chdir: chdir)
|
|
175
|
-
|
|
176
|
-
unless result[:success]
|
|
177
|
-
logger.warn('Final checks failed, attempting fix...')
|
|
178
|
-
post_step_comment(issue_number,
|
|
179
|
-
"\u{26A0}\u{FE0F} **Final verification failed** \u2014 attempting auto-fix...",
|
|
180
|
-
state: state)
|
|
181
|
-
fix_prompt = "Fix these test/lint failures:\n\n" \
|
|
182
|
-
"<verification_output>\n#{result[:output]}\n</verification_output>"
|
|
183
|
-
claude.run_agent('implementer', fix_prompt,
|
|
184
|
-
chdir: chdir, model: STEP_MODELS['implementer'])
|
|
185
|
-
result = run_final_checks(logger, chdir: chdir)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
state.steps_run += 1
|
|
189
|
-
if result[:success]
|
|
190
|
-
post_step_comment(issue_number, "\u{2705} **Phase: final-verify** completed", state: state)
|
|
191
|
-
nil
|
|
192
|
-
else
|
|
193
|
-
post_step_comment(issue_number, "\u{274C} **Phase: final-verify** failed", state: state)
|
|
194
|
-
{ success: false, phase: 'final-verify', output: result[:output] }
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
128
|
def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
|
|
199
129
|
commit_changes(issue_number, chdir, logger: logger)
|
|
200
130
|
|
|
201
131
|
_, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
|
|
202
132
|
unless status.success?
|
|
203
133
|
logger.error("Push failed: #{stderr}")
|
|
204
|
-
handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger)
|
|
134
|
+
handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger, branch: branch)
|
|
205
135
|
return
|
|
206
136
|
end
|
|
207
137
|
|
|
@@ -224,7 +154,7 @@ module Ocak
|
|
|
224
154
|
puts "PR created: #{pr_url}"
|
|
225
155
|
else
|
|
226
156
|
logger.error("PR creation failed: #{stderr}")
|
|
227
|
-
handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger)
|
|
157
|
+
handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger, branch: branch)
|
|
228
158
|
end
|
|
229
159
|
end
|
|
230
160
|
|
|
@@ -247,41 +177,43 @@ module Ocak
|
|
|
247
177
|
body
|
|
248
178
|
end
|
|
249
179
|
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
warn "Issue ##{issue_number} failed at phase: #{phase}"
|
|
255
|
-
_, stderr, status = Open3.capture3('git', 'checkout', 'main', chdir: @config.project_dir)
|
|
256
|
-
logger.warn("Cleanup checkout to main failed: #{stderr}") unless status.success?
|
|
180
|
+
def fail_pipeline(issue_number, phase, output, start_time:, state:, logger:, branch: nil) # rubocop:disable Metrics/ParameterLists
|
|
181
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
182
|
+
post_hiz_summary_comment(issue_number, duration, success: false, failed_phase: phase, state: state)
|
|
183
|
+
handle_failure(issue_number, phase, output, issues: state.issues, logger: logger, branch: branch)
|
|
257
184
|
end
|
|
258
185
|
|
|
259
|
-
def
|
|
260
|
-
|
|
186
|
+
def handle_failure(issue_number, phase, output, issues:, logger:, branch: nil)
|
|
187
|
+
logger.error("Issue ##{issue_number} failed at phase: #{phase}")
|
|
188
|
+
issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
|
|
189
|
+
begin
|
|
190
|
+
sanitized = output.to_s[0..1000].gsub('```', "'''")
|
|
191
|
+
issues.comment(issue_number,
|
|
192
|
+
"Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{sanitized}\n```")
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
warn "Issue ##{issue_number} failed at phase: #{phase}"
|
|
197
|
+
GitUtils.checkout_main(chdir: @config.project_dir, logger: logger)
|
|
198
|
+
delete_branch(branch, logger: logger) if branch
|
|
261
199
|
end
|
|
262
200
|
|
|
263
|
-
def
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
201
|
+
def delete_branch(branch, logger:)
|
|
202
|
+
_, stderr, status = Open3.capture3('git', 'branch', '-D', branch, chdir: @config.project_dir)
|
|
203
|
+
logger.warn("Failed to delete branch #{branch}: #{stderr}") unless status.success?
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
logger.warn("Error deleting branch #{branch}: #{e.message}")
|
|
267
206
|
end
|
|
268
207
|
|
|
269
|
-
def
|
|
270
|
-
|
|
271
|
-
cost = format('%.3f', result.cost_usd.to_f)
|
|
272
|
-
if result.success?
|
|
273
|
-
post_step_comment(issue_number, "\u{2705} **Phase: #{role}** completed \u2014 #{duration}s | $#{cost}",
|
|
274
|
-
state: state)
|
|
275
|
-
else
|
|
276
|
-
post_step_comment(issue_number, "\u{274C} **Phase: #{role}** failed \u2014 #{duration}s | $#{cost}",
|
|
277
|
-
state: state)
|
|
278
|
-
end
|
|
208
|
+
def build_logger(issue_number)
|
|
209
|
+
PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
|
|
279
210
|
end
|
|
280
211
|
|
|
281
212
|
def post_hiz_start_comment(issue_number, state:)
|
|
282
213
|
steps = "implement \u2192 review \u2225 security"
|
|
283
214
|
steps += " \u2192 verify" if @config.test_command || @config.lint_check_command
|
|
284
|
-
post_step_comment(issue_number, "\u{1F680} **Hiz (fast mode) started** \u2014 #{steps}",
|
|
215
|
+
post_step_comment(issue_number, "\u{1F680} **Hiz (fast mode) started** \u2014 #{steps}",
|
|
216
|
+
issues: state.issues)
|
|
285
217
|
end
|
|
286
218
|
|
|
287
219
|
def post_hiz_summary_comment(issue_number, duration, success:, state:, failed_phase: nil)
|
|
@@ -292,12 +224,12 @@ module Ocak
|
|
|
292
224
|
post_step_comment(issue_number,
|
|
293
225
|
"\u{2705} **Pipeline complete** \u2014 #{state.steps_run}/#{total} steps run " \
|
|
294
226
|
"| 0 skipped | $#{cost} total | #{duration}s",
|
|
295
|
-
|
|
227
|
+
issues: state.issues)
|
|
296
228
|
else
|
|
297
229
|
post_step_comment(issue_number,
|
|
298
230
|
"\u{274C} **Pipeline failed** at phase: #{failed_phase} \u2014 " \
|
|
299
231
|
"#{state.steps_run}/#{total} steps completed | $#{cost} total",
|
|
300
|
-
|
|
232
|
+
issues: state.issues)
|
|
301
233
|
end
|
|
302
234
|
end
|
|
303
235
|
end
|
data/lib/ocak/commands/init.rb
CHANGED
|
@@ -85,7 +85,12 @@ module Ocak
|
|
|
85
85
|
|
|
86
86
|
def update_settings(project_dir, stack)
|
|
87
87
|
settings_path = File.join(project_dir, '.claude', 'settings.json')
|
|
88
|
-
existing =
|
|
88
|
+
existing = begin
|
|
89
|
+
File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
puts ' Warning: .claude/settings.json is not valid JSON, creating fresh'
|
|
92
|
+
{}
|
|
93
|
+
end
|
|
89
94
|
|
|
90
95
|
merge_permissions(existing, stack)
|
|
91
96
|
merge_hooks(existing)
|
|
@@ -211,11 +216,7 @@ module Ocak
|
|
|
211
216
|
end
|
|
212
217
|
|
|
213
218
|
def init_logger
|
|
214
|
-
@init_logger ||=
|
|
215
|
-
def l.info(msg)
|
|
216
|
-
puts " #{msg}"
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
+
@init_logger ||= Struct.new(:_) { def info(msg) = puts(" #{msg}") }.new
|
|
219
220
|
end
|
|
220
221
|
end
|
|
221
222
|
end
|
data/lib/ocak/commands/resume.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../config'
|
|
4
|
+
require_relative '../failure_reporting'
|
|
4
5
|
require_relative '../git_utils'
|
|
5
6
|
require_relative '../pipeline_runner'
|
|
6
7
|
require_relative '../pipeline_state'
|
|
@@ -13,6 +14,8 @@ require_relative '../logger'
|
|
|
13
14
|
module Ocak
|
|
14
15
|
module Commands
|
|
15
16
|
class Resume < Dry::CLI::Command
|
|
17
|
+
include FailureReporting
|
|
18
|
+
|
|
16
19
|
desc 'Resume a failed pipeline from the last successful step'
|
|
17
20
|
|
|
18
21
|
argument :issue, type: :integer, required: true, desc: 'Issue number to resume'
|
|
@@ -93,10 +96,7 @@ module Ocak
|
|
|
93
96
|
if result[:success]
|
|
94
97
|
attempt_merge(ctx)
|
|
95
98
|
else
|
|
96
|
-
ctx[:issues
|
|
97
|
-
to: ctx[:config].label_failed)
|
|
98
|
-
ctx[:issues].comment(ctx[:issue_number],
|
|
99
|
-
"Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
|
|
99
|
+
report_pipeline_failure(ctx[:issue_number], result, issues: ctx[:issues], config: ctx[:config])
|
|
100
100
|
warn "Issue ##{ctx[:issue_number]} failed again at phase: #{result[:phase]}"
|
|
101
101
|
end
|
|
102
102
|
end
|
data/lib/ocak/config.rb
CHANGED
|
@@ -34,12 +34,17 @@ module Ocak
|
|
|
34
34
|
def format_command = dig(:stack, :format_command)
|
|
35
35
|
def setup_command = dig(:stack, :setup_command)
|
|
36
36
|
|
|
37
|
-
# Returns the lint command
|
|
37
|
+
# Returns the lint command suitable for check-only verification.
|
|
38
|
+
# Uses explicit lint_check_command config if provided; otherwise strips known fix flags from lint_command.
|
|
38
39
|
def lint_check_command
|
|
40
|
+
explicit = dig(:stack, :lint_check_command)
|
|
41
|
+
return explicit if explicit && !explicit.empty?
|
|
42
|
+
|
|
39
43
|
cmd = lint_command
|
|
40
44
|
return nil unless cmd
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
# Longer --fix-* variants must precede --fix in the alternation due to \b matching after 'fix'
|
|
47
|
+
cmd.gsub(/\s+(?:-A|--fix-dry-run|--fix-type\s+\S+|--unsafe-fix|--fix|--write|--allow-dirty)\b/, '').strip
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
def security_commands
|
|
@@ -49,8 +54,15 @@ module Ocak
|
|
|
49
54
|
# Pipeline
|
|
50
55
|
def max_parallel = @overrides[:max_parallel] || dig(:pipeline, :max_parallel) || 5
|
|
51
56
|
def poll_interval = @overrides[:poll_interval] || dig(:pipeline, :poll_interval) || 60
|
|
52
|
-
|
|
53
|
-
def
|
|
57
|
+
|
|
58
|
+
def worktree_dir
|
|
59
|
+
validate_path(dig(:pipeline, :worktree_dir) || '.claude/worktrees')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def log_dir
|
|
63
|
+
validate_path(dig(:pipeline, :log_dir) || 'logs/pipeline')
|
|
64
|
+
end
|
|
65
|
+
|
|
54
66
|
def cost_budget = dig(:pipeline, :cost_budget)
|
|
55
67
|
def manual_review = @overrides[:manual_review] || dig(:pipeline, :manual_review) || false
|
|
56
68
|
def audit_mode = @overrides[:audit_mode] || dig(:pipeline, :audit_mode) || false
|
|
@@ -80,13 +92,20 @@ module Ocak
|
|
|
80
92
|
# Agent paths
|
|
81
93
|
def agent_path(name)
|
|
82
94
|
custom = dig(:agents, name.to_sym)
|
|
83
|
-
return File.join(@project_dir, custom) if custom
|
|
95
|
+
return File.join(@project_dir, validate_path(custom)) if custom
|
|
84
96
|
|
|
85
97
|
File.join(@project_dir, '.claude', 'agents', "#{name.to_s.tr('_', '-')}.md")
|
|
86
98
|
end
|
|
87
99
|
|
|
88
100
|
private
|
|
89
101
|
|
|
102
|
+
def validate_path(relative)
|
|
103
|
+
expanded = File.expand_path(File.join(@project_dir, relative))
|
|
104
|
+
return relative if expanded.start_with?("#{@project_dir}/") || expanded == @project_dir
|
|
105
|
+
|
|
106
|
+
raise ConfigError, "Path '#{relative}' escapes project directory"
|
|
107
|
+
end
|
|
108
|
+
|
|
90
109
|
def dig(*keys)
|
|
91
110
|
keys.reduce(@data) { |h, k| h.is_a?(Hash) ? h[k] : nil }
|
|
92
111
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
# Shared pipeline failure reporting — label transition + comment posting.
|
|
5
|
+
# Included by PipelineRunner and Commands::Resume.
|
|
6
|
+
module FailureReporting
|
|
7
|
+
def report_pipeline_failure(issue_number, result, issues:, config:)
|
|
8
|
+
issues.transition(issue_number, from: config.label_in_progress, to: config.label_failed)
|
|
9
|
+
sanitized = result[:output][0..1000].to_s.gsub('```', "'''")
|
|
10
|
+
issues.comment(issue_number,
|
|
11
|
+
"Pipeline failed at phase: #{result[:phase]}\n\n```\n#{sanitized}\n```")
|
|
12
|
+
rescue StandardError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/ocak/git_utils.rb
CHANGED
|
@@ -38,5 +38,14 @@ module Ocak
|
|
|
38
38
|
|
|
39
39
|
true
|
|
40
40
|
end
|
|
41
|
+
|
|
42
|
+
# Checks out the main branch. Intended for cleanup/ensure blocks.
|
|
43
|
+
# Rescues all errors so it never crashes the caller.
|
|
44
|
+
def self.checkout_main(chdir:, logger: nil)
|
|
45
|
+
_, stderr, status = Open3.capture3('git', 'checkout', 'main', chdir: chdir)
|
|
46
|
+
logger&.warn("Cleanup checkout to main failed: #{stderr}") unless status.success?
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
logger&.warn("Cleanup checkout to main error: #{e.message}")
|
|
49
|
+
end
|
|
41
50
|
end
|
|
42
51
|
end
|
data/lib/ocak/issue_fetcher.rb
CHANGED
|
@@ -20,13 +20,12 @@ module Ocak
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def fetch_ready
|
|
23
|
-
stdout, _, status =
|
|
24
|
-
'
|
|
23
|
+
stdout, _, status = run_gh(
|
|
24
|
+
'issue', 'list',
|
|
25
25
|
'--label', @config.label_ready,
|
|
26
26
|
'--state', 'open',
|
|
27
27
|
'--json', 'number,title,body,labels,author',
|
|
28
|
-
'--limit', '50'
|
|
29
|
-
chdir: @config.project_dir
|
|
28
|
+
'--limit', '50'
|
|
30
29
|
)
|
|
31
30
|
return [] unless status.success?
|
|
32
31
|
|
|
@@ -57,13 +56,12 @@ module Ocak
|
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
def fetch_reready_prs
|
|
60
|
-
stdout, _, status =
|
|
61
|
-
'
|
|
59
|
+
stdout, _, status = run_gh(
|
|
60
|
+
'pr', 'list',
|
|
62
61
|
'--label', @config.label_reready,
|
|
63
62
|
'--state', 'open',
|
|
64
63
|
'--json', 'number,title,body,headRefName,labels',
|
|
65
|
-
'--limit', '20'
|
|
66
|
-
chdir: @config.project_dir
|
|
64
|
+
'--limit', '20'
|
|
67
65
|
)
|
|
68
66
|
return [] unless status.success?
|
|
69
67
|
|
|
@@ -74,10 +72,9 @@ module Ocak
|
|
|
74
72
|
end
|
|
75
73
|
|
|
76
74
|
def fetch_pr_comments(pr_number)
|
|
77
|
-
stdout, _, status =
|
|
78
|
-
'
|
|
79
|
-
'--json', 'comments,reviews'
|
|
80
|
-
chdir: @config.project_dir
|
|
75
|
+
stdout, _, status = run_gh(
|
|
76
|
+
'pr', 'view', pr_number.to_s,
|
|
77
|
+
'--json', 'comments,reviews'
|
|
81
78
|
)
|
|
82
79
|
return { comments: [], reviews: [] } unless status.success?
|
|
83
80
|
|
|
@@ -96,16 +93,12 @@ module Ocak
|
|
|
96
93
|
|
|
97
94
|
def pr_transition(pr_number, remove_label: nil, add_label: nil)
|
|
98
95
|
if remove_label
|
|
99
|
-
_, _, status =
|
|
100
|
-
'--remove-label', remove_label,
|
|
101
|
-
chdir: @config.project_dir)
|
|
96
|
+
_, _, status = run_gh('pr', 'edit', pr_number.to_s, '--remove-label', remove_label)
|
|
102
97
|
return false unless status.success?
|
|
103
98
|
end
|
|
104
99
|
|
|
105
100
|
if add_label
|
|
106
|
-
_, _, status =
|
|
107
|
-
'--add-label', add_label,
|
|
108
|
-
chdir: @config.project_dir)
|
|
101
|
+
_, _, status = run_gh('pr', 'edit', pr_number.to_s, '--add-label', add_label)
|
|
109
102
|
return false unless status.success?
|
|
110
103
|
end
|
|
111
104
|
|
|
@@ -113,9 +106,7 @@ module Ocak
|
|
|
113
106
|
end
|
|
114
107
|
|
|
115
108
|
def pr_comment(pr_number, body)
|
|
116
|
-
_, _, status =
|
|
117
|
-
'--body', body,
|
|
118
|
-
chdir: @config.project_dir)
|
|
109
|
+
_, _, status = run_gh('pr', 'comment', pr_number.to_s, '--body', body)
|
|
119
110
|
status.success?
|
|
120
111
|
end
|
|
121
112
|
|
|
@@ -125,17 +116,15 @@ module Ocak
|
|
|
125
116
|
|
|
126
117
|
def ensure_label(label)
|
|
127
118
|
color = LABEL_COLORS.fetch(label, 'ededed')
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
rescue StandardError => e
|
|
119
|
+
run_gh('label', 'create', label, '--force', '--color', color) # --force: update if exists
|
|
120
|
+
rescue Errno::ENOENT => e
|
|
131
121
|
@logger&.warn("Failed to create label '#{label}': #{e.message}")
|
|
132
122
|
end
|
|
133
123
|
|
|
134
124
|
def view(issue_number, fields: 'number,title,body,labels')
|
|
135
|
-
stdout, _, status =
|
|
136
|
-
'
|
|
137
|
-
'--json', fields
|
|
138
|
-
chdir: @config.project_dir
|
|
125
|
+
stdout, _, status = run_gh(
|
|
126
|
+
'issue', 'view', issue_number.to_s,
|
|
127
|
+
'--json', fields
|
|
139
128
|
)
|
|
140
129
|
return nil unless status.success?
|
|
141
130
|
|
|
@@ -155,20 +144,14 @@ module Ocak
|
|
|
155
144
|
authors = allowed_authors
|
|
156
145
|
author_login = issue.dig('author', 'login')
|
|
157
146
|
|
|
158
|
-
if authors.
|
|
159
|
-
check_comment_requirement(issue)
|
|
160
|
-
elsif authors.
|
|
161
|
-
|
|
162
|
-
if author_login == current_user
|
|
163
|
-
check_comment_requirement(issue)
|
|
164
|
-
else
|
|
165
|
-
@logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
|
|
166
|
-
false
|
|
167
|
-
end
|
|
168
|
-
else
|
|
169
|
-
@logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
|
|
170
|
-
false
|
|
147
|
+
if authors.empty?
|
|
148
|
+
return check_comment_requirement(issue) if author_login == current_user
|
|
149
|
+
elsif authors.include?(author_login)
|
|
150
|
+
return check_comment_requirement(issue)
|
|
171
151
|
end
|
|
152
|
+
|
|
153
|
+
@logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
|
|
154
|
+
false
|
|
172
155
|
end
|
|
173
156
|
|
|
174
157
|
def check_comment_requirement(issue)
|
|
@@ -191,10 +174,9 @@ module Ocak
|
|
|
191
174
|
end
|
|
192
175
|
|
|
193
176
|
def fetch_comments(issue_number)
|
|
194
|
-
stdout, _, status =
|
|
195
|
-
'
|
|
196
|
-
'--json', 'comments'
|
|
197
|
-
chdir: @config.project_dir
|
|
177
|
+
stdout, _, status = run_gh(
|
|
178
|
+
'issue', 'view', issue_number.to_s,
|
|
179
|
+
'--json', 'comments'
|
|
198
180
|
)
|
|
199
181
|
return [] unless status.success?
|
|
200
182
|
|
|
@@ -209,10 +191,21 @@ module Ocak
|
|
|
209
191
|
end
|
|
210
192
|
|
|
211
193
|
def current_user
|
|
212
|
-
@current_user
|
|
194
|
+
return @current_user if defined?(@current_user_resolved)
|
|
195
|
+
|
|
196
|
+
2.times do |attempt|
|
|
213
197
|
stdout, _, status = Open3.capture3('gh', 'api', 'user', '--jq', '.login')
|
|
214
|
-
status.success?
|
|
198
|
+
if status.success?
|
|
199
|
+
@current_user = stdout.strip
|
|
200
|
+
@current_user_resolved = true
|
|
201
|
+
return @current_user
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
@logger&.warn("Could not determine current user via 'gh api user' (attempt #{attempt + 1}/2)")
|
|
205
|
+
sleep(1) if attempt.zero?
|
|
215
206
|
end
|
|
207
|
+
|
|
208
|
+
nil
|
|
216
209
|
end
|
|
217
210
|
|
|
218
211
|
def run_gh(*)
|