ocak 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/bin/ocak +7 -0
- data/lib/ocak/agent_generator.rb +171 -0
- data/lib/ocak/claude_runner.rb +169 -0
- data/lib/ocak/cli.rb +28 -0
- data/lib/ocak/commands/audit.rb +25 -0
- data/lib/ocak/commands/clean.rb +30 -0
- data/lib/ocak/commands/debt.rb +21 -0
- data/lib/ocak/commands/design.rb +34 -0
- data/lib/ocak/commands/init.rb +212 -0
- data/lib/ocak/commands/resume.rb +128 -0
- data/lib/ocak/commands/run.rb +60 -0
- data/lib/ocak/commands/status.rb +102 -0
- data/lib/ocak/config.rb +109 -0
- data/lib/ocak/issue_fetcher.rb +137 -0
- data/lib/ocak/logger.rb +192 -0
- data/lib/ocak/merge_manager.rb +158 -0
- data/lib/ocak/pipeline_runner.rb +389 -0
- data/lib/ocak/pipeline_state.rb +51 -0
- data/lib/ocak/planner.rb +68 -0
- data/lib/ocak/process_runner.rb +82 -0
- data/lib/ocak/stack_detector.rb +333 -0
- data/lib/ocak/stream_parser.rb +189 -0
- data/lib/ocak/templates/agents/auditor.md.erb +87 -0
- data/lib/ocak/templates/agents/documenter.md.erb +67 -0
- data/lib/ocak/templates/agents/implementer.md.erb +154 -0
- data/lib/ocak/templates/agents/merger.md.erb +97 -0
- data/lib/ocak/templates/agents/pipeline.md.erb +126 -0
- data/lib/ocak/templates/agents/planner.md.erb +86 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +98 -0
- data/lib/ocak/templates/agents/security_reviewer.md.erb +112 -0
- data/lib/ocak/templates/gitignore_additions.txt +10 -0
- data/lib/ocak/templates/hooks/post_edit_lint.sh.erb +57 -0
- data/lib/ocak/templates/hooks/task_completed_test.sh.erb +34 -0
- data/lib/ocak/templates/ocak.yml.erb +99 -0
- data/lib/ocak/templates/skills/audit/SKILL.md.erb +132 -0
- data/lib/ocak/templates/skills/debt/SKILL.md.erb +128 -0
- data/lib/ocak/templates/skills/design/SKILL.md.erb +131 -0
- data/lib/ocak/templates/skills/scan_file/SKILL.md.erb +113 -0
- data/lib/ocak/verification.rb +83 -0
- data/lib/ocak/worktree_manager.rb +92 -0
- data/lib/ocak.rb +13 -0
- metadata +115 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../config'
|
|
4
|
+
require_relative '../worktree_manager'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
module Commands
|
|
8
|
+
class Clean < Dry::CLI::Command
|
|
9
|
+
desc 'Remove stale worktrees and prune git worktree list'
|
|
10
|
+
|
|
11
|
+
def call(**)
|
|
12
|
+
config = Config.load
|
|
13
|
+
manager = WorktreeManager.new(config: config)
|
|
14
|
+
|
|
15
|
+
puts 'Cleaning stale worktrees...'
|
|
16
|
+
removed = manager.clean_stale
|
|
17
|
+
|
|
18
|
+
if removed.empty?
|
|
19
|
+
puts 'No stale worktrees found.'
|
|
20
|
+
else
|
|
21
|
+
removed.each { |path| puts " Removed: #{path}" }
|
|
22
|
+
puts "Cleaned #{removed.size} worktree(s)."
|
|
23
|
+
end
|
|
24
|
+
rescue Config::ConfigNotFound => e
|
|
25
|
+
warn "Error: #{e.message}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
module Commands
|
|
5
|
+
class Debt < Dry::CLI::Command
|
|
6
|
+
desc 'Track technical debt'
|
|
7
|
+
|
|
8
|
+
def call(**)
|
|
9
|
+
skill_path = File.join(Dir.pwd, '.claude', 'skills', 'debt', 'SKILL.md')
|
|
10
|
+
|
|
11
|
+
unless File.exist?(skill_path)
|
|
12
|
+
warn 'No debt skill found. Run `ocak init` first.'
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
puts 'Run this inside Claude Code:'
|
|
17
|
+
puts ' /debt'
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
module Commands
|
|
5
|
+
class Design < Dry::CLI::Command
|
|
6
|
+
desc 'Launch interactive issue design session'
|
|
7
|
+
|
|
8
|
+
argument :description, type: :string, required: false, desc: 'Rough description of what to build'
|
|
9
|
+
|
|
10
|
+
def call(description: nil, **)
|
|
11
|
+
skill_path = File.join(Dir.pwd, '.claude', 'skills', 'design', 'SKILL.md')
|
|
12
|
+
|
|
13
|
+
unless File.exist?(skill_path)
|
|
14
|
+
warn 'No design skill found. Run `ocak init` first.'
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
puts 'Starting interactive design session...'
|
|
19
|
+
puts 'This will open Claude Code with the /design skill.'
|
|
20
|
+
puts ''
|
|
21
|
+
|
|
22
|
+
if description
|
|
23
|
+
exec('claude', '--skill', skill_path, '--', description)
|
|
24
|
+
else
|
|
25
|
+
puts 'Run this inside Claude Code:'
|
|
26
|
+
puts ' /design <description of what you want to build>'
|
|
27
|
+
puts ''
|
|
28
|
+
puts 'Or provide a description directly:'
|
|
29
|
+
puts ' ocak design "add user authentication with OAuth"'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative '../stack_detector'
|
|
6
|
+
require_relative '../agent_generator'
|
|
7
|
+
require_relative '../config'
|
|
8
|
+
|
|
9
|
+
module Ocak
|
|
10
|
+
module Commands
|
|
11
|
+
class Init < Dry::CLI::Command
|
|
12
|
+
desc 'Initialize Ocak pipeline in the current project'
|
|
13
|
+
|
|
14
|
+
option :force, type: :boolean, default: false, desc: 'Overwrite existing configuration'
|
|
15
|
+
option :no_ai, type: :boolean, default: false, desc: 'Skip AI-powered agent customization'
|
|
16
|
+
option :config_only, type: :boolean, default: false, desc: 'Only generate config, hooks, and settings'
|
|
17
|
+
option :skip_agents, type: :boolean, default: false, desc: 'Skip agent generation'
|
|
18
|
+
option :skip_skills, type: :boolean, default: false, desc: 'Skip skill generation'
|
|
19
|
+
|
|
20
|
+
def call(**options)
|
|
21
|
+
project_dir = Dir.pwd
|
|
22
|
+
|
|
23
|
+
if File.exist?(File.join(project_dir, 'ocak.yml')) && !options[:force]
|
|
24
|
+
puts 'ocak.yml already exists. Use --force to overwrite.'
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
puts 'Detecting project stack...'
|
|
29
|
+
stack = StackDetector.new(project_dir).detect
|
|
30
|
+
print_stack(stack)
|
|
31
|
+
|
|
32
|
+
generator = AgentGenerator.new(
|
|
33
|
+
stack: stack, project_dir: project_dir, use_ai: !options[:no_ai], logger: init_logger
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
generate_files(generator, project_dir, options)
|
|
37
|
+
update_settings(project_dir, stack)
|
|
38
|
+
update_gitignore(project_dir)
|
|
39
|
+
|
|
40
|
+
puts ''
|
|
41
|
+
print_summary(project_dir, stack, options)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def generate_files(generator, project_dir, options)
|
|
47
|
+
generator.generate_config(File.join(project_dir, 'ocak.yml'))
|
|
48
|
+
generate_agents(generator, project_dir, options)
|
|
49
|
+
generate_skills(generator, project_dir, options)
|
|
50
|
+
generator.generate_hooks(File.join(project_dir, '.claude', 'hooks'))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def generate_agents(generator, project_dir, options)
|
|
54
|
+
return if options[:config_only] || options[:skip_agents]
|
|
55
|
+
|
|
56
|
+
agents_dir = File.join(project_dir, '.claude', 'agents')
|
|
57
|
+
if agents_exist?(agents_dir) && !options[:force]
|
|
58
|
+
puts ' Existing agents found in .claude/agents/ — skipping (use --force to overwrite)'
|
|
59
|
+
else
|
|
60
|
+
generator.generate_agents(agents_dir)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def generate_skills(generator, project_dir, options)
|
|
65
|
+
return if options[:config_only] || options[:skip_skills]
|
|
66
|
+
|
|
67
|
+
generator.generate_skills(File.join(project_dir, '.claude', 'skills'))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def agents_exist?(agents_dir)
|
|
71
|
+
Dir.exist?(agents_dir) && Dir.glob(File.join(agents_dir, '*.md')).any?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def print_stack(stack)
|
|
75
|
+
puts " Language: #{stack.language}"
|
|
76
|
+
puts " Framework: #{stack.framework || 'none detected'}"
|
|
77
|
+
puts " Tests: #{stack.test_command || 'none detected'}"
|
|
78
|
+
puts " Lint: #{stack.lint_command || 'none detected'}"
|
|
79
|
+
puts " Security: #{stack.security_commands.empty? ? 'none detected' : stack.security_commands.join(', ')}"
|
|
80
|
+
puts " Monorepo: yes (#{stack.packages.size} packages)" if stack.respond_to?(:monorepo) && stack.monorepo
|
|
81
|
+
puts ''
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def update_settings(project_dir, stack)
|
|
85
|
+
settings_path = File.join(project_dir, '.claude', 'settings.json')
|
|
86
|
+
existing = File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
|
|
87
|
+
|
|
88
|
+
merge_permissions(existing, stack)
|
|
89
|
+
merge_hooks(existing)
|
|
90
|
+
|
|
91
|
+
FileUtils.mkdir_p(File.dirname(settings_path))
|
|
92
|
+
File.write(settings_path, JSON.pretty_generate(existing))
|
|
93
|
+
puts ' Updated .claude/settings.json'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def merge_permissions(settings, stack)
|
|
97
|
+
settings['permissions'] ||= {}
|
|
98
|
+
settings['permissions']['allow'] ||= []
|
|
99
|
+
allowed = settings['permissions']['allow']
|
|
100
|
+
|
|
101
|
+
build_permissions(stack).each do |perm|
|
|
102
|
+
allowed << perm unless allowed.include?(perm)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def merge_hooks(settings)
|
|
107
|
+
settings['hooks'] ||= {}
|
|
108
|
+
settings['hooks']['PostToolUse'] ||= []
|
|
109
|
+
settings['hooks']['TaskCompleted'] ||= []
|
|
110
|
+
|
|
111
|
+
add_hook_unless_exists(settings['hooks']['PostToolUse'], 'post-edit-lint',
|
|
112
|
+
'matcher' => 'Edit|Write',
|
|
113
|
+
'hooks' => [{ 'type' => 'command', 'command' => '.claude/hooks/post-edit-lint.sh' }])
|
|
114
|
+
|
|
115
|
+
add_hook_unless_exists(settings['hooks']['TaskCompleted'], 'task-completed-test',
|
|
116
|
+
'hooks' => [{ 'type' => 'command',
|
|
117
|
+
'command' => '.claude/hooks/task-completed-test.sh' }])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_hook_unless_exists(hooks_list, hook_name, hook_entry)
|
|
121
|
+
return if hooks_list.any? { |h| h.dig('hooks', 0, 'command')&.include?(hook_name) }
|
|
122
|
+
|
|
123
|
+
hooks_list << hook_entry
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_permissions(stack)
|
|
127
|
+
perms = language_permissions(stack)
|
|
128
|
+
# Always allow gh CLI for pipeline operations
|
|
129
|
+
perms.push('Bash(gh issue*)', 'Bash(gh pr*)', 'Bash(gh label*)')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def language_permissions(stack)
|
|
133
|
+
case stack.language
|
|
134
|
+
when 'ruby'
|
|
135
|
+
ruby_permissions(stack)
|
|
136
|
+
when 'typescript', 'javascript'
|
|
137
|
+
['Bash(npm test*)', 'Bash(npx biome*)', 'Bash(npx eslint*)', 'Bash(npm audit*)', 'Bash(npm run typecheck*)']
|
|
138
|
+
when 'python'
|
|
139
|
+
['Bash(pytest*)', 'Bash(python -m pytest*)', 'Bash(ruff*)', 'Bash(flake8*)']
|
|
140
|
+
when 'rust'
|
|
141
|
+
['Bash(cargo test*)', 'Bash(cargo clippy*)', 'Bash(cargo fmt*)']
|
|
142
|
+
when 'go'
|
|
143
|
+
['Bash(go test*)', 'Bash(golangci-lint*)']
|
|
144
|
+
else
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ruby_permissions(stack)
|
|
150
|
+
perms = ['Bash(bundle exec rubocop*)', 'Bash(bundle exec rspec*)', 'Bash(bundle exec rake test*)']
|
|
151
|
+
perms << 'Bash(bundle exec brakeman*)' if stack.security_commands.any? { |c| c.include?('brakeman') }
|
|
152
|
+
perms << 'Bash(bundle exec bundler-audit*)' if stack.security_commands.any? { |c| c.include?('bundler-audit') }
|
|
153
|
+
perms
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def update_gitignore(project_dir)
|
|
157
|
+
gitignore_path = File.join(project_dir, '.gitignore')
|
|
158
|
+
additions_path = File.join(Ocak.templates_dir, 'gitignore_additions.txt')
|
|
159
|
+
additions = File.read(additions_path)
|
|
160
|
+
|
|
161
|
+
existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ''
|
|
162
|
+
|
|
163
|
+
new_lines = additions.lines.reject do |line|
|
|
164
|
+
line.strip.empty? || line.start_with?('#') || existing.include?(line.strip)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return if new_lines.empty?
|
|
168
|
+
|
|
169
|
+
File.open(gitignore_path, 'a') do |f|
|
|
170
|
+
f.puts '' unless existing.end_with?("\n\n")
|
|
171
|
+
f.puts '# Ocak / Claude Code'
|
|
172
|
+
new_lines.each { |line| f.puts line }
|
|
173
|
+
end
|
|
174
|
+
puts ' Updated .gitignore'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def print_summary(_project_dir, _stack, options)
|
|
178
|
+
puts 'Ocak initialized successfully!'
|
|
179
|
+
puts ''
|
|
180
|
+
puts 'Created:'
|
|
181
|
+
puts ' ocak.yml — pipeline configuration'
|
|
182
|
+
unless options[:config_only]
|
|
183
|
+
puts ' .claude/agents/ — 8 pipeline agents' unless options[:skip_agents]
|
|
184
|
+
puts ' .claude/skills/ — 4 interactive skills' unless options[:skip_skills]
|
|
185
|
+
end
|
|
186
|
+
puts ' .claude/hooks/ — lint + test hooks'
|
|
187
|
+
puts ' .claude/settings.json — permissions & hooks config'
|
|
188
|
+
puts ''
|
|
189
|
+
puts 'Next steps:'
|
|
190
|
+
puts ' 1. Review ocak.yml and adjust settings'
|
|
191
|
+
puts ' 2. Review .claude/agents/ and customize if needed'
|
|
192
|
+
puts ' 3. Create issues with: claude then /design'
|
|
193
|
+
puts " 4. Label issues 'auto-ready'"
|
|
194
|
+
puts ' 5. Run the pipeline: ocak run --once'
|
|
195
|
+
puts ''
|
|
196
|
+
puts 'Quick commands:'
|
|
197
|
+
puts ' ocak run --single 42 Run one issue'
|
|
198
|
+
puts ' ocak run --watch Run with live output'
|
|
199
|
+
puts ' ocak status Check pipeline state'
|
|
200
|
+
puts ' ocak audit Run codebase audit'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def init_logger
|
|
204
|
+
@init_logger ||= Object.new.tap do |l|
|
|
205
|
+
def l.info(msg)
|
|
206
|
+
puts " #{msg}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../config'
|
|
4
|
+
require_relative '../pipeline_runner'
|
|
5
|
+
require_relative '../pipeline_state'
|
|
6
|
+
require_relative '../claude_runner'
|
|
7
|
+
require_relative '../issue_fetcher'
|
|
8
|
+
require_relative '../worktree_manager'
|
|
9
|
+
require_relative '../merge_manager'
|
|
10
|
+
require_relative '../logger'
|
|
11
|
+
|
|
12
|
+
module Ocak
|
|
13
|
+
module Commands
|
|
14
|
+
class Resume < Dry::CLI::Command
|
|
15
|
+
desc 'Resume a failed pipeline from the last successful step'
|
|
16
|
+
|
|
17
|
+
argument :issue, type: :integer, required: true, desc: 'Issue number to resume'
|
|
18
|
+
option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
|
|
19
|
+
|
|
20
|
+
def call(issue:, **options)
|
|
21
|
+
config = Config.load
|
|
22
|
+
issue_number = issue.to_i
|
|
23
|
+
saved = load_state(config, issue_number)
|
|
24
|
+
chdir = resolve_worktree(config, saved)
|
|
25
|
+
|
|
26
|
+
print_resume_info(issue_number, saved, config)
|
|
27
|
+
run_resumed_pipeline(config, issue_number, saved, chdir, options)
|
|
28
|
+
rescue Config::ConfigNotFound => e
|
|
29
|
+
warn "Error: #{e.message}"
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_state(config, issue_number)
|
|
36
|
+
log_dir = File.join(config.project_dir, config.log_dir)
|
|
37
|
+
state = PipelineState.new(log_dir: log_dir)
|
|
38
|
+
saved = state.load(issue_number)
|
|
39
|
+
unless saved
|
|
40
|
+
warn "No saved state for issue ##{issue_number}. Nothing to resume."
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
saved
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def print_resume_info(issue_number, saved, config)
|
|
47
|
+
puts "Resuming issue ##{issue_number}"
|
|
48
|
+
puts " Completed steps: #{saved[:completed_steps].size}/#{config.steps.size}"
|
|
49
|
+
puts " Worktree: #{saved[:worktree_path]}"
|
|
50
|
+
puts " Branch: #{saved[:branch]}"
|
|
51
|
+
puts ''
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_resumed_pipeline(config, issue_number, saved, chdir, options)
|
|
55
|
+
log_dir = File.join(config.project_dir, config.log_dir)
|
|
56
|
+
logger = PipelineLogger.new(log_dir: log_dir, issue_number: issue_number)
|
|
57
|
+
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
58
|
+
claude = ClaudeRunner.new(config: config, logger: logger, watch: watch_formatter)
|
|
59
|
+
issues = IssueFetcher.new(config: config, logger: logger)
|
|
60
|
+
|
|
61
|
+
issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
|
|
62
|
+
|
|
63
|
+
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])
|
|
67
|
+
|
|
68
|
+
ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
|
|
69
|
+
issues: issues, claude: claude, logger: logger, watch: watch_formatter }
|
|
70
|
+
handle_result(result, ctx)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle_result(result, ctx)
|
|
74
|
+
if result[:success]
|
|
75
|
+
attempt_merge(ctx)
|
|
76
|
+
else
|
|
77
|
+
ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
|
|
78
|
+
to: ctx[:config].label_failed)
|
|
79
|
+
ctx[:issues].comment(ctx[:issue_number],
|
|
80
|
+
"Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
|
|
81
|
+
warn "Issue ##{ctx[:issue_number]} failed again at phase: #{result[:phase]}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def attempt_merge(ctx)
|
|
86
|
+
merger = MergeManager.new(config: ctx[:config], claude: ctx[:claude],
|
|
87
|
+
logger: ctx[:logger], watch: ctx[:watch])
|
|
88
|
+
worktree = WorktreeManager::Worktree.new(
|
|
89
|
+
path: ctx[:chdir], branch: ctx[:saved][:branch], issue_number: ctx[:issue_number]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if merger.merge(ctx[:issue_number], worktree)
|
|
93
|
+
ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
|
|
94
|
+
to: ctx[:config].label_completed)
|
|
95
|
+
puts "Issue ##{ctx[:issue_number]} resumed and merged successfully!"
|
|
96
|
+
else
|
|
97
|
+
ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
|
|
98
|
+
to: ctx[:config].label_failed)
|
|
99
|
+
warn "Issue ##{ctx[:issue_number]} merge failed after resume"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve_worktree(config, saved)
|
|
104
|
+
return saved[:worktree_path] if saved[:worktree_path] && Dir.exist?(saved[:worktree_path])
|
|
105
|
+
|
|
106
|
+
recreate_from_branch(config, saved)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def recreate_from_branch(config, saved)
|
|
110
|
+
unless saved[:branch]
|
|
111
|
+
warn 'Worktree no longer exists and no branch saved. Cannot resume.'
|
|
112
|
+
exit 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
_, _, status = Open3.capture3('git', 'rev-parse', '--verify', saved[:branch], chdir: config.project_dir)
|
|
116
|
+
unless status.success?
|
|
117
|
+
warn "Worktree no longer exists and branch '#{saved[:branch]}' not found. Cannot resume."
|
|
118
|
+
exit 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
worktrees = WorktreeManager.new(config: config)
|
|
122
|
+
wt = worktrees.create(saved[:issue_number], setup_command: config.setup_command)
|
|
123
|
+
Open3.capture3('git', 'checkout', saved[:branch], chdir: wt.path)
|
|
124
|
+
wt.path
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../config'
|
|
4
|
+
require_relative '../pipeline_runner'
|
|
5
|
+
require_relative '../claude_runner'
|
|
6
|
+
require_relative '../issue_fetcher'
|
|
7
|
+
require_relative '../worktree_manager'
|
|
8
|
+
require_relative '../merge_manager'
|
|
9
|
+
require_relative '../logger'
|
|
10
|
+
|
|
11
|
+
module Ocak
|
|
12
|
+
module Commands
|
|
13
|
+
class Run < Dry::CLI::Command
|
|
14
|
+
desc 'Run the issue processing pipeline'
|
|
15
|
+
|
|
16
|
+
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
|
+
option :dry_run, type: :boolean, default: false, desc: 'Show what would happen'
|
|
19
|
+
option :once, type: :boolean, default: false, desc: 'Process current batch and exit'
|
|
20
|
+
option :max_parallel, type: :integer, desc: 'Max concurrent pipelines'
|
|
21
|
+
option :poll_interval, type: :integer, desc: 'Seconds between polls'
|
|
22
|
+
|
|
23
|
+
def call(**options)
|
|
24
|
+
config = Config.load
|
|
25
|
+
|
|
26
|
+
# CLI options override config
|
|
27
|
+
config.override(:max_parallel, options[:max_parallel]) if options[:max_parallel]
|
|
28
|
+
config.override(:poll_interval, options[:poll_interval]) if options[:poll_interval]
|
|
29
|
+
|
|
30
|
+
runner = PipelineRunner.new(
|
|
31
|
+
config: config,
|
|
32
|
+
options: {
|
|
33
|
+
watch: options[:watch],
|
|
34
|
+
single: options[:single],
|
|
35
|
+
dry_run: options[:dry_run],
|
|
36
|
+
once: options[:once]
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
setup_signal_handlers(runner)
|
|
41
|
+
runner.run
|
|
42
|
+
rescue Config::ConfigNotFound => e
|
|
43
|
+
warn "Error: #{e.message}"
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def setup_signal_handlers(runner)
|
|
50
|
+
%w[INT TERM].each do |signal|
|
|
51
|
+
trap(signal) do
|
|
52
|
+
warn "\nReceived #{signal}, shutting down gracefully..."
|
|
53
|
+
runner.shutdown!
|
|
54
|
+
exit 0
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative '../config'
|
|
6
|
+
require_relative '../worktree_manager'
|
|
7
|
+
|
|
8
|
+
module Ocak
|
|
9
|
+
module Commands
|
|
10
|
+
class Status < Dry::CLI::Command
|
|
11
|
+
desc 'Show pipeline status'
|
|
12
|
+
|
|
13
|
+
def call(**)
|
|
14
|
+
config = Config.load
|
|
15
|
+
|
|
16
|
+
puts 'Pipeline Status'
|
|
17
|
+
puts '=' * 40
|
|
18
|
+
puts ''
|
|
19
|
+
|
|
20
|
+
show_issues(config)
|
|
21
|
+
puts ''
|
|
22
|
+
show_worktrees(config)
|
|
23
|
+
puts ''
|
|
24
|
+
show_recent_logs(config)
|
|
25
|
+
rescue Config::ConfigNotFound => e
|
|
26
|
+
warn "Error: #{e.message}"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def show_issues(config)
|
|
33
|
+
puts 'Issues:'
|
|
34
|
+
|
|
35
|
+
%w[ready in_progress completed failed].each do |state|
|
|
36
|
+
label = config.send(:"label_#{state}")
|
|
37
|
+
count = fetch_issue_count(label, config)
|
|
38
|
+
icon = { 'ready' => ' ', 'in_progress' => ' ', 'completed' => ' ', 'failed' => ' ' }[state]
|
|
39
|
+
puts " #{icon} #{state.tr('_', ' ')}: #{count} (label: #{label})"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def show_worktrees(config)
|
|
44
|
+
puts 'Worktrees:'
|
|
45
|
+
manager = WorktreeManager.new(config: config)
|
|
46
|
+
worktrees = manager.list
|
|
47
|
+
|
|
48
|
+
pipeline_wts = worktrees.select { |wt| wt[:branch]&.start_with?('auto/') }
|
|
49
|
+
if pipeline_wts.empty?
|
|
50
|
+
puts ' No active pipeline worktrees'
|
|
51
|
+
else
|
|
52
|
+
pipeline_wts.each do |wt|
|
|
53
|
+
puts " #{wt[:branch]} -> #{wt[:path]}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def show_recent_logs(config)
|
|
59
|
+
log_dir = File.join(config.project_dir, config.log_dir)
|
|
60
|
+
return unless Dir.exist?(log_dir)
|
|
61
|
+
|
|
62
|
+
puts 'Recent logs:'
|
|
63
|
+
logs = Dir.glob(File.join(log_dir, '*.log')).last(5)
|
|
64
|
+
if logs.empty?
|
|
65
|
+
puts ' No logs yet'
|
|
66
|
+
else
|
|
67
|
+
logs.reverse_each do |log|
|
|
68
|
+
name = File.basename(log)
|
|
69
|
+
size = File.size(log)
|
|
70
|
+
puts " #{name} (#{format_size(size)})"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch_issue_count(label, config)
|
|
76
|
+
stdout, _, status = Open3.capture3(
|
|
77
|
+
'gh', 'issue', 'list',
|
|
78
|
+
'--label', label,
|
|
79
|
+
'--state', 'open',
|
|
80
|
+
'--json', 'number',
|
|
81
|
+
'--limit', '100',
|
|
82
|
+
chdir: config.project_dir
|
|
83
|
+
)
|
|
84
|
+
return 0 unless status.success?
|
|
85
|
+
|
|
86
|
+
JSON.parse(stdout).size
|
|
87
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
88
|
+
0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def format_size(bytes)
|
|
92
|
+
if bytes < 1024
|
|
93
|
+
"#{bytes}B"
|
|
94
|
+
elsif bytes < 1024 * 1024
|
|
95
|
+
"#{(bytes / 1024.0).round(1)}KB"
|
|
96
|
+
else
|
|
97
|
+
"#{(bytes / (1024.0 * 1024)).round(1)}MB"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|