ocak 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +27 -0
- data/lib/ocak/agent_generator.rb +2 -1
- data/lib/ocak/batch_processing.rb +102 -0
- data/lib/ocak/claude_runner.rb +12 -8
- data/lib/ocak/cli.rb +13 -0
- data/lib/ocak/command_runner.rb +39 -0
- data/lib/ocak/commands/hiz.rb +8 -7
- data/lib/ocak/commands/init.rb +5 -0
- data/lib/ocak/commands/issue/close.rb +37 -0
- data/lib/ocak/commands/issue/create.rb +59 -0
- data/lib/ocak/commands/issue/edit.rb +31 -0
- data/lib/ocak/commands/issue/list.rb +43 -0
- data/lib/ocak/commands/issue/view.rb +58 -0
- data/lib/ocak/commands/resume.rb +4 -3
- data/lib/ocak/commands/status.rb +20 -0
- data/lib/ocak/config.rb +4 -0
- data/lib/ocak/failure_reporting.rb +3 -2
- data/lib/ocak/git_utils.rb +18 -11
- data/lib/ocak/instance_builders.rb +50 -0
- data/lib/ocak/issue_backend.rb +31 -0
- data/lib/ocak/local_issue_fetcher.rb +165 -0
- data/lib/ocak/local_merge_manager.rb +104 -0
- data/lib/ocak/merge_manager.rb +31 -32
- data/lib/ocak/merge_orchestration.rb +8 -2
- data/lib/ocak/parallel_execution.rb +36 -0
- data/lib/ocak/pipeline_executor.rb +9 -183
- data/lib/ocak/pipeline_runner.rb +15 -180
- data/lib/ocak/planner.rb +1 -1
- data/lib/ocak/project_key.rb +38 -0
- data/lib/ocak/reready_processor.rb +11 -11
- data/lib/ocak/run_report.rb +5 -2
- data/lib/ocak/shutdown_handling.rb +67 -0
- data/lib/ocak/state_management.rb +104 -0
- data/lib/ocak/step_execution.rb +66 -0
- data/lib/ocak/templates/agents/auditor.md.erb +38 -9
- data/lib/ocak/templates/agents/implementer.md.erb +32 -8
- data/lib/ocak/templates/agents/merger.md.erb +12 -5
- data/lib/ocak/templates/agents/pipeline.md.erb +4 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
- data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
- data/lib/ocak/templates/ocak.yml.erb +15 -0
- data/lib/ocak/verification.rb +6 -1
- data/lib/ocak/worktree_manager.rb +2 -0
- data/lib/ocak.rb +1 -1
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb7c3ddd6ff9b6dacc60c4ee6f3fabae90089eace4e457a28e824be15a901cda
|
|
4
|
+
data.tar.gz: c9b5a76e36361dddb4731fff6298388f0246421272923951f83fdcebde9e5d4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f37e6ad101953a378c73a1c76817e194386424174a87841d9f13321a4b465c5708ecb374f0526d2e2744bbcd19bd2473727ea288205dd731325ba80164fcec8
|
|
7
|
+
data.tar.gz: 18301a7948e7b979f7ee0daecd75369477ddbcd147d12d67305fdc55a7aeab44e371bd41859135f5c8105d5886ff49663f77cc61afaad9b9872a13581bac15f1
|
data/README.md
CHANGED
|
@@ -173,6 +173,19 @@ When `--manual-review` is enabled, PRs sit open for human review. After leaving
|
|
|
173
173
|
4. Remove the `auto-reready` label
|
|
174
174
|
5. Comment "Feedback addressed. Please re-review."
|
|
175
175
|
|
|
176
|
+
### Local Issues (Offline Mode)
|
|
177
|
+
|
|
178
|
+
Ocak can run without GitHub. Set `issues.backend: local` in `ocak.yml` (or just create `.ocak/issues/` and it auto-detects). Issues are stored as numbered markdown files with YAML frontmatter.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
ocak issue create "Add retry logic to API client" --label auto-ready
|
|
182
|
+
ocak issue list
|
|
183
|
+
ocak issue view 1
|
|
184
|
+
ocak run 1 --watch
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Issues live in `.ocak/issues/0001.md`, `.ocak/issues/0002.md`, etc. Labels, complexity, and pipeline comments are all tracked in the file. Merging goes straight to main (no PRs) via `LocalMergeManager`.
|
|
188
|
+
|
|
176
189
|
### Graceful Shutdown
|
|
177
190
|
|
|
178
191
|
`Ctrl+C` once — current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
|
|
@@ -199,6 +212,10 @@ stack:
|
|
|
199
212
|
- "bundle exec brakeman -q"
|
|
200
213
|
- "bundle exec bundler-audit check"
|
|
201
214
|
|
|
215
|
+
# Issue backend (omit for GitHub, or set to "local" for offline mode)
|
|
216
|
+
issues:
|
|
217
|
+
backend: github # or "local" — auto-detected if .ocak/issues/ exists
|
|
218
|
+
|
|
202
219
|
# Pipeline settings
|
|
203
220
|
pipeline:
|
|
204
221
|
max_parallel: 5
|
|
@@ -398,6 +415,16 @@ ocak clean Remove stale worktrees
|
|
|
398
415
|
--all Clean worktrees and logs
|
|
399
416
|
--keep N Only remove artifacts older than N days
|
|
400
417
|
|
|
418
|
+
ocak issue create TITLE [options] Create a local issue
|
|
419
|
+
--body TEXT Issue body (opens $EDITOR if omitted)
|
|
420
|
+
--label LABEL Add label (repeatable)
|
|
421
|
+
--complexity full|simple Set complexity (default: full)
|
|
422
|
+
|
|
423
|
+
ocak issue list [--label LABEL] List local issues
|
|
424
|
+
ocak issue view ISSUE View a local issue
|
|
425
|
+
ocak issue edit ISSUE Open issue in $EDITOR
|
|
426
|
+
ocak issue close ISSUE Mark issue as completed
|
|
427
|
+
|
|
401
428
|
ocak design [DESCRIPTION] Launch interactive issue design session
|
|
402
429
|
ocak audit [SCOPE] Print instructions for /audit skill
|
|
403
430
|
ocak debt Print instructions for /debt skill
|
data/lib/ocak/agent_generator.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'erb'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'open3'
|
|
6
|
+
require_relative 'claude_runner'
|
|
6
7
|
|
|
7
8
|
module Ocak
|
|
8
9
|
class AgentGenerator
|
|
@@ -161,7 +162,7 @@ module Ocak
|
|
|
161
162
|
stdout, _, status = Open3.capture3(
|
|
162
163
|
'claude', '-p',
|
|
163
164
|
'--output-format', 'text',
|
|
164
|
-
'--model',
|
|
165
|
+
'--model', ClaudeRunner::MODEL_HAIKU,
|
|
165
166
|
'--allowedTools', 'Read,Glob,Grep',
|
|
166
167
|
'--', prompt,
|
|
167
168
|
chdir: @project_dir
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
# Batch processing logic — process_issues, run_batch, process_one_issue, build_issue_result.
|
|
5
|
+
# Extracted from PipelineRunner to reduce file size.
|
|
6
|
+
module BatchProcessing
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def process_issues(ready_issues, logger:, issues:)
|
|
10
|
+
if ready_issues.size > @config.max_issues_per_run
|
|
11
|
+
logger.warn("Capping to #{@config.max_issues_per_run} issues (found #{ready_issues.size})")
|
|
12
|
+
ready_issues = ready_issues.first(@config.max_issues_per_run)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
claude = build_claude(logger)
|
|
16
|
+
batches = @executor.plan_batches(ready_issues, logger: logger, claude: claude)
|
|
17
|
+
|
|
18
|
+
batches.each_with_index do |batch, idx|
|
|
19
|
+
batch_issues = batch['issues'][0...@config.max_parallel]
|
|
20
|
+
logger.info("Running batch #{idx + 1}/#{batches.size} (#{batch_issues.size} issues)")
|
|
21
|
+
|
|
22
|
+
if @options[:dry_run]
|
|
23
|
+
batch_issues.each { |i| logger.info("[DRY RUN] Would process issue ##{i['number']}: #{i['title']}") }
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
run_batch(batch_issues, logger: logger, issues: issues)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_batch(batch_issues, logger:, issues:)
|
|
32
|
+
worktrees = WorktreeManager.new(config: @config, logger: logger)
|
|
33
|
+
|
|
34
|
+
threads = batch_issues.map do |issue|
|
|
35
|
+
Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
|
|
36
|
+
end
|
|
37
|
+
results = threads.map(&:value)
|
|
38
|
+
|
|
39
|
+
unless @shutting_down
|
|
40
|
+
merger = build_merge_manager(logger: logger, issues: issues)
|
|
41
|
+
results.select { |r| r[:success] }.each do |result|
|
|
42
|
+
merge_completed_issue(result, merger: merger, issues: issues, logger: logger)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
results.each do |result|
|
|
47
|
+
next unless result[:worktree]
|
|
48
|
+
next if result[:interrupted]
|
|
49
|
+
|
|
50
|
+
worktrees.remove(result[:worktree])
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
|
|
56
|
+
raise programming_error if programming_error
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def process_one_issue(issue, worktrees:, issues:)
|
|
60
|
+
issue_number = issue['number']
|
|
61
|
+
logger = build_logger(issue_number: issue_number)
|
|
62
|
+
claude = build_claude(logger)
|
|
63
|
+
worktree = nil
|
|
64
|
+
|
|
65
|
+
@active_mutex.synchronize do
|
|
66
|
+
@active_issues << issue_number
|
|
67
|
+
end
|
|
68
|
+
issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
|
|
69
|
+
worktree = worktrees.create(issue_number, setup_command: @config.setup_command)
|
|
70
|
+
logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
|
|
71
|
+
|
|
72
|
+
complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
|
|
73
|
+
result = run_pipeline(issue_number, logger: logger, claude: claude, chdir: worktree.path,
|
|
74
|
+
complexity: complexity, skip_merge: true)
|
|
75
|
+
|
|
76
|
+
build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
|
|
77
|
+
logger: logger)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
handle_process_error(e, issue_number: issue_number, logger: logger, issues: issues)
|
|
80
|
+
result = { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
|
|
81
|
+
# NameError includes NoMethodError
|
|
82
|
+
result[:programming_error] = e if e.is_a?(NameError) || e.is_a?(TypeError)
|
|
83
|
+
result
|
|
84
|
+
ensure
|
|
85
|
+
@active_mutex.synchronize { @active_issues.delete(issue_number) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_issue_result(result, issue_number:, worktree:, issues:, logger: nil)
|
|
89
|
+
if result[:interrupted]
|
|
90
|
+
handle_interrupted_issue(issue_number, worktree&.path, result[:phase],
|
|
91
|
+
logger: logger || build_logger(issue_number: issue_number), issues: issues)
|
|
92
|
+
{ issue_number: issue_number, success: false, worktree: worktree, interrupted: true }
|
|
93
|
+
elsif result[:success]
|
|
94
|
+
{ issue_number: issue_number, success: true, worktree: worktree,
|
|
95
|
+
audit_blocked: result[:audit_blocked], audit_output: result[:audit_output] }
|
|
96
|
+
else
|
|
97
|
+
report_pipeline_failure(issue_number, result, issues: issues, config: @config, logger: logger)
|
|
98
|
+
{ issue_number: issue_number, success: false, worktree: worktree }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/ocak/claude_runner.rb
CHANGED
|
@@ -28,15 +28,19 @@ module Ocak
|
|
|
28
28
|
'planner' => 'Read,Glob,Grep,Bash'
|
|
29
29
|
}.freeze
|
|
30
30
|
|
|
31
|
+
MODEL_HAIKU = ENV.fetch('OCAK_MODEL_HAIKU', 'haiku')
|
|
32
|
+
MODEL_SONNET = ENV.fetch('OCAK_MODEL_SONNET', 'sonnet')
|
|
33
|
+
MODEL_OPUS = ENV.fetch('OCAK_MODEL_OPUS', 'opus')
|
|
34
|
+
|
|
31
35
|
AGENT_MODELS = {
|
|
32
|
-
'planner' =>
|
|
33
|
-
'reviewer' =>
|
|
34
|
-
'security-reviewer' =>
|
|
35
|
-
'auditor' =>
|
|
36
|
-
'documenter' =>
|
|
37
|
-
'merger' =>
|
|
38
|
-
'implementer' =>
|
|
39
|
-
'pipeline' =>
|
|
36
|
+
'planner' => MODEL_SONNET,
|
|
37
|
+
'reviewer' => MODEL_OPUS,
|
|
38
|
+
'security-reviewer' => MODEL_SONNET,
|
|
39
|
+
'auditor' => MODEL_SONNET,
|
|
40
|
+
'documenter' => MODEL_SONNET,
|
|
41
|
+
'merger' => MODEL_SONNET,
|
|
42
|
+
'implementer' => MODEL_SONNET,
|
|
43
|
+
'pipeline' => MODEL_OPUS
|
|
40
44
|
}.freeze
|
|
41
45
|
|
|
42
46
|
TIMEOUT = 600 # 10 minutes per agent invocation
|
data/lib/ocak/cli.rb
CHANGED
|
@@ -10,6 +10,11 @@ require_relative 'commands/status'
|
|
|
10
10
|
require_relative 'commands/clean'
|
|
11
11
|
require_relative 'commands/resume'
|
|
12
12
|
require_relative 'commands/hiz'
|
|
13
|
+
require_relative 'commands/issue/create'
|
|
14
|
+
require_relative 'commands/issue/list'
|
|
15
|
+
require_relative 'commands/issue/view'
|
|
16
|
+
require_relative 'commands/issue/edit'
|
|
17
|
+
require_relative 'commands/issue/close'
|
|
13
18
|
|
|
14
19
|
module Ocak
|
|
15
20
|
module CLI
|
|
@@ -25,6 +30,14 @@ module Ocak
|
|
|
25
30
|
register 'clean', Ocak::Commands::Clean
|
|
26
31
|
register 'resume', Ocak::Commands::Resume
|
|
27
32
|
register 'hiz', Ocak::Commands::Hiz
|
|
33
|
+
|
|
34
|
+
register 'issue' do |prefix|
|
|
35
|
+
prefix.register 'create', Ocak::Commands::Issue::Create
|
|
36
|
+
prefix.register 'list', Ocak::Commands::Issue::List
|
|
37
|
+
prefix.register 'view', Ocak::Commands::Issue::View
|
|
38
|
+
prefix.register 'edit', Ocak::Commands::Issue::Edit
|
|
39
|
+
prefix.register 'close', Ocak::Commands::Issue::Close
|
|
40
|
+
end
|
|
28
41
|
end
|
|
29
42
|
end
|
|
30
43
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
module CommandRunner
|
|
7
|
+
CommandResult = Struct.new(:stdout, :stderr, :status) do
|
|
8
|
+
def success?
|
|
9
|
+
status&.success? == true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def output
|
|
13
|
+
stdout.strip
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def error
|
|
17
|
+
stderr[0...500]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def run_git(*, chdir: nil)
|
|
24
|
+
run_command('git', *, chdir: chdir)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_gh(*, chdir: nil)
|
|
28
|
+
run_command('gh', *, chdir: chdir)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_command(*, chdir: nil)
|
|
32
|
+
opts = chdir ? { chdir: chdir } : {}
|
|
33
|
+
stdout, stderr, status = Open3.capture3(*, **opts)
|
|
34
|
+
CommandResult.new(stdout, stderr, status)
|
|
35
|
+
rescue Errno::ENOENT => e
|
|
36
|
+
CommandResult.new('', e.message, nil)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/ocak/commands/hiz.rb
CHANGED
|
@@ -5,7 +5,7 @@ require 'securerandom'
|
|
|
5
5
|
require_relative '../config'
|
|
6
6
|
require_relative '../claude_runner'
|
|
7
7
|
require_relative '../git_utils'
|
|
8
|
-
require_relative '../
|
|
8
|
+
require_relative '../issue_backend'
|
|
9
9
|
require_relative '../pipeline_executor'
|
|
10
10
|
require_relative '../step_comments'
|
|
11
11
|
require_relative '../logger'
|
|
@@ -24,9 +24,9 @@ module Ocak
|
|
|
24
24
|
option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
|
|
25
25
|
|
|
26
26
|
HIZ_STEPS = [
|
|
27
|
-
{ agent: 'implementer', role: 'implement', model:
|
|
28
|
-
{ agent: 'reviewer', role: 'review', model:
|
|
29
|
-
{ agent: 'security-reviewer', role: 'security', model:
|
|
27
|
+
{ agent: 'implementer', role: 'implement', model: ClaudeRunner::MODEL_SONNET },
|
|
28
|
+
{ agent: 'reviewer', role: 'review', model: ClaudeRunner::MODEL_HAIKU, parallel: true },
|
|
29
|
+
{ agent: 'security-reviewer', role: 'security', model: ClaudeRunner::MODEL_SONNET, parallel: true }
|
|
30
30
|
].freeze
|
|
31
31
|
|
|
32
32
|
HizState = Struct.new(:issues, :total_cost, :steps_run, :review_results)
|
|
@@ -43,7 +43,7 @@ module Ocak
|
|
|
43
43
|
@logger = logger = build_logger(issue_number)
|
|
44
44
|
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
45
45
|
claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
|
|
46
|
-
issues =
|
|
46
|
+
issues = IssueBackend.build(config: @config, logger: logger)
|
|
47
47
|
|
|
48
48
|
logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
|
|
49
49
|
|
|
@@ -81,7 +81,7 @@ module Ocak
|
|
|
81
81
|
executor = PipelineExecutor.new(config: @config, issues: issues)
|
|
82
82
|
result = executor.run_pipeline(
|
|
83
83
|
issue_number, logger: logger, claude: claude, chdir: chdir,
|
|
84
|
-
steps: HIZ_STEPS, verification_model:
|
|
84
|
+
steps: HIZ_STEPS, verification_model: ClaudeRunner::MODEL_SONNET,
|
|
85
85
|
post_start_comment: false, post_summary_comment: false
|
|
86
86
|
)
|
|
87
87
|
|
|
@@ -190,7 +190,8 @@ module Ocak
|
|
|
190
190
|
sanitized = output.to_s[0..1000].gsub('```', "'''")
|
|
191
191
|
issues.comment(issue_number,
|
|
192
192
|
"Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{sanitized}\n```")
|
|
193
|
-
rescue StandardError
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
logger&.debug("Failure comment failed: #{e.message}")
|
|
194
195
|
nil
|
|
195
196
|
end
|
|
196
197
|
warn "Issue ##{issue_number} failed at phase: #{phase}"
|
data/lib/ocak/commands/init.rb
CHANGED
|
@@ -183,6 +183,11 @@ module Ocak
|
|
|
183
183
|
|
|
184
184
|
def create_labels(project_dir)
|
|
185
185
|
config = Config.load(project_dir)
|
|
186
|
+
if config.local_issues?
|
|
187
|
+
puts ' Skipped label creation (local issue backend)'
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
186
191
|
fetcher = IssueFetcher.new(config: config)
|
|
187
192
|
fetcher.ensure_labels(config.all_labels)
|
|
188
193
|
puts ' Created GitHub labels'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../config'
|
|
4
|
+
require_relative '../../local_issue_fetcher'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
module Commands
|
|
8
|
+
module Issue
|
|
9
|
+
class Close < Dry::CLI::Command
|
|
10
|
+
desc 'Close a local issue (sets completed label)'
|
|
11
|
+
|
|
12
|
+
argument :issue, type: :integer, required: true, desc: 'Issue number'
|
|
13
|
+
|
|
14
|
+
def call(issue:, **)
|
|
15
|
+
config = Config.load
|
|
16
|
+
fetcher = LocalIssueFetcher.new(config: config)
|
|
17
|
+
issue_number = issue.to_i
|
|
18
|
+
|
|
19
|
+
data = fetcher.view(issue_number)
|
|
20
|
+
unless data
|
|
21
|
+
warn "Issue ##{issue} not found."
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
fetcher.remove_label(issue_number, config.label_ready)
|
|
26
|
+
fetcher.remove_label(issue_number, config.label_in_progress)
|
|
27
|
+
fetcher.add_label(issue_number, config.label_completed)
|
|
28
|
+
|
|
29
|
+
puts "Closed issue ##{issue_number}: #{data['title']}"
|
|
30
|
+
rescue Config::ConfigNotFound => e
|
|
31
|
+
warn "Error: #{e.message}"
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../config'
|
|
4
|
+
require_relative '../../local_issue_fetcher'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
module Commands
|
|
8
|
+
module Issue
|
|
9
|
+
class Create < Dry::CLI::Command
|
|
10
|
+
desc 'Create a local issue'
|
|
11
|
+
|
|
12
|
+
argument :title, type: :string, required: true, desc: 'Issue title'
|
|
13
|
+
option :body, type: :string, default: '', desc: 'Issue body (opens $EDITOR if omitted)'
|
|
14
|
+
option :label, type: :array, default: [], desc: 'Labels to add (repeatable)'
|
|
15
|
+
option :complexity, type: :string, default: 'full', desc: 'Issue complexity (full or simple)'
|
|
16
|
+
|
|
17
|
+
def call(title:, **options)
|
|
18
|
+
config = Config.load
|
|
19
|
+
fetcher = LocalIssueFetcher.new(config: config)
|
|
20
|
+
|
|
21
|
+
body = options[:body]
|
|
22
|
+
body = read_from_editor(title) if body.empty?
|
|
23
|
+
|
|
24
|
+
number = fetcher.create(
|
|
25
|
+
title: title,
|
|
26
|
+
body: body,
|
|
27
|
+
labels: options[:label],
|
|
28
|
+
complexity: options[:complexity]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
path = File.join('.ocak', 'issues', format('%04d.md', number))
|
|
32
|
+
puts "Created issue ##{number} (#{path})"
|
|
33
|
+
rescue Config::ConfigNotFound => e
|
|
34
|
+
warn "Error: #{e.message}"
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_from_editor(title)
|
|
41
|
+
editor = ENV.fetch('EDITOR', 'vi')
|
|
42
|
+
require 'tempfile'
|
|
43
|
+
file = Tempfile.new(['ocak-issue', '.md'])
|
|
44
|
+
file.write("#{title}\n\n")
|
|
45
|
+
file.close
|
|
46
|
+
|
|
47
|
+
system(editor, file.path)
|
|
48
|
+
content = File.read(file.path)
|
|
49
|
+
# Strip the title line if it's still there
|
|
50
|
+
lines = content.lines
|
|
51
|
+
lines.shift if lines.first&.strip == title
|
|
52
|
+
lines.join.strip
|
|
53
|
+
ensure
|
|
54
|
+
file&.unlink
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../config'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
module Commands
|
|
7
|
+
module Issue
|
|
8
|
+
class Edit < Dry::CLI::Command
|
|
9
|
+
desc 'Edit a local issue in $EDITOR'
|
|
10
|
+
|
|
11
|
+
argument :issue, type: :integer, required: true, desc: 'Issue number'
|
|
12
|
+
|
|
13
|
+
def call(issue:, **)
|
|
14
|
+
config = Config.load
|
|
15
|
+
path = File.join(config.project_dir, '.ocak', 'issues', format('%04d.md', issue.to_i))
|
|
16
|
+
|
|
17
|
+
unless File.exist?(path)
|
|
18
|
+
warn "Issue ##{issue} not found at #{path}"
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
editor = ENV.fetch('EDITOR', 'vi')
|
|
23
|
+
system(editor, path)
|
|
24
|
+
rescue Config::ConfigNotFound => e
|
|
25
|
+
warn "Error: #{e.message}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../config'
|
|
4
|
+
require_relative '../../local_issue_fetcher'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
module Commands
|
|
8
|
+
module Issue
|
|
9
|
+
class List < Dry::CLI::Command
|
|
10
|
+
desc 'List local issues'
|
|
11
|
+
|
|
12
|
+
option :label, type: :string, desc: 'Filter by label'
|
|
13
|
+
|
|
14
|
+
def call(**options)
|
|
15
|
+
config = Config.load
|
|
16
|
+
fetcher = LocalIssueFetcher.new(config: config)
|
|
17
|
+
issues = fetcher.all_issues
|
|
18
|
+
|
|
19
|
+
if options[:label]
|
|
20
|
+
issues = issues.select do |i|
|
|
21
|
+
i['labels']&.any? { |l| l['name'] == options[:label] }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if issues.empty?
|
|
26
|
+
puts 'No issues found.'
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
issues.sort_by { |i| i['number'] }.each do |issue|
|
|
31
|
+
labels = (issue['labels'] || []).map { |l| l['name'] }.join(', ')
|
|
32
|
+
label_str = labels.empty? ? '' : " [#{labels}]"
|
|
33
|
+
puts format('#%-4<num>d %<title>s%<labels>s',
|
|
34
|
+
num: issue['number'], title: issue['title'], labels: label_str)
|
|
35
|
+
end
|
|
36
|
+
rescue Config::ConfigNotFound => e
|
|
37
|
+
warn "Error: #{e.message}"
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../config'
|
|
4
|
+
require_relative '../../local_issue_fetcher'
|
|
5
|
+
|
|
6
|
+
module Ocak
|
|
7
|
+
module Commands
|
|
8
|
+
module Issue
|
|
9
|
+
class View < Dry::CLI::Command
|
|
10
|
+
desc 'View a local issue'
|
|
11
|
+
|
|
12
|
+
argument :issue, type: :integer, required: true, desc: 'Issue number'
|
|
13
|
+
|
|
14
|
+
def call(issue:, **)
|
|
15
|
+
config = Config.load
|
|
16
|
+
fetcher = LocalIssueFetcher.new(config: config)
|
|
17
|
+
data = fetcher.view(issue.to_i)
|
|
18
|
+
|
|
19
|
+
unless data
|
|
20
|
+
warn "Issue ##{issue} not found."
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
labels = (data['labels'] || []).map { |l| l['name'] }.join(', ')
|
|
25
|
+
puts "##{data['number']} #{data['title']}"
|
|
26
|
+
puts "Labels: #{labels}" unless labels.empty?
|
|
27
|
+
puts "Complexity: #{data['complexity']}" if data['complexity'] && data['complexity'] != 'full'
|
|
28
|
+
puts ''
|
|
29
|
+
puts data['body'] unless data['body'].to_s.empty?
|
|
30
|
+
|
|
31
|
+
# Show pipeline comments from the raw file
|
|
32
|
+
path = File.join('.ocak', 'issues', format('%04d.md', issue.to_i))
|
|
33
|
+
show_pipeline_comments(path)
|
|
34
|
+
rescue Config::ConfigNotFound => e
|
|
35
|
+
warn "Error: #{e.message}"
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def show_pipeline_comments(path)
|
|
42
|
+
return unless File.exist?(path)
|
|
43
|
+
|
|
44
|
+
content = File.read(path)
|
|
45
|
+
sentinel = LocalIssueFetcher::COMMENTS_SENTINEL
|
|
46
|
+
return unless content.include?(sentinel)
|
|
47
|
+
|
|
48
|
+
comments = content.split(sentinel, 2).last.to_s.strip
|
|
49
|
+
return if comments.empty?
|
|
50
|
+
|
|
51
|
+
puts ''
|
|
52
|
+
puts '--- Pipeline Activity ---'
|
|
53
|
+
puts comments
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/ocak/commands/resume.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative '../git_utils'
|
|
|
6
6
|
require_relative '../pipeline_runner'
|
|
7
7
|
require_relative '../pipeline_state'
|
|
8
8
|
require_relative '../claude_runner'
|
|
9
|
-
require_relative '../
|
|
9
|
+
require_relative '../issue_backend'
|
|
10
10
|
require_relative '../worktree_manager'
|
|
11
11
|
require_relative '../merge_manager'
|
|
12
12
|
require_relative '../logger'
|
|
@@ -78,7 +78,7 @@ module Ocak
|
|
|
78
78
|
logger = PipelineLogger.new(log_dir: log_dir, issue_number: issue_number)
|
|
79
79
|
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
80
80
|
claude = ClaudeRunner.new(config: config, logger: logger, watch: watch_formatter)
|
|
81
|
-
issues =
|
|
81
|
+
issues = IssueBackend.build(config: config, logger: logger)
|
|
82
82
|
|
|
83
83
|
issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
|
|
84
84
|
|
|
@@ -96,7 +96,8 @@ module Ocak
|
|
|
96
96
|
if result[:success]
|
|
97
97
|
attempt_merge(ctx)
|
|
98
98
|
else
|
|
99
|
-
report_pipeline_failure(ctx[:issue_number], result, issues: ctx[:issues], config: ctx[:config]
|
|
99
|
+
report_pipeline_failure(ctx[:issue_number], result, issues: ctx[:issues], config: ctx[:config],
|
|
100
|
+
logger: ctx[:logger])
|
|
100
101
|
warn "Issue ##{ctx[:issue_number]} failed again at phase: #{result[:phase]}"
|
|
101
102
|
end
|
|
102
103
|
end
|
data/lib/ocak/commands/status.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
require 'json'
|
|
5
5
|
require_relative '../config'
|
|
6
|
+
require_relative '../issue_backend'
|
|
6
7
|
require_relative '../run_report'
|
|
7
8
|
require_relative '../worktree_manager'
|
|
8
9
|
|
|
@@ -42,7 +43,26 @@ module Ocak
|
|
|
42
43
|
|
|
43
44
|
def show_issues(config)
|
|
44
45
|
puts 'Issues:'
|
|
46
|
+
fetcher = IssueBackend.build(config: config)
|
|
45
47
|
|
|
48
|
+
if fetcher.is_a?(LocalIssueFetcher)
|
|
49
|
+
show_local_issues(fetcher, config)
|
|
50
|
+
else
|
|
51
|
+
show_github_issues(config)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def show_local_issues(fetcher, config)
|
|
56
|
+
all = fetcher.all_issues
|
|
57
|
+
%w[ready in_progress completed failed].each do |state|
|
|
58
|
+
label = config.send(:"label_#{state}")
|
|
59
|
+
count = all.count { |i| i['labels']&.any? { |l| l['name'] == label } }
|
|
60
|
+
icon = { 'ready' => ' ', 'in_progress' => ' ', 'completed' => ' ', 'failed' => ' ' }[state]
|
|
61
|
+
puts " #{icon} #{state.tr('_', ' ')}: #{count} (label: #{label})"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def show_github_issues(config)
|
|
46
66
|
%w[ready in_progress completed failed].each do |state|
|
|
47
67
|
label = config.send(:"label_#{state}")
|
|
48
68
|
count = fetch_issue_count(label, config)
|
data/lib/ocak/config.rb
CHANGED
|
@@ -72,6 +72,10 @@ module Ocak
|
|
|
72
72
|
def require_comment = dig(:safety, :require_comment)
|
|
73
73
|
def max_issues_per_run = dig(:safety, :max_issues_per_run) || 5
|
|
74
74
|
|
|
75
|
+
# Issues
|
|
76
|
+
def issue_backend = dig(:issues, :backend)
|
|
77
|
+
def local_issues? = issue_backend == 'local'
|
|
78
|
+
|
|
75
79
|
# Labels
|
|
76
80
|
def label_ready = dig(:labels, :ready) || 'auto-ready'
|
|
77
81
|
def label_in_progress = dig(:labels, :in_progress) || 'auto-doing'
|
|
@@ -4,12 +4,13 @@ module Ocak
|
|
|
4
4
|
# Shared pipeline failure reporting — label transition + comment posting.
|
|
5
5
|
# Included by PipelineRunner and Commands::Resume.
|
|
6
6
|
module FailureReporting
|
|
7
|
-
def report_pipeline_failure(issue_number, result, issues:, config:)
|
|
7
|
+
def report_pipeline_failure(issue_number, result, issues:, config:, logger: nil)
|
|
8
8
|
issues.transition(issue_number, from: config.label_in_progress, to: config.label_failed)
|
|
9
9
|
sanitized = result[:output][0..1000].to_s.gsub('```', "'''")
|
|
10
10
|
issues.comment(issue_number,
|
|
11
11
|
"Pipeline failed at phase: #{result[:phase]}\n\n```\n#{sanitized}\n```")
|
|
12
|
-
rescue StandardError
|
|
12
|
+
rescue StandardError => e
|
|
13
|
+
logger&.debug("Failure report failed: #{e.message}")
|
|
13
14
|
nil
|
|
14
15
|
end
|
|
15
16
|
end
|