From 56e3f8395521b10864719b1fa598789b31a38449 Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 2 Feb 2026 13:55:50 -0700 Subject: [PATCH] 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 --- CLAUDE.md | 3 + cmd/rdev-api/openapi.go | 2 + cmd/rdev-api/openapi_ext.go | 316 ++++++++ cmd/sdlc/cmd_archive.go | 5 +- cmd/sdlc/cmd_artifact.go | 14 +- cmd/sdlc/cmd_branch.go | 14 +- cmd/sdlc/cmd_config.go | 25 +- cmd/sdlc/cmd_feature.go | 25 +- cmd/sdlc/cmd_init.go | 3 +- cmd/sdlc/cmd_merge.go | 79 +- cmd/sdlc/cmd_next.go | 11 +- cmd/sdlc/cmd_query.go | 9 +- cmd/sdlc/cmd_state.go | 3 +- cmd/sdlc/cmd_task.go | 17 +- cmd/sdlc/root.go | 14 +- cookbooks/.gitignore | 2 + cookbooks/scripts/lib/checkpoint.sh | 337 +++++++++ cookbooks/scripts/lib/tree-parser.sh | 400 ++++++++++ cookbooks/scripts/tree-runner.sh | 686 ++++++++++++++++++ cookbooks/trees/composable-app.yaml | 89 +++ cookbooks/trees/landing-page.yaml | 58 ++ cookbooks/trees/sdlc-flow.yaml | 141 ++++ docs/guides/sdlc/api-reference.md | 279 +++++++ docs/guides/sdlc/cli-reference.md | 283 ++++++++ docs/guides/sdlc/command-catalog.md | 122 ++++ docs/guides/sdlc/getting-started.md | 88 +++ internal/adapter/cloudflare/client.go | 33 +- internal/adapter/kubernetes/sdlc_executor.go | 11 +- .../.claude/commands/archive-feature.md | 2 +- .../.claude/commands/implement-task.md | 4 +- .../.claude/commands/merge-feature.md | 10 +- internal/handlers/agents.go | 12 +- internal/handlers/builds_test.go | 12 - internal/handlers/claude_config.go | 47 +- internal/handlers/claude_config_test.go | 1 + internal/handlers/components.go | 10 +- internal/handlers/create_and_build.go | 5 +- internal/handlers/credentials.go | 25 +- internal/handlers/credentials_test.go | 1 + internal/handlers/helpers_test.go | 19 + internal/handlers/infrastructure.go | 54 +- .../handlers/infrastructure_domains_test.go | 3 + .../handlers/infrastructure_pipelines_test.go | 1 + internal/handlers/infrastructure_test.go | 3 + internal/handlers/project_management.go | 27 +- internal/handlers/project_management_test.go | 3 + internal/handlers/projects.go | 15 +- internal/handlers/projects_commands_test.go | 6 + internal/handlers/projects_test.go | 9 + internal/handlers/queue.go | 13 +- internal/handlers/sdlc.go | 73 +- internal/handlers/sdlc_merge.go | 9 +- internal/handlers/sdlc_orchestrator.go | 8 +- internal/handlers/sdlc_orchestrator_test.go | 3 +- internal/handlers/sdlc_test.go | 10 +- internal/handlers/webhooks.go | 28 +- internal/handlers/work.go | 29 +- internal/handlers/work_lifecycle_test.go | 5 + internal/handlers/work_test.go | 3 + internal/port/sdlc_executor.go | 11 +- internal/sdlc/artifact_test.go | 149 ++++ internal/sdlc/branch.go | 9 +- internal/sdlc/branch_test.go | 25 +- internal/sdlc/rules.go | 11 +- internal/sdlc/state.go | 8 +- internal/sdlc/state_test.go | 6 +- internal/sdlc/task.go | 12 +- internal/service/sdlc_orchestrator.go | 1 - internal/service/sdlc_service.go | 4 +- internal/service/sdlc_service_test.go | 10 +- 70 files changed, 3468 insertions(+), 297 deletions(-) create mode 100644 cookbooks/.gitignore create mode 100755 cookbooks/scripts/lib/checkpoint.sh create mode 100755 cookbooks/scripts/lib/tree-parser.sh create mode 100755 cookbooks/scripts/tree-runner.sh create mode 100644 cookbooks/trees/composable-app.yaml create mode 100644 cookbooks/trees/landing-page.yaml create mode 100644 cookbooks/trees/sdlc-flow.yaml create mode 100644 docs/guides/sdlc/api-reference.md create mode 100644 docs/guides/sdlc/cli-reference.md create mode 100644 docs/guides/sdlc/command-catalog.md create mode 100644 docs/guides/sdlc/getting-started.md create mode 100644 internal/handlers/helpers_test.go create mode 100644 internal/sdlc/artifact_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 6f209b0..1eedaf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go index 90b7725..77a986a 100644 --- a/cmd/rdev-api/openapi.go +++ b/cmd/rdev-api/openapi.go @@ -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 } diff --git a/cmd/rdev-api/openapi_ext.go b/cmd/rdev-api/openapi_ext.go index d94f9fb..5f729ad 100644 --- a/cmd/rdev-api/openapi_ext.go +++ b/cmd/rdev-api/openapi_ext.go @@ -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", diff --git a/cmd/sdlc/cmd_archive.go b/cmd/sdlc/cmd_archive.go index c66176d..00fc82a 100644 --- a/cmd/sdlc/cmd_archive.go +++ b/cmd/sdlc/cmd_archive.go @@ -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) diff --git a/cmd/sdlc/cmd_artifact.go b/cmd/sdlc/cmd_artifact.go index 4477bbb..6376f79 100644 --- a/cmd/sdlc/cmd_artifact.go +++ b/cmd/sdlc/cmd_artifact.go @@ -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) diff --git a/cmd/sdlc/cmd_branch.go b/cmd/sdlc/cmd_branch.go index f4f942f..f9c4778 100644 --- a/cmd/sdlc/cmd_branch.go +++ b/cmd/sdlc/cmd_branch.go @@ -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) diff --git a/cmd/sdlc/cmd_config.go b/cmd/sdlc/cmd_config.go index c8eaa75..d1063c8 100644 --- a/cmd/sdlc/cmd_config.go +++ b/cmd/sdlc/cmd_config.go @@ -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) diff --git a/cmd/sdlc/cmd_feature.go b/cmd/sdlc/cmd_feature.go index e1b8e8c..db9222f 100644 --- a/cmd/sdlc/cmd_feature.go +++ b/cmd/sdlc/cmd_feature.go @@ -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) diff --git a/cmd/sdlc/cmd_init.go b/cmd/sdlc/cmd_init.go index d922a09..71c1244 100644 --- a/cmd/sdlc/cmd_init.go +++ b/cmd/sdlc/cmd_init.go @@ -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") diff --git a/cmd/sdlc/cmd_merge.go b/cmd/sdlc/cmd_merge.go index 358c873..d769ca5 100644 --- a/cmd/sdlc/cmd_merge.go +++ b/cmd/sdlc/cmd_merge.go @@ -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) diff --git a/cmd/sdlc/cmd_next.go b/cmd/sdlc/cmd_next.go index e5520bb..7fbd612 100644 --- a/cmd/sdlc/cmd_next.go +++ b/cmd/sdlc/cmd_next.go @@ -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 ") 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) diff --git a/cmd/sdlc/cmd_query.go b/cmd/sdlc/cmd_query.go index f5bda7d..0993c8c 100644 --- a/cmd/sdlc/cmd_query.go +++ b/cmd/sdlc/cmd_query.go @@ -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 { diff --git a/cmd/sdlc/cmd_state.go b/cmd/sdlc/cmd_state.go index d5c1380..17968bf 100644 --- a/cmd/sdlc/cmd_state.go +++ b/cmd/sdlc/cmd_state.go @@ -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) diff --git a/cmd/sdlc/cmd_task.go b/cmd/sdlc/cmd_task.go index 0553348..eb4e4c1 100644 --- a/cmd/sdlc/cmd_task.go +++ b/cmd/sdlc/cmd_task.go @@ -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) diff --git a/cmd/sdlc/root.go b/cmd/sdlc/root.go index a8ccc9b..c9c1bda 100644 --- a/cmd/sdlc/root.go +++ b/cmd/sdlc/root.go @@ -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 } diff --git a/cookbooks/.gitignore b/cookbooks/.gitignore new file mode 100644 index 0000000..b8cb17d --- /dev/null +++ b/cookbooks/.gitignore @@ -0,0 +1,2 @@ +# Checkpoint storage (local state for tree runner) +.checkpoints/ diff --git a/cookbooks/scripts/lib/checkpoint.sh b/cookbooks/scripts/lib/checkpoint.sh new file mode 100755 index 0000000..d5b15d2 --- /dev/null +++ b/cookbooks/scripts/lib/checkpoint.sh @@ -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" +} diff --git a/cookbooks/scripts/lib/tree-parser.sh b/cookbooks/scripts/lib/tree-parser.sh new file mode 100755 index 0000000..52a3545 --- /dev/null +++ b/cookbooks/scripts/lib/tree-parser.sh @@ -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 +} diff --git a/cookbooks/scripts/tree-runner.sh b/cookbooks/scripts/tree-runner.sh new file mode 100755 index 0000000..c431f57 --- /dev/null +++ b/cookbooks/scripts/tree-runner.sh @@ -0,0 +1,686 @@ +#!/bin/bash +set -euo pipefail + +# Tree Runner - Execute cookbook trees with checkpoint support +# +# Usage: +# ./tree-runner.sh run [--var-name value]... +# ./tree-runner.sh resume +# ./tree-runner.sh only +# ./tree-runner.sh status +# ./tree-runner.sh teardown +# ./tree-runner.sh list +# ./tree-runner.sh clean +# +# 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 [args]" + echo "" + echo "Commands:" + echo " run [--var-name value]... Run a tree from the beginning" + echo " resume Resume from last checkpoint" + echo " only Run only a specific step" + echo " status Show checkpoint status" + echo " teardown Run tree's teardown steps" + echo " list List available trees" + echo " clean 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 [--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 " + 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 " + 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 " + 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 " + 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 " + 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 diff --git a/cookbooks/trees/composable-app.yaml b/cookbooks/trees/composable-app.yaml new file mode 100644 index 0000000..2c45a98 --- /dev/null +++ b/cookbooks/trees/composable-app.yaml @@ -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 }}" diff --git a/cookbooks/trees/landing-page.yaml b/cookbooks/trees/landing-page.yaml new file mode 100644 index 0000000..11d7521 --- /dev/null +++ b/cookbooks/trees/landing-page.yaml @@ -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 }}" diff --git a/cookbooks/trees/sdlc-flow.yaml b/cookbooks/trees/sdlc-flow.yaml new file mode 100644 index 0000000..fd8d973 --- /dev/null +++ b/cookbooks/trees/sdlc-flow.yaml @@ -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 diff --git a/docs/guides/sdlc/api-reference.md b/docs/guides/sdlc/api-reference.md new file mode 100644 index 0000000..91066a9 --- /dev/null +++ b/docs/guides/sdlc/api-reference.md @@ -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 diff --git a/docs/guides/sdlc/cli-reference.md b/docs/guides/sdlc/cli-reference.md new file mode 100644 index 0000000..4b7481a --- /dev/null +++ b/docs/guides/sdlc/cli-reference.md @@ -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 ] +``` + +Returns the action type, message, and command to execute. + +### Feature Management + +#### sdlc feature create + +Create a new feature. + +```bash +sdlc feature create --title "Feature Title" +``` + +#### sdlc feature list + +List all features. + +```bash +sdlc feature list +``` + +#### sdlc feature show + +Show feature details. + +```bash +sdlc feature show +``` + +#### sdlc feature transition + +Move feature to a new phase. + +```bash +sdlc feature transition +``` + +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 --reason "Waiting for dependency" +``` + +#### sdlc feature unblock + +Remove all blockers from a feature. + +```bash +sdlc feature unblock +``` + +#### sdlc feature delete + +Delete a feature. + +```bash +sdlc feature delete [--force] +``` + +### Artifact Management + +#### sdlc artifact status + +Show artifact statuses for a feature. + +```bash +sdlc artifact status +``` + +#### sdlc artifact create + +Register a new artifact. + +```bash +sdlc artifact create +``` + +Types: spec, design, tasks, qa_plan, review, audit, qa_results + +#### sdlc artifact approve + +Approve an artifact. + +```bash +sdlc artifact approve +``` + +#### sdlc artifact reject + +Reject an artifact. + +```bash +sdlc artifact reject +``` + +### Task Management + +#### sdlc task list + +List tasks for a feature. + +```bash +sdlc task list +``` + +#### sdlc task add + +Add a new task. + +```bash +sdlc task add --title "Task title" +``` + +#### sdlc task start + +Start working on a task. + +```bash +sdlc task start +``` + +#### sdlc task complete + +Mark a task as complete. + +```bash +sdlc task complete +``` + +#### sdlc task block + +Mark a task as blocked. + +```bash +sdlc task block +``` + +### Branch Management + +#### sdlc branch create + +Create a feature branch. + +```bash +sdlc branch create +``` + +Creates both the git branch and the branch manifest. + +#### sdlc branch status + +Show branch status and merge checklist. + +```bash +sdlc branch status +``` + +#### sdlc branch sync + +Sync feature branch with base branch. + +```bash +sdlc branch sync +``` + +### Merge and Archive + +#### sdlc merge + +Merge a feature branch after all gates pass. + +```bash +sdlc merge [--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 +``` + +### 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 +``` + +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) diff --git a/docs/guides/sdlc/command-catalog.md b/docs/guides/sdlc/command-catalog.md new file mode 100644 index 0000000..f8e8a64 --- /dev/null +++ b/docs/guides/sdlc/command-catalog.md @@ -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 `` + +Create a new feature in draft phase. + +**Example:** `/create-feature auth-flow` + +### /spec-feature `` + +Write the specification document for a feature. Creates `.sdlc/features//spec.md`. + +### /design-feature `` + +Write the design document for a feature. Creates `.sdlc/features//design.md`. + +### /breakdown-feature `` + +Create the task breakdown for a feature. Creates `.sdlc/features//tasks.md`. + +### /create-qa-plan `` + +Create the QA test plan for a feature. Creates `.sdlc/features//qa-plan.md`. + +## Implementation Commands + +### /implement-task `` `` + +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 `` + +Perform code review of a feature. Creates `.sdlc/features//review.md`. + +### /audit-feature `` + +Perform security audit of a feature. Creates `.sdlc/features//audit.md`. + +### /run-qa `` + +Execute the QA test plan for a feature. Creates `.sdlc/features//qa-results.md`. + +## Merge and Archive Commands + +### /merge-feature `` + +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 `` + +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 `` + +Get the classifier's recommended next action for a feature. + +## Artifact Commands + +### /approve-artifact `` `` + +Approve a feature artifact. + +**Types:** spec, design, tasks, qa_plan, review, audit, qa_results + +### /reject-artifact `` `` + +Reject a feature artifact. + +## Command Arguments + +| Argument | Description | Example | +|----------|-------------|---------| +| `` | Feature identifier (lowercase, hyphens) | `auth-flow` | +| `` | Task identifier | `task-001` | +| `` | 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` diff --git a/docs/guides/sdlc/getting-started.md b/docs/guides/sdlc/getting-started.md new file mode 100644 index 0000000..94ba11f --- /dev/null +++ b/docs/guides/sdlc/getting-started.md @@ -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 diff --git a/internal/adapter/cloudflare/client.go b/internal/adapter/cloudflare/client.go index 8177fd2..a5e41f2 100644 --- a/internal/adapter/cloudflare/client.go +++ b/internal/adapter/cloudflare/client.go @@ -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) diff --git a/internal/adapter/kubernetes/sdlc_executor.go b/internal/adapter/kubernetes/sdlc_executor.go index 7dcb86c..8b3d3cd 100644 --- a/internal/adapter/kubernetes/sdlc_executor.go +++ b/internal/adapter/kubernetes/sdlc_executor.go @@ -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. diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md index e135aab..520d374 100644 --- a/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md @@ -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. diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md b/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md index 5397ab6..021d01e 100644 --- a/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md @@ -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 diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md index 7a1c0d3..31903b3 100644 --- a/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md @@ -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 diff --git a/internal/handlers/agents.go b/internal/handlers/agents.go index 512e878..79d613c 100644 --- a/internal/handlers/agents.go +++ b/internal/handlers/agents.go @@ -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) }) } diff --git a/internal/handlers/builds_test.go b/internal/handlers/builds_test.go index 45eed7d..c8dacf2 100644 --- a/internal/handlers/builds_test.go +++ b/internal/handlers/builds_test.go @@ -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 diff --git a/internal/handlers/claude_config.go b/internal/handlers/claude_config.go index 7211dc4..42783b4 100644 --- a/internal/handlers/claude_config.go +++ b/internal/handlers/claude_config.go @@ -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) }) } diff --git a/internal/handlers/claude_config_test.go b/internal/handlers/claude_config_test.go index 3e82948..358c31b 100644 --- a/internal/handlers/claude_config_test.go +++ b/internal/handlers/claude_config_test.go @@ -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 diff --git a/internal/handlers/components.go b/internal/handlers/components.go index eac6a74..bf6fd74 100644 --- a/internal/handlers/components.go +++ b/internal/handlers/components.go @@ -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) }) } diff --git a/internal/handlers/create_and_build.go b/internal/handlers/create_and_build.go index 6e3d74c..6cabb4f 100644 --- a/internal/handlers/create_and_build.go +++ b/internal/handlers/create_and_build.go @@ -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. diff --git a/internal/handlers/credentials.go b/internal/handlers/credentials.go index 03514db..1d12d8a 100644 --- a/internal/handlers/credentials.go +++ b/internal/handlers/credentials.go @@ -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{ diff --git a/internal/handlers/credentials_test.go b/internal/handlers/credentials_test.go index 42215a0..e800fe2 100644 --- a/internal/handlers/credentials_test.go +++ b/internal/handlers/credentials_test.go @@ -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 } diff --git a/internal/handlers/helpers_test.go b/internal/handlers/helpers_test.go new file mode 100644 index 0000000..1ddcbca --- /dev/null +++ b/internal/handlers/helpers_test.go @@ -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)) + }) +} diff --git a/internal/handlers/infrastructure.go b/internal/handlers/infrastructure.go index 3b5b0ff..b9f18c6 100644 --- a/internal/handlers/infrastructure.go +++ b/internal/handlers/infrastructure.go @@ -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. diff --git a/internal/handlers/infrastructure_domains_test.go b/internal/handlers/infrastructure_domains_test.go index 365b466..f0fc9f7 100644 --- a/internal/handlers/infrastructure_domains_test.go +++ b/internal/handlers/infrastructure_domains_test.go @@ -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"}) diff --git a/internal/handlers/infrastructure_pipelines_test.go b/internal/handlers/infrastructure_pipelines_test.go index 0809cfb..33dced6 100644 --- a/internal/handlers/infrastructure_pipelines_test.go +++ b/internal/handlers/infrastructure_pipelines_test.go @@ -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 } diff --git a/internal/handlers/infrastructure_test.go b/internal/handlers/infrastructure_test.go index ad99212..b19641d 100644 --- a/internal/handlers/infrastructure_test.go +++ b/internal/handlers/infrastructure_test.go @@ -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"}) diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index 763bc7c..fa615ab 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -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. diff --git a/internal/handlers/project_management_test.go b/internal/handlers/project_management_test.go index 33a34f6..1239b2b 100644 --- a/internal/handlers/project_management_test.go +++ b/internal/handlers/project_management_test.go @@ -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"}) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 285186a..5a03c43 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -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) }) } diff --git a/internal/handlers/projects_commands_test.go b/internal/handlers/projects_commands_test.go index d095725..3fb947c 100644 --- a/internal/handlers/projects_commands_test.go +++ b/internal/handlers/projects_commands_test.go @@ -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"}}) diff --git a/internal/handlers/projects_test.go b/internal/handlers/projects_test.go index e493978..5fa35db 100644 --- a/internal/handlers/projects_test.go +++ b/internal/handlers/projects_test.go @@ -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{ diff --git a/internal/handlers/queue.go b/internal/handlers/queue.go index f2b3329..ab12721 100644 --- a/internal/handlers/queue.go +++ b/internal/handlers/queue.go @@ -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) }) } diff --git a/internal/handlers/sdlc.go b/internal/handlers/sdlc.go index 5aa3805..4b7159c 100644 --- a/internal/handlers/sdlc.go +++ b/internal/handlers/sdlc.go @@ -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) }) } diff --git a/internal/handlers/sdlc_merge.go b/internal/handlers/sdlc_merge.go index 9812a62..a7ee150 100644 --- a/internal/handlers/sdlc_merge.go +++ b/internal/handlers/sdlc_merge.go @@ -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 diff --git a/internal/handlers/sdlc_orchestrator.go b/internal/handlers/sdlc_orchestrator.go index 21390ee..4d3bf34 100644 --- a/internal/handlers/sdlc_orchestrator.go +++ b/internal/handlers/sdlc_orchestrator.go @@ -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) }) } diff --git a/internal/handlers/sdlc_orchestrator_test.go b/internal/handlers/sdlc_orchestrator_test.go index a9c3aeb..a5460a7 100644 --- a/internal/handlers/sdlc_orchestrator_test.go +++ b/internal/handlers/sdlc_orchestrator_test.go @@ -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() diff --git a/internal/handlers/sdlc_test.go b/internal/handlers/sdlc_test.go index 42c1b87..d617fe5 100644 --- a/internal/handlers/sdlc_test.go +++ b/internal/handlers/sdlc_test.go @@ -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) diff --git a/internal/handlers/webhooks.go b/internal/handlers/webhooks.go index 9343883..0026994 100644 --- a/internal/handlers/webhooks.go +++ b/internal/handlers/webhooks.go @@ -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) diff --git a/internal/handlers/work.go b/internal/handlers/work.go index 490cf41..0c4fadc 100644 --- a/internal/handlers/work.go +++ b/internal/handlers/work.go @@ -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) }) } diff --git a/internal/handlers/work_lifecycle_test.go b/internal/handlers/work_lifecycle_test.go index 221bbd8..66c41c7 100644 --- a/internal/handlers/work_lifecycle_test.go +++ b/internal/handlers/work_lifecycle_test.go @@ -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) diff --git a/internal/handlers/work_test.go b/internal/handlers/work_test.go index b7f1b9b..7bcb756 100644 --- a/internal/handlers/work_test.go +++ b/internal/handlers/work_test.go @@ -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 { diff --git a/internal/port/sdlc_executor.go b/internal/port/sdlc_executor.go index 49bfbfd..f6ca632 100644 --- a/internal/port/sdlc_executor.go +++ b/internal/port/sdlc_executor.go @@ -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"` +} diff --git a/internal/sdlc/artifact_test.go b/internal/sdlc/artifact_test.go new file mode 100644 index 0000000..ce4d115 --- /dev/null +++ b/internal/sdlc/artifact_test.go @@ -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) + } +} diff --git a/internal/sdlc/branch.go b/internal/sdlc/branch.go index 5166faf..0b097fd 100644 --- a/internal/sdlc/branch.go +++ b/internal/sdlc/branch.go @@ -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 diff --git a/internal/sdlc/branch_test.go b/internal/sdlc/branch_test.go index c94860e..3fddac1 100644 --- a/internal/sdlc/branch_test.go +++ b/internal/sdlc/branch_test.go @@ -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 { diff --git a/internal/sdlc/rules.go b/internal/sdlc/rules.go index 321f3ac..cdf49c3 100644 --- a/internal/sdlc/rules.go +++ b/internal/sdlc/rules.go @@ -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" }, } } diff --git a/internal/sdlc/state.go b/internal/sdlc/state.go index dceaa50..4fb97fd 100644 --- a/internal/sdlc/state.go +++ b/internal/sdlc/state.go @@ -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 diff --git a/internal/sdlc/state_test.go b/internal/sdlc/state_test.go index fe2f59b..264b705 100644 --- a/internal/sdlc/state_test.go +++ b/internal/sdlc/state_test.go @@ -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)) diff --git a/internal/sdlc/task.go b/internal/sdlc/task.go index 242913d..9cf7c40 100644 --- a/internal/sdlc/task.go +++ b/internal/sdlc/task.go @@ -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, diff --git a/internal/service/sdlc_orchestrator.go b/internal/service/sdlc_orchestrator.go index e074e6e..9dbd557 100644 --- a/internal/service/sdlc_orchestrator.go +++ b/internal/service/sdlc_orchestrator.go @@ -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. diff --git a/internal/service/sdlc_service.go b/internal/service/sdlc_service.go index ab73eb0..03279e3 100644 --- a/internal/service/sdlc_service.go +++ b/internal/service/sdlc_service.go @@ -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 diff --git a/internal/service/sdlc_service_test.go b/internal/service/sdlc_service_test.go index 29a967a..cde0dde 100644 --- a/internal/service/sdlc_service_test.go +++ b/internal/service/sdlc_service_test.go @@ -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 {