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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -0
  3. data/lib/ocak/agent_generator.rb +2 -1
  4. data/lib/ocak/batch_processing.rb +102 -0
  5. data/lib/ocak/claude_runner.rb +12 -8
  6. data/lib/ocak/cli.rb +13 -0
  7. data/lib/ocak/command_runner.rb +39 -0
  8. data/lib/ocak/commands/hiz.rb +8 -7
  9. data/lib/ocak/commands/init.rb +5 -0
  10. data/lib/ocak/commands/issue/close.rb +37 -0
  11. data/lib/ocak/commands/issue/create.rb +59 -0
  12. data/lib/ocak/commands/issue/edit.rb +31 -0
  13. data/lib/ocak/commands/issue/list.rb +43 -0
  14. data/lib/ocak/commands/issue/view.rb +58 -0
  15. data/lib/ocak/commands/resume.rb +4 -3
  16. data/lib/ocak/commands/status.rb +20 -0
  17. data/lib/ocak/config.rb +4 -0
  18. data/lib/ocak/failure_reporting.rb +3 -2
  19. data/lib/ocak/git_utils.rb +18 -11
  20. data/lib/ocak/instance_builders.rb +50 -0
  21. data/lib/ocak/issue_backend.rb +31 -0
  22. data/lib/ocak/local_issue_fetcher.rb +165 -0
  23. data/lib/ocak/local_merge_manager.rb +104 -0
  24. data/lib/ocak/merge_manager.rb +31 -32
  25. data/lib/ocak/merge_orchestration.rb +8 -2
  26. data/lib/ocak/parallel_execution.rb +36 -0
  27. data/lib/ocak/pipeline_executor.rb +9 -183
  28. data/lib/ocak/pipeline_runner.rb +15 -180
  29. data/lib/ocak/planner.rb +1 -1
  30. data/lib/ocak/project_key.rb +38 -0
  31. data/lib/ocak/reready_processor.rb +11 -11
  32. data/lib/ocak/run_report.rb +5 -2
  33. data/lib/ocak/shutdown_handling.rb +67 -0
  34. data/lib/ocak/state_management.rb +104 -0
  35. data/lib/ocak/step_execution.rb +66 -0
  36. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  37. data/lib/ocak/templates/agents/implementer.md.erb +32 -8
  38. data/lib/ocak/templates/agents/merger.md.erb +12 -5
  39. data/lib/ocak/templates/agents/pipeline.md.erb +4 -0
  40. data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
  41. data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
  42. data/lib/ocak/templates/ocak.yml.erb +15 -0
  43. data/lib/ocak/verification.rb +6 -1
  44. data/lib/ocak/worktree_manager.rb +2 -0
  45. data/lib/ocak.rb +1 -1
  46. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f09506ff0c348c7f0822da37268c614ea209b9ca8edb643b7a4619df96113a3
4
- data.tar.gz: 90a52276424ab7f2dbaa19643ca22ffde525460089d7f2b3f97670c638b43f2b
3
+ metadata.gz: bb7c3ddd6ff9b6dacc60c4ee6f3fabae90089eace4e457a28e824be15a901cda
4
+ data.tar.gz: c9b5a76e36361dddb4731fff6298388f0246421272923951f83fdcebde9e5d4b
5
5
  SHA512:
6
- metadata.gz: 04fd822b0fc96ae9f845d97c431a40552c081d56475925069c2158685d126fb816ef87ddd9946b041c2bf624e9e6676a92c2ab30da2b8d10128eebb7a133a57b
7
- data.tar.gz: 111a2c5c925cbad3919f65a92ed3d0d16c88acfd934d0e8d1baf97b21d966a53822129255f28f1941b692bd03eb455eff8ca186555d9e78b2cce189085f53853
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
@@ -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', 'haiku',
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
@@ -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' => 'haiku',
33
- 'reviewer' => 'sonnet',
34
- 'security-reviewer' => 'sonnet',
35
- 'auditor' => 'sonnet',
36
- 'documenter' => 'sonnet',
37
- 'merger' => 'sonnet',
38
- 'implementer' => nil,
39
- 'pipeline' => nil
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
@@ -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 '../issue_fetcher'
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: 'sonnet' },
28
- { agent: 'reviewer', role: 'review', model: 'haiku', parallel: true },
29
- { agent: 'security-reviewer', role: 'security', model: 'sonnet', parallel: true }
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 = IssueFetcher.new(config: @config, logger: logger)
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: 'sonnet',
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}"
@@ -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
@@ -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 '../issue_fetcher'
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 = IssueFetcher.new(config: config, logger: logger)
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
@@ -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