ocak 0.1.0 → 0.3.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.
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'securerandom'
5
+ require_relative '../config'
6
+ require_relative '../claude_runner'
7
+ require_relative '../git_utils'
8
+ require_relative '../issue_fetcher'
9
+ require_relative '../verification'
10
+ require_relative '../planner'
11
+ require_relative '../step_comments'
12
+ require_relative '../logger'
13
+
14
+ module Ocak
15
+ module Commands
16
+ class Hiz < Dry::CLI::Command
17
+ include Verification
18
+ include Planner
19
+ include StepComments
20
+
21
+ desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
22
+
23
+ argument :issue, type: :integer, required: true, desc: 'Issue number to process'
24
+ option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
25
+ option :dry_run, type: :boolean, default: false, desc: 'Show pipeline plan without executing'
26
+ option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
27
+ option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
28
+
29
+ STEP_MODELS = {
30
+ 'implementer' => 'sonnet',
31
+ 'reviewer' => 'haiku',
32
+ 'security-reviewer' => 'sonnet'
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' }
40
+ ].freeze
41
+
42
+ HizState = Struct.new(:issues, :total_cost, :steps_run, :review_results)
43
+
44
+ def call(issue:, **options)
45
+ @config = Config.load
46
+ issue_number = issue.to_i
47
+
48
+ if options[:dry_run]
49
+ print_dry_run(issue_number)
50
+ return
51
+ end
52
+
53
+ logger = build_logger(issue_number)
54
+ watch_formatter = options[:watch] ? WatchFormatter.new : nil
55
+ claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
56
+ issues = IssueFetcher.new(config: @config, logger: logger)
57
+
58
+ logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
59
+
60
+ run_fast_pipeline(issue_number, claude: claude, logger: logger, issues: issues)
61
+ rescue Config::ConfigNotFound => e
62
+ warn "Error: #{e.message}"
63
+ exit 1
64
+ end
65
+
66
+ private
67
+
68
+ def print_dry_run(issue_number)
69
+ puts "[DRY RUN] Hiz pipeline for issue ##{issue_number}:"
70
+ puts ' 1. implement (implementer) [sonnet]'
71
+ puts ' 2. review (reviewer) [haiku] || security (security-reviewer) [sonnet]'
72
+ has_verify = @config.test_command || @config.lint_check_command
73
+ puts ' 3. final-verify (verification)' if has_verify
74
+ end
75
+
76
+ def run_fast_pipeline(issue_number, claude:, logger:, issues:)
77
+ state = HizState.new(issues, 0.0, 0, {})
78
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ chdir = @config.project_dir
80
+
81
+ post_hiz_start_comment(issue_number, state: state)
82
+ branch = create_branch(issue_number, chdir)
83
+
84
+ failure = run_agents(issue_number, claude: claude, logger: logger, chdir: chdir, state: state)
85
+ if failure
86
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
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)
90
+ return
91
+ end
92
+
93
+ verification_failure = run_final_verification_step(issue_number, claude: claude, logger: logger,
94
+ chdir: chdir, state: state)
95
+ if verification_failure
96
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
97
+ post_hiz_summary_comment(issue_number, duration, success: false, failed_phase: 'final-verify',
98
+ state: state)
99
+ handle_failure(issue_number, 'final-verify', verification_failure[:output],
100
+ issues: state.issues, logger: logger)
101
+ return
102
+ end
103
+
104
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
105
+ post_hiz_summary_comment(issue_number, duration, success: true, state: state)
106
+ push_and_create_pr(issue_number, branch, logger: logger, chdir: chdir, state: state)
107
+ end
108
+
109
+ def run_agents(issue_number, claude:, logger:, chdir:, state:)
110
+ result = run_step(IMPLEMENT_STEP, issue_number, claude: claude, logger: logger, chdir: chdir, state: state)
111
+ state.steps_run += 1
112
+ state.total_cost += result.cost_usd.to_f
113
+ unless result.success?
114
+ logger.error("Implementation failed for issue ##{issue_number}")
115
+ return { phase: 'implement', output: result.output }
116
+ end
117
+
118
+ state.review_results = run_reviews_in_parallel(issue_number, claude: claude, logger: logger,
119
+ chdir: chdir, state: state)
120
+ nil
121
+ end
122
+
123
+ def create_branch(issue_number, chdir)
124
+ branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
125
+ _, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
126
+ raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
127
+
128
+ branch
129
+ end
130
+
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
+ def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
199
+ commit_changes(issue_number, chdir, logger: logger)
200
+
201
+ _, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
202
+ unless status.success?
203
+ logger.error("Push failed: #{stderr}")
204
+ handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger)
205
+ return
206
+ end
207
+
208
+ issue_data = state.issues.view(issue_number)
209
+ issue_title = issue_data&.dig('title')
210
+ pr_title = issue_title ? "Fix ##{issue_number}: #{issue_title}" : "Fix ##{issue_number}"
211
+ pr_body = build_pr_body(issue_number, state: state)
212
+
213
+ stdout, stderr, status = Open3.capture3(
214
+ 'gh', 'pr', 'create',
215
+ '--title', pr_title,
216
+ '--body', pr_body,
217
+ '--head', branch,
218
+ chdir: chdir
219
+ )
220
+
221
+ if status.success?
222
+ pr_url = stdout.strip
223
+ logger.info("PR created: #{pr_url}")
224
+ puts "PR created: #{pr_url}"
225
+ else
226
+ logger.error("PR creation failed: #{stderr}")
227
+ handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger)
228
+ end
229
+ end
230
+
231
+ def commit_changes(issue_number, chdir, logger:)
232
+ GitUtils.commit_changes(
233
+ chdir: chdir,
234
+ message: "feat: implement issue ##{issue_number} [hiz]",
235
+ logger: logger
236
+ )
237
+ end
238
+
239
+ def build_pr_body(issue_number, state:)
240
+ body = "Automated PR for issue ##{issue_number} (hiz fast mode)\n\nCloses ##{issue_number}"
241
+ return body if state.review_results.nil? || state.review_results.empty?
242
+
243
+ state.review_results.each do |role, result|
244
+ heading = role == 'review' ? 'Review Findings' : 'Security Review Findings'
245
+ body += "\n\n---\n\n## #{heading}\n\n#{result.output}"
246
+ end
247
+ body
248
+ end
249
+
250
+ def handle_failure(issue_number, phase, output, issues:, logger:)
251
+ logger.error("Issue ##{issue_number} failed at phase: #{phase}")
252
+ issues.comment(issue_number,
253
+ "Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{output.to_s[0..1000]}\n```")
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?
257
+ end
258
+
259
+ def build_logger(issue_number)
260
+ PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
261
+ end
262
+
263
+ def post_step_comment(issue_number, body, state:)
264
+ state.issues&.comment(issue_number, body)
265
+ rescue StandardError
266
+ nil
267
+ end
268
+
269
+ def post_step_completion_comment(issue_number, role, result, state:)
270
+ duration = (result.duration_ms.to_f / 1000).round
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
279
+ end
280
+
281
+ def post_hiz_start_comment(issue_number, state:)
282
+ steps = "implement \u2192 review \u2225 security"
283
+ 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}", state: state)
285
+ end
286
+
287
+ def post_hiz_summary_comment(issue_number, duration, success:, state:, failed_phase: nil)
288
+ total = 3 + (@config.test_command || @config.lint_check_command ? 1 : 0)
289
+ cost = format('%.2f', state.total_cost)
290
+
291
+ if success
292
+ post_step_comment(issue_number,
293
+ "\u{2705} **Pipeline complete** \u2014 #{state.steps_run}/#{total} steps run " \
294
+ "| 0 skipped | $#{cost} total | #{duration}s",
295
+ state: state)
296
+ else
297
+ post_step_comment(issue_number,
298
+ "\u{274C} **Pipeline failed** at phase: #{failed_phase} \u2014 " \
299
+ "#{state.steps_run}/#{total} steps completed | $#{cost} total",
300
+ state: state)
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -5,6 +5,7 @@ require 'fileutils'
5
5
  require_relative '../stack_detector'
6
6
  require_relative '../agent_generator'
7
7
  require_relative '../config'
8
+ require_relative '../issue_fetcher'
8
9
 
9
10
  module Ocak
10
11
  module Commands
@@ -36,6 +37,7 @@ module Ocak
36
37
  generate_files(generator, project_dir, options)
37
38
  update_settings(project_dir, stack)
38
39
  update_gitignore(project_dir)
40
+ create_labels(project_dir)
39
41
 
40
42
  puts ''
41
43
  print_summary(project_dir, stack, options)
@@ -174,6 +176,15 @@ module Ocak
174
176
  puts ' Updated .gitignore'
175
177
  end
176
178
 
179
+ def create_labels(project_dir)
180
+ config = Config.load(project_dir)
181
+ fetcher = IssueFetcher.new(config: config)
182
+ fetcher.ensure_labels(config.all_labels)
183
+ puts ' Created GitHub labels'
184
+ rescue StandardError => e
185
+ puts " Warning: could not create labels: #{e.message}"
186
+ end
187
+
177
188
  def print_summary(_project_dir, _stack, options)
178
189
  puts 'Ocak initialized successfully!'
179
190
  puts ''
@@ -194,10 +205,9 @@ module Ocak
194
205
  puts ' 5. Run the pipeline: ocak run --once'
195
206
  puts ''
196
207
  puts 'Quick commands:'
197
- puts ' ocak run --single 42 Run one issue'
208
+ puts ' ocak run 42 Run one issue'
198
209
  puts ' ocak run --watch Run with live output'
199
210
  puts ' ocak status Check pipeline state'
200
- puts ' ocak audit Run codebase audit'
201
211
  end
202
212
 
203
213
  def init_logger
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../config'
4
+ require_relative '../git_utils'
4
5
  require_relative '../pipeline_runner'
5
6
  require_relative '../pipeline_state'
6
7
  require_relative '../claude_runner'
@@ -16,14 +17,23 @@ module Ocak
16
17
 
17
18
  argument :issue, type: :integer, required: true, desc: 'Issue number to resume'
18
19
  option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
20
+ option :dry_run, type: :boolean, default: false, desc: 'Show what would re-run without executing'
21
+ option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
22
+ option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
19
23
 
20
24
  def call(issue:, **options)
21
25
  config = Config.load
22
26
  issue_number = issue.to_i
23
27
  saved = load_state(config, issue_number)
24
- chdir = resolve_worktree(config, saved)
25
28
 
26
29
  print_resume_info(issue_number, saved, config)
30
+
31
+ if options[:dry_run]
32
+ print_dry_run(saved, config)
33
+ return
34
+ end
35
+
36
+ chdir = resolve_worktree(config, saved)
27
37
  run_resumed_pipeline(config, issue_number, saved, chdir, options)
28
38
  rescue Config::ConfigNotFound => e
29
39
  warn "Error: #{e.message}"
@@ -51,6 +61,15 @@ module Ocak
51
61
  puts ''
52
62
  end
53
63
 
64
+ def print_dry_run(saved, config)
65
+ completed = saved[:completed_steps] || []
66
+ puts '[DRY RUN] Steps that would re-run:'
67
+ config.steps.each_with_index do |step, idx|
68
+ status = completed.include?(idx) ? 'skip (completed)' : 'run'
69
+ puts " #{idx + 1}. #{step['role']} (#{step['agent']}) — #{status}"
70
+ end
71
+ end
72
+
54
73
  def run_resumed_pipeline(config, issue_number, saved, chdir, options)
55
74
  log_dir = File.join(config.project_dir, config.log_dir)
56
75
  logger = PipelineLogger.new(log_dir: log_dir, issue_number: issue_number)
@@ -61,9 +80,9 @@ module Ocak
61
80
  issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
62
81
 
63
82
  runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
64
- result = runner.send(:run_pipeline, issue_number,
65
- logger: logger, claude: claude, chdir: chdir,
66
- skip_steps: saved[:completed_steps])
83
+ result = runner.run_pipeline(issue_number,
84
+ logger: logger, claude: claude, chdir: chdir,
85
+ skip_steps: saved[:completed_steps])
67
86
 
68
87
  ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
69
88
  issues: issues, claude: claude, logger: logger, watch: watch_formatter }
@@ -84,7 +103,7 @@ module Ocak
84
103
 
85
104
  def attempt_merge(ctx)
86
105
  merger = MergeManager.new(config: ctx[:config], claude: ctx[:claude],
87
- logger: ctx[:logger], watch: ctx[:watch])
106
+ logger: ctx[:logger], issues: ctx[:issues], watch: ctx[:watch])
88
107
  worktree = WorktreeManager::Worktree.new(
89
108
  path: ctx[:chdir], branch: ctx[:saved][:branch], issue_number: ctx[:issue_number]
90
109
  )
@@ -112,6 +131,11 @@ module Ocak
112
131
  exit 1
113
132
  end
114
133
 
134
+ unless GitUtils.safe_branch_name?(saved[:branch])
135
+ warn "Unsafe branch name '#{saved[:branch]}'. Cannot resume."
136
+ exit 1
137
+ end
138
+
115
139
  _, _, status = Open3.capture3('git', 'rev-parse', '--verify', saved[:branch], chdir: config.project_dir)
116
140
  unless status.success?
117
141
  warn "Worktree no longer exists and branch '#{saved[:branch]}' not found. Cannot resume."
@@ -120,7 +144,11 @@ module Ocak
120
144
 
121
145
  worktrees = WorktreeManager.new(config: config)
122
146
  wt = worktrees.create(saved[:issue_number], setup_command: config.setup_command)
123
- Open3.capture3('git', 'checkout', saved[:branch], chdir: wt.path)
147
+ _, stderr, status = Open3.capture3('git', 'checkout', saved[:branch], chdir: wt.path)
148
+ unless status.success?
149
+ warn "Failed to checkout branch '#{saved[:branch]}': #{stderr}"
150
+ exit 1
151
+ end
124
152
  wt.path
125
153
  end
126
154
  end
@@ -13,32 +13,49 @@ module Ocak
13
13
  class Run < Dry::CLI::Command
14
14
  desc 'Run the issue processing pipeline'
15
15
 
16
+ argument :issue, type: :integer, required: false, desc: 'Issue number (single-issue mode)'
17
+
16
18
  option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
17
- option :single, type: :integer, desc: 'Run a single issue without worktrees'
18
19
  option :dry_run, type: :boolean, default: false, desc: 'Show what would happen'
19
20
  option :once, type: :boolean, default: false, desc: 'Process current batch and exit'
20
21
  option :max_parallel, type: :integer, desc: 'Max concurrent pipelines'
21
22
  option :poll_interval, type: :integer, desc: 'Seconds between polls'
23
+ option :manual_review, type: :boolean, default: false,
24
+ desc: 'Create PRs without auto-merge; wait for human review'
25
+ option :audit, type: :boolean, default: false,
26
+ desc: 'Run auditor as post-pipeline gate; create PR with findings if issues found'
27
+ option :fast, type: :boolean, default: false,
28
+ desc: 'Lean pipeline — skip security, document, audit steps'
29
+ option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
30
+ option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
22
31
 
23
- def call(**options)
32
+ def call(issue: nil, **options)
24
33
  config = Config.load
25
34
 
26
35
  # CLI options override config
27
36
  config.override(:max_parallel, options[:max_parallel]) if options[:max_parallel]
28
37
  config.override(:poll_interval, options[:poll_interval]) if options[:poll_interval]
38
+ config.override(:manual_review, true) if options[:manual_review]
39
+ config.override(:audit_mode, true) if options[:audit]
40
+
41
+ log_level = resolve_log_level(options)
29
42
 
30
43
  runner = PipelineRunner.new(
31
44
  config: config,
32
45
  options: {
33
46
  watch: options[:watch],
34
- single: options[:single],
47
+ single: issue&.to_i,
35
48
  dry_run: options[:dry_run],
36
- once: options[:once]
49
+ once: options[:once],
50
+ fast: options[:fast],
51
+ log_level: log_level
37
52
  }
38
53
  )
39
54
 
40
55
  setup_signal_handlers(runner)
41
56
  runner.run
57
+ runner.print_shutdown_summary if runner.shutting_down?
58
+ exit 130 if runner.shutting_down?
42
59
  rescue Config::ConfigNotFound => e
43
60
  warn "Error: #{e.message}"
44
61
  exit 1
@@ -46,12 +63,17 @@ module Ocak
46
63
 
47
64
  private
48
65
 
66
+ def resolve_log_level(options)
67
+ return :quiet if options[:quiet]
68
+ return :verbose if options[:verbose]
69
+
70
+ :normal
71
+ end
72
+
49
73
  def setup_signal_handlers(runner)
50
74
  %w[INT TERM].each do |signal|
51
75
  trap(signal) do
52
- warn "\nReceived #{signal}, shutting down gracefully..."
53
76
  runner.shutdown!
54
- exit 0
55
77
  end
56
78
  end
57
79
  end
@@ -3,6 +3,7 @@
3
3
  require 'open3'
4
4
  require 'json'
5
5
  require_relative '../config'
6
+ require_relative '../run_report'
6
7
  require_relative '../worktree_manager'
7
8
 
8
9
  module Ocak
@@ -10,9 +11,24 @@ module Ocak
10
11
  class Status < Dry::CLI::Command
11
12
  desc 'Show pipeline status'
12
13
 
13
- def call(**)
14
+ option :report, type: :boolean, default: false, desc: 'Show run reports'
15
+
16
+ def call(**options)
14
17
  config = Config.load
15
18
 
19
+ if options[:report]
20
+ show_reports(config)
21
+ else
22
+ show_default_status(config)
23
+ end
24
+ rescue Config::ConfigNotFound => e
25
+ warn "Error: #{e.message}"
26
+ exit 1
27
+ end
28
+
29
+ private
30
+
31
+ def show_default_status(config)
16
32
  puts 'Pipeline Status'
17
33
  puts '=' * 40
18
34
  puts ''
@@ -22,13 +38,8 @@ module Ocak
22
38
  show_worktrees(config)
23
39
  puts ''
24
40
  show_recent_logs(config)
25
- rescue Config::ConfigNotFound => e
26
- warn "Error: #{e.message}"
27
- exit 1
28
41
  end
29
42
 
30
- private
31
-
32
43
  def show_issues(config)
33
44
  puts 'Issues:'
34
45
 
@@ -72,6 +83,109 @@ module Ocak
72
83
  end
73
84
  end
74
85
 
86
+ def show_reports(config)
87
+ reports = RunReport.load_all(project_dir: config.project_dir)
88
+
89
+ if reports.empty?
90
+ puts 'No run reports found.'
91
+ return
92
+ end
93
+
94
+ sorted = reports.sort_by { |r| r[:started_at].to_s }.reverse
95
+ show_recent_runs(sorted)
96
+ puts ''
97
+ show_aggregates(sorted)
98
+ end
99
+
100
+ def show_recent_runs(reports)
101
+ puts 'Recent Runs (last 10):'
102
+ reports.first(10).each do |r|
103
+ icon = r[:success] ? "\u2705" : "\u274C"
104
+ steps_str = step_count_str(r)
105
+ date = format_report_date(r[:started_at])
106
+ failed = r[:success] ? '' : " (failed: #{r[:failed_phase]})"
107
+ cost = format('$%.2f', r[:total_cost_usd].to_f)
108
+ puts " ##{r[:issue_number]} #{icon} #{r[:total_duration_s]}s #{cost} #{steps_str} #{date}#{failed}"
109
+ end
110
+ end
111
+
112
+ def show_aggregates(reports)
113
+ recent = reports.first(20)
114
+ puts "Aggregates (last #{recent.size} runs):"
115
+
116
+ avg_cost = recent.sum { |r| r[:total_cost_usd].to_f } / recent.size
117
+ avg_duration = recent.sum { |r| r[:total_duration_s].to_i } / recent.size
118
+ success_count = recent.count { |r| r[:success] }
119
+ success_rate = (success_count.to_f / recent.size * 100).round
120
+
121
+ puts " Avg cost: $#{format('%.2f', avg_cost)}"
122
+ puts " Avg duration: #{avg_duration}s"
123
+ puts " Success rate: #{success_rate}%"
124
+
125
+ show_slowest_step(recent)
126
+ show_most_skipped(recent)
127
+ end
128
+
129
+ def show_slowest_step(reports)
130
+ durations, costs = collect_step_metrics(reports)
131
+ return if durations.empty?
132
+
133
+ name, dur_values = durations.max_by { |_, vals| vals.sum.to_f / vals.size }
134
+ avg_dur = (dur_values.sum.to_f / dur_values.size).round
135
+ avg_cost = costs[name].sum / costs[name].size
136
+ puts " Slowest step: #{name} (avg #{avg_dur}s, $#{format('%.2f', avg_cost)})"
137
+ end
138
+
139
+ def collect_step_metrics(reports)
140
+ durations = Hash.new { |h, k| h[k] = [] }
141
+ costs = Hash.new { |h, k| h[k] = [] }
142
+
143
+ reports.each do |r|
144
+ (r[:steps] || []).each do |s|
145
+ next unless s[:status] == 'completed'
146
+
147
+ durations[s[:role]] << s[:duration_s].to_i
148
+ costs[s[:role]] << s[:cost_usd].to_f
149
+ end
150
+ end
151
+
152
+ [durations, costs]
153
+ end
154
+
155
+ def show_most_skipped(reports)
156
+ skip_counts = Hash.new(0)
157
+ total_counts = Hash.new(0)
158
+
159
+ reports.each do |r|
160
+ (r[:steps] || []).each do |s|
161
+ total_counts[s[:role]] += 1
162
+ skip_counts[s[:role]] += 1 if s[:status] == 'skipped'
163
+ end
164
+ end
165
+
166
+ skipped_roles = skip_counts.select { |_, count| count.positive? }
167
+ return if skipped_roles.empty?
168
+
169
+ most = skipped_roles.max_by { |role, count| count.to_f / total_counts[role] }
170
+ name = most[0]
171
+ rate = (most[1].to_f / total_counts[name] * 100).round
172
+ puts " Most skipped: #{name} (#{rate}% skip rate)"
173
+ end
174
+
175
+ def step_count_str(report)
176
+ steps = report[:steps] || []
177
+ completed = steps.count { |s| s[:status] == 'completed' }
178
+ "#{completed}/#{steps.size} steps"
179
+ end
180
+
181
+ def format_report_date(iso_str)
182
+ return 'unknown' unless iso_str
183
+
184
+ Time.parse(iso_str).strftime('%Y-%m-%d %H:%M')
185
+ rescue ArgumentError
186
+ iso_str.to_s[0..15]
187
+ end
188
+
75
189
  def fetch_issue_count(label, config)
76
190
  stdout, _, status = Open3.capture3(
77
191
  'gh', 'issue', 'list',