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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3ade72c57a02f3647218543b060295454aec639d90ba261b4fe9b88fa05f1bf
4
- data.tar.gz: 86decb5ce7099d43214859b82f3b12fc7d55b5cdf3d1d57a30ed38dc7c74a957
3
+ metadata.gz: 0f09506ff0c348c7f0822da37268c614ea209b9ca8edb643b7a4619df96113a3
4
+ data.tar.gz: 90a52276424ab7f2dbaa19643ca22ffde525460089d7f2b3f97670c638b43f2b
5
5
  SHA512:
6
- metadata.gz: 6ba522815876568e13b9453cdbd943a873c2ad28cee59a6c92d99b6dc62ccfeeadac57a2a2815b95f834b7c7173ffb2baf40107dcdfabc2cc74cf3d04446ad2d
7
- data.tar.gz: 9b4d7d602aa8c2c213d64bbf75a5d9c5dfecb01952d327140c268f32474aaa02e3664f6b29cd60c9eee45fa1ac97e6f5012b9bbac51d54b1de68d40c34291a64
6
+ metadata.gz: 04fd822b0fc96ae9f845d97c431a40552c081d56475925069c2158685d126fb816ef87ddd9946b041c2bf624e9e6676a92c2ab30da2b8d10128eebb7a133a57b
7
+ data.tar.gz: 111a2c5c925cbad3919f65a92ed3d0d16c88acfd934d0e8d1baf97b21d966a53822129255f28f1941b692bd03eb455eff8ca186555d9e78b2cce189085f53853
@@ -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(agent, current_content, context)
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(_agent, template_content, context)
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
 
@@ -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 '../verification'
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
- 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' }
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
- 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)
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
- 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)
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 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
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
- state.review_results = run_reviews_in_parallel(issue_number, claude: claude, logger: logger,
119
- chdir: chdir, state: state)
120
- nil
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 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?
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 build_logger(issue_number)
260
- PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
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 post_step_comment(issue_number, body, state:)
264
- state.issues&.comment(issue_number, body)
265
- rescue StandardError
266
- nil
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 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
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}", state: state)
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
- state: state)
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
- state: state)
232
+ issues: state.issues)
301
233
  end
302
234
  end
303
235
  end
@@ -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 = File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
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 ||= Object.new.tap do |l|
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
@@ -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].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
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 with auto-fix flags stripped, suitable for check-only verification.
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
- cmd.gsub(/\s+(?:-A|--fix|--write|--allow-dirty)\b/, '').strip
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
- def worktree_dir = dig(:pipeline, :worktree_dir) || '.claude/worktrees'
53
- def log_dir = dig(:pipeline, :log_dir) || 'logs/pipeline'
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
@@ -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
@@ -20,13 +20,12 @@ module Ocak
20
20
  end
21
21
 
22
22
  def fetch_ready
23
- stdout, _, status = Open3.capture3(
24
- 'gh', 'issue', 'list',
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 = Open3.capture3(
61
- 'gh', 'pr', 'list',
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 = Open3.capture3(
78
- 'gh', 'pr', 'view', pr_number.to_s,
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 = Open3.capture3('gh', 'pr', 'edit', pr_number.to_s,
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 = Open3.capture3('gh', 'pr', 'edit', pr_number.to_s,
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 = Open3.capture3('gh', 'pr', 'comment', pr_number.to_s,
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
- Open3.capture3('gh', 'label', 'create', label, '--force', '--color', color,
129
- chdir: @config.project_dir) # --force: update if exists
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 = Open3.capture3(
136
- 'gh', 'issue', 'view', issue_number.to_s,
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.any? && authors.include?(author_login)
159
- check_comment_requirement(issue)
160
- elsif authors.empty?
161
- # Default: current user only
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 = Open3.capture3(
195
- 'gh', 'issue', 'view', issue_number.to_s,
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 ||= begin
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? ? stdout.strip : nil
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(*)