ocak 0.5.0 → 0.6.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 +75 -22
- data/lib/ocak/agent_generator.rb +9 -0
- data/lib/ocak/batch_processing.rb +35 -5
- data/lib/ocak/commands/hiz.rb +20 -21
- data/lib/ocak/commands/init.rb +32 -0
- data/lib/ocak/commands/resume.rb +7 -6
- data/lib/ocak/commands/status.rb +9 -12
- data/lib/ocak/config.rb +68 -1
- data/lib/ocak/conflict_resolution.rb +73 -0
- data/lib/ocak/failure_reporting.rb +3 -1
- data/lib/ocak/instance_builders.rb +4 -0
- data/lib/ocak/issue_fetcher.rb +9 -0
- data/lib/ocak/issue_state_machine.rb +36 -0
- data/lib/ocak/merge_manager.rb +22 -94
- data/lib/ocak/merge_orchestration.rb +30 -24
- data/lib/ocak/merge_verification.rb +40 -0
- data/lib/ocak/pipeline_executor.rb +8 -2
- data/lib/ocak/pipeline_runner.rb +18 -1
- data/lib/ocak/planner.rb +15 -0
- data/lib/ocak/shutdown_handling.rb +2 -2
- data/lib/ocak/stream_parser.rb +1 -1
- data/lib/ocak/target_resolver.rb +41 -0
- data/lib/ocak/templates/agents/implementer.md.erb +3 -0
- data/lib/ocak/templates/agents/merger.md.erb +12 -0
- data/lib/ocak/templates/agents/pipeline.md.erb +18 -0
- data/lib/ocak/templates/gitignore_additions.txt +1 -0
- data/lib/ocak/templates/ocak.yml.erb +9 -0
- data/lib/ocak/worktree_manager.rb +7 -6
- data/lib/ocak.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21e35193d8d3fc3b9c24f7e75502749ce4f9a57352da20d9bb0c980c2191ad9a
|
|
4
|
+
data.tar.gz: 2204cd3797a57348f3a3742fa17385375050d8b66fe37d4bdb466a4f603f31dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7fddde997f1ba1c8f5e5e9db9e3f6dc9281ac1db82743b651f72f658967fcefeca67b05c5e338dbbaad9be798adae10f24807d1fec3ddd9975e72daba853f709
|
|
7
|
+
data.tar.gz: 0d5f6f2f9e7300eb8b242e654296dd156192e4993976a40d0ebb050a1bf417e9b36e85f1b6b4f4e8e99cc1e375275be5175f6ef9489056c2dcc1d3025cffaa9e
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Ocak
|
|
2
2
|
|
|
3
|
-
*Ocak (pronounced "oh-JAHK") is Turkish for "forge" or "hearth"
|
|
3
|
+
*Ocak (pronounced "oh-JAHK") is Turkish for "forge" or "hearth" - the place where raw material meets fire and becomes something useful. Also: let 'em cook.*
|
|
4
4
|
|
|
5
5
|
Multi-agent pipeline that processes GitHub issues autonomously with Claude Code. You write an issue, label it, and ocak runs it through implement → review → fix → security review → document → audit → merge. Each issue gets its own worktree so they can run in parrallel.
|
|
6
6
|
|
|
@@ -83,8 +83,8 @@ stateDiagram-v2
|
|
|
83
83
|
### Complexity Classification
|
|
84
84
|
|
|
85
85
|
The planner classifes each issue as `simple` or `full`:
|
|
86
|
-
- **Simple**
|
|
87
|
-
- **Full**
|
|
86
|
+
- **Simple** - skips security review, second fix pass, documenter, and auditor
|
|
87
|
+
- **Full** - runs the whole thing
|
|
88
88
|
|
|
89
89
|
`--fast` forces all issues to `simple`, giving you: implement → review → fix (if needed) → verify (if fixed) → merge.
|
|
90
90
|
|
|
@@ -102,7 +102,7 @@ flowchart LR
|
|
|
102
102
|
E --> F["Merger agent<br>create PR + merge"]
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
Merging is sequential
|
|
105
|
+
Merging is sequential - one at a time - so you dont get conflicts between parallel worktrees.
|
|
106
106
|
|
|
107
107
|
## Agents
|
|
108
108
|
|
|
@@ -119,7 +119,7 @@ Merging is sequential — one at a time — so you dont get conflicts between pa
|
|
|
119
119
|
| **planner** | Batch issues, classify complexity | Read, Glob, Grep, Bash (read-only) | haiku |
|
|
120
120
|
| **pipeline** | Self-contained single-agent mode | All tools | opus |
|
|
121
121
|
|
|
122
|
-
Review agents (reviewer, security-reviewer, auditor) have no Write/Edit access
|
|
122
|
+
Review agents (reviewer, security-reviewer, auditor) have no Write/Edit access - they can only read and report stuff back.
|
|
123
123
|
|
|
124
124
|
## Skills
|
|
125
125
|
|
|
@@ -128,13 +128,13 @@ Interactive skills for use inside Claude Code:
|
|
|
128
128
|
| Skill | Description |
|
|
129
129
|
|-------|-------------|
|
|
130
130
|
| `/design [description]` | Researches your codebase, asks clarifying questions, produces a detailed implementation-ready issue |
|
|
131
|
-
| `/audit [scope]` | Codebase sweep
|
|
131
|
+
| `/audit [scope]` | Codebase sweep - scopes: `security`, `errors`, `patterns`, `tests`, `data`, `dependencies`, or `all` |
|
|
132
132
|
| `/scan-file <path>` | Deep single-file analysis with test coverage check, scored 1-10 per method |
|
|
133
133
|
| `/debt` | Tech debt tracker with risk scoring (churn, coverage, suppressions, age, blast radius) |
|
|
134
134
|
|
|
135
135
|
## Modes
|
|
136
136
|
|
|
137
|
-
### Full Pipeline
|
|
137
|
+
### Full Pipeline - `ocak run`
|
|
138
138
|
|
|
139
139
|
The default. Polls for `auto-ready` issues, plans batches, runs the full step sequence in parallel worktrees, merges sequentally.
|
|
140
140
|
|
|
@@ -146,7 +146,7 @@ ocak run --audit --watch # Auditor as merge gate
|
|
|
146
146
|
ocak run --fast --watch # Skip security/docs/audit steps
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
### Fast Mode
|
|
149
|
+
### Fast Mode - `ocak hiz`
|
|
150
150
|
|
|
151
151
|
Lightweight alternative for quick PRs you'll review youself:
|
|
152
152
|
|
|
@@ -186,11 +186,41 @@ ocak run 1 --watch
|
|
|
186
186
|
|
|
187
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
188
|
|
|
189
|
+
### Multi-Repo Mode
|
|
190
|
+
|
|
191
|
+
Run issues across multiple repos from a single issue tracker. One "god repo" holds all the issues, agents run in worktrees of whatever repo the issue targets.
|
|
192
|
+
|
|
193
|
+
Each issue specifies its target with YAML front-matter at the top of the body:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
---
|
|
197
|
+
target_repo: my-service
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
Actual issue description here...
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Enable it in `ocak.yml`:
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
multi_repo: true
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Map repo names to local paths in `~/.config/ocak/config.yml`:
|
|
210
|
+
|
|
211
|
+
```yaml
|
|
212
|
+
repos:
|
|
213
|
+
my-service: ~/dev/my-service
|
|
214
|
+
other-thing: ~/dev/other-thing
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Labels and comments stay on the god repo's issues. PRs get created in the target repo. Worktree isolation, parallel batches, sequential merging all work the same.
|
|
218
|
+
|
|
189
219
|
### Graceful Shutdown
|
|
190
220
|
|
|
191
|
-
`Ctrl+C` once
|
|
221
|
+
`Ctrl+C` once - current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
|
|
192
222
|
|
|
193
|
-
`Ctrl+C` twice
|
|
223
|
+
`Ctrl+C` twice - kills active subprocesses immediatley (SIGTERM → wait → SIGKILL), then same cleanup runs.
|
|
194
224
|
|
|
195
225
|
```bash
|
|
196
226
|
ocak resume 42 --watch # Pick up from where it stopped
|
|
@@ -214,7 +244,7 @@ stack:
|
|
|
214
244
|
|
|
215
245
|
# Issue backend (omit for GitHub, or set to "local" for offline mode)
|
|
216
246
|
issues:
|
|
217
|
-
backend: github # or "local"
|
|
247
|
+
backend: github # or "local" - auto-detected if .ocak/issues/ exists
|
|
218
248
|
|
|
219
249
|
# Pipeline settings
|
|
220
250
|
pipeline:
|
|
@@ -241,7 +271,7 @@ labels:
|
|
|
241
271
|
reready: "auto-reready"
|
|
242
272
|
awaiting_review: "auto-pending-human"
|
|
243
273
|
|
|
244
|
-
# Pipeline steps
|
|
274
|
+
# Pipeline steps - add, remove, reorder as you like
|
|
245
275
|
steps:
|
|
246
276
|
- agent: implementer
|
|
247
277
|
role: implement
|
|
@@ -276,6 +306,25 @@ agents:
|
|
|
276
306
|
# ...
|
|
277
307
|
```
|
|
278
308
|
|
|
309
|
+
### User Config
|
|
310
|
+
|
|
311
|
+
`ocak init` scaffolds `~/.config/ocak/config.yml` (or `$XDG_CONFIG_HOME/ocak/config.yml`) for machine-specific settings. When both files exist, project `ocak.yml` wins on conflicts.
|
|
312
|
+
|
|
313
|
+
```yaml
|
|
314
|
+
# ~/.config/ocak/config.yml
|
|
315
|
+
|
|
316
|
+
# Repo path mappings for multi-repo mode
|
|
317
|
+
repos:
|
|
318
|
+
my-service: ~/dev/my-service
|
|
319
|
+
|
|
320
|
+
# Machine-level overrides
|
|
321
|
+
pipeline:
|
|
322
|
+
max_parallel: 3
|
|
323
|
+
cost_budget: 10.00
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The `repos:` key only comes from user config so repo paths dont leak into project config.
|
|
327
|
+
|
|
279
328
|
## Customization
|
|
280
329
|
|
|
281
330
|
### Swap Agents
|
|
@@ -351,8 +400,8 @@ steps:
|
|
|
351
400
|
| Python | Django, Flask, FastAPI | pytest | ruff, flake8 | bandit, safety |
|
|
352
401
|
| Rust | Actix, Axum, Rocket | cargo test | cargo clippy | cargo audit |
|
|
353
402
|
| Go | Gin, Echo, Fiber, Chi | go test | golangci-lint | gosec |
|
|
354
|
-
| Java |
|
|
355
|
-
| Elixir | Phoenix | mix test | mix credo |
|
|
403
|
+
| Java | - | gradle test | - | - |
|
|
404
|
+
| Elixir | Phoenix | mix test | mix credo | - |
|
|
356
405
|
|
|
357
406
|
Monorepo detection: npm/pnpm workspaces, Cargo workspaces, Go workspaces, Lerna, and convention-based (`packages/`, `apps/`, `services/`).
|
|
358
407
|
|
|
@@ -368,15 +417,15 @@ Shows per-run stats (cost, duration, steps completed, failures) and aggregates a
|
|
|
368
417
|
|
|
369
418
|
## Writing Good Issues
|
|
370
419
|
|
|
371
|
-
The `/design` skill produces issues formatted for zero-context agents. Think of it as writing a ticket for a contractor who's never seen your codebase
|
|
420
|
+
The `/design` skill produces issues formatted for zero-context agents. Think of it as writing a ticket for a contractor who's never seen your codebase - everthing they need should be in the issue body. The key sections:
|
|
372
421
|
|
|
373
|
-
- **Context**
|
|
374
|
-
- **Acceptance Criteria**
|
|
375
|
-
- **Implementation Guide**
|
|
376
|
-
- **Patterns to Follow**
|
|
377
|
-
- **Security Considerations**
|
|
378
|
-
- **Test Requirements**
|
|
379
|
-
- **Out of Scope**
|
|
422
|
+
- **Context** - what part of the system, with specific file paths
|
|
423
|
+
- **Acceptance Criteria** - "when X, then Y" format, each independantly testable
|
|
424
|
+
- **Implementation Guide** - exact files to create/modify
|
|
425
|
+
- **Patterns to Follow** - references to actual files in the codebase
|
|
426
|
+
- **Security Considerations** - auth, validation, data exposure
|
|
427
|
+
- **Test Requirements** - specific test cases with file paths
|
|
428
|
+
- **Out of Scope** - explicit boundaries so it doesnt scope creep
|
|
380
429
|
|
|
381
430
|
## CLI Reference
|
|
382
431
|
|
|
@@ -440,6 +489,10 @@ Depends on the issue. Simple stuff is $2-5, complex issues can be $10-15. The im
|
|
|
440
489
|
|
|
441
490
|
Reasonably. Review agents are read-only (no Write/Edit tools), merging is sequential so you dont get conflicts, and failed piplines get labeled and logged. You can always `--dry-run` first to see what it would do.
|
|
442
491
|
|
|
492
|
+
**Can I trust `ocak.yml` from someone else's repo?**
|
|
493
|
+
|
|
494
|
+
Treat it like a Makefile. Commands in `test_command`, `lint_command`, `security_commands`, etc. run with your user privileges. First time you run ocak in a new repo it'll print a warning. Review the commands before running `ocak run` on repos you didnt write.
|
|
495
|
+
|
|
443
496
|
**What if it breaks?**
|
|
444
497
|
|
|
445
498
|
Issues get labeled `pipeline-failed` with a comment explaining what went wrong. Worktrees get cleaned up automaticaly. Run `ocak clean` to remove any stragglers, and check `logs/pipeline/` for detailed logs.
|
data/lib/ocak/agent_generator.rb
CHANGED
|
@@ -4,9 +4,12 @@ require 'erb'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'open3'
|
|
6
6
|
require_relative 'claude_runner'
|
|
7
|
+
require_relative 'command_runner'
|
|
7
8
|
|
|
8
9
|
module Ocak
|
|
9
10
|
class AgentGenerator
|
|
11
|
+
include CommandRunner
|
|
12
|
+
|
|
10
13
|
AGENT_TEMPLATES = %w[
|
|
11
14
|
implementer reviewer security_reviewer documenter
|
|
12
15
|
merger pipeline planner auditor
|
|
@@ -137,10 +140,14 @@ module Ocak
|
|
|
137
140
|
You are customizing a Claude Code agent for a specific project.
|
|
138
141
|
|
|
139
142
|
Here is the project context:
|
|
143
|
+
<project_context>
|
|
140
144
|
#{context}
|
|
145
|
+
</project_context>
|
|
141
146
|
|
|
142
147
|
Here is the base agent template:
|
|
148
|
+
<base_template>
|
|
143
149
|
#{template_content}
|
|
150
|
+
</base_template>
|
|
144
151
|
|
|
145
152
|
Customize this agent to reference this project's actual conventions, file paths,
|
|
146
153
|
and patterns. Keep the same structure (YAML frontmatter + markdown). Keep the same
|
|
@@ -151,6 +158,7 @@ module Ocak
|
|
|
151
158
|
end
|
|
152
159
|
|
|
153
160
|
def claude_available?
|
|
161
|
+
# Uses Open3 directly; 'which' is not a git/gh command
|
|
154
162
|
_, _, status = Open3.capture3('which', 'claude')
|
|
155
163
|
status.success?
|
|
156
164
|
rescue Errno::ENOENT => e
|
|
@@ -159,6 +167,7 @@ module Ocak
|
|
|
159
167
|
end
|
|
160
168
|
|
|
161
169
|
def run_claude_prompt(prompt)
|
|
170
|
+
# Uses Open3 directly; 'claude' is not a git/gh command
|
|
162
171
|
stdout, _, status = Open3.capture3(
|
|
163
172
|
'claude', '-p',
|
|
164
173
|
'--output-format', 'text',
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'target_resolver'
|
|
4
|
+
|
|
3
5
|
module Ocak
|
|
4
6
|
# Batch processing logic — process_issues, run_batch, process_one_issue, build_issue_result.
|
|
5
7
|
# Extracted from PipelineRunner to reduce file size.
|
|
@@ -12,6 +14,8 @@ module Ocak
|
|
|
12
14
|
ready_issues = ready_issues.first(@config.max_issues_per_run)
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
ready_issues = resolve_targets(ready_issues, logger: logger) if @config.multi_repo?
|
|
18
|
+
|
|
15
19
|
claude = build_claude(logger)
|
|
16
20
|
batches = @executor.plan_batches(ready_issues, logger: logger, claude: claude)
|
|
17
21
|
|
|
@@ -29,10 +33,10 @@ module Ocak
|
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def run_batch(batch_issues, logger:, issues:)
|
|
32
|
-
|
|
36
|
+
shared_worktrees = @config.multi_repo? ? nil : WorktreeManager.new(config: @config, logger: logger)
|
|
33
37
|
|
|
34
38
|
threads = batch_issues.map do |issue|
|
|
35
|
-
Thread.new { process_one_issue(issue, worktrees:
|
|
39
|
+
Thread.new { process_one_issue(issue, worktrees: shared_worktrees, issues: issues) }
|
|
36
40
|
end
|
|
37
41
|
results = threads.map(&:value)
|
|
38
42
|
|
|
@@ -47,7 +51,8 @@ module Ocak
|
|
|
47
51
|
next unless result[:worktree]
|
|
48
52
|
next if result[:interrupted]
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
worktree_manager_for(result[:worktree], shared: shared_worktrees, logger: logger)
|
|
55
|
+
.remove(result[:worktree])
|
|
51
56
|
rescue StandardError => e
|
|
52
57
|
logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
|
|
53
58
|
end
|
|
@@ -65,8 +70,15 @@ module Ocak
|
|
|
65
70
|
@active_mutex.synchronize do
|
|
66
71
|
@active_issues << issue_number
|
|
67
72
|
end
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
@state_machine.mark_in_progress(issue_number)
|
|
74
|
+
|
|
75
|
+
target = issue['_target']
|
|
76
|
+
worktree_mgr = if target
|
|
77
|
+
WorktreeManager.new(config: @config, repo_dir: target[:path], logger: logger)
|
|
78
|
+
else
|
|
79
|
+
worktrees
|
|
80
|
+
end
|
|
81
|
+
worktree = worktree_mgr.create(issue_number, setup_command: @config.setup_command)
|
|
70
82
|
logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
|
|
71
83
|
|
|
72
84
|
complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
|
|
@@ -98,5 +110,23 @@ module Ocak
|
|
|
98
110
|
{ issue_number: issue_number, success: false, worktree: worktree }
|
|
99
111
|
end
|
|
100
112
|
end
|
|
113
|
+
|
|
114
|
+
def worktree_manager_for(worktree, shared:, logger:)
|
|
115
|
+
target = worktree.target_repo
|
|
116
|
+
return WorktreeManager.new(config: @config, repo_dir: target[:path], logger: logger) if target
|
|
117
|
+
|
|
118
|
+
shared || WorktreeManager.new(config: @config, logger: logger)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_targets(ready_issues, logger:)
|
|
122
|
+
ready_issues.filter_map do |issue|
|
|
123
|
+
target = TargetResolver.resolve(issue, config: @config)
|
|
124
|
+
issue['_target'] = target
|
|
125
|
+
issue
|
|
126
|
+
rescue TargetResolver::TargetResolutionError => e
|
|
127
|
+
logger.error("Skipping issue ##{issue['number']}: #{e.message}")
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
101
131
|
end
|
|
102
132
|
end
|
data/lib/ocak/commands/hiz.rb
CHANGED
|
@@ -4,8 +4,10 @@ require 'open3'
|
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require_relative '../config'
|
|
6
6
|
require_relative '../claude_runner'
|
|
7
|
+
require_relative '../command_runner'
|
|
7
8
|
require_relative '../git_utils'
|
|
8
9
|
require_relative '../issue_backend'
|
|
10
|
+
require_relative '../issue_state_machine'
|
|
9
11
|
require_relative '../pipeline_executor'
|
|
10
12
|
require_relative '../step_comments'
|
|
11
13
|
require_relative '../logger'
|
|
@@ -14,6 +16,7 @@ module Ocak
|
|
|
14
16
|
module Commands
|
|
15
17
|
class Hiz < Dry::CLI::Command
|
|
16
18
|
include StepComments
|
|
19
|
+
include CommandRunner
|
|
17
20
|
|
|
18
21
|
desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
|
|
19
22
|
|
|
@@ -44,6 +47,7 @@ module Ocak
|
|
|
44
47
|
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
45
48
|
claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
|
|
46
49
|
issues = IssueBackend.build(config: @config, logger: logger)
|
|
50
|
+
@state_machine = IssueStateMachine.new(config: @config, issues: issues)
|
|
47
51
|
|
|
48
52
|
logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
|
|
49
53
|
|
|
@@ -68,7 +72,7 @@ module Ocak
|
|
|
68
72
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
69
73
|
chdir = @config.project_dir
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
@state_machine.mark_in_progress(issue_number)
|
|
72
76
|
post_hiz_start_comment(issue_number, state: state)
|
|
73
77
|
begin
|
|
74
78
|
branch = create_branch(issue_number, chdir)
|
|
@@ -119,8 +123,8 @@ module Ocak
|
|
|
119
123
|
branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
|
|
120
124
|
raise "Unsafe branch name: #{branch}" unless GitUtils.safe_branch_name?(branch)
|
|
121
125
|
|
|
122
|
-
|
|
123
|
-
raise "Failed to create branch #{branch}: #{
|
|
126
|
+
result = run_git('checkout', '-b', branch, chdir: chdir)
|
|
127
|
+
raise "Failed to create branch #{branch}: #{result.error}" unless result.success?
|
|
124
128
|
|
|
125
129
|
branch
|
|
126
130
|
end
|
|
@@ -128,10 +132,10 @@ module Ocak
|
|
|
128
132
|
def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
|
|
129
133
|
commit_changes(issue_number, chdir, logger: logger)
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
unless
|
|
133
|
-
logger.error("Push failed: #{
|
|
134
|
-
handle_failure(issue_number, 'push',
|
|
135
|
+
push_result = run_git('push', '-u', 'origin', branch, chdir: chdir)
|
|
136
|
+
unless push_result.success?
|
|
137
|
+
logger.error("Push failed: #{push_result.error}")
|
|
138
|
+
handle_failure(issue_number, 'push', push_result.error, issues: state.issues, logger: logger, branch: branch)
|
|
135
139
|
return
|
|
136
140
|
end
|
|
137
141
|
|
|
@@ -140,21 +144,16 @@ module Ocak
|
|
|
140
144
|
pr_title = issue_title ? "Fix ##{issue_number}: #{issue_title}" : "Fix ##{issue_number}"
|
|
141
145
|
pr_body = build_pr_body(issue_number, state: state)
|
|
142
146
|
|
|
143
|
-
|
|
144
|
-
'gh', 'pr', 'create',
|
|
145
|
-
'--title', pr_title,
|
|
146
|
-
'--body', pr_body,
|
|
147
|
-
'--head', branch,
|
|
148
|
-
chdir: chdir
|
|
149
|
-
)
|
|
147
|
+
pr_result = run_gh('pr', 'create', '--title', pr_title, '--body', pr_body, '--head', branch, chdir: chdir)
|
|
150
148
|
|
|
151
|
-
if
|
|
152
|
-
pr_url =
|
|
149
|
+
if pr_result.success?
|
|
150
|
+
pr_url = pr_result.output
|
|
153
151
|
logger.info("PR created: #{pr_url}")
|
|
154
152
|
puts "PR created: #{pr_url}"
|
|
155
153
|
else
|
|
156
|
-
logger.error("PR creation failed: #{
|
|
157
|
-
handle_failure(issue_number, 'pr-create',
|
|
154
|
+
logger.error("PR creation failed: #{pr_result.error}")
|
|
155
|
+
handle_failure(issue_number, 'pr-create', pr_result.error, issues: state.issues, logger: logger,
|
|
156
|
+
branch: branch)
|
|
158
157
|
end
|
|
159
158
|
end
|
|
160
159
|
|
|
@@ -185,7 +184,7 @@ module Ocak
|
|
|
185
184
|
|
|
186
185
|
def handle_failure(issue_number, phase, output, issues:, logger:, branch: nil)
|
|
187
186
|
logger.error("Issue ##{issue_number} failed at phase: #{phase}")
|
|
188
|
-
|
|
187
|
+
@state_machine.mark_failed(issue_number)
|
|
189
188
|
begin
|
|
190
189
|
sanitized = output.to_s[0..1000].gsub('```', "'''")
|
|
191
190
|
issues.comment(issue_number,
|
|
@@ -200,8 +199,8 @@ module Ocak
|
|
|
200
199
|
end
|
|
201
200
|
|
|
202
201
|
def delete_branch(branch, logger:)
|
|
203
|
-
|
|
204
|
-
logger.warn("Failed to delete branch #{branch}: #{
|
|
202
|
+
result = run_git('branch', '-D', branch, chdir: @config.project_dir)
|
|
203
|
+
logger.warn("Failed to delete branch #{branch}: #{result.error}") unless result.success?
|
|
205
204
|
rescue StandardError => e
|
|
206
205
|
logger.warn("Error deleting branch #{branch}: #{e.message}")
|
|
207
206
|
end
|
data/lib/ocak/commands/init.rb
CHANGED
|
@@ -35,6 +35,7 @@ module Ocak
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
generate_files(generator, project_dir, options)
|
|
38
|
+
scaffold_user_config
|
|
38
39
|
update_settings(project_dir, stack)
|
|
39
40
|
update_gitignore(project_dir)
|
|
40
41
|
create_labels(project_dir)
|
|
@@ -83,6 +84,36 @@ module Ocak
|
|
|
83
84
|
puts ''
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
def scaffold_user_config
|
|
88
|
+
path = Config.user_config_path
|
|
89
|
+
if File.exist?(path)
|
|
90
|
+
puts " #{path} already exists — skipping"
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
95
|
+
File.write(path, user_config_template)
|
|
96
|
+
puts " Created #{path}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def user_config_template
|
|
100
|
+
<<~YAML
|
|
101
|
+
# ~/.config/ocak/config.yml
|
|
102
|
+
# Machine-specific Ocak settings (not committed to any repo)
|
|
103
|
+
|
|
104
|
+
# Map repo names to local paths (used with multi_repo: true in project ocak.yml)
|
|
105
|
+
# repos:
|
|
106
|
+
# my-gem: ~/dev/my-gem
|
|
107
|
+
# other-gem: ~/dev/other-gem
|
|
108
|
+
|
|
109
|
+
# Pipeline tuning for this machine
|
|
110
|
+
# pipeline:
|
|
111
|
+
# max_parallel: 3 # default: 5
|
|
112
|
+
# poll_interval: 60 # seconds between polls, default: 60
|
|
113
|
+
# cost_budget: 10.00 # max $ per pipeline run
|
|
114
|
+
YAML
|
|
115
|
+
end
|
|
116
|
+
|
|
86
117
|
def update_settings(project_dir, stack)
|
|
87
118
|
settings_path = File.join(project_dir, '.claude', 'settings.json')
|
|
88
119
|
existing = begin
|
|
@@ -206,6 +237,7 @@ module Ocak
|
|
|
206
237
|
end
|
|
207
238
|
puts ' .claude/hooks/ — lint + test hooks'
|
|
208
239
|
puts ' .claude/settings.json — permissions & hooks config'
|
|
240
|
+
puts " #{Config.user_config_path} — machine-specific settings (not committed)"
|
|
209
241
|
puts ''
|
|
210
242
|
puts 'Next steps:'
|
|
211
243
|
puts ' 1. Review ocak.yml and adjust settings'
|
data/lib/ocak/commands/resume.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative '../config'
|
|
4
4
|
require_relative '../failure_reporting'
|
|
5
5
|
require_relative '../git_utils'
|
|
6
|
+
require_relative '../issue_state_machine'
|
|
6
7
|
require_relative '../pipeline_runner'
|
|
7
8
|
require_relative '../pipeline_state'
|
|
8
9
|
require_relative '../claude_runner'
|
|
@@ -79,8 +80,9 @@ module Ocak
|
|
|
79
80
|
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
80
81
|
claude = ClaudeRunner.new(config: config, logger: logger, watch: watch_formatter)
|
|
81
82
|
issues = IssueBackend.build(config: config, logger: logger)
|
|
83
|
+
state_machine = IssueStateMachine.new(config: config, issues: issues)
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
state_machine.mark_resuming(issue_number)
|
|
84
86
|
|
|
85
87
|
runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
|
|
86
88
|
result = runner.run_pipeline(issue_number,
|
|
@@ -88,7 +90,8 @@ module Ocak
|
|
|
88
90
|
skip_steps: saved[:completed_steps])
|
|
89
91
|
|
|
90
92
|
ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
|
|
91
|
-
issues: issues, claude: claude, logger: logger,
|
|
93
|
+
issues: issues, state_machine: state_machine, claude: claude, logger: logger,
|
|
94
|
+
watch: watch_formatter }
|
|
92
95
|
handle_result(result, ctx)
|
|
93
96
|
end
|
|
94
97
|
|
|
@@ -110,12 +113,10 @@ module Ocak
|
|
|
110
113
|
)
|
|
111
114
|
|
|
112
115
|
if merger.merge(ctx[:issue_number], worktree)
|
|
113
|
-
ctx[:
|
|
114
|
-
to: ctx[:config].label_completed)
|
|
116
|
+
ctx[:state_machine].mark_completed(ctx[:issue_number])
|
|
115
117
|
puts "Issue ##{ctx[:issue_number]} resumed and merged successfully!"
|
|
116
118
|
else
|
|
117
|
-
ctx[:
|
|
118
|
-
to: ctx[:config].label_failed)
|
|
119
|
+
ctx[:state_machine].mark_failed(ctx[:issue_number])
|
|
119
120
|
warn "Issue ##{ctx[:issue_number]} merge failed after resume"
|
|
120
121
|
end
|
|
121
122
|
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 '../command_runner'
|
|
6
7
|
require_relative '../issue_backend'
|
|
7
8
|
require_relative '../run_report'
|
|
8
9
|
require_relative '../worktree_manager'
|
|
@@ -10,6 +11,8 @@ require_relative '../worktree_manager'
|
|
|
10
11
|
module Ocak
|
|
11
12
|
module Commands
|
|
12
13
|
class Status < Dry::CLI::Command
|
|
14
|
+
include CommandRunner
|
|
15
|
+
|
|
13
16
|
desc 'Show pipeline status'
|
|
14
17
|
|
|
15
18
|
option :report, type: :boolean, default: false, desc: 'Show run reports'
|
|
@@ -207,18 +210,12 @@ module Ocak
|
|
|
207
210
|
end
|
|
208
211
|
|
|
209
212
|
def fetch_issue_count(label, config)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
chdir: config.project_dir
|
|
217
|
-
)
|
|
218
|
-
return 0 unless status.success?
|
|
219
|
-
|
|
220
|
-
JSON.parse(stdout).size
|
|
221
|
-
rescue JSON::ParserError, Errno::ENOENT
|
|
213
|
+
result = run_gh('issue', 'list', '--label', label, '--state', 'open',
|
|
214
|
+
'--json', 'number', '--limit', '100', chdir: config.project_dir)
|
|
215
|
+
return 0 unless result.success?
|
|
216
|
+
|
|
217
|
+
JSON.parse(result.stdout).size
|
|
218
|
+
rescue JSON::ParserError
|
|
222
219
|
0
|
|
223
220
|
end
|
|
224
221
|
|
data/lib/ocak/config.rb
CHANGED
|
@@ -5,14 +5,28 @@ require 'yaml'
|
|
|
5
5
|
module Ocak
|
|
6
6
|
class Config
|
|
7
7
|
CONFIG_FILE = 'ocak.yml'
|
|
8
|
+
USER_CONFIG_DIR = 'ocak'
|
|
9
|
+
USER_CONFIG_FILE = 'config.yml'
|
|
8
10
|
|
|
9
11
|
attr_reader :project_dir
|
|
10
12
|
|
|
13
|
+
def self.user_config_path
|
|
14
|
+
base = ENV.fetch('XDG_CONFIG_HOME', File.expand_path('~/.config'))
|
|
15
|
+
File.join(base, USER_CONFIG_DIR, USER_CONFIG_FILE)
|
|
16
|
+
end
|
|
17
|
+
|
|
11
18
|
def self.load(dir = Dir.pwd)
|
|
12
19
|
path = File.join(dir, CONFIG_FILE)
|
|
13
20
|
raise ConfigNotFound, "No ocak.yml found in #{dir}. Run `ocak init` first." unless File.exist?(path)
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
project_data = YAML.safe_load_file(path, symbolize_names: true) || {}
|
|
23
|
+
user_data = load_user_config
|
|
24
|
+
|
|
25
|
+
merged = deep_merge(user_data, project_data)
|
|
26
|
+
# repos: is user-only — always take from user config, never project
|
|
27
|
+
merged[:repos] = user_data[:repos] if user_data[:repos]
|
|
28
|
+
|
|
29
|
+
new(merged, dir)
|
|
16
30
|
end
|
|
17
31
|
|
|
18
32
|
def initialize(data, project_dir = Dir.pwd)
|
|
@@ -51,6 +65,10 @@ module Ocak
|
|
|
51
65
|
dig(:stack, :security_commands) || []
|
|
52
66
|
end
|
|
53
67
|
|
|
68
|
+
def custom_commands?
|
|
69
|
+
!!(test_command || lint_command || format_command || setup_command || security_commands.any?)
|
|
70
|
+
end
|
|
71
|
+
|
|
54
72
|
# Pipeline
|
|
55
73
|
def max_parallel = @overrides[:max_parallel] || dig(:pipeline, :max_parallel) || 5
|
|
56
74
|
def poll_interval = @overrides[:poll_interval] || dig(:pipeline, :poll_interval) || 60
|
|
@@ -72,6 +90,33 @@ module Ocak
|
|
|
72
90
|
def require_comment = dig(:safety, :require_comment)
|
|
73
91
|
def max_issues_per_run = dig(:safety, :max_issues_per_run) || 5
|
|
74
92
|
|
|
93
|
+
# Multi-repo
|
|
94
|
+
def repos
|
|
95
|
+
@data[:repos]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def multi_repo?
|
|
99
|
+
r = repos
|
|
100
|
+
r.is_a?(Hash) && !r.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def target_field
|
|
104
|
+
dig(:target_field) || 'target_repo'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def resolve_repo(name)
|
|
108
|
+
raise ConfigError, 'No repos configured in ocak.yml' unless multi_repo?
|
|
109
|
+
|
|
110
|
+
key = name.to_sym
|
|
111
|
+
path_value = repos[key]
|
|
112
|
+
raise ConfigError, "Unknown repo '#{name}'. Known repos: #{repos.keys.join(', ')}" unless path_value
|
|
113
|
+
|
|
114
|
+
expanded = File.expand_path(path_value.to_s)
|
|
115
|
+
raise ConfigError, "Repo path does not exist: #{expanded}" unless Dir.exist?(expanded)
|
|
116
|
+
|
|
117
|
+
{ name: name.to_s, path: expanded }
|
|
118
|
+
end
|
|
119
|
+
|
|
75
120
|
# Issues
|
|
76
121
|
def issue_backend = dig(:issues, :backend)
|
|
77
122
|
def local_issues? = issue_backend == 'local'
|
|
@@ -136,5 +181,27 @@ module Ocak
|
|
|
136
181
|
|
|
137
182
|
class ConfigNotFound < StandardError; end
|
|
138
183
|
class ConfigError < StandardError; end
|
|
184
|
+
|
|
185
|
+
private_class_method def self.load_user_config
|
|
186
|
+
path = user_config_path
|
|
187
|
+
return {} unless File.exist?(path)
|
|
188
|
+
|
|
189
|
+
data = YAML.safe_load_file(path, symbolize_names: true)
|
|
190
|
+
raise ConfigError, "#{path} must be a YAML hash" unless data.is_a?(Hash) || data.nil?
|
|
191
|
+
|
|
192
|
+
data || {}
|
|
193
|
+
rescue Psych::SyntaxError => e
|
|
194
|
+
raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private_class_method def self.deep_merge(base, override)
|
|
198
|
+
base.merge(override) do |_key, old_val, new_val|
|
|
199
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
200
|
+
deep_merge(old_val, new_val)
|
|
201
|
+
else
|
|
202
|
+
new_val
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
139
206
|
end
|
|
140
207
|
end
|