feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements

- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-02 13:55:50 -07:00
parent f22b220c6d
commit 56e3f83955
70 changed files with 3468 additions and 297 deletions

View File

@ -24,6 +24,7 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
| **Project templates** | [services/templates.md](.claude/guides/services/templates.md) |
| **Composable monorepo templates** | [services/composable-monorepo.md](.claude/guides/services/composable-monorepo.md) |
| **Write E2E cookbook test scripts** | [cookbook-scripts/SKILL.md](.claude/skills/cookbook-scripts/SKILL.md) |
| **Cookbook tree system (checkpoints)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) |
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) |
| **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) |
@ -52,8 +53,10 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
- **Config:** Use `envutil.GetEnv()` / `GetEnvInt()` / `GetEnvBool()` from `internal/envutil` for all env var reads with defaults. NEVER define local `getEnv` helpers — they duplicate and drift. Raw `os.Getenv()` is fine for required values with no default (secrets, passwords).
- **Handler timeouts:** NEVER use inline `time.Duration` in `context.WithTimeout` inside handlers. Use constants from `internal/handlers/timeouts.go`: `TimeoutFastLookup` (5s), `TimeoutLookup` (10s), `TimeoutStandard` (30s), `TimeoutHeavyWrite` (60s), `TimeoutOrchestration` (90s), `TimeoutLongRunning` (10m).
- **Response helpers:** Use `api.WriteUnauthorized`, `api.WriteForbidden`, `api.WriteBadRequest`, `api.WriteNotFound`, `api.WriteInternalError` instead of bare `api.WriteError` with status codes. Only use `api.WriteError` directly for custom error codes (e.g., KEY_REVOKED, IP_NOT_ALLOWED).
- **Auth scopes:** EVERY route in a handler's `Mount()` function MUST use `r.With(auth.RequireScope(...))`. Use `ScopeProjectsRead` for GET endpoints, `ScopeProjectsExecute` for mutation endpoints. Use the appropriate domain scope (e.g., `ScopeQueueRead`, `ScopeBuildWrite`) when available. Admin-only endpoints use `auth.ScopeAdmin` alone. See `internal/handlers/builds.go` for the canonical pattern.
- **JSON decoding:** ALWAYS use `api.DecodeJSON(r, &req)` to decode request bodies. NEVER use raw `json.NewDecoder(r.Body).Decode()`. The helper handles nil body, EOF, and returns typed errors. Decode error message is always `"invalid request body"`.
- **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`.
- **Error wrapping:** ALWAYS use `%w` (not `%v`) when wrapping errors in `fmt.Errorf`. Using `%v` stringifies the error and breaks `errors.Is`/`errors.As` chains. For non-error types (structs, slices), create a typed error implementing `error` instead of stringifying with `%v`.
## Quick Reference

View File

@ -58,6 +58,7 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
spec.WithTag("Code Agents", "Multi-provider code agent management")
spec.WithTag("Workers", "Worker pool management")
spec.WithTag("Builds", "Build orchestration and history")
spec.WithTag("SDLC", "Software Development Lifecycle orchestration")
spec.WithTag("System", "Health and readiness endpoints")
// Register all path operations
@ -71,6 +72,7 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
registerAgentPaths(spec)
registerWorkerPaths(spec)
registerBuildPaths(spec)
registerSDLCPaths(spec)
return spec
}

View File

@ -271,6 +271,322 @@ func registerWorkerPaths(spec *api.OpenAPISpec) {
))
}
func registerSDLCPaths(spec *api.OpenAPISpec) {
// State and classifier
spec.AddPath("/projects/{id}/sdlc/state", "get", withAuthAndParams(
"Get SDLC state",
"Returns the global SDLC state for a project.",
"SDLC",
"projects:read",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
))
spec.AddPath("/projects/{id}/sdlc/next", "get", map[string]any{
"summary": "Get next action",
"description": "Returns the classifier's recommended next action.\n\n**Required scope**: `projects:read`",
"tags": []string{"SDLC"},
"security": []map[string]any{{"ApiKeyAuth": []string{}}},
"parameters": []map[string]any{
{"name": "id", "in": "path", "description": "Project ID", "required": true, "schema": map[string]any{"type": "string"}},
{"name": "feature", "in": "query", "description": "Feature slug (optional)", "schema": map[string]any{"type": "string"}},
},
"responses": map[string]any{
"200": map[string]any{"description": "Success"},
"401": map[string]any{"description": "Unauthorized"},
},
})
// Features
spec.AddPath("/projects/{id}/sdlc/features", "get", withAuthAndParams(
"List features",
"Returns all features in the project.",
"SDLC",
"projects:read",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
))
spec.AddPath("/projects/{id}/sdlc/features", "post", withAuthBodyAndParams(
"Create feature",
"Creates a new feature in the draft phase.",
"SDLC",
"projects:execute",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
`{"slug": "auth-flow", "title": "User Authentication Flow"}`,
`{"slug": "auth-flow", "title": "User Authentication Flow", "phase": "draft"}`,
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}", "get", withAuthAndParams(
"Get feature",
"Returns details for a specific feature.",
"SDLC",
"projects:read",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}", "delete", withAuthAndParams(
"Delete feature",
"Deletes a feature from the project.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/transition", "post", withAuthBodyAndParams(
"Transition feature",
"Moves a feature to a new phase.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
`{"phase": "specified"}`,
`{"status": "transitioned", "phase": "specified"}`,
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/block", "post", withAuthBodyAndParams(
"Block feature",
"Adds a blocker reason to a feature.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
`{"reason": "Waiting for API design"}`,
`{"status": "blocked"}`,
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/unblock", "post", withAuthAndParams(
"Unblock feature",
"Removes all blockers from a feature.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
// Artifacts
spec.AddPath("/projects/{id}/sdlc/features/{slug}/artifacts", "get", withAuthAndParams(
"Get artifact status",
"Returns artifact statuses for a feature.",
"SDLC",
"projects:read",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve", "post", withAuthAndParams(
"Approve artifact",
"Approves a feature artifact.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
{Name: "type", In: "path", Description: "Artifact type (spec, design, tasks, qa_plan, review, audit, qa_results)", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject", "post", withAuthAndParams(
"Reject artifact",
"Rejects a feature artifact.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
{Name: "type", In: "path", Description: "Artifact type", Required: true},
},
))
// Tasks
spec.AddPath("/projects/{id}/sdlc/features/{slug}/tasks", "get", withAuthAndParams(
"List tasks",
"Returns all tasks for a feature.",
"SDLC",
"projects:read",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/tasks", "post", withAuthBodyAndParams(
"Add task",
"Adds a new task to a feature.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
`{"title": "Implement login handler"}`,
`{"id": "task-001", "title": "Implement login handler", "status": "pending"}`,
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start", "post", withAuthAndParams(
"Start task",
"Marks a task as in-progress.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
{Name: "taskId", In: "path", Description: "Task ID", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete", "post", withAuthAndParams(
"Complete task",
"Marks a task as complete.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
{Name: "taskId", In: "path", Description: "Task ID", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block", "post", withAuthAndParams(
"Block task",
"Marks a task as blocked.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
{Name: "taskId", In: "path", Description: "Task ID", Required: true},
},
))
// Branch management
spec.AddPath("/projects/{id}/sdlc/features/{slug}/branch", "post", withAuthAndParams(
"Create branch",
"Creates a feature branch and its manifest.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/branch", "get", withAuthAndParams(
"Get branch status",
"Returns branch status and merge checklist.",
"SDLC",
"projects:read",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/branch/sync", "post", withAuthAndParams(
"Sync branch",
"Syncs feature branch with base branch.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
// Merge and archive
spec.AddPath("/projects/{id}/sdlc/features/{slug}/merge", "post", withAuthBodyAndParams(
"Merge feature",
"Merges a feature branch after all gates pass.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
`{"strategy": "squash"}`,
`{"status": "merged", "strategy": "squash"}`,
))
spec.AddPath("/projects/{id}/sdlc/features/{slug}/archive", "post", withAuthAndParams(
"Archive feature",
"Archives a released feature.",
"SDLC",
"projects:execute",
[]param{
{Name: "id", In: "path", Description: "Project ID", Required: true},
{Name: "slug", In: "path", Description: "Feature slug", Required: true},
},
))
// Query endpoints
spec.AddPath("/projects/{id}/sdlc/query/blocked", "get", withAuthAndParams(
"Query blocked features",
"Returns all blocked features.",
"SDLC",
"projects:read",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
))
spec.AddPath("/projects/{id}/sdlc/query/ready", "get", withAuthAndParams(
"Query ready features",
"Returns features ready for work.",
"SDLC",
"projects:read",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
))
spec.AddPath("/projects/{id}/sdlc/query/needs-approval", "get", withAuthAndParams(
"Query features needing approval",
"Returns features awaiting approval.",
"SDLC",
"projects:read",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
))
// Orchestrator endpoints
spec.AddPath("/projects/{id}/sdlc/execute", "post", withAuthBodyAndParams(
"Execute action",
"Executes the classifier's recommended next action.",
"SDLC",
"projects:execute",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
`{"feature": "auth-flow", "provider": "claude"}`,
`{"action": "CREATE_SPEC", "success": true, "output": "..."}`,
))
spec.AddPath("/projects/{id}/sdlc/resolve", "post", withAuthBodyAndParams(
"Resolve blocker",
"Resolves a feature blocker and re-classifies.",
"SDLC",
"projects:execute",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
`{"feature": "auth-flow"}`,
`{"action": "TRANSITION", "success": true, "output": "Feature unblocked"}`,
))
spec.AddPath("/projects/{id}/sdlc/commit", "post", withAuthBodyAndParams(
"Commit changes",
"Commits and optionally pushes changes in the project.",
"SDLC",
"projects:execute",
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
`{"feature": "auth-flow", "message": "feat: implement login", "push": true}`,
`{"commit_sha": "abc123", "files_changed": ["handler.go"], "pushed": true}`,
))
}
func registerBuildPaths(spec *api.OpenAPISpec) {
spec.AddPath("/projects/{id}/builds", "post", withAuthBodyAndParams(
"Start build",

View File

@ -25,17 +25,16 @@ var archiveCmd = &cobra.Command{
return err
}
state.RemoveActiveFeature(slug)
state.RecordAction("archived", slug, "cli")
state.RecordAction("ARCHIVE_FEATURE", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"feature": slug,
"status": "archived",
})
return nil
}
fmt.Printf("Archived: %s\n", slug)

View File

@ -52,13 +52,12 @@ var artifactCreateCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"feature": slug,
"artifact": string(artType),
"status": string(art.Status),
"path": path,
})
return nil
}
fmt.Printf("Created artifact: %s/%s\n", slug, artType)
@ -97,18 +96,17 @@ var artifactApproveCmd = &cobra.Command{
if err != nil {
return err
}
state.RecordAction("APPROVE_ARTIFACT", slug, "user")
state.RecordAction("APPROVE_ARTIFACT", slug, "user", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"feature": slug,
"artifact": string(artType),
"status": "approved",
})
return nil
}
fmt.Printf("Approved: %s/%s\n", slug, artType)
@ -141,12 +139,11 @@ var artifactRejectCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"feature": slug,
"artifact": string(artType),
"status": "rejected",
})
return nil
}
fmt.Printf("Rejected: %s/%s\n", slug, artType)
@ -167,8 +164,7 @@ var artifactStatusCmd = &cobra.Command{
}
if jsonOutput {
printJSON(f.Artifacts)
return nil
return printJSON(f.Artifacts)
}
fmt.Printf("Artifacts for %s:\n", f.Slug)

View File

@ -46,14 +46,13 @@ var branchCreateCmd = &cobra.Command{
if err != nil {
return err
}
state.RecordAction("branch_created", slug, "cli")
state.RecordAction("CREATE_BRANCH", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(manifest)
return nil
return printJSON(manifest)
}
fmt.Printf("Created branch: %s (from %s)\n", branchName, manifest.BaseBranch)
@ -76,8 +75,7 @@ var branchStatusCmd = &cobra.Command{
if f.Branch == "" {
if jsonOutput {
printJSON(map[string]string{"error": "no branch associated with feature"})
return nil
return printJSON(map[string]string{"error": "no branch associated with feature"})
}
fmt.Printf("Feature %s has no branch.\n", slug)
return nil
@ -94,12 +92,11 @@ var branchStatusCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]any{
return printJSON(map[string]any{
"branch": manifest,
"checklist": checklist,
"ready": len(checklist) == 0,
})
return nil
}
fmt.Printf("Branch: %s\n", manifest.Name)
@ -168,13 +165,12 @@ var branchSyncCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"feature": slug,
"branch": manifest.Name,
"synced": "true",
"synced_at": now.Format(time.RFC3339),
})
return nil
}
fmt.Printf("Synced %s with origin/%s\n", manifest.Name, manifest.BaseBranch)

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"strconv"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
@ -24,8 +25,7 @@ var configShowCmd = &cobra.Command{
}
if jsonOutput {
printJSON(cfg)
return nil
return printJSON(cfg)
}
fmt.Printf("SDLC Config (v%d)\n", cfg.Version)
@ -75,11 +75,23 @@ var configSetCmd = &cobra.Command{
case "branches.feature_prefix":
cfg.Branches.FeaturePrefix = value
case "compliance.require_approvals":
cfg.Compliance.RequireApprovals = value == "true"
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean value for %s: %w", key, err)
}
cfg.Compliance.RequireApprovals = b
case "compliance.require_branch":
cfg.Compliance.RequireBranch = value == "true"
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean value for %s: %w", key, err)
}
cfg.Compliance.RequireBranch = b
case "compliance.require_qa":
cfg.Compliance.RequireQA = value == "true"
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean value for %s: %w", key, err)
}
cfg.Compliance.RequireQA = b
default:
return fmt.Errorf("unknown config key: %s", key)
}
@ -89,8 +101,7 @@ var configSetCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{"key": key, "value": value, "status": "set"})
return nil
return printJSON(map[string]string{"key": key, "value": value, "status": "set"})
}
fmt.Printf("Set %s = %s\n", key, value)

View File

@ -38,14 +38,13 @@ var featureCreateCmd = &cobra.Command{
return err
}
state.AddActiveFeature(slug, sdlc.PhaseDraft)
state.RecordAction("CREATE_FEATURE", slug, "cli")
state.RecordAction("CREATE_FEATURE", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(f)
return nil
return printJSON(f)
}
fmt.Printf("Created feature: %s\n", slug)
@ -70,8 +69,7 @@ var featureListCmd = &cobra.Command{
}
if jsonOutput {
printJSON(features)
return nil
return printJSON(features)
}
if len(features) == 0 {
@ -110,8 +108,7 @@ var featureShowCmd = &cobra.Command{
}
if jsonOutput {
printJSON(f)
return nil
return printJSON(f)
}
fmt.Printf("Feature: %s\n", f.Slug)
@ -177,8 +174,7 @@ var featureStatusCmd = &cobra.Command{
"tasks": sdlc.SummarizeTasks(f.Tasks),
"blockers": f.Blockers,
}
printJSON(result)
return nil
return printJSON(result)
}
fmt.Printf("Feature: %s\n", f.Slug)
@ -231,18 +227,17 @@ var featureTransitionCmd = &cobra.Command{
return err
}
state.UpdateActiveFeature(slug, phase, f.Branch)
state.RecordAction("TRANSITION", slug, "cli")
state.RecordAction("TRANSITION", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"slug": slug,
"from": string(from),
"to": string(phase),
})
return nil
}
fmt.Printf("Transitioned %s: %s -> %s\n", slug, from, phase)
@ -269,8 +264,7 @@ var featureBlockCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{"slug": slug, "blocker": reason})
return nil
return printJSON(map[string]string{"slug": slug, "blocker": reason})
}
fmt.Printf("Blocked %s: %s\n", slug, reason)
@ -297,8 +291,7 @@ var featureUnblockCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{"slug": slug, "status": "unblocked"})
return nil
return printJSON(map[string]string{"slug": slug, "status": "unblocked"})
}
fmt.Printf("Unblocked %s\n", slug)

View File

@ -26,12 +26,11 @@ var initCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"status": "initialized",
"root": root,
"project": name,
})
return nil
}
fmt.Println("Initialized .sdlc/ structure")

View File

@ -3,6 +3,7 @@ package main
import (
"fmt"
"os/exec"
"strings"
"time"
"github.com/orchard9/rdev/internal/sdlc"
@ -26,12 +27,14 @@ var mergeCmd = &cobra.Command{
}
if len(checklist) > 0 {
if jsonOutput {
printJSON(map[string]any{
if err := printJSON(map[string]any{
"error": "merge not ready",
"checklist": checklist,
})
}); err != nil {
return err
}
}
return fmt.Errorf("%w: %v", sdlc.ErrMergeNotReady, checklist)
return fmt.Errorf("%w: %s", sdlc.ErrMergeNotReady, strings.Join(checklist, ", "))
}
f, err := sdlc.LoadFeature(root, slug)
@ -53,6 +56,39 @@ var mergeCmd = &cobra.Command{
strategy = "squash"
}
// Record original branch for rollback
origBranchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
origBranchCmd.Dir = root
origBranchOut, err := origBranchCmd.Output()
if err != nil {
return fmt.Errorf("get current branch: %w", err)
}
originalBranch := strings.TrimSpace(string(origBranchOut))
// Record HEAD for rollback
headCmd := exec.Command("git", "rev-parse", "HEAD")
headCmd.Dir = root
headOut, err := headCmd.Output()
if err != nil {
return fmt.Errorf("get HEAD: %w", err)
}
originalHead := strings.TrimSpace(string(headOut))
// rollback reverts git state on failure
rollback := func() {
// Reset to original HEAD
resetCmd := exec.Command("git", "reset", "--hard", originalHead)
resetCmd.Dir = root
_ = resetCmd.Run()
// Checkout original branch if different
if originalBranch != manifest.BaseBranch {
checkoutOrigCmd := exec.Command("git", "checkout", originalBranch)
checkoutOrigCmd.Dir = root
_ = checkoutOrigCmd.Run()
}
}
// Checkout main branch
checkoutCmd := exec.Command("git", "checkout", manifest.BaseBranch)
checkoutCmd.Dir = root
@ -60,6 +96,23 @@ var mergeCmd = &cobra.Command{
return fmt.Errorf("git checkout %s: %s: %w", manifest.BaseBranch, string(out), err)
}
// Record main branch HEAD for rollback after checkout
mainHeadCmd := exec.Command("git", "rev-parse", "HEAD")
mainHeadCmd.Dir = root
mainHeadOut, err := mainHeadCmd.Output()
if err != nil {
rollback()
return fmt.Errorf("get main HEAD: %w", err)
}
mainHead := strings.TrimSpace(string(mainHeadOut))
// Update rollback to use main branch HEAD
rollbackAfterMerge := func() {
resetCmd := exec.Command("git", "reset", "--hard", mainHead)
resetCmd.Dir = root
_ = resetCmd.Run()
}
// Merge
mergeArgs := []string{"merge"}
if strategy == "squash" {
@ -70,6 +123,7 @@ var mergeCmd = &cobra.Command{
gitMerge := exec.Command("git", mergeArgs...)
gitMerge.Dir = root
if out, err := gitMerge.CombinedOutput(); err != nil {
rollbackAfterMerge()
return fmt.Errorf("git merge %s: %s: %w", f.Branch, string(out), err)
}
@ -79,24 +133,28 @@ var mergeCmd = &cobra.Command{
commitCmd := exec.Command("git", "commit", "-m", commitMsg)
commitCmd.Dir = root
if out, err := commitCmd.CombinedOutput(); err != nil {
rollbackAfterMerge()
return fmt.Errorf("git commit: %s: %w", string(out), err)
}
}
// Update branch manifest
// Update branch manifest - rollback git on failure
now := time.Now().UTC()
manifest.MergedAt = &now
manifest.MergeStrategy = strategy
if err := sdlc.SaveBranch(root, manifest); err != nil {
return err
rollbackAfterMerge()
return fmt.Errorf("save branch manifest (git rolled back): %w", err)
}
// Transition feature to released
// Transition feature to released - rollback git on failure
if err := f.Transition(sdlc.PhaseReleased); err != nil {
return err
rollbackAfterMerge()
return fmt.Errorf("transition feature (git rolled back): %w", err)
}
if err := f.Save(root); err != nil {
return err
rollbackAfterMerge()
return fmt.Errorf("save feature (git rolled back): %w", err)
}
// Record action
@ -104,19 +162,18 @@ var mergeCmd = &cobra.Command{
if err != nil {
return err
}
state.RecordAction("merged", slug, "cli")
state.RecordAction("MERGE_FEATURE", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
return printJSON(map[string]string{
"feature": slug,
"branch": f.Branch,
"strategy": strategy,
"status": "merged",
})
return nil
}
fmt.Printf("Merged: %s -> %s (strategy: %s)\n", f.Branch, manifest.BaseBranch, strategy)

View File

@ -38,8 +38,7 @@ var nextCmd = &cobra.Command{
// Classify all active features, return first actionable
if len(state.ActiveWork.Features) == 0 {
if jsonOutput {
printJSON(map[string]string{"action": "IDLE", "message": "No active features"})
return nil
return printJSON(map[string]string{"action": "IDLE", "message": "No active features"})
}
fmt.Println("No active features. Create one: sdlc feature create <slug>")
return nil
@ -64,8 +63,7 @@ var nextCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{"action": "IDLE", "message": "No actionable work found"})
return nil
return printJSON(map[string]string{"action": "IDLE", "message": "No actionable work found"})
}
fmt.Println("No actionable work found across active features.")
@ -94,7 +92,7 @@ func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifie
return err
}
state.UpdateActiveFeature(slug, cl.TransitionTo, f.Branch)
state.RecordAction("transition", slug, "cli")
state.RecordAction("TRANSITION", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
@ -125,8 +123,7 @@ func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifie
func printClassification(cl *sdlc.Classification, f *sdlc.Feature) error {
if jsonOutput {
printJSON(cl)
return nil
return printJSON(cl)
}
fmt.Printf("Feature: %s\n", cl.Feature)

View File

@ -41,8 +41,7 @@ var queryBlockedCmd = &cobra.Command{
}
if jsonOutput {
printJSON(blocked)
return nil
return printJSON(blocked)
}
if len(blocked) == 0 {
@ -111,8 +110,7 @@ var queryReadyCmd = &cobra.Command{
}
if jsonOutput {
printJSON(ready)
return nil
return printJSON(ready)
}
if len(ready) == 0 {
@ -175,8 +173,7 @@ var queryNeedsApprovalCmd = &cobra.Command{
}
if jsonOutput {
printJSON(pending)
return nil
return printJSON(pending)
}
if len(pending) == 0 {

View File

@ -19,8 +19,7 @@ var stateCmd = &cobra.Command{
}
if jsonOutput {
printJSON(state)
return nil
return printJSON(state)
}
fmt.Printf("SDLC State (v%d)\n", state.Version)

View File

@ -25,8 +25,7 @@ var taskListCmd = &cobra.Command{
}
if jsonOutput {
printJSON(f.Tasks)
return nil
return printJSON(f.Tasks)
}
if len(f.Tasks) == 0 {
@ -77,8 +76,7 @@ var taskStartCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "in_progress"})
return nil
return printJSON(map[string]string{"feature": slug, "task": taskID, "status": "in_progress"})
}
fmt.Printf("Started: %s/%s\n", slug, taskID)
@ -114,14 +112,13 @@ var taskCompleteCmd = &cobra.Command{
if err != nil {
return err
}
state.RecordAction("COMPLETE_TASK", slug, "cli")
state.RecordAction("COMPLETE_TASK", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "complete"})
return nil
return printJSON(map[string]string{"feature": slug, "task": taskID, "status": "complete"})
}
s := sdlc.SummarizeTasks(f.Tasks)
@ -154,8 +151,7 @@ var taskBlockCmd = &cobra.Command{
}
if jsonOutput {
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "blocked"})
return nil
return printJSON(map[string]string{"feature": slug, "task": taskID, "status": "blocked"})
}
fmt.Printf("Blocked: %s/%s\n", slug, taskID)
@ -186,8 +182,7 @@ var taskAddCmd = &cobra.Command{
newTask := f.Tasks[len(f.Tasks)-1]
if jsonOutput {
printJSON(newTask)
return nil
return printJSON(newTask)
}
fmt.Printf("Added: %s/%s - %s\n", slug, newTask.ID, title)

View File

@ -60,22 +60,24 @@ func resolveRoot() (string, error) {
return cwd, nil
}
// mustResolveRoot resolves root or exits.
// mustResolveRoot resolves root or panics (for use in RunE where error is returned).
// Note: Panicking here is intentional as these commands run under cobra which
// catches panics and converts them to errors. For production use, prefer resolveRoot().
func mustResolveRoot() string {
root, err := resolveRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
panic(fmt.Sprintf("resolve root: %v", err))
}
return root
}
// printJSON marshals v as indented JSON and prints to stdout.
func printJSON(v any) {
// Returns an error if marshaling fails instead of exiting.
func printJSON(v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
os.Exit(1)
return fmt.Errorf("marshal JSON: %w", err)
}
fmt.Println(string(data))
return nil
}

2
cookbooks/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# Checkpoint storage (local state for tree runner)
.checkpoints/

View File

@ -0,0 +1,337 @@
#!/bin/bash
# Checkpoint utilities for tree-based cookbook execution
#
# Usage:
# source "$(dirname "${BASH_SOURCE[0]}")/checkpoint.sh"
#
# Provides:
# - checkpoint_init() - Initialize a new checkpoint
# - checkpoint_load() - Load existing checkpoint
# - checkpoint_save() - Persist checkpoint to disk
# - checkpoint_step_start() - Mark step as started
# - checkpoint_step_complete() - Mark step as completed with output
# - checkpoint_step_fail() - Mark step as failed with error
# - checkpoint_get_output() - Get output from a completed step
# - checkpoint_status() - Get overall checkpoint status
# - checkpoint_list() - List all checkpoints
# - checkpoint_delete() - Delete a checkpoint
# Checkpoint directory
CHECKPOINT_DIR="${CHECKPOINT_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../.checkpoints}"
# Ensure checkpoint directory exists
_checkpoint_ensure_dir() {
mkdir -p "$CHECKPOINT_DIR"
}
# Get checkpoint file path for a tree
_checkpoint_path() {
local tree_name="$1"
echo "$CHECKPOINT_DIR/${tree_name}.json"
}
# Initialize a new checkpoint
# Arguments: tree_name vars_json
# Returns: run_id
# Example: run_id=$(checkpoint_init "landing-page" '{"project_name": "test"}')
checkpoint_init() {
local tree_name="$1"
local vars_json="${2:-{}}"
_checkpoint_ensure_dir
local run_id="${tree_name}-$(date +%s)"
local now
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local checkpoint
checkpoint=$(jq -n \
--arg tree "$tree_name" \
--arg run_id "$run_id" \
--arg started "$now" \
--argjson vars "$vars_json" \
'{
tree: $tree,
run_id: $run_id,
status: "pending",
vars: $vars,
steps: {},
started_at: $started,
last_completed_step: null
}')
echo "$checkpoint" > "$(_checkpoint_path "$tree_name")"
echo "$run_id"
}
# Load existing checkpoint
# Arguments: tree_name
# Returns: checkpoint JSON on stdout, exit 1 if not found
# Example: checkpoint=$(checkpoint_load "landing-page")
checkpoint_load() {
local tree_name="$1"
local path
path="$(_checkpoint_path "$tree_name")"
if [[ ! -f "$path" ]]; then
return 1
fi
cat "$path"
}
# Save checkpoint to disk
# Arguments: tree_name checkpoint_json
# Example: checkpoint_save "landing-page" "$checkpoint"
checkpoint_save() {
local tree_name="$1"
local checkpoint="$2"
_checkpoint_ensure_dir
echo "$checkpoint" > "$(_checkpoint_path "$tree_name")"
}
# Mark step as started
# Arguments: tree_name step_name
# Example: checkpoint_step_start "landing-page" "create-project"
checkpoint_step_start() {
local tree_name="$1"
local step_name="$2"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
local now
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
checkpoint=$(echo "$checkpoint" | jq \
--arg step "$step_name" \
--arg started "$now" \
'.steps[$step] = {status: "running", started_at: $started} | .status = "partial"')
checkpoint_save "$tree_name" "$checkpoint"
}
# Mark step as completed with output
# Arguments: tree_name step_name output_json
# Example: checkpoint_step_complete "landing-page" "create-project" '{"project_id": "test"}'
checkpoint_step_complete() {
local tree_name="$1"
local step_name="$2"
local output_json="${3:-{}}"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
local now
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
checkpoint=$(echo "$checkpoint" | jq \
--arg step "$step_name" \
--arg completed "$now" \
--argjson output "$output_json" \
'.steps[$step].status = "completed" |
.steps[$step].completed_at = $completed |
.steps[$step].output = $output |
.last_completed_step = $step')
checkpoint_save "$tree_name" "$checkpoint"
}
# Mark step as failed with error
# Arguments: tree_name step_name error_message
# Example: checkpoint_step_fail "landing-page" "wait-pipeline" "Pipeline failed"
checkpoint_step_fail() {
local tree_name="$1"
local step_name="$2"
local error_msg="$3"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
local now
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
checkpoint=$(echo "$checkpoint" | jq \
--arg step "$step_name" \
--arg completed "$now" \
--arg error "$error_msg" \
'.steps[$step].status = "failed" |
.steps[$step].completed_at = $completed |
.steps[$step].error = $error |
.status = "failed"')
checkpoint_save "$tree_name" "$checkpoint"
}
# Get output from a completed step
# Arguments: tree_name step_name [output_key]
# Returns: output value or full output JSON
# Example: project_id=$(checkpoint_get_output "landing-page" "create-project" "project_id")
checkpoint_get_output() {
local tree_name="$1"
local step_name="$2"
local output_key="${3:-}"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
if [[ -n "$output_key" ]]; then
echo "$checkpoint" | jq -r ".steps[\"$step_name\"].output[\"$output_key\"] // empty"
else
echo "$checkpoint" | jq ".steps[\"$step_name\"].output // {}"
fi
}
# Get variable from checkpoint
# Arguments: tree_name var_name
# Returns: variable value
# Example: project_name=$(checkpoint_get_var "landing-page" "project_name")
checkpoint_get_var() {
local tree_name="$1"
local var_name="$2"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
echo "$checkpoint" | jq -r ".vars[\"$var_name\"] // empty"
}
# Get overall checkpoint status
# Arguments: tree_name
# Returns: status string (pending, partial, completed, failed)
# Example: status=$(checkpoint_status "landing-page")
checkpoint_status() {
local tree_name="$1"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || {
echo "none"
return 0
}
echo "$checkpoint" | jq -r '.status // "unknown"'
}
# Get detailed checkpoint status (for display)
# Arguments: tree_name
# Returns: formatted status output
checkpoint_status_detail() {
local tree_name="$1"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || {
echo "No checkpoint found for tree: $tree_name"
return 1
}
echo "$checkpoint" | jq -r '
"Tree: \(.tree)",
"Run ID: \(.run_id)",
"Status: \(.status)",
"Started: \(.started_at)",
"",
"Variables:",
(.vars | to_entries | .[] | " \(.key): \(.value)"),
"",
"Steps:",
(.steps | to_entries | sort_by(.value.started_at // "") | .[] |
if .value.status == "completed" then
" \u001b[32m\u2713\u001b[0m \(.key): completed"
elif .value.status == "failed" then
" \u001b[31m\u2717\u001b[0m \(.key): failed - \(.value.error)"
elif .value.status == "running" then
" \u001b[33m\u25d0\u001b[0m \(.key): running..."
else
" \u25cb \(.key): \(.value.status)"
end
),
"",
"Last completed: \(.last_completed_step // "none")"
'
}
# Get list of completed steps
# Arguments: tree_name
# Returns: newline-separated list of completed step names
checkpoint_completed_steps() {
local tree_name="$1"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
echo "$checkpoint" | jq -r '.steps | to_entries | .[] | select(.value.status == "completed") | .key'
}
# Get list of failed steps
# Arguments: tree_name
# Returns: newline-separated list of failed step names
checkpoint_failed_steps() {
local tree_name="$1"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
echo "$checkpoint" | jq -r '.steps | to_entries | .[] | select(.value.status == "failed") | .key'
}
# List all checkpoints
# Returns: newline-separated list of tree names with checkpoints
# Example: trees=$(checkpoint_list)
checkpoint_list() {
_checkpoint_ensure_dir
for f in "$CHECKPOINT_DIR"/*.json; do
[[ -e "$f" ]] || continue
basename "$f" .json
done
}
# Delete a checkpoint
# Arguments: tree_name
# Example: checkpoint_delete "landing-page"
checkpoint_delete() {
local tree_name="$1"
local path
path="$(_checkpoint_path "$tree_name")"
if [[ -f "$path" ]]; then
rm "$path"
return 0
fi
return 1
}
# Mark entire tree as completed
# Arguments: tree_name
checkpoint_mark_completed() {
local tree_name="$1"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
local now
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
checkpoint=$(echo "$checkpoint" | jq \
--arg completed "$now" \
'.status = "completed" | .completed_at = $completed')
checkpoint_save "$tree_name" "$checkpoint"
}
# Update vars in checkpoint (for resume with different vars)
# Arguments: tree_name vars_json
checkpoint_update_vars() {
local tree_name="$1"
local vars_json="$2"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || return 1
checkpoint=$(echo "$checkpoint" | jq \
--argjson new_vars "$vars_json" \
'.vars = (.vars + $new_vars)')
checkpoint_save "$tree_name" "$checkpoint"
}

View File

@ -0,0 +1,400 @@
#!/bin/bash
# Tree parser utilities for YAML cookbook definitions
#
# Usage:
# source "$(dirname "${BASH_SOURCE[0]}")/tree-parser.sh"
#
# Provides:
# - tree_parse() - Parse a tree YAML file
# - tree_get_step() - Get a specific step definition
# - tree_get_deps() - Get dependencies for a step
# - tree_execution_order() - Get topological sort of steps
# - tree_step_ready() - Check if step's dependencies are satisfied
# - tree_expand_template() - Expand Go template variables
# - tree_list() - List all available trees
# - tree_get_teardown() - Get teardown steps
# Trees directory
TREES_DIR="${TREES_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../trees}"
# Check for yq
_tree_check_yq() {
if ! command -v yq &> /dev/null; then
echo "Error: yq is required but not installed" >&2
echo "Install with: brew install yq" >&2
return 1
fi
}
# Get tree file path
_tree_path() {
local tree_name="$1"
echo "$TREES_DIR/${tree_name}.yaml"
}
# Parse a tree YAML file
# Arguments: tree_name
# Returns: tree JSON on stdout
# Example: tree_json=$(tree_parse "landing-page")
tree_parse() {
local tree_name="$1"
local path
path="$(_tree_path "$tree_name")"
_tree_check_yq || return 1
if [[ ! -f "$path" ]]; then
echo "Error: Tree '$tree_name' not found at $path" >&2
return 1
fi
yq -o=json "$path"
}
# Get tree metadata
# Arguments: tree_name
# Returns: JSON with name, description, version
tree_get_meta() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '{name: .name, description: .description, version: .version}'
}
# Get default vars from tree
# Arguments: tree_name
# Returns: JSON object of default vars
tree_get_default_vars() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '.vars // {}'
}
# Get a specific step definition
# Arguments: tree_name step_name
# Returns: step JSON on stdout
# Example: step=$(tree_get_step "landing-page" "create-project")
tree_get_step() {
local tree_name="$1"
local step_name="$2"
local tree
tree=$(tree_parse "$tree_name") || return 1
local step
step=$(echo "$tree" | jq --arg step "$step_name" '.steps[$step] // null')
if [[ "$step" == "null" ]]; then
echo "Error: Step '$step_name' not found in tree '$tree_name'" >&2
return 1
fi
# Add step name to the JSON for convenience
echo "$step" | jq --arg name "$step_name" '. + {name: $name}'
}
# Get all step names
# Arguments: tree_name
# Returns: newline-separated list of step names
tree_get_steps() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq -r '.steps | keys[]'
}
# Get dependencies for a step
# Arguments: tree_name step_name
# Returns: newline-separated list of dependency step names
# Example: deps=$(tree_get_deps "landing-page" "add-component")
tree_get_deps() {
local tree_name="$1"
local step_name="$2"
local step
step=$(tree_get_step "$tree_name" "$step_name") || return 1
echo "$step" | jq -r '.depends_on // [] | .[]'
}
# Get topological sort of steps (execution order)
# Arguments: tree_name
# Returns: newline-separated list of step names in execution order
# Example: order=$(tree_execution_order "landing-page")
tree_execution_order() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
# Kahn's algorithm for topological sort
# Build adjacency list and in-degree count
local steps_json
steps_json=$(echo "$tree" | jq '.steps')
# Use jq to compute the topological order
echo "$steps_json" | jq -r '
# Build in-degree map and adjacency list
. as $steps |
(keys | map({key: ., value: 0}) | from_entries) as $initial_degrees |
reduce keys[] as $step (
{degrees: $initial_degrees, adj: {}};
. as $state |
($steps[$step].depends_on // []) as $deps |
reduce $deps[] as $dep (
$state;
.degrees[$step] += 1 |
.adj[$dep] = ((.adj[$dep] // []) + [$step])
)
) |
# Kahns algorithm
. as $graph |
{
result: [],
queue: [$graph.degrees | to_entries | .[] | select(.value == 0) | .key],
degrees: $graph.degrees,
adj: $graph.adj
} |
until(.queue | length == 0;
.queue[0] as $node |
.result += [$node] |
.queue = .queue[1:] |
reduce ((.adj[$node] // [])[] ) as $neighbor (
.;
.degrees[$neighbor] -= 1 |
if .degrees[$neighbor] == 0 then
.queue += [$neighbor]
else
.
end
)
) |
.result[]
'
}
# Check if a step's dependencies are satisfied
# Arguments: tree_name step_name completed_steps_json
# Returns: 0 if ready, 1 if not
# Example: if tree_step_ready "landing-page" "add-component" '["create-project"]'; then ...
tree_step_ready() {
local tree_name="$1"
local step_name="$2"
local completed_steps="$3" # JSON array of completed step names
local deps
deps=$(tree_get_deps "$tree_name" "$step_name") || return 1
if [[ -z "$deps" ]]; then
return 0 # No dependencies, always ready
fi
# Check each dependency
while IFS= read -r dep; do
if ! echo "$completed_steps" | jq -e --arg d "$dep" 'index($d) != null' > /dev/null; then
return 1 # Dependency not satisfied
fi
done <<< "$deps"
return 0
}
# Expand Go template variables in a string
# Arguments: template_string vars_json outputs_json
# Returns: expanded string
# Example: result=$(tree_expand_template "{{ .vars.project_name }}" '{"project_name":"test"}' '{}')
tree_expand_template() {
local template="$1"
local vars_json="${2:-{}}"
local outputs_json="${3:-{}}"
# Build context for template expansion
local context
context=$(jq -n \
--argjson vars "$vars_json" \
--argjson outputs "$outputs_json" \
'{vars: $vars, outputs: $outputs}')
# Simple template expansion using sed
# Handle {{ .vars.NAME }} patterns
local result="$template"
# Extract and replace all {{ .vars.xxx }} patterns
while [[ "$result" =~ \{\{[[:space:]]*\.vars\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do
local var_name="${BASH_REMATCH[1]}"
local var_value
var_value=$(echo "$vars_json" | jq -r ".[\"$var_name\"] // \"\"")
result="${result//\{\{ .vars.$var_name \}\}/$var_value}"
result="${result//\{\{.vars.$var_name\}\}/$var_value}"
done
# Extract and replace all {{ .outputs.step.key }} patterns
while [[ "$result" =~ \{\{[[:space:]]*\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do
local step_name="${BASH_REMATCH[1]}"
local key_name="${BASH_REMATCH[2]}"
local out_value
out_value=$(echo "$outputs_json" | jq -r ".[\"$step_name\"][\"$key_name\"] // \"\"")
result="${result//\{\{ .outputs.$step_name.$key_name \}\}/$out_value}"
result="${result//\{\{.outputs.$step_name.$key_name\}\}/$out_value}"
done
echo "$result"
}
# Expand templates in a step definition
# Arguments: step_json vars_json outputs_json
# Returns: step JSON with templates expanded
tree_expand_step() {
local step_json="$1"
local vars_json="${2:-{}}"
local outputs_json="${3:-{}}"
# Convert step to string and expand templates
local step_str
step_str=$(echo "$step_json" | jq -c '.')
local expanded
expanded=$(tree_expand_template "$step_str" "$vars_json" "$outputs_json")
echo "$expanded"
}
# List all available trees
# Returns: newline-separated list of tree names
# Example: trees=$(tree_list)
tree_list() {
_tree_check_yq || return 1
for f in "$TREES_DIR"/*.yaml; do
[[ -e "$f" ]] || continue
basename "$f" .yaml
done
}
# List trees with descriptions
# Returns: formatted list of trees with descriptions
tree_list_detail() {
_tree_check_yq || return 1
for f in "$TREES_DIR"/*.yaml; do
[[ -e "$f" ]] || continue
local name
name=$(basename "$f" .yaml)
local desc
desc=$(yq -r '.description // "No description"' "$f" 2>/dev/null)
printf " %-20s %s\n" "$name" "$desc"
done
}
# Get teardown steps
# Arguments: tree_name
# Returns: JSON array of teardown steps
# Example: teardown=$(tree_get_teardown "landing-page")
tree_get_teardown() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '.teardown // []'
}
# Get step action type
# Arguments: step_json
# Returns: action type string
tree_step_action() {
local step_json="$1"
echo "$step_json" | jq -r '.action // "unknown"'
}
# Get step on_error behavior
# Arguments: step_json
# Returns: "fail" or "continue"
tree_step_on_error() {
local step_json="$1"
echo "$step_json" | jq -r '.on_error // "fail"'
}
# Get output extraction rules from step
# Arguments: step_json
# Returns: JSON array of output rules [{name, jq_path}]
tree_step_outputs() {
local step_json="$1"
# outputs can be:
# - array of {key: jq_path}
# Example: [{project_id: ".data.name"}]
echo "$step_json" | jq '
.outputs // [] |
map(to_entries | .[0] | {name: .key, jq_path: .value})
'
}
# Validate tree YAML
# Arguments: tree_name
# Returns: 0 if valid, 1 with error messages if not
tree_validate() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
local errors=()
# Check required fields
local name
name=$(echo "$tree" | jq -r '.name // ""')
if [[ -z "$name" ]]; then
errors+=("Missing required field: name")
fi
# Check that all steps have action field
local steps_without_action
steps_without_action=$(echo "$tree" | jq -r '.steps | to_entries | .[] | select(.value.action == null) | .key')
if [[ -n "$steps_without_action" ]]; then
while IFS= read -r step; do
errors+=("Step '$step' missing required field: action")
done <<< "$steps_without_action"
fi
# Check that dependencies reference existing steps
local all_steps
all_steps=$(echo "$tree" | jq -r '.steps | keys')
local invalid_deps
invalid_deps=$(echo "$tree" | jq -r --argjson all_steps "$all_steps" '
.steps | to_entries | .[] |
.key as $step |
(.value.depends_on // [])[] |
select(. as $dep | $all_steps | index($dep) == null) |
"\($step) depends on non-existent step: \(.)"
')
if [[ -n "$invalid_deps" ]]; then
while IFS= read -r err; do
errors+=("$err")
done <<< "$invalid_deps"
fi
# Check for cycles (execution_order will fail if there are cycles)
if ! tree_execution_order "$tree_name" > /dev/null 2>&1; then
errors+=("Dependency cycle detected")
fi
# Report errors
if [[ ${#errors[@]} -gt 0 ]]; then
echo "Validation errors in tree '$tree_name':" >&2
for err in "${errors[@]}"; do
echo " - $err" >&2
done
return 1
fi
return 0
}

686
cookbooks/scripts/tree-runner.sh Executable file
View File

@ -0,0 +1,686 @@
#!/bin/bash
set -euo pipefail
# Tree Runner - Execute cookbook trees with checkpoint support
#
# Usage:
# ./tree-runner.sh run <tree> [--var-name value]...
# ./tree-runner.sh resume <tree>
# ./tree-runner.sh only <tree> <step>
# ./tree-runner.sh status <tree>
# ./tree-runner.sh teardown <tree>
# ./tree-runner.sh list
# ./tree-runner.sh clean <tree>
#
# Examples:
# ./tree-runner.sh run landing-page --project-name my-test
# ./tree-runner.sh resume landing-page
# ./tree-runner.sh only landing-page wait-pipeline
# ./tree-runner.sh status landing-page
# ./tree-runner.sh teardown landing-page
# ./tree-runner.sh list
# ./tree-runner.sh clean landing-page
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source dependencies
source "$SCRIPT_DIR/common.sh"
source "$SCRIPT_DIR/lib/checkpoint.sh"
source "$SCRIPT_DIR/lib/tree-parser.sh"
# Parse command
COMMAND="${1:-}"
if [[ -z "$COMMAND" ]]; then
echo "Tree Runner - Execute cookbook trees with checkpoint support"
echo ""
echo "Usage: $0 <command> [args]"
echo ""
echo "Commands:"
echo " run <tree> [--var-name value]... Run a tree from the beginning"
echo " resume <tree> Resume from last checkpoint"
echo " only <tree> <step> Run only a specific step"
echo " status <tree> Show checkpoint status"
echo " teardown <tree> Run tree's teardown steps"
echo " list List available trees"
echo " clean <tree> Delete checkpoint for a tree"
echo ""
echo "Examples:"
echo " $0 run landing-page --project-name my-test-\$(date +%s)"
echo " $0 resume landing-page"
echo " $0 only landing-page wait-pipeline"
echo " $0 status landing-page"
echo " $0 teardown landing-page"
echo " $0 list"
echo " $0 clean landing-page"
echo ""
exit 1
fi
shift
# ============================================================================
# Step Executors
# ============================================================================
# Execute an API step
# Arguments: step_json
# Returns: response JSON
execute_api_step() {
local step_json="$1"
local method endpoint body
method=$(echo "$step_json" | jq -r '.method // "GET"')
endpoint=$(echo "$step_json" | jq -r '.endpoint')
body=$(echo "$step_json" | jq -c '.body // null')
local response
if [[ "$body" != "null" ]]; then
response=$(api_call "$method" "$endpoint" "$body")
else
response=$(api_call "$method" "$endpoint")
fi
echo "$response"
}
# Execute a wait_pipeline step
# Arguments: step_json
# Returns: 0 on success, 1 on failure
execute_wait_pipeline_step() {
local step_json="$1"
local project_id max_attempts poll_interval
project_id=$(echo "$step_json" | jq -r '.project_id')
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 60')
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
wait_for_pipeline "$project_id" "$max_attempts" "$poll_interval"
}
# Execute a wait_site step
# Arguments: step_json
# Returns: 0 on success, 1 on failure
execute_wait_site_step() {
local step_json="$1"
local domain project_id max_attempts poll_interval
domain=$(echo "$step_json" | jq -r '.domain')
project_id=$(echo "$step_json" | jq -r '.project_id // ""')
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 30')
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
wait_for_site "$domain" "$max_attempts" "$poll_interval" "$project_id"
}
# Execute a diagnose step
# Arguments: step_json
execute_diagnose_step() {
local step_json="$1"
local diag_type project_id domain
diag_type=$(echo "$step_json" | jq -r '.type // "pipeline"')
project_id=$(echo "$step_json" | jq -r '.project_id // ""')
domain=$(echo "$step_json" | jq -r '.domain // ""')
case "$diag_type" in
pipeline)
diagnose_pipeline_failure "$project_id"
;;
site)
diagnose_site_failure "$domain" "$project_id"
;;
*)
print_error "Unknown diagnose type: $diag_type"
return 1
;;
esac
}
# Execute a shell step
# Arguments: step_json
# Returns: command output
execute_shell_step() {
local step_json="$1"
local command
command=$(echo "$step_json" | jq -r '.command')
eval "$command"
}
# Extract outputs from response
# Arguments: response_json output_rules_json
# Returns: JSON object of extracted outputs
extract_outputs() {
local response="$1"
local output_rules="$2"
if [[ "$output_rules" == "[]" || -z "$output_rules" ]]; then
echo "{}"
return
fi
# Extract each output using jq
local outputs="{}"
while IFS= read -r rule; do
local name jq_path value
name=$(echo "$rule" | jq -r '.name')
jq_path=$(echo "$rule" | jq -r '.jq_path')
value=$(echo "$response" | jq -r "$jq_path // \"\"")
outputs=$(echo "$outputs" | jq --arg k "$name" --arg v "$value" '. + {($k): $v}')
done < <(echo "$output_rules" | jq -c '.[]')
echo "$outputs"
}
# Execute a single step
# Arguments: tree_name step_name vars_json outputs_json
# Returns: 0 on success, 1 on failure
# Updates outputs_json with new outputs
execute_step() {
local tree_name="$1"
local step_name="$2"
local vars_json="$3"
local outputs_json="$4"
# Get and expand step definition
local step_raw
step_raw=$(tree_get_step "$tree_name" "$step_name") || return 1
local step
step=$(tree_expand_step "$step_raw" "$vars_json" "$outputs_json")
local action description on_error
action=$(tree_step_action "$step")
description=$(echo "$step" | jq -r '.description // ""')
on_error=$(tree_step_on_error "$step")
# Print step header
if [[ -n "$description" ]]; then
echo -e "${CYAN}Step: $step_name${NC} - $description"
else
echo -e "${CYAN}Step: $step_name${NC}"
fi
# Mark step as started
checkpoint_step_start "$tree_name" "$step_name"
local response=""
local step_failed=0
case "$action" in
api)
response=$(execute_api_step "$step") || step_failed=1
if [[ $step_failed -eq 0 ]]; then
# Check for error in response
local error
error=$(echo "$response" | jq -r '.error // ""')
if [[ -n "$error" && "$error" != "null" ]]; then
print_error "API error: $error"
step_failed=1
fi
fi
;;
wait_pipeline)
execute_wait_pipeline_step "$step" || step_failed=1
response="{}"
;;
wait_site)
execute_wait_site_step "$step" || step_failed=1
response="{}"
;;
diagnose)
execute_diagnose_step "$step"
response="{}"
;;
shell)
response=$(execute_shell_step "$step") || step_failed=1
# Try to parse as JSON, otherwise wrap in object
if ! echo "$response" | jq -e '.' > /dev/null 2>&1; then
response=$(jq -n --arg out "$response" '{output: $out}')
fi
;;
*)
print_error "Unknown action type: $action"
checkpoint_step_fail "$tree_name" "$step_name" "Unknown action: $action"
return 1
;;
esac
if [[ $step_failed -eq 1 ]]; then
checkpoint_step_fail "$tree_name" "$step_name" "Step failed"
if [[ "$on_error" == "continue" ]]; then
print_warning "Step failed but continuing (on_error: continue)"
checkpoint_step_complete "$tree_name" "$step_name" "{}"
return 0
fi
return 1
fi
# Extract outputs if defined
local output_rules
output_rules=$(tree_step_outputs "$step")
local step_outputs
step_outputs=$(extract_outputs "$response" "$output_rules")
# Save outputs to checkpoint
checkpoint_step_complete "$tree_name" "$step_name" "$step_outputs"
# Return outputs for use by subsequent steps
echo "$step_outputs"
print_success "Step completed: $step_name"
return 0
}
# Build outputs JSON from checkpoint
# Arguments: tree_name
# Returns: outputs JSON object
build_outputs_from_checkpoint() {
local tree_name="$1"
local checkpoint
checkpoint=$(checkpoint_load "$tree_name") || {
echo "{}"
return
}
echo "$checkpoint" | jq '
.steps | to_entries |
map(select(.value.status == "completed")) |
map({key: .key, value: .value.output}) |
from_entries
'
}
# ============================================================================
# Commands
# ============================================================================
# Run a tree from the beginning
cmd_run() {
local tree_name="${1:-}"
shift || true
if [[ -z "$tree_name" ]]; then
echo "Usage: $0 run <tree> [--var-name value]..."
exit 1
fi
# Validate tree exists
if ! tree_parse "$tree_name" > /dev/null 2>&1; then
print_error "Tree '$tree_name' not found"
echo ""
echo "Available trees:"
tree_list_detail
exit 1
fi
# Parse variables from args
local vars_json
vars_json=$(tree_get_default_vars "$tree_name")
while [[ $# -gt 0 ]]; do
case "$1" in
--*)
local var_name="${1#--}"
var_name="${var_name//-/_}" # Convert dashes to underscores
local var_value="${2:-}"
if [[ -z "$var_value" ]]; then
print_error "Missing value for --$var_name"
exit 1
fi
vars_json=$(echo "$vars_json" | jq --arg k "$var_name" --arg v "$var_value" '. + {($k): $v}')
shift 2
;;
*)
print_error "Unknown argument: $1"
exit 1
;;
esac
done
# Check required vars (empty string values)
local missing_vars
missing_vars=$(echo "$vars_json" | jq -r 'to_entries | .[] | select(.value == "") | .key')
if [[ -n "$missing_vars" ]]; then
print_error "Missing required variables:"
echo "$missing_vars" | sed 's/^/ --/'
exit 1
fi
# Initialize checkpoint
local run_id
run_id=$(checkpoint_init "$tree_name" "$vars_json")
print_header "Running tree: $tree_name"
echo "Run ID: $run_id"
echo "Variables:"
echo "$vars_json" | jq -r 'to_entries | .[] | " \(.key): \(.value)"'
echo ""
# Get execution order
local execution_order
execution_order=$(tree_execution_order "$tree_name")
# Build outputs as we go
local outputs_json="{}"
# Execute steps in order
while IFS= read -r step_name; do
local step_outputs
if step_outputs=$(execute_step "$tree_name" "$step_name" "$vars_json" "$outputs_json"); then
# Merge step outputs into cumulative outputs
outputs_json=$(echo "$outputs_json" | jq --arg step "$step_name" --argjson out "$step_outputs" '. + {($step): $out}')
else
print_error "Tree execution failed at step: $step_name"
echo ""
echo "To resume from this point:"
echo " $0 resume $tree_name"
echo ""
echo "To run just this step:"
echo " $0 only $tree_name $step_name"
echo ""
echo "To teardown resources:"
echo " $0 teardown $tree_name"
exit 1
fi
echo ""
done <<< "$execution_order"
# Mark as completed
checkpoint_mark_completed "$tree_name"
print_header "Tree completed successfully!"
echo "Run ID: $run_id"
echo ""
echo "To view final state:"
echo " $0 status $tree_name"
echo ""
echo "To teardown resources:"
echo " $0 teardown $tree_name"
}
# Resume from last checkpoint
cmd_resume() {
local tree_name="${1:-}"
if [[ -z "$tree_name" ]]; then
echo "Usage: $0 resume <tree>"
exit 1
fi
# Load checkpoint
local checkpoint
if ! checkpoint=$(checkpoint_load "$tree_name"); then
print_error "No checkpoint found for tree: $tree_name"
echo ""
echo "Run the tree first:"
echo " $0 run $tree_name [--var-name value]..."
exit 1
fi
local status
status=$(echo "$checkpoint" | jq -r '.status')
if [[ "$status" == "completed" ]]; then
print_success "Tree already completed. Nothing to resume."
echo ""
echo "To run again:"
echo " $0 clean $tree_name && $0 run $tree_name ..."
exit 0
fi
print_header "Resuming tree: $tree_name"
echo "Status: $status"
# Get vars from checkpoint
local vars_json
vars_json=$(echo "$checkpoint" | jq '.vars')
# Build outputs from completed steps
local outputs_json
outputs_json=$(build_outputs_from_checkpoint "$tree_name")
# Get completed steps
local completed_steps
completed_steps=$(checkpoint_completed_steps "$tree_name")
echo "Completed steps:"
if [[ -n "$completed_steps" ]]; then
echo "$completed_steps" | sed 's/^/ ✓ /'
else
echo " (none)"
fi
echo ""
# Get execution order
local execution_order
execution_order=$(tree_execution_order "$tree_name")
# Execute remaining steps
while IFS= read -r step_name; do
# Skip completed steps
if echo "$completed_steps" | grep -q "^${step_name}$"; then
continue
fi
local step_outputs
if step_outputs=$(execute_step "$tree_name" "$step_name" "$vars_json" "$outputs_json"); then
outputs_json=$(echo "$outputs_json" | jq --arg step "$step_name" --argjson out "$step_outputs" '. + {($step): $out}')
else
print_error "Tree execution failed at step: $step_name"
echo ""
echo "To resume again:"
echo " $0 resume $tree_name"
exit 1
fi
echo ""
done <<< "$execution_order"
checkpoint_mark_completed "$tree_name"
print_header "Tree completed successfully!"
}
# Run only a specific step
cmd_only() {
local tree_name="${1:-}"
local step_name="${2:-}"
if [[ -z "$tree_name" || -z "$step_name" ]]; then
echo "Usage: $0 only <tree> <step>"
exit 1
fi
# Validate step exists
if ! tree_get_step "$tree_name" "$step_name" > /dev/null 2>&1; then
print_error "Step '$step_name' not found in tree '$tree_name'"
echo ""
echo "Available steps:"
tree_get_steps "$tree_name" | sed 's/^/ /'
exit 1
fi
# Load checkpoint (or use empty state)
local vars_json="{}"
local outputs_json="{}"
if checkpoint=$(checkpoint_load "$tree_name" 2>/dev/null); then
vars_json=$(echo "$checkpoint" | jq '.vars')
outputs_json=$(build_outputs_from_checkpoint "$tree_name")
fi
print_header "Running single step: $step_name"
echo "Tree: $tree_name"
echo ""
local step_outputs
if step_outputs=$(execute_step "$tree_name" "$step_name" "$vars_json" "$outputs_json"); then
print_success "Step completed"
echo ""
echo "Outputs:"
echo "$step_outputs" | jq '.'
else
print_error "Step failed"
exit 1
fi
}
# Show checkpoint status
cmd_status() {
local tree_name="${1:-}"
if [[ -z "$tree_name" ]]; then
echo "Usage: $0 status <tree>"
exit 1
fi
checkpoint_status_detail "$tree_name"
}
# Run teardown steps
cmd_teardown() {
local tree_name="${1:-}"
if [[ -z "$tree_name" ]]; then
echo "Usage: $0 teardown <tree>"
exit 1
fi
print_header "Teardown: $tree_name"
# Load checkpoint for outputs
local vars_json="{}"
local outputs_json="{}"
if checkpoint=$(checkpoint_load "$tree_name" 2>/dev/null); then
vars_json=$(echo "$checkpoint" | jq '.vars')
outputs_json=$(build_outputs_from_checkpoint "$tree_name")
else
print_warning "No checkpoint found - teardown may not have all variables"
fi
# Get teardown steps
local teardown_steps
teardown_steps=$(tree_get_teardown "$tree_name")
local teardown_count
teardown_count=$(echo "$teardown_steps" | jq 'length')
if [[ "$teardown_count" -eq 0 ]]; then
echo "No teardown steps defined for this tree."
return 0
fi
echo "Running $teardown_count teardown steps..."
echo ""
# Execute teardown steps
local i=0
while IFS= read -r step_json; do
((i++))
# Expand templates
local step
step=$(tree_expand_step "$step_json" "$vars_json" "$outputs_json")
local action description
action=$(echo "$step" | jq -r '.action // "unknown"')
description=$(echo "$step" | jq -r '.description // "Teardown step $i"')
echo -e "${CYAN}Teardown $i:${NC} $description"
case "$action" in
api)
execute_api_step "$step" > /dev/null && print_success "Done" || print_warning "Failed (continuing)"
;;
shell)
execute_shell_step "$step" > /dev/null && print_success "Done" || print_warning "Failed (continuing)"
;;
*)
print_warning "Skipping unknown action: $action"
;;
esac
done < <(echo "$teardown_steps" | jq -c '.[]')
echo ""
print_success "Teardown complete"
# Optionally clean checkpoint
echo ""
echo "Checkpoint preserved. To remove:"
echo " $0 clean $tree_name"
}
# List available trees
cmd_list() {
print_header "Available Trees"
tree_list_detail
echo ""
echo "Checkpoints:"
local checkpoints
checkpoints=$(checkpoint_list)
if [[ -n "$checkpoints" ]]; then
while IFS= read -r tree; do
local status
status=$(checkpoint_status "$tree")
printf " %-20s %s\n" "$tree" "($status)"
done <<< "$checkpoints"
else
echo " (none)"
fi
}
# Clean checkpoint
cmd_clean() {
local tree_name="${1:-}"
if [[ -z "$tree_name" ]]; then
echo "Usage: $0 clean <tree>"
exit 1
fi
if checkpoint_delete "$tree_name"; then
print_success "Checkpoint deleted for tree: $tree_name"
else
echo "No checkpoint found for tree: $tree_name"
fi
}
# ============================================================================
# Main dispatch
# ============================================================================
case "$COMMAND" in
run)
cmd_run "$@"
;;
resume)
cmd_resume "$@"
;;
only)
cmd_only "$@"
;;
status)
cmd_status "$@"
;;
teardown)
cmd_teardown "$@"
;;
list)
cmd_list
;;
clean)
cmd_clean "$@"
;;
*)
echo "Unknown command: $COMMAND"
echo "Valid commands: run, resume, only, status, teardown, list, clean"
exit 1
;;
esac

View File

@ -0,0 +1,89 @@
name: composable-app
description: Deploy a composable app with backend service and frontend
version: 1
vars:
project_name: "" # Required
service_name: "api"
frontend_name: "web"
steps:
create-project:
description: Create project with monorepo skeleton
action: api
method: POST
endpoint: /project
body:
name: "{{ .vars.project_name }}"
description: "Composable app E2E test"
outputs:
- project_id: .data.name
- domain: .data.domain
add-service:
description: Add backend service component
depends_on: [create-project]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: service
name: "{{ .vars.service_name }}"
template: service
outputs:
- service_path: .data.path
- service_port: .data.port
add-frontend:
description: Add frontend app component (app-react)
depends_on: [create-project]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: app-react
name: "{{ .vars.frontend_name }}"
template: app-react
outputs:
- frontend_path: .data.path
- frontend_port: .data.port
verify-components:
description: Verify all components were added
depends_on: [add-service, add-frontend]
action: api
method: GET
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
outputs:
- component_count: ".data.components | length"
wait-pipeline:
description: Wait for CI pipeline to complete
depends_on: [verify-components]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60
poll_interval: 5
on_error: continue
verify-site:
description: Verify site is accessible
depends_on: [wait-pipeline]
action: wait_site
domain: "{{ .outputs.create-project.domain }}"
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 30
poll_interval: 5
test-api-health:
description: Test API health endpoint
depends_on: [verify-site]
action: shell
command: "curl -s 'https://{{ .outputs.create-project.domain }}/api/health' 2>/dev/null || echo '{\"error\":\"connection failed\"}'"
on_error: continue
teardown:
- description: Delete project
action: api
method: DELETE
endpoint: "/project/{{ .outputs.create-project.project_id }}"

View File

@ -0,0 +1,58 @@
name: landing-page
description: Deploy a landing page using composable monorepo template
version: 1
vars:
project_name: "" # Required
template: "app-astro"
steps:
create-project:
description: Create project with monorepo skeleton
action: api
method: POST
endpoint: /project
body:
name: "{{ .vars.project_name }}"
description: "Landing page E2E test"
outputs:
- project_id: .data.name
- domain: .data.domain
add-component:
description: Add landing page component (app-astro)
depends_on: [create-project]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: "{{ .vars.template }}"
name: landing
template: "{{ .vars.template }}"
outputs:
- component_path: .data.path
- component_port: .data.port
wait-pipeline:
description: Wait for CI pipeline to complete
depends_on: [add-component]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60
poll_interval: 5
on_error: continue
verify-site:
description: Verify site is accessible
depends_on: [wait-pipeline]
action: wait_site
domain: "{{ .outputs.create-project.domain }}"
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 30
poll_interval: 5
teardown:
- description: Delete project
action: api
method: DELETE
endpoint: "/project/{{ .outputs.create-project.project_id }}"

View File

@ -0,0 +1,141 @@
name: sdlc-flow
description: Test SDLC orchestration lifecycle endpoints
version: 1
vars:
project_id: "" # Required - existing project with SDLC initialized
feature_slug: "" # Optional - auto-generated if empty
steps:
get-state:
description: Get SDLC state (verify endpoint works)
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/state"
outputs:
- version: .data.version
create-feature:
description: Create a test feature
depends_on: [get-state]
action: shell
command: |
slug="{{ .vars.feature_slug }}"
if [ -z "$slug" ]; then
slug="sdlc-test-$(date +%s)"
fi
curl -s -X POST "$RDEV_API_URL/projects/{{ .vars.project_id }}/sdlc/features" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"slug\": \"$slug\", \"title\": \"SDLC E2E Test Feature\"}" | \
jq --arg slug "$slug" '. + {created_slug: $slug}'
outputs:
- feature_slug: .created_slug
list-features:
description: List all features
depends_on: [create-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features"
outputs:
- feature_count: ".data | length"
get-feature:
description: Get feature detail
depends_on: [create-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}"
outputs:
- phase: .data.phase
get-next:
description: Get classifier recommendation
depends_on: [get-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/next?feature={{ .outputs.create-feature.feature_slug }}"
outputs:
- action: .data.action
- message: .data.message
get-artifacts:
description: Check artifact status
depends_on: [get-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/artifacts"
add-task:
description: Add a task to the feature
depends_on: [create-feature]
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/tasks"
body:
title: "Test task for E2E validation"
outputs:
- task_id: .data.id
list-tasks:
description: List feature tasks
depends_on: [add-task]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/tasks"
outputs:
- task_count: ".data | length"
block-feature:
description: Block the feature
depends_on: [list-tasks]
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/block"
body:
reason: "E2E test blocker"
query-blocked:
description: Query blocked features
depends_on: [block-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/query/blocked"
outputs:
- blocked_count: ".data | length"
unblock-feature:
description: Unblock the feature
depends_on: [query-blocked]
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/unblock"
query-ready:
description: Query ready features
depends_on: [unblock-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/query/ready"
query-needs-approval:
description: Query features needing approval
depends_on: [unblock-feature]
action: api
method: GET
endpoint: "/projects/{{ .vars.project_id }}/sdlc/query/needs-approval"
cleanup:
description: Delete the test feature
depends_on: [query-ready, query-needs-approval]
action: api
method: DELETE
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}"
teardown:
- description: Delete test feature (if still exists)
action: shell
command: |
curl -s -X DELETE "$RDEV_API_URL/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}" \
-H "X-API-Key: $RDEV_API_KEY" || true

View File

@ -0,0 +1,279 @@
# SDLC API Reference
The rdev API provides REST endpoints for SDLC orchestration within project pods.
## Authentication
All endpoints require API key authentication:
```
X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
## Base Path
All SDLC endpoints are prefixed with `/projects/{id}/sdlc/`.
## State Endpoints
### GET /projects/{id}/sdlc/state
Get the global SDLC state for a project.
**Response:**
```json
{
"data": {
"version": 1,
"project": {"name": "My Project"},
"active_work": {"features": [...]},
"blocked": [],
"last_updated": "2026-01-15T10:00:00Z"
}
}
```
### GET /projects/{id}/sdlc/next
Get the classifier's recommended next action.
**Query Parameters:**
- `feature` - Feature slug (optional, defaults to active feature)
**Response:**
```json
{
"data": {
"action": "CREATE_SPEC",
"feature": "auth-flow",
"message": "Create specification document",
"next_command": "/spec-feature auth-flow"
}
}
```
## Feature Endpoints
### GET /projects/{id}/sdlc/features
List all features.
**Response:**
```json
{
"data": [
{
"slug": "auth-flow",
"title": "User Authentication Flow",
"phase": "draft",
"created_at": "2026-01-15T10:00:00Z"
}
]
}
```
### POST /projects/{id}/sdlc/features
Create a new feature.
**Request Body:**
```json
{
"slug": "auth-flow",
"title": "User Authentication Flow"
}
```
### GET /projects/{id}/sdlc/features/{slug}
Get feature details.
### DELETE /projects/{id}/sdlc/features/{slug}
Delete a feature.
### POST /projects/{id}/sdlc/features/{slug}/transition
Transition feature to a new phase.
**Request Body:**
```json
{
"phase": "specified"
}
```
### POST /projects/{id}/sdlc/features/{slug}/block
Block a feature.
**Request Body:**
```json
{
"reason": "Waiting for API design"
}
```
### POST /projects/{id}/sdlc/features/{slug}/unblock
Unblock a feature.
## Artifact Endpoints
### GET /projects/{id}/sdlc/features/{slug}/artifacts
Get artifact statuses for a feature.
### POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve
Approve an artifact.
### POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject
Reject an artifact.
## Task Endpoints
### GET /projects/{id}/sdlc/features/{slug}/tasks
List tasks for a feature.
### POST /projects/{id}/sdlc/features/{slug}/tasks
Add a new task.
**Request Body:**
```json
{
"title": "Implement login handler"
}
```
### POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start
Start a task.
### POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete
Complete a task.
### POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block
Block a task.
## Branch Endpoints
### POST /projects/{id}/sdlc/features/{slug}/branch
Create a feature branch.
### GET /projects/{id}/sdlc/features/{slug}/branch
Get branch status and merge checklist.
**Response:**
```json
{
"data": {
"branch": {
"name": "feature/auth-flow",
"base_branch": "main",
"created_at": "2026-01-15T10:00:00Z"
},
"checklist": [],
"ready": true
}
}
```
### POST /projects/{id}/sdlc/features/{slug}/branch/sync
Sync feature branch with base.
## Merge and Archive Endpoints
### POST /projects/{id}/sdlc/features/{slug}/merge
Merge a feature branch.
**Request Body:**
```json
{
"strategy": "squash"
}
```
### POST /projects/{id}/sdlc/features/{slug}/archive
Archive a released feature.
## Query Endpoints
### GET /projects/{id}/sdlc/query/blocked
List blocked features.
### GET /projects/{id}/sdlc/query/ready
List features ready for work.
### GET /projects/{id}/sdlc/query/needs-approval
List features awaiting approval.
## Orchestrator Endpoints
### POST /projects/{id}/sdlc/execute
Execute the next classifier action.
**Request Body:**
```json
{
"feature": "auth-flow",
"provider": "claude"
}
```
### POST /projects/{id}/sdlc/resolve
Resolve a blocker.
**Request Body:**
```json
{
"feature": "auth-flow"
}
```
### POST /projects/{id}/sdlc/commit
Commit changes in the project.
**Request Body:**
```json
{
"feature": "auth-flow",
"message": "feat: implement login handler",
"push": true
}
```
## Error Responses
All errors return:
```json
{
"error": "error_code",
"message": "Human readable message"
}
```
Common error codes:
- `not_initialized` - SDLC not initialized in project
- `feature_not_found` - Feature does not exist
- `feature_exists` - Feature already exists
- `invalid_transition` - Invalid phase transition
- `invalid_phase` - Unknown phase
- `merge_not_ready` - Feature not ready to merge

View File

@ -0,0 +1,283 @@
# SDLC CLI Reference
The `sdlc` CLI provides deterministic SDLC orchestration within project pods.
## Global Flags
| Flag | Description |
|------|-------------|
| `--root` | Project root directory (default: auto-detect) |
| `--json` | Output as JSON |
## Commands
### sdlc init
Initialize SDLC in a project.
```bash
sdlc init --project "Project Name" [--type go|node|python]
```
### sdlc state
Show the global SDLC state.
```bash
sdlc state
```
### sdlc next
Get the classifier's recommended next action.
```bash
sdlc next [--feature <slug>]
```
Returns the action type, message, and command to execute.
### Feature Management
#### sdlc feature create
Create a new feature.
```bash
sdlc feature create <slug> --title "Feature Title"
```
#### sdlc feature list
List all features.
```bash
sdlc feature list
```
#### sdlc feature show
Show feature details.
```bash
sdlc feature show <slug>
```
#### sdlc feature transition
Move feature to a new phase.
```bash
sdlc feature transition <slug> <phase>
```
Valid phases: draft, specified, planned, ready, implementation, review, audit, qa, merge, released
#### sdlc feature block
Block a feature with a reason.
```bash
sdlc feature block <slug> --reason "Waiting for dependency"
```
#### sdlc feature unblock
Remove all blockers from a feature.
```bash
sdlc feature unblock <slug>
```
#### sdlc feature delete
Delete a feature.
```bash
sdlc feature delete <slug> [--force]
```
### Artifact Management
#### sdlc artifact status
Show artifact statuses for a feature.
```bash
sdlc artifact status <slug>
```
#### sdlc artifact create
Register a new artifact.
```bash
sdlc artifact create <slug> <type>
```
Types: spec, design, tasks, qa_plan, review, audit, qa_results
#### sdlc artifact approve
Approve an artifact.
```bash
sdlc artifact approve <slug> <type>
```
#### sdlc artifact reject
Reject an artifact.
```bash
sdlc artifact reject <slug> <type>
```
### Task Management
#### sdlc task list
List tasks for a feature.
```bash
sdlc task list <slug>
```
#### sdlc task add
Add a new task.
```bash
sdlc task add <slug> --title "Task title"
```
#### sdlc task start
Start working on a task.
```bash
sdlc task start <slug> <task-id>
```
#### sdlc task complete
Mark a task as complete.
```bash
sdlc task complete <slug> <task-id>
```
#### sdlc task block
Mark a task as blocked.
```bash
sdlc task block <slug> <task-id>
```
### Branch Management
#### sdlc branch create
Create a feature branch.
```bash
sdlc branch create <slug>
```
Creates both the git branch and the branch manifest.
#### sdlc branch status
Show branch status and merge checklist.
```bash
sdlc branch status <slug>
```
#### sdlc branch sync
Sync feature branch with base branch.
```bash
sdlc branch sync <slug>
```
### Merge and Archive
#### sdlc merge
Merge a feature branch after all gates pass.
```bash
sdlc merge <slug> [--strategy squash|merge]
```
This command:
1. Checks merge readiness (all gates passed)
2. Checkouts the main branch
3. Merges/squashes the feature branch
4. Creates the commit
5. Updates the branch manifest
6. Transitions feature to released
#### sdlc archive
Archive a released feature.
```bash
sdlc archive <slug>
```
### Query Commands
#### sdlc query blocked
List blocked features.
```bash
sdlc query blocked
```
#### sdlc query ready
List features ready for work.
```bash
sdlc query ready
```
#### sdlc query needs-approval
List features awaiting approval.
```bash
sdlc query needs-approval
```
### Configuration
#### sdlc config show
Show current configuration.
```bash
sdlc config show
```
#### sdlc config set
Set a configuration value.
```bash
sdlc config set <key> <value>
```
Keys:
- `project.name`
- `project.type`
- `branches.main`
- `branches.feature_prefix`
- `compliance.require_approvals` (true/false)
- `compliance.require_branch` (true/false)
- `compliance.require_qa` (true/false)

View File

@ -0,0 +1,122 @@
# SDLC Command Catalog
Claude Code commands for SDLC orchestration. These commands are available in projects with the skeleton template.
## Feature Lifecycle Commands
### /create-feature `<slug>`
Create a new feature in draft phase.
**Example:** `/create-feature auth-flow`
### /spec-feature `<slug>`
Write the specification document for a feature. Creates `.sdlc/features/<slug>/spec.md`.
### /design-feature `<slug>`
Write the design document for a feature. Creates `.sdlc/features/<slug>/design.md`.
### /breakdown-feature `<slug>`
Create the task breakdown for a feature. Creates `.sdlc/features/<slug>/tasks.md`.
### /create-qa-plan `<slug>`
Create the QA test plan for a feature. Creates `.sdlc/features/<slug>/qa-plan.md`.
## Implementation Commands
### /implement-task `<slug>` `<task-id>`
Implement a specific task from the feature breakdown.
**Flow:**
1. Start the task via CLI
2. Load context (spec, design, tasks)
3. Study existing patterns
4. Implement code changes
5. Run tests
6. Complete the task
### /review-feature `<slug>`
Perform code review of a feature. Creates `.sdlc/features/<slug>/review.md`.
### /audit-feature `<slug>`
Perform security audit of a feature. Creates `.sdlc/features/<slug>/audit.md`.
### /run-qa `<slug>`
Execute the QA test plan for a feature. Creates `.sdlc/features/<slug>/qa-results.md`.
## Merge and Archive Commands
### /merge-feature `<slug>`
Merge a completed feature branch.
**Prerequisites:**
- All required artifacts approved (review, audit, qa_results)
- No blockers
- Feature in merge phase
**Flow:**
1. Check merge readiness
2. Verify branch status
3. Run final tests
4. Execute merge via `sdlc merge`
5. Report results
### /archive-feature `<slug>`
Archive a released feature.
**Prerequisites:**
- Feature must be in released phase
## Query Commands
### /sdlc-status
Show the current SDLC state and active features.
### /next-action `<slug>`
Get the classifier's recommended next action for a feature.
## Artifact Commands
### /approve-artifact `<slug>` `<type>`
Approve a feature artifact.
**Types:** spec, design, tasks, qa_plan, review, audit, qa_results
### /reject-artifact `<slug>` `<type>`
Reject a feature artifact.
## Command Arguments
| Argument | Description | Example |
|----------|-------------|---------|
| `<slug>` | Feature identifier (lowercase, hyphens) | `auth-flow` |
| `<task-id>` | Task identifier | `task-001` |
| `<type>` | Artifact type | `spec`, `design`, `review` |
## Typical Workflow
1. `/create-feature my-feature`
2. `/spec-feature my-feature` -> Approve spec
3. `/design-feature my-feature` -> Approve design
4. `/breakdown-feature my-feature` -> Approve tasks
5. `/create-qa-plan my-feature` -> Approve QA plan
6. `/implement-task my-feature task-001` (repeat for each task)
7. `/review-feature my-feature` -> Approve review
8. `/audit-feature my-feature` -> Approve audit
9. `/run-qa my-feature` -> QA passes
10. `/merge-feature my-feature`
11. `/archive-feature my-feature`

View File

@ -0,0 +1,88 @@
# SDLC Orchestration - Getting Started
This guide helps you set up and use the SDLC (Software Development Lifecycle) orchestration system in your project.
## Overview
The SDLC system provides deterministic feature lifecycle management through:
- **State tracking**: Features progress through defined phases
- **Artifacts**: Required deliverables at each phase (specs, designs, reviews)
- **Classifier**: Recommends the next action based on feature state
- **Branch management**: Tracks feature branches with merge readiness checks
- **Task tracking**: Break down features into implementable tasks
## Quick Start
### 1. Initialize SDLC in Your Project
From your project root:
```bash
sdlc init --project "My Project"
```
This creates the `.sdlc/` directory structure:
```
.sdlc/
├── config.yaml # Project configuration
├── state.yaml # Global state
├── features/ # Feature directories
└── branches/ # Branch manifests
```
### 2. Create Your First Feature
```bash
sdlc feature create my-feature --title "My First Feature"
```
This creates a feature in the `draft` phase.
### 3. Check What to Do Next
```bash
sdlc next --feature my-feature
```
The classifier returns the recommended action (e.g., `CREATE_SPEC`).
### 4. Progress Through Phases
Features move through 10 phases:
1. **draft** - Initial feature creation
2. **specified** - Requirements documented and approved
3. **planned** - Design and task breakdown complete
4. **ready** - Ready for implementation
5. **implementation** - Active coding
6. **review** - Code review
7. **audit** - Security audit
8. **qa** - Quality assurance testing
9. **merge** - Ready to merge
10. **released** - Merged and complete
## Configuration
Edit `.sdlc/config.yaml` to customize:
```yaml
version: 1
project:
name: "My Project"
type: "go"
branches:
main: "main"
feature_prefix: "feature/"
compliance:
require_approvals: true
require_branch: true
require_qa: true
```
## Next Steps
- [CLI Reference](./cli-reference.md) - Full command documentation
- [API Reference](./api-reference.md) - REST API endpoints
- [Command Catalog](./command-catalog.md) - Claude Code commands for SDLC

View File

@ -67,7 +67,7 @@ func (c *Client) CreateRecord(ctx context.Context, record domain.DNSRecord) (*do
}
if !result.Success {
return nil, fmt.Errorf("cloudflare error: %v", result.Errors)
return nil, &CloudflareError{Errors: result.Errors}
}
return recordFromCF(result.Result), nil
@ -96,7 +96,7 @@ func (c *Client) UpdateRecord(ctx context.Context, recordID string, record domai
}
if !result.Success {
return nil, fmt.Errorf("cloudflare error: %v", result.Errors)
return nil, &CloudflareError{Errors: result.Errors}
}
return recordFromCF(result.Result), nil
@ -136,7 +136,7 @@ func (c *Client) GetRecord(ctx context.Context, recordID string) (*domain.DNSRec
}
if !result.Success {
return nil, fmt.Errorf("cloudflare error: %v", result.Errors)
return nil, &CloudflareError{Errors: result.Errors}
}
return recordFromCF(result.Result), nil
@ -160,7 +160,7 @@ func (c *Client) ListRecords(ctx context.Context, recordType string) ([]*domain.
}
if !result.Success {
return nil, fmt.Errorf("cloudflare error: %v", result.Errors)
return nil, &CloudflareError{Errors: result.Errors}
}
records := make([]*domain.DNSRecord, len(result.Result))
@ -186,7 +186,7 @@ func (c *Client) FindRecord(ctx context.Context, recordType, name string) (*doma
}
if !result.Success {
return nil, fmt.Errorf("cloudflare error: %v", result.Errors)
return nil, &CloudflareError{Errors: result.Errors}
}
if len(result.Result) == 0 {
@ -315,6 +315,29 @@ type cfError struct {
Message string `json:"message"`
}
// CloudflareError wraps Cloudflare API errors with structured data.
type CloudflareError struct {
Errors []cfError
}
func (e *CloudflareError) Error() string {
msgs := make([]string, len(e.Errors))
for i, ce := range e.Errors {
msgs[i] = fmt.Sprintf("[%d] %s", ce.Code, ce.Message)
}
return "cloudflare API: " + strings.Join(msgs, "; ")
}
// HasCode returns true if any of the errors has the given code.
func (e *CloudflareError) HasCode(code int) bool {
for _, ce := range e.Errors {
if ce.Code == code {
return true
}
}
return false
}
// recordFromCF converts a Cloudflare record response to domain.DNSRecord.
func recordFromCF(r map[string]interface{}) *domain.DNSRecord {
return recordFromCFMap(r)

View File

@ -303,20 +303,17 @@ func (e *SDLCExecutor) CreateBranch(ctx context.Context, podName, slug string) (
return &manifest, nil
}
// GetBranchStatus returns the branch manifest for a feature.
func (e *SDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) {
// GetBranchStatus returns the full branch status including checklist.
func (e *SDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*port.BranchStatus, error) {
out, err := e.execSDLC(ctx, podName, "branch", "status", slug)
if err != nil {
return nil, err
}
// The CLI outputs {branch, checklist, ready} — extract the branch part
var result struct {
Branch *sdlc.BranchManifest `json:"branch"`
}
var result port.BranchStatus
if err := json.Unmarshal(out, &result); err != nil {
return nil, fmt.Errorf("parse sdlc branch status: %w", err)
}
return result.Branch, nil
return &result, nil
}
// SyncBranch syncs a feature branch with its base branch.

View File

@ -19,7 +19,7 @@ Confirm the feature current phase is `released`. Only released features can be a
### 2. Archive the Feature
```bash
sdlc feature delete $ARGUMENTS
sdlc archive $ARGUMENTS
```
This moves the feature from active tracking to the archive. The `.sdlc/features/$ARGUMENTS/` directory and its artifacts are preserved in git history.

View File

@ -41,11 +41,11 @@ Write the code changes specified by the task. Follow existing patterns. For each
### 6. Run Tests
```bash
go test ./... 2>/dev/null || true
go test ./... -v 2>&1 | tee /tmp/task-test-output.txt
# or the appropriate test command for the project stack
```
All existing tests must continue to pass. New tests must pass.
All existing tests must continue to pass. New tests must pass. Check the output and only proceed if all tests pass.
### 7. Complete the Task

View File

@ -30,7 +30,7 @@ Confirm the feature appears in the ready-to-merge list.
### 3. Run Final Tests
```bash
go test ./... 2>/dev/null || true
go test ./... -v 2>&1 | tee /tmp/merge-test-output.txt
```
All tests must pass before merge.
@ -38,10 +38,14 @@ All tests must pass before merge.
### 4. Execute the Merge
```bash
sdlc feature transition $ARGUMENTS released
sdlc merge $ARGUMENTS
```
This transitions the feature to the `released` phase.
This command atomically:
- Checks out the main branch
- Merges (or squashes) the feature branch
- Updates the branch manifest
- Transitions the feature to the `released` phase
### 5. Report

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
@ -26,10 +27,13 @@ func NewAgentsHandler(registry port.CodeAgentRegistry) *AgentsHandler {
// Mount registers the agent routes.
func (h *AgentsHandler) Mount(r api.Router) {
r.Route("/agents", func(r chi.Router) {
r.Get("/", h.List)
r.Get("/health", h.Health)
r.Get("/{provider}", h.GetCapabilities)
r.Post("/default", h.SetDefault)
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/health", h.Health)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/{provider}", h.GetCapabilities)
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/default", h.SetDefault)
})
}

View File

@ -10,23 +10,11 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
)
// testAdminAuth is a chi middleware that injects an admin API key into the
// request context so auth.RequireScope passes in tests.
func testAdminAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithAPIKey(r.Context(), &domain.APIKey{
Scopes: []domain.Scope{domain.ScopeAdmin},
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// mockBuildAudit implements port.BuildAudit for testing.
type mockBuildAudit struct {
entries map[string]*domain.BuildAuditEntry

View File

@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
@ -54,29 +55,35 @@ func NewClaudeConfigHandlerWithService(
// Mount registers the claude-config routes.
func (h *ClaudeConfigHandler) Mount(r api.Router) {
r.Route("/projects/{id}/claude-config", func(r chi.Router) {
// Overview
r.Get("/", h.Overview)
// Overview (read)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.Overview)
// Commands
r.Get("/commands", h.ListCommands)
r.Post("/commands", h.CreateCommand)
r.Get("/commands/{name}", h.GetCommand)
r.Put("/commands/{name}", h.UpdateCommand)
r.Delete("/commands/{name}", h.DeleteCommand)
// Commands - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/commands", h.ListCommands)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/commands/{name}", h.GetCommand)
// Skills
r.Get("/skills", h.ListSkills)
r.Post("/skills", h.CreateSkill)
r.Get("/skills/{name}", h.GetSkill)
r.Put("/skills/{name}", h.UpdateSkill)
r.Delete("/skills/{name}", h.DeleteSkill)
// Commands - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/commands", h.CreateCommand)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/commands/{name}", h.UpdateCommand)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/commands/{name}", h.DeleteCommand)
// Agents
r.Get("/agents", h.ListAgents)
r.Post("/agents", h.CreateAgent)
r.Get("/agents/{name}", h.GetAgent)
r.Put("/agents/{name}", h.UpdateAgent)
r.Delete("/agents/{name}", h.DeleteAgent)
// Skills - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/skills", h.ListSkills)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/skills/{name}", h.GetSkill)
// Skills - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/skills", h.CreateSkill)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/skills/{name}", h.UpdateSkill)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/skills/{name}", h.DeleteSkill)
// Agents - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/agents", h.ListAgents)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/agents/{name}", h.GetAgent)
// Agents - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/agents", h.CreateAgent)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/agents/{name}", h.UpdateAgent)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/agents/{name}", h.DeleteAgent)
})
}

View File

@ -175,6 +175,7 @@ func setupTestRouter(t *testing.T) (*chi.Mux, *kubernetes.ProjectRepository) {
handler := NewClaudeConfigHandler(repo, exec)
router := chi.NewRouter()
router.Use(testAdminAuth) // Add auth middleware for tests
handler.Mount(router)
return router, repo

View File

@ -8,6 +8,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
@ -41,9 +42,12 @@ func (h *ComponentsHandler) SetOperationService(svc *service.OperationService) *
// Mount registers the component routes.
func (h *ComponentsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/components", func(r chi.Router) {
r.Post("/", h.Add)
r.Get("/", h.List)
r.Delete("/*", h.Remove) // Wildcard to capture path like "services/auth-api"
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List)
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/", h.Add)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/*", h.Remove)
})
}

View File

@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
@ -38,7 +39,9 @@ func NewCreateAndBuildHandler(
// Mount registers the create-and-build route.
func (h *CreateAndBuildHandler) Mount(r api.Router) {
r.Post("/project/create-and-build", h.CreateAndBuild)
// Requires both project execute (create) and build write (start build)
r.With(auth.RequireScope(auth.ScopeBuildWrite, auth.ScopeAdmin)).
Post("/project/create-and-build", h.CreateAndBuild)
}
// CreateAndBuildRequest is the request body for POST /project/create-and-build.

View File

@ -4,6 +4,7 @@ package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
@ -36,14 +37,15 @@ func updatedBy(ctx context.Context) string {
}
// Mount registers the credentials routes.
// All routes require superadmin authentication (handled by middleware).
// All routes require admin authentication for security.
func (h *CredentialsHandler) Mount(r api.Router) {
r.Route("/credentials", func(r chi.Router) {
r.Get("/", h.List) // GET /credentials - List all (masked)
r.Post("/", h.Set) // POST /credentials - Set single
r.Post("/batch", h.SetBatch) // POST /credentials/batch - Set multiple
r.Get("/{key}", h.Get) // GET /credentials/{key} - Get single
r.Delete("/{key}", h.Delete) // DELETE /credentials/{key} - Delete
// All credential operations require admin scope
r.With(auth.RequireScope(auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeAdmin)).Post("/", h.Set)
r.With(auth.RequireScope(auth.ScopeAdmin)).Post("/batch", h.SetBatch)
r.With(auth.RequireScope(auth.ScopeAdmin)).Get("/{key}", h.Get)
r.With(auth.RequireScope(auth.ScopeAdmin)).Delete("/{key}", h.Delete)
})
}
@ -190,12 +192,11 @@ func (h *CredentialsHandler) SetBatch(w http.ResponseWriter, r *http.Request) {
creds := make([]domain.Credential, len(req.Credentials))
for i, c := range req.Credentials {
if c.Key == "" {
api.WriteBadRequest(w, r, "key is required for all credentials")
return
}
if c.Value == "" {
api.WriteBadRequest(w, r, "value is required for all credentials")
v := validate.New()
v.Required(c.Key, fmt.Sprintf("credentials[%d].key", i))
v.Required(c.Value, fmt.Sprintf("credentials[%d].value", i))
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
creds[i] = domain.Credential{

View File

@ -122,6 +122,7 @@ func setupCredentialsHandler() (*CredentialsHandler, *mockCredentialStore, chi.R
store := newMockCredentialStore()
h := NewCredentialsHandler(store)
r := chi.NewRouter()
r.Use(testAdminAuth) // Add auth middleware for tests
h.Mount(r)
return h, store, r
}

View File

@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
)
// testAdminAuth is a chi middleware that injects an admin API key into the
// request context so auth.RequireScope passes in tests.
func testAdminAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithAPIKey(r.Context(), &domain.APIKey{
Scopes: []domain.Scope{domain.ScopeAdmin},
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@ -8,6 +8,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
@ -86,31 +87,48 @@ func NewInfrastructureHandler(
// Mount registers the infrastructure routes.
func (h *InfrastructureHandler) Mount(r api.Router) {
// Git repository endpoints
r.Post("/projects/{id}/repo", h.CreateRepo)
r.Get("/projects/{id}/repo", h.GetRepo)
r.Delete("/projects/{id}/repo", h.DeleteRepo)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/repo", h.CreateRepo)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/repo", h.GetRepo)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/repo", h.DeleteRepo)
// Deployment endpoints
r.Post("/projects/{id}/deploy", h.Deploy)
r.Get("/projects/{id}/deploy/status", h.GetDeployStatus)
r.Delete("/projects/{id}/deploy", h.Undeploy)
r.Post("/projects/{id}/deploy/restart", h.RestartDeploy)
r.Post("/projects/{id}/deploy/scale", h.ScaleDeploy)
r.Get("/projects/{id}/deploy/logs", h.GetDeployLogs)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/deploy", h.Deploy)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/deploy/status", h.GetDeployStatus)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/deploy", h.Undeploy)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/deploy/restart", h.RestartDeploy)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/deploy/scale", h.ScaleDeploy)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/deploy/logs", h.GetDeployLogs)
// Domain endpoints (single)
r.Post("/projects/{id}/domain", h.AddDomain)
r.Delete("/projects/{id}/domain", h.RemoveDomain)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/domain", h.AddDomain)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/domain", h.RemoveDomain)
// Domain alias management (multi-domain)
r.Get("/projects/{id}/domains", h.ListDomains)
r.Post("/projects/{id}/domains", h.AddDomainAlias)
r.Delete("/projects/{id}/domains/{domain}", h.RemoveDomainAlias)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/domains", h.ListDomains)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/domains", h.AddDomainAlias)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/domains/{domain}", h.RemoveDomainAlias)
// CI pipeline endpoints
r.Get("/projects/{id}/pipelines", h.ListPipelines)
r.Get("/projects/{id}/pipelines/{number}", h.GetPipeline)
r.Get("/projects/{id}/pipelines/{number}/steps", h.GetPipelineSteps)
// CI pipeline endpoints (read-only)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/pipelines", h.ListPipelines)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/pipelines/{number}", h.GetPipeline)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/pipelines/{number}/steps", h.GetPipelineSteps)
}
// CreateRepoRequest is the request body for POST /projects/{id}/repo.

View File

@ -73,6 +73,7 @@ func setupInfraDomainHandler() (*InfrastructureHandler, *mockDomainService, chi.
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
return h, domainSvc, r
}
@ -113,6 +114,7 @@ func TestInfrastructureHandler_ListDomains(t *testing.T) {
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
req := httptest.NewRequest("GET", "/projects/myapp/domains", nil)
@ -197,6 +199,7 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai"})

View File

@ -102,6 +102,7 @@ func setupInfraHandlerWithCI(ci port.CIProvider) chi.Router {
DefaultDomain: "threesix.ai",
})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
return r
}

View File

@ -21,6 +21,7 @@ func setupInfraHandler() (*InfrastructureHandler, *mockGitRepository, *mockDNSPr
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
return h, git, dns, deployer, r
}
@ -70,6 +71,7 @@ func TestInfrastructureHandler_CreateRepo(t *testing.T) {
t.Run("git not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
req := httptest.NewRequest("POST", "/projects/myapp/repo", bytes.NewReader([]byte("{}")))
@ -164,6 +166,7 @@ func TestInfrastructureHandler_Deploy(t *testing.T) {
t.Run("deployer not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{})
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
body, _ := json.Marshal(DeployRequest{Image: "myimage:latest"})

View File

@ -8,6 +8,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
@ -42,16 +43,26 @@ func (h *ProjectManagementHandler) SetOperationService(svc *service.OperationSer
// Mount registers the project management routes.
func (h *ProjectManagementHandler) Mount(r api.Router) {
r.Route("/project", func(r chi.Router) {
r.Post("/", h.Create) // POST /project - Create new project
r.Get("/", h.List) // GET /project - List all projects
r.Get("/{name}", h.Status) // GET /project/{name} - Get project status
r.Delete("/{name}", h.Delete) // DELETE /project/{name} - Delete project
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/", h.Create)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/{name}", h.Delete)
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/{name}", h.Status)
})
// Template endpoints
r.Get("/templates", h.ListTemplates) // GET /templates - List available templates
r.Get("/templates/components", h.ListComponentTemplates) // GET /templates/components - List component templates
r.Get("/templates/{name}", h.GetTemplate) // GET /templates/{name} - Get template details
// Template endpoints (read-only)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates", h.ListTemplates)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates/components", h.ListComponentTemplates)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates/{name}", h.GetTemplate)
}
// CreateRequest is the request body for POST /project.

View File

@ -16,6 +16,7 @@ import (
func TestProjectManagementHandler_NilService(t *testing.T) {
h := NewProjectManagementHandler(nil, slog.Default())
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
tests := []struct {
@ -56,6 +57,7 @@ func TestProjectManagementHandler_CreateValidation(t *testing.T) {
// This tests that the nil check takes precedence.
h := NewProjectManagementHandler(nil, slog.Default())
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
t.Run("nil service returns 500 even with missing name", func(t *testing.T) {
@ -125,6 +127,7 @@ func TestProjectManagementHandler_CreateTracksOperation(t *testing.T) {
SetOperationService(opSvc)
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
body, _ := json.Marshal(CreateRequest{Name: "test-project"})

View File

@ -50,12 +50,15 @@ func NewProjectsHandlerWithService(projectService *service.ProjectService) *Proj
// Mount registers the projects routes.
func (h *ProjectsHandler) Mount(r api.Router) {
r.Route("/projects", func(r chi.Router) {
r.Get("/", h.List)
r.Get("/{id}", h.Get)
r.Post("/{id}/claude", h.RunClaude)
r.Post("/{id}/shell", h.RunShell)
r.Post("/{id}/git", h.RunGit)
r.Get("/{id}/events", h.Events)
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/{id}", h.Get)
// Execute operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/{id}/claude", h.RunClaude)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/{id}/shell", h.RunShell)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/{id}/git", h.RunGit)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Get("/{id}/events", h.Events)
})
}

View File

@ -13,6 +13,7 @@ import (
func TestProjectsHandler_RunClaude_InvalidJSON(t *testing.T) {
h := &ProjectsHandler{streams: newStreamManager()}
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
req := httptest.NewRequest("POST", "/projects/myapp/claude", bytes.NewReader([]byte("not json")))
@ -27,6 +28,7 @@ func TestProjectsHandler_RunClaude_InvalidJSON(t *testing.T) {
func TestProjectsHandler_RunClaude_NoServiceConfigured(t *testing.T) {
h := &ProjectsHandler{streams: newStreamManager()}
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
body, _ := json.Marshal(ClaudeRequest{Prompt: "hello"})
@ -42,6 +44,7 @@ func TestProjectsHandler_RunClaude_NoServiceConfigured(t *testing.T) {
func TestProjectsHandler_RunShell_InvalidJSON(t *testing.T) {
h := &ProjectsHandler{streams: newStreamManager()}
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
req := httptest.NewRequest("POST", "/projects/myapp/shell", bytes.NewReader([]byte("not json")))
@ -56,6 +59,7 @@ func TestProjectsHandler_RunShell_InvalidJSON(t *testing.T) {
func TestProjectsHandler_RunShell_NoServiceConfigured(t *testing.T) {
h := &ProjectsHandler{streams: newStreamManager()}
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
body, _ := json.Marshal(ShellRequest{Command: "ls"})
@ -71,6 +75,7 @@ func TestProjectsHandler_RunShell_NoServiceConfigured(t *testing.T) {
func TestProjectsHandler_RunGit_InvalidJSON(t *testing.T) {
h := &ProjectsHandler{streams: newStreamManager()}
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
req := httptest.NewRequest("POST", "/projects/myapp/git", bytes.NewReader([]byte("not json")))
@ -85,6 +90,7 @@ func TestProjectsHandler_RunGit_InvalidJSON(t *testing.T) {
func TestProjectsHandler_RunGit_NoServiceConfigured(t *testing.T) {
h := &ProjectsHandler{streams: newStreamManager()}
r := chi.NewRouter()
r.Use(testAdminAuth)
h.Mount(r)
body, _ := json.Marshal(GitRequest{Args: []string{"status"}})

View File

@ -23,6 +23,7 @@ func newTestProjectsHandler() *ProjectsHandler {
func TestProjectsHandler_List(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
req := httptest.NewRequest("GET", "/projects", nil)
@ -52,6 +53,7 @@ func TestProjectsHandler_List(t *testing.T) {
func TestProjectsHandler_Get(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
@ -81,6 +83,7 @@ func TestProjectsHandler_Get(t *testing.T) {
func TestProjectsHandler_RunClaude(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
@ -160,6 +163,7 @@ func TestProjectsHandler_RunClaude(t *testing.T) {
func TestProjectsHandler_RunShell(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
@ -265,6 +269,7 @@ func TestProjectsHandler_RunShell(t *testing.T) {
func TestProjectsHandler_RunGit(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
@ -360,6 +365,7 @@ func TestProjectsHandler_RunGit(t *testing.T) {
func TestProjectsHandler_Events(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
// Note: SSE tests with headers are difficult in httptest because the
@ -381,6 +387,7 @@ func TestProjectsHandler_Events(t *testing.T) {
func TestProjectsHandler_InvalidJSON(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
endpoints := []struct {
@ -415,6 +422,7 @@ func TestProjectsHandler_InvalidJSON(t *testing.T) {
func TestCommandIDGeneration(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
// Send two requests and verify they get different command IDs
@ -448,6 +456,7 @@ func TestCommandIDGeneration(t *testing.T) {
func TestCustomStreamID(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
body := ClaudeRequest{

View File

@ -33,11 +33,14 @@ func NewQueueHandler(queue port.CommandQueue, projects port.ProjectRepository) *
// Mount registers the queue routes.
func (h *QueueHandler) Mount(r api.Router) {
r.Route("/projects/{id}/queue", func(r chi.Router) {
r.Post("/", h.Enqueue)
r.Get("/", h.List)
r.Get("/stats", h.Stats)
r.Get("/{cmdId}", h.GetByID)
r.Delete("/{cmdId}", h.Cancel)
// Write operations
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Post("/", h.Enqueue)
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Delete("/{cmdId}", h.Cancel)
// Read operations
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/stats", h.Stats)
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/{cmdId}", h.GetByID)
})
}

View File

@ -7,6 +7,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/orchard9/rdev/internal/service"
@ -33,44 +34,52 @@ func NewSDLCHandler(sdlcService *service.SDLCService, logger *slog.Logger) *SDLC
// Mount registers all SDLC routes under /projects/{id}/sdlc/.
func (h *SDLCHandler) Mount(r api.Router) {
r.Route("/projects/{id}/sdlc", func(r chi.Router) {
// State
r.Get("/state", h.GetState)
r.Get("/next", h.GetNext)
// State (read)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/state", h.GetState)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/next", h.GetNext)
// Features
r.Get("/features", h.ListFeatures)
r.Post("/features", h.CreateFeature)
r.Get("/features/{slug}", h.GetFeature)
r.Post("/features/{slug}/transition", h.TransitionFeature)
r.Post("/features/{slug}/block", h.BlockFeature)
r.Post("/features/{slug}/unblock", h.UnblockFeature)
r.Delete("/features/{slug}", h.DeleteFeature)
// Features - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features", h.ListFeatures)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}", h.GetFeature)
// Artifacts
r.Get("/features/{slug}/artifacts", h.GetArtifactStatus)
r.Post("/features/{slug}/artifacts/{type}/approve", h.ApproveArtifact)
r.Post("/features/{slug}/artifacts/{type}/reject", h.RejectArtifact)
// Features - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features", h.CreateFeature)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/transition", h.TransitionFeature)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/block", h.BlockFeature)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/unblock", h.UnblockFeature)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/features/{slug}", h.DeleteFeature)
// Tasks
r.Get("/features/{slug}/tasks", h.ListTasks)
r.Post("/features/{slug}/tasks", h.AddTask)
r.Post("/features/{slug}/tasks/{taskId}/start", h.StartTask)
r.Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
r.Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
// Artifacts - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}/artifacts", h.GetArtifactStatus)
// Branches
r.Post("/features/{slug}/branches", h.CreateBranch)
r.Get("/features/{slug}/branches", h.GetBranchStatus)
r.Post("/features/{slug}/branches/sync", h.SyncBranch)
// Artifacts - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/approve", h.ApproveArtifact)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/reject", h.RejectArtifact)
// Merge / Archive
r.Post("/features/{slug}/merge", h.MergeFeature)
r.Post("/features/{slug}/archive", h.ArchiveFeature)
// Tasks - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}/tasks", h.ListTasks)
// Queries
r.Get("/query/blocked", h.QueryBlocked)
r.Get("/query/ready", h.QueryReady)
r.Get("/query/needs-approval", h.QueryNeedsApproval)
// Tasks - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks", h.AddTask)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks/{taskId}/start", h.StartTask)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
// Branches - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}/branches", h.GetBranchStatus)
// Branches - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/branches", h.CreateBranch)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/branches/sync", h.SyncBranch)
// Merge / Archive (write)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/merge", h.MergeFeature)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/archive", h.ArchiveFeature)
// Queries (read)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/query/blocked", h.QueryBlocked)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/query/ready", h.QueryReady)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/query/needs-approval", h.QueryNeedsApproval)
})
}

View File

@ -2,6 +2,7 @@ package handlers
import (
"context"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
@ -20,11 +21,9 @@ func (h *SDLCHandler) MergeFeature(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var req MergeFeatureRequest
if r.Body != nil && r.ContentLength > 0 {
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if err := api.DecodeJSON(r, &req); err != nil && !errors.Is(err, api.ErrEmptyBody) {
api.WriteBadRequest(w, r, "invalid request body")
return
}
strategy := req.Strategy

View File

@ -6,6 +6,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
)
@ -30,9 +31,10 @@ func NewSDLCOrchestratorHandler(orchestrator *service.SDLCOrchestratorService, l
// Mount registers orchestration routes under /projects/{id}/sdlc/.
func (h *SDLCOrchestratorHandler) Mount(r api.Router) {
r.Route("/projects/{id}/sdlc", func(r chi.Router) {
r.Post("/execute", h.Execute)
r.Post("/resolve", h.Resolve)
r.Post("/commit", h.Commit)
// All orchestration operations are write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/execute", h.Execute)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/resolve", h.Resolve)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/commit", h.Commit)
})
}

View File

@ -29,6 +29,7 @@ func setupOrchestratorHandler(exec *testSDLCExecutor) (*SDLCOrchestratorHandler,
handler := NewSDLCOrchestratorHandler(orchestrator, nil)
r := chi.NewRouter()
r.Use(testAdminAuth)
handler.Mount(r)
return handler, r
}
@ -128,7 +129,7 @@ func TestSDLCOrchestratorHandler_Resolve(t *testing.T) {
}
_, router := setupOrchestratorHandler(exec)
body, _ := json.Marshal(service.ResolveRequest{Feature: "auth-flow", Answer: "fixed it"})
body, _ := json.Marshal(service.ResolveRequest{Feature: "auth-flow"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

View File

@ -94,11 +94,15 @@ func (m *testSDLCExecutor) CreateBranch(_ context.Context, _, slug string) (*sdl
}
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
}
func (m *testSDLCExecutor) GetBranchStatus(_ context.Context, _, slug string) (*sdlc.BranchManifest, error) {
func (m *testSDLCExecutor) GetBranchStatus(_ context.Context, _, slug string) (*port.BranchStatus, error) {
if m.err != nil {
return nil, m.err
}
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
return &port.BranchStatus{
Branch: &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug},
Checklist: nil,
Ready: true,
}, nil
}
func (m *testSDLCExecutor) SyncBranch(_ context.Context, _, _ string) error { return m.err }
func (m *testSDLCExecutor) MergeFeature(_ context.Context, _, _, _ string) error { return m.err }
@ -130,6 +134,7 @@ func setupSDLCHandler(exec *testSDLCExecutor) (*SDLCHandler, *chi.Mux) {
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
handler := NewSDLCHandler(svc, nil)
r := chi.NewRouter()
r.Use(testAdminAuth)
handler.Mount(r)
return handler, r
}
@ -170,6 +175,7 @@ func TestSDLCHandler_GetState_ProjectNotFound(t *testing.T) {
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
handler := NewSDLCHandler(svc, nil)
r := chi.NewRouter()
r.Use(testAdminAuth)
handler.Mount(r)
req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/sdlc/state", nil)

View File

@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/validate"
@ -34,12 +35,15 @@ func NewWebhookHandler(webhooks port.WebhookRepository, projects port.ProjectRep
// Mount registers the webhook routes.
func (h *WebhookHandler) Mount(r api.Router) {
r.Route("/projects/{id}/webhooks", func(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{webhookId}", h.Get)
r.Put("/{webhookId}", h.Update)
r.Delete("/{webhookId}", h.Delete)
r.Get("/{webhookId}/deliveries", h.GetDeliveries)
// Write operations
r.With(auth.RequireScope(auth.ScopeWebhookWrite, auth.ScopeAdmin)).Post("/", h.Create)
r.With(auth.RequireScope(auth.ScopeWebhookWrite, auth.ScopeAdmin)).Put("/{webhookId}", h.Update)
r.With(auth.RequireScope(auth.ScopeWebhookWrite, auth.ScopeAdmin)).Delete("/{webhookId}", h.Delete)
// Read operations
r.With(auth.RequireScope(auth.ScopeWebhookRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeWebhookRead, auth.ScopeAdmin)).Get("/{webhookId}", h.Get)
r.With(auth.RequireScope(auth.ScopeWebhookRead, auth.ScopeAdmin)).Get("/{webhookId}/deliveries", h.GetDeliveries)
})
}
@ -109,21 +113,19 @@ func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
// Validate URL
// Validate required fields
if req.URL == "" {
api.WriteBadRequest(w, r, "url is required")
return
}
if err := validate.HTTPURL(req.URL, "url"); err != nil {
api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL")
return
}
// Validate events
if len(req.Events) == 0 {
api.WriteBadRequest(w, r, "at least one event type is required")
return
}
if err := validate.HTTPURL(req.URL, "url"); err != nil {
api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL")
return
}
events := make([]domain.WebhookEventType, len(req.Events))
for i, e := range req.Events {
eventType := domain.WebhookEventType(e)

View File

@ -8,6 +8,7 @@ import (
"strconv"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
@ -29,24 +30,18 @@ func NewWorkHandler(workService *service.WorkService) *WorkHandler {
// Mount registers the work queue routes.
func (h *WorkHandler) Mount(r api.Router) {
r.Route("/work", func(r chi.Router) {
// Task submission
r.Post("/enqueue", h.Enqueue)
// Write operations (task submission and management)
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Post("/enqueue", h.Enqueue)
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Post("/dequeue", h.Dequeue)
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Post("/{taskId}/complete", h.Complete)
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Post("/{taskId}/fail", h.Fail)
r.With(auth.RequireScope(auth.ScopeQueueWrite, auth.ScopeAdmin)).Post("/{taskId}/cancel", h.Cancel)
// Worker endpoints (for workers polling for tasks)
r.Post("/dequeue", h.Dequeue)
// Task management
r.Get("/{taskId}", h.GetTask)
r.Get("/{taskId}/status", h.GetStatus)
r.Post("/{taskId}/complete", h.Complete)
r.Post("/{taskId}/fail", h.Fail)
r.Post("/{taskId}/cancel", h.Cancel)
// Project-scoped list
r.Get("/projects/{projectId}", h.ListByProject)
// Queue stats
r.Get("/stats", h.Stats)
// Read operations
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/{taskId}", h.GetTask)
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/{taskId}/status", h.GetStatus)
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/projects/{projectId}", h.ListByProject)
r.With(auth.RequireScope(auth.ScopeQueueRead, auth.ScopeAdmin)).Get("/stats", h.Stats)
})
}

View File

@ -30,6 +30,7 @@ func TestWorkHandler_Fail(t *testing.T) {
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
@ -96,6 +97,7 @@ func TestWorkHandler_Cancel(t *testing.T) {
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
@ -152,6 +154,7 @@ func TestWorkHandler_GetTask(t *testing.T) {
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
@ -214,6 +217,7 @@ func TestWorkHandler_ListByProject(t *testing.T) {
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("list_all_for_project", func(t *testing.T) {
@ -349,6 +353,7 @@ func TestWorkHandler_Stats(t *testing.T) {
mockQueue.tasks["task-5"] = &domain.WorkTask{ID: "task-5", Status: domain.WorkTaskStatusFailed}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
req := httptest.NewRequest(http.MethodGet, "/work/stats", nil)

View File

@ -193,6 +193,7 @@ func TestWorkHandler_Enqueue(t *testing.T) {
handler := NewWorkHandler(workService)
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
@ -304,6 +305,7 @@ func TestWorkHandler_Dequeue(t *testing.T) {
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
@ -372,6 +374,7 @@ func TestWorkHandler_Complete(t *testing.T) {
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {

View File

@ -73,8 +73,8 @@ type SDLCExecutor interface {
// CreateBranch creates a feature branch and its manifest.
CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error)
// GetBranchStatus returns the branch manifest and merge checklist.
GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error)
// GetBranchStatus returns the branch manifest, merge checklist, and readiness.
GetBranchStatus(ctx context.Context, podName, slug string) (*BranchStatus, error)
// SyncBranch syncs a feature branch with its base branch.
SyncBranch(ctx context.Context, podName, slug string) error
@ -106,3 +106,10 @@ type ApprovalInfo struct {
Phase string `json:"phase"`
Message string `json:"message"`
}
// BranchStatus contains the full branch status response including checklist.
type BranchStatus struct {
Branch *sdlc.BranchManifest `json:"branch"`
Checklist []string `json:"checklist"`
Ready bool `json:"ready"`
}

View File

@ -0,0 +1,149 @@
package sdlc
import (
"testing"
"time"
)
func TestNewArtifact(t *testing.T) {
tests := []struct {
artType ArtifactType
wantPath string
}{
{ArtifactSpec, "spec.md"},
{ArtifactDesign, "design.md"},
{ArtifactTasks, "tasks.md"},
{ArtifactQAPlan, "qa-plan.md"},
{ArtifactReview, "review.md"},
{ArtifactAudit, "audit.md"},
{ArtifactQAResults, "qa-results.md"},
}
for _, tt := range tests {
t.Run(string(tt.artType), func(t *testing.T) {
art := NewArtifact(tt.artType)
if art.Status != StatusPending {
t.Errorf("NewArtifact() status = %v, want %v", art.Status, StatusPending)
}
if art.Path != tt.wantPath {
t.Errorf("NewArtifact() path = %v, want %v", art.Path, tt.wantPath)
}
})
}
}
func TestArtifact_Approve(t *testing.T) {
art := NewArtifact(ArtifactSpec)
art.Approve("user@example.com")
if art.Status != StatusApproved {
t.Errorf("Approve() status = %v, want %v", art.Status, StatusApproved)
}
if art.ApprovedBy != "user@example.com" {
t.Errorf("Approve() approvedBy = %v, want %v", art.ApprovedBy, "user@example.com")
}
if art.ApprovedAt == nil {
t.Error("Approve() approvedAt should not be nil")
}
if art.RejectedBy != "" {
t.Errorf("Approve() should clear rejectedBy, got %v", art.RejectedBy)
}
if art.RejectedAt != nil {
t.Error("Approve() should clear rejectedAt")
}
}
func TestArtifact_Reject(t *testing.T) {
art := NewArtifact(ArtifactSpec)
art.Reject("reviewer@example.com")
if art.Status != StatusRejected {
t.Errorf("Reject() status = %v, want %v", art.Status, StatusRejected)
}
if art.RejectedBy != "reviewer@example.com" {
t.Errorf("Reject() rejectedBy = %v, want %v", art.RejectedBy, "reviewer@example.com")
}
if art.RejectedAt == nil {
t.Error("Reject() rejectedAt should not be nil")
}
}
func TestArtifact_ApproveAfterReject(t *testing.T) {
art := NewArtifact(ArtifactSpec)
// First reject
art.Reject("reviewer@example.com")
if art.Status != StatusRejected {
t.Fatalf("expected rejected status after Reject()")
}
// Then approve
art.Approve("approver@example.com")
if art.Status != StatusApproved {
t.Errorf("Approve() after Reject() status = %v, want %v", art.Status, StatusApproved)
}
if art.RejectedBy != "" {
t.Errorf("Approve() should clear rejectedBy, got %v", art.RejectedBy)
}
if art.RejectedAt != nil {
t.Error("Approve() should clear rejectedAt")
}
}
func TestArtifact_MarkDraft(t *testing.T) {
art := NewArtifact(ArtifactSpec)
art.MarkDraft()
if art.Status != StatusDraft {
t.Errorf("MarkDraft() status = %v, want %v", art.Status, StatusDraft)
}
}
func TestArtifact_MarkPassed(t *testing.T) {
art := NewArtifact(ArtifactQAResults)
art.MarkPassed()
if art.Status != StatusPassed {
t.Errorf("MarkPassed() status = %v, want %v", art.Status, StatusPassed)
}
}
func TestArtifact_MarkFailed(t *testing.T) {
art := NewArtifact(ArtifactQAResults)
art.MarkFailed()
if art.Status != StatusFailed {
t.Errorf("MarkFailed() status = %v, want %v", art.Status, StatusFailed)
}
}
func TestArtifact_MarkNeedsFix(t *testing.T) {
art := NewArtifact(ArtifactReview)
art.MarkNeedsFix()
if art.Status != StatusNeedsFix {
t.Errorf("MarkNeedsFix() status = %v, want %v", art.Status, StatusNeedsFix)
}
}
func TestArtifact_ApprovedAtTimestamp(t *testing.T) {
art := NewArtifact(ArtifactSpec)
before := time.Now().UTC()
art.Approve("user@example.com")
after := time.Now().UTC()
if art.ApprovedAt.Before(before) || art.ApprovedAt.After(after) {
t.Errorf("ApprovedAt %v not in expected range [%v, %v]", art.ApprovedAt, before, after)
}
}
func TestArtifact_RejectedAtTimestamp(t *testing.T) {
art := NewArtifact(ArtifactSpec)
before := time.Now().UTC()
art.Reject("user@example.com")
after := time.Now().UTC()
if art.RejectedAt.Before(before) || art.RejectedAt.After(after) {
t.Errorf("RejectedAt %v not in expected range [%v, %v]", art.RejectedAt, before, after)
}
}

View File

@ -26,13 +26,20 @@ func BranchPath(root, branchName string) string {
}
// CreateBranch creates a new branch manifest for a feature.
// It validates the feature exists and constructs the branch name from config.
// It validates the feature exists, is in PhasePlanned or later, and constructs the branch name from config.
func CreateBranch(root, slug string, cfg *Config) (*BranchManifest, error) {
f, err := LoadFeature(root, slug)
if err != nil {
return nil, err
}
// Phase gate: branches can only be created at PhasePlanned or later
plannedIdx := PhaseIndex(PhasePlanned)
currentIdx := PhaseIndex(f.Phase)
if currentIdx < plannedIdx {
return nil, fmt.Errorf("%w: feature must be in planned phase or later (current: %s)", ErrInvalidTransition, f.Phase)
}
branchName := cfg.Branches.FeaturePrefix + slug
// Check if branch manifest already exists

View File

@ -19,11 +19,15 @@ func TestCreateBranch(t *testing.T) {
root := setupTestSDLC(t)
cfg := DefaultConfig("test")
// Create a feature first
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
// Create a feature and transition to planned phase (branch creation requires planned or later)
f, err := CreateFeature(root, "auth-flow", "Auth Flow")
if err != nil {
t.Fatalf("create feature: %v", err)
}
f.Phase = PhasePlanned
if err := f.Save(root); err != nil {
t.Fatalf("save feature: %v", err)
}
manifest, err := CreateBranch(root, "auth-flow", cfg)
if err != nil {
@ -41,7 +45,7 @@ func TestCreateBranch(t *testing.T) {
}
// Verify feature was updated with branch reference
f, err := LoadFeature(root, "auth-flow")
f, err = LoadFeature(root, "auth-flow")
if err != nil {
t.Fatalf("load feature: %v", err)
}
@ -54,13 +58,16 @@ func TestCreateBranch_AlreadyExists(t *testing.T) {
root := setupTestSDLC(t)
cfg := DefaultConfig("test")
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
f, err := CreateFeature(root, "auth-flow", "Auth Flow")
if err != nil {
t.Fatalf("create feature: %v", err)
}
f.Phase = PhasePlanned
if err := f.Save(root); err != nil {
t.Fatalf("save feature: %v", err)
}
_, err = CreateBranch(root, "auth-flow", cfg)
if err != nil {
if _, err := CreateBranch(root, "auth-flow", cfg); err != nil {
t.Fatalf("create branch: %v", err)
}
@ -84,10 +91,14 @@ func TestLoadBranch(t *testing.T) {
root := setupTestSDLC(t)
cfg := DefaultConfig("test")
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
f, err := CreateFeature(root, "auth-flow", "Auth Flow")
if err != nil {
t.Fatalf("create feature: %v", err)
}
f.Phase = PhasePlanned
if err := f.Save(root); err != nil {
t.Fatalf("save feature: %v", err)
}
_, err = CreateBranch(root, "auth-flow", cfg)
if err != nil {

View File

@ -263,15 +263,14 @@ func readyToImplementRule() Rule {
return Rule{
ID: "ready-to-implement",
Condition: func(ctx *EvalContext) bool {
return ctx.Feature.Phase == PhasePlanned || ctx.Feature.Phase == PhaseReady
// Only trigger transition for PhasePlanned -> PhaseReady
// PhaseReady should proceed to implementNextTaskRule, not re-transition
return ctx.Feature.Phase == PhasePlanned
},
Action: ActionTransition,
TransitionTo: PhaseReady,
Message: func(ctx *EvalContext) string {
if ctx.Feature.Phase == PhasePlanned {
return "Ready for implementation: transition to ready, then implementation"
}
return "Ready for implementation"
Message: func(_ *EvalContext) string {
return "Ready for implementation: transitioning to ready phase"
},
}
}

View File

@ -56,13 +56,17 @@ func (s *State) Save(root string) error {
}
// RecordAction appends a history entry and updates the last-action fields.
func (s *State) RecordAction(action, feature, actor string) {
// The result parameter allows recording success or failure status.
func (s *State) RecordAction(action, feature, actor, result string) {
if result == "" {
result = "success"
}
entry := HistoryEntry{
Timestamp: time.Now().UTC(),
Action: action,
Feature: feature,
Actor: actor,
Result: "success",
Result: result,
}
s.History = append(s.History, entry)
s.LastAction = action

View File

@ -14,7 +14,7 @@ func TestStateRoundTrip(t *testing.T) {
}
original := DefaultState("test-project")
original.RecordAction("test-action", "auth", "tester")
original.RecordAction("test-action", "auth", "tester", "success")
original.AddActiveFeature("auth", PhaseDraft)
if err := original.Save(root); err != nil {
@ -62,8 +62,8 @@ func TestLoadStateNotInitialized(t *testing.T) {
func TestRecordAction(t *testing.T) {
s := DefaultState("test")
s.RecordAction("CREATE_SPEC", "auth", "claude")
s.RecordAction("TRANSITION", "auth", "classifier")
s.RecordAction("CREATE_SPEC", "auth", "claude", "success")
s.RecordAction("TRANSITION", "auth", "classifier", "success")
if len(s.History) != 2 {
t.Fatalf("History len = %d, want 2", len(s.History))

View File

@ -63,8 +63,18 @@ func BlockTask(tasks []Task, taskID string) ([]Task, error) {
}
// AddTask appends a new task with an auto-generated ID.
// The ID is based on the max existing ID + 1 to avoid collisions after deletions.
func AddTask(tasks []Task, title string) []Task {
id := fmt.Sprintf("task-%03d", len(tasks)+1)
maxNum := 0
for _, t := range tasks {
var num int
if _, err := fmt.Sscanf(t.ID, "task-%d", &num); err == nil {
if num > maxNum {
maxNum = num
}
}
}
id := fmt.Sprintf("task-%03d", maxNum+1)
return append(tasks, Task{
ID: id,
Title: title,

View File

@ -79,7 +79,6 @@ type ExecutionResult struct {
// ResolveRequest describes a blocker resolution.
type ResolveRequest struct {
Feature string `json:"feature"`
Answer string `json:"answer,omitempty"`
}
// CommitRequest describes a commit operation.

View File

@ -272,8 +272,8 @@ func (s *SDLCService) CreateBranch(ctx context.Context, projectID, slug string)
return m, nil
}
// GetBranchStatus returns the branch manifest for a feature.
func (s *SDLCService) GetBranchStatus(ctx context.Context, projectID, slug string) (*sdlc.BranchManifest, error) {
// GetBranchStatus returns the full branch status including checklist.
func (s *SDLCService) GetBranchStatus(ctx context.Context, projectID, slug string) (*port.BranchStatus, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err

View File

@ -33,7 +33,7 @@ type mockSDLCExecutor struct {
queryReadyFn func(ctx context.Context, podName string) ([]port.ReadyInfo, error)
queryNeedsApprFn func(ctx context.Context, podName string) ([]port.ApprovalInfo, error)
createBranchFn func(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error)
getBranchStatusFn func(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error)
getBranchStatusFn func(ctx context.Context, podName, slug string) (*port.BranchStatus, error)
syncBranchFn func(ctx context.Context, podName, slug string) error
mergeFeatureFn func(ctx context.Context, podName, slug, strategy string) error
archiveFeatureFn func(ctx context.Context, podName, slug string) error
@ -186,11 +186,15 @@ func (m *mockSDLCExecutor) CreateBranch(ctx context.Context, podName, slug strin
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
}
func (m *mockSDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) {
func (m *mockSDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*port.BranchStatus, error) {
if m.getBranchStatusFn != nil {
return m.getBranchStatusFn(ctx, podName, slug)
}
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
return &port.BranchStatus{
Branch: &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug},
Checklist: nil,
Ready: true,
}, nil
}
func (m *mockSDLCExecutor) SyncBranch(ctx context.Context, podName, slug string) error {