feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization - Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator) - Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog) - Add artifact_test.go for SDLC artifact coverage - Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w - Fix error wrapping to use %w instead of %v throughout codebase - Improve CLI merge command with conflict detection and resolution - Fix handler tests to include auth middleware for RequireScope - Add cookbook tree runner scripts for automated testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f22b220c6d
commit
56e3f83955
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -38,8 +38,7 @@ var nextCmd = &cobra.Command{
|
||||
// Classify all active features, return first actionable
|
||||
if len(state.ActiveWork.Features) == 0 {
|
||||
if jsonOutput {
|
||||
printJSON(map[string]string{"action": "IDLE", "message": "No active features"})
|
||||
return nil
|
||||
return printJSON(map[string]string{"action": "IDLE", "message": "No active features"})
|
||||
}
|
||||
fmt.Println("No active features. Create one: sdlc feature create <slug>")
|
||||
return nil
|
||||
@ -64,8 +63,7 @@ var nextCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
printJSON(map[string]string{"action": "IDLE", "message": "No actionable work found"})
|
||||
return nil
|
||||
return printJSON(map[string]string{"action": "IDLE", "message": "No actionable work found"})
|
||||
}
|
||||
|
||||
fmt.Println("No actionable work found across active features.")
|
||||
@ -94,7 +92,7 @@ func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifie
|
||||
return err
|
||||
}
|
||||
state.UpdateActiveFeature(slug, cl.TransitionTo, f.Branch)
|
||||
state.RecordAction("transition", slug, "cli")
|
||||
state.RecordAction("TRANSITION", slug, "cli", "success")
|
||||
if err := state.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -125,8 +123,7 @@ func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifie
|
||||
|
||||
func printClassification(cl *sdlc.Classification, f *sdlc.Feature) error {
|
||||
if jsonOutput {
|
||||
printJSON(cl)
|
||||
return nil
|
||||
return printJSON(cl)
|
||||
}
|
||||
|
||||
fmt.Printf("Feature: %s\n", cl.Feature)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -60,22 +60,24 @@ func resolveRoot() (string, error) {
|
||||
return cwd, nil
|
||||
}
|
||||
|
||||
// mustResolveRoot resolves root or exits.
|
||||
// mustResolveRoot resolves root or panics (for use in RunE where error is returned).
|
||||
// Note: Panicking here is intentional as these commands run under cobra which
|
||||
// catches panics and converts them to errors. For production use, prefer resolveRoot().
|
||||
func mustResolveRoot() string {
|
||||
root, err := resolveRoot()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
panic(fmt.Sprintf("resolve root: %v", err))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// printJSON marshals v as indented JSON and prints to stdout.
|
||||
func printJSON(v any) {
|
||||
// Returns an error if marshaling fails instead of exiting.
|
||||
func printJSON(v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("marshal JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
2
cookbooks/.gitignore
vendored
Normal file
2
cookbooks/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Checkpoint storage (local state for tree runner)
|
||||
.checkpoints/
|
||||
337
cookbooks/scripts/lib/checkpoint.sh
Executable file
337
cookbooks/scripts/lib/checkpoint.sh
Executable file
@ -0,0 +1,337 @@
|
||||
#!/bin/bash
|
||||
# Checkpoint utilities for tree-based cookbook execution
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/checkpoint.sh"
|
||||
#
|
||||
# Provides:
|
||||
# - checkpoint_init() - Initialize a new checkpoint
|
||||
# - checkpoint_load() - Load existing checkpoint
|
||||
# - checkpoint_save() - Persist checkpoint to disk
|
||||
# - checkpoint_step_start() - Mark step as started
|
||||
# - checkpoint_step_complete() - Mark step as completed with output
|
||||
# - checkpoint_step_fail() - Mark step as failed with error
|
||||
# - checkpoint_get_output() - Get output from a completed step
|
||||
# - checkpoint_status() - Get overall checkpoint status
|
||||
# - checkpoint_list() - List all checkpoints
|
||||
# - checkpoint_delete() - Delete a checkpoint
|
||||
|
||||
# Checkpoint directory
|
||||
CHECKPOINT_DIR="${CHECKPOINT_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../.checkpoints}"
|
||||
|
||||
# Ensure checkpoint directory exists
|
||||
_checkpoint_ensure_dir() {
|
||||
mkdir -p "$CHECKPOINT_DIR"
|
||||
}
|
||||
|
||||
# Get checkpoint file path for a tree
|
||||
_checkpoint_path() {
|
||||
local tree_name="$1"
|
||||
echo "$CHECKPOINT_DIR/${tree_name}.json"
|
||||
}
|
||||
|
||||
# Initialize a new checkpoint
|
||||
# Arguments: tree_name vars_json
|
||||
# Returns: run_id
|
||||
# Example: run_id=$(checkpoint_init "landing-page" '{"project_name": "test"}')
|
||||
checkpoint_init() {
|
||||
local tree_name="$1"
|
||||
local vars_json="${2:-{}}"
|
||||
|
||||
_checkpoint_ensure_dir
|
||||
|
||||
local run_id="${tree_name}-$(date +%s)"
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(jq -n \
|
||||
--arg tree "$tree_name" \
|
||||
--arg run_id "$run_id" \
|
||||
--arg started "$now" \
|
||||
--argjson vars "$vars_json" \
|
||||
'{
|
||||
tree: $tree,
|
||||
run_id: $run_id,
|
||||
status: "pending",
|
||||
vars: $vars,
|
||||
steps: {},
|
||||
started_at: $started,
|
||||
last_completed_step: null
|
||||
}')
|
||||
|
||||
echo "$checkpoint" > "$(_checkpoint_path "$tree_name")"
|
||||
echo "$run_id"
|
||||
}
|
||||
|
||||
# Load existing checkpoint
|
||||
# Arguments: tree_name
|
||||
# Returns: checkpoint JSON on stdout, exit 1 if not found
|
||||
# Example: checkpoint=$(checkpoint_load "landing-page")
|
||||
checkpoint_load() {
|
||||
local tree_name="$1"
|
||||
local path
|
||||
path="$(_checkpoint_path "$tree_name")"
|
||||
|
||||
if [[ ! -f "$path" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
cat "$path"
|
||||
}
|
||||
|
||||
# Save checkpoint to disk
|
||||
# Arguments: tree_name checkpoint_json
|
||||
# Example: checkpoint_save "landing-page" "$checkpoint"
|
||||
checkpoint_save() {
|
||||
local tree_name="$1"
|
||||
local checkpoint="$2"
|
||||
|
||||
_checkpoint_ensure_dir
|
||||
echo "$checkpoint" > "$(_checkpoint_path "$tree_name")"
|
||||
}
|
||||
|
||||
# Mark step as started
|
||||
# Arguments: tree_name step_name
|
||||
# Example: checkpoint_step_start "landing-page" "create-project"
|
||||
checkpoint_step_start() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
checkpoint=$(echo "$checkpoint" | jq \
|
||||
--arg step "$step_name" \
|
||||
--arg started "$now" \
|
||||
'.steps[$step] = {status: "running", started_at: $started} | .status = "partial"')
|
||||
|
||||
checkpoint_save "$tree_name" "$checkpoint"
|
||||
}
|
||||
|
||||
# Mark step as completed with output
|
||||
# Arguments: tree_name step_name output_json
|
||||
# Example: checkpoint_step_complete "landing-page" "create-project" '{"project_id": "test"}'
|
||||
checkpoint_step_complete() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
local output_json="${3:-{}}"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
checkpoint=$(echo "$checkpoint" | jq \
|
||||
--arg step "$step_name" \
|
||||
--arg completed "$now" \
|
||||
--argjson output "$output_json" \
|
||||
'.steps[$step].status = "completed" |
|
||||
.steps[$step].completed_at = $completed |
|
||||
.steps[$step].output = $output |
|
||||
.last_completed_step = $step')
|
||||
|
||||
checkpoint_save "$tree_name" "$checkpoint"
|
||||
}
|
||||
|
||||
# Mark step as failed with error
|
||||
# Arguments: tree_name step_name error_message
|
||||
# Example: checkpoint_step_fail "landing-page" "wait-pipeline" "Pipeline failed"
|
||||
checkpoint_step_fail() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
local error_msg="$3"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
checkpoint=$(echo "$checkpoint" | jq \
|
||||
--arg step "$step_name" \
|
||||
--arg completed "$now" \
|
||||
--arg error "$error_msg" \
|
||||
'.steps[$step].status = "failed" |
|
||||
.steps[$step].completed_at = $completed |
|
||||
.steps[$step].error = $error |
|
||||
.status = "failed"')
|
||||
|
||||
checkpoint_save "$tree_name" "$checkpoint"
|
||||
}
|
||||
|
||||
# Get output from a completed step
|
||||
# Arguments: tree_name step_name [output_key]
|
||||
# Returns: output value or full output JSON
|
||||
# Example: project_id=$(checkpoint_get_output "landing-page" "create-project" "project_id")
|
||||
checkpoint_get_output() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
local output_key="${3:-}"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
if [[ -n "$output_key" ]]; then
|
||||
echo "$checkpoint" | jq -r ".steps[\"$step_name\"].output[\"$output_key\"] // empty"
|
||||
else
|
||||
echo "$checkpoint" | jq ".steps[\"$step_name\"].output // {}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get variable from checkpoint
|
||||
# Arguments: tree_name var_name
|
||||
# Returns: variable value
|
||||
# Example: project_name=$(checkpoint_get_var "landing-page" "project_name")
|
||||
checkpoint_get_var() {
|
||||
local tree_name="$1"
|
||||
local var_name="$2"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
echo "$checkpoint" | jq -r ".vars[\"$var_name\"] // empty"
|
||||
}
|
||||
|
||||
# Get overall checkpoint status
|
||||
# Arguments: tree_name
|
||||
# Returns: status string (pending, partial, completed, failed)
|
||||
# Example: status=$(checkpoint_status "landing-page")
|
||||
checkpoint_status() {
|
||||
local tree_name="$1"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || {
|
||||
echo "none"
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "$checkpoint" | jq -r '.status // "unknown"'
|
||||
}
|
||||
|
||||
# Get detailed checkpoint status (for display)
|
||||
# Arguments: tree_name
|
||||
# Returns: formatted status output
|
||||
checkpoint_status_detail() {
|
||||
local tree_name="$1"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || {
|
||||
echo "No checkpoint found for tree: $tree_name"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "$checkpoint" | jq -r '
|
||||
"Tree: \(.tree)",
|
||||
"Run ID: \(.run_id)",
|
||||
"Status: \(.status)",
|
||||
"Started: \(.started_at)",
|
||||
"",
|
||||
"Variables:",
|
||||
(.vars | to_entries | .[] | " \(.key): \(.value)"),
|
||||
"",
|
||||
"Steps:",
|
||||
(.steps | to_entries | sort_by(.value.started_at // "") | .[] |
|
||||
if .value.status == "completed" then
|
||||
" \u001b[32m\u2713\u001b[0m \(.key): completed"
|
||||
elif .value.status == "failed" then
|
||||
" \u001b[31m\u2717\u001b[0m \(.key): failed - \(.value.error)"
|
||||
elif .value.status == "running" then
|
||||
" \u001b[33m\u25d0\u001b[0m \(.key): running..."
|
||||
else
|
||||
" \u25cb \(.key): \(.value.status)"
|
||||
end
|
||||
),
|
||||
"",
|
||||
"Last completed: \(.last_completed_step // "none")"
|
||||
'
|
||||
}
|
||||
|
||||
# Get list of completed steps
|
||||
# Arguments: tree_name
|
||||
# Returns: newline-separated list of completed step names
|
||||
checkpoint_completed_steps() {
|
||||
local tree_name="$1"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
echo "$checkpoint" | jq -r '.steps | to_entries | .[] | select(.value.status == "completed") | .key'
|
||||
}
|
||||
|
||||
# Get list of failed steps
|
||||
# Arguments: tree_name
|
||||
# Returns: newline-separated list of failed step names
|
||||
checkpoint_failed_steps() {
|
||||
local tree_name="$1"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
echo "$checkpoint" | jq -r '.steps | to_entries | .[] | select(.value.status == "failed") | .key'
|
||||
}
|
||||
|
||||
# List all checkpoints
|
||||
# Returns: newline-separated list of tree names with checkpoints
|
||||
# Example: trees=$(checkpoint_list)
|
||||
checkpoint_list() {
|
||||
_checkpoint_ensure_dir
|
||||
|
||||
for f in "$CHECKPOINT_DIR"/*.json; do
|
||||
[[ -e "$f" ]] || continue
|
||||
basename "$f" .json
|
||||
done
|
||||
}
|
||||
|
||||
# Delete a checkpoint
|
||||
# Arguments: tree_name
|
||||
# Example: checkpoint_delete "landing-page"
|
||||
checkpoint_delete() {
|
||||
local tree_name="$1"
|
||||
local path
|
||||
path="$(_checkpoint_path "$tree_name")"
|
||||
|
||||
if [[ -f "$path" ]]; then
|
||||
rm "$path"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Mark entire tree as completed
|
||||
# Arguments: tree_name
|
||||
checkpoint_mark_completed() {
|
||||
local tree_name="$1"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
checkpoint=$(echo "$checkpoint" | jq \
|
||||
--arg completed "$now" \
|
||||
'.status = "completed" | .completed_at = $completed')
|
||||
|
||||
checkpoint_save "$tree_name" "$checkpoint"
|
||||
}
|
||||
|
||||
# Update vars in checkpoint (for resume with different vars)
|
||||
# Arguments: tree_name vars_json
|
||||
checkpoint_update_vars() {
|
||||
local tree_name="$1"
|
||||
local vars_json="$2"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || return 1
|
||||
|
||||
checkpoint=$(echo "$checkpoint" | jq \
|
||||
--argjson new_vars "$vars_json" \
|
||||
'.vars = (.vars + $new_vars)')
|
||||
|
||||
checkpoint_save "$tree_name" "$checkpoint"
|
||||
}
|
||||
400
cookbooks/scripts/lib/tree-parser.sh
Executable file
400
cookbooks/scripts/lib/tree-parser.sh
Executable file
@ -0,0 +1,400 @@
|
||||
#!/bin/bash
|
||||
# Tree parser utilities for YAML cookbook definitions
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/tree-parser.sh"
|
||||
#
|
||||
# Provides:
|
||||
# - tree_parse() - Parse a tree YAML file
|
||||
# - tree_get_step() - Get a specific step definition
|
||||
# - tree_get_deps() - Get dependencies for a step
|
||||
# - tree_execution_order() - Get topological sort of steps
|
||||
# - tree_step_ready() - Check if step's dependencies are satisfied
|
||||
# - tree_expand_template() - Expand Go template variables
|
||||
# - tree_list() - List all available trees
|
||||
# - tree_get_teardown() - Get teardown steps
|
||||
|
||||
# Trees directory
|
||||
TREES_DIR="${TREES_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../trees}"
|
||||
|
||||
# Check for yq
|
||||
_tree_check_yq() {
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo "Error: yq is required but not installed" >&2
|
||||
echo "Install with: brew install yq" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Get tree file path
|
||||
_tree_path() {
|
||||
local tree_name="$1"
|
||||
echo "$TREES_DIR/${tree_name}.yaml"
|
||||
}
|
||||
|
||||
# Parse a tree YAML file
|
||||
# Arguments: tree_name
|
||||
# Returns: tree JSON on stdout
|
||||
# Example: tree_json=$(tree_parse "landing-page")
|
||||
tree_parse() {
|
||||
local tree_name="$1"
|
||||
local path
|
||||
path="$(_tree_path "$tree_name")"
|
||||
|
||||
_tree_check_yq || return 1
|
||||
|
||||
if [[ ! -f "$path" ]]; then
|
||||
echo "Error: Tree '$tree_name' not found at $path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
yq -o=json "$path"
|
||||
}
|
||||
|
||||
# Get tree metadata
|
||||
# Arguments: tree_name
|
||||
# Returns: JSON with name, description, version
|
||||
tree_get_meta() {
|
||||
local tree_name="$1"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
echo "$tree" | jq '{name: .name, description: .description, version: .version}'
|
||||
}
|
||||
|
||||
# Get default vars from tree
|
||||
# Arguments: tree_name
|
||||
# Returns: JSON object of default vars
|
||||
tree_get_default_vars() {
|
||||
local tree_name="$1"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
echo "$tree" | jq '.vars // {}'
|
||||
}
|
||||
|
||||
# Get a specific step definition
|
||||
# Arguments: tree_name step_name
|
||||
# Returns: step JSON on stdout
|
||||
# Example: step=$(tree_get_step "landing-page" "create-project")
|
||||
tree_get_step() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
local step
|
||||
step=$(echo "$tree" | jq --arg step "$step_name" '.steps[$step] // null')
|
||||
|
||||
if [[ "$step" == "null" ]]; then
|
||||
echo "Error: Step '$step_name' not found in tree '$tree_name'" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Add step name to the JSON for convenience
|
||||
echo "$step" | jq --arg name "$step_name" '. + {name: $name}'
|
||||
}
|
||||
|
||||
# Get all step names
|
||||
# Arguments: tree_name
|
||||
# Returns: newline-separated list of step names
|
||||
tree_get_steps() {
|
||||
local tree_name="$1"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
echo "$tree" | jq -r '.steps | keys[]'
|
||||
}
|
||||
|
||||
# Get dependencies for a step
|
||||
# Arguments: tree_name step_name
|
||||
# Returns: newline-separated list of dependency step names
|
||||
# Example: deps=$(tree_get_deps "landing-page" "add-component")
|
||||
tree_get_deps() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
|
||||
local step
|
||||
step=$(tree_get_step "$tree_name" "$step_name") || return 1
|
||||
|
||||
echo "$step" | jq -r '.depends_on // [] | .[]'
|
||||
}
|
||||
|
||||
# Get topological sort of steps (execution order)
|
||||
# Arguments: tree_name
|
||||
# Returns: newline-separated list of step names in execution order
|
||||
# Example: order=$(tree_execution_order "landing-page")
|
||||
tree_execution_order() {
|
||||
local tree_name="$1"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
# Kahn's algorithm for topological sort
|
||||
# Build adjacency list and in-degree count
|
||||
local steps_json
|
||||
steps_json=$(echo "$tree" | jq '.steps')
|
||||
|
||||
# Use jq to compute the topological order
|
||||
echo "$steps_json" | jq -r '
|
||||
# Build in-degree map and adjacency list
|
||||
. as $steps |
|
||||
(keys | map({key: ., value: 0}) | from_entries) as $initial_degrees |
|
||||
reduce keys[] as $step (
|
||||
{degrees: $initial_degrees, adj: {}};
|
||||
. as $state |
|
||||
($steps[$step].depends_on // []) as $deps |
|
||||
reduce $deps[] as $dep (
|
||||
$state;
|
||||
.degrees[$step] += 1 |
|
||||
.adj[$dep] = ((.adj[$dep] // []) + [$step])
|
||||
)
|
||||
) |
|
||||
|
||||
# Kahns algorithm
|
||||
. as $graph |
|
||||
{
|
||||
result: [],
|
||||
queue: [$graph.degrees | to_entries | .[] | select(.value == 0) | .key],
|
||||
degrees: $graph.degrees,
|
||||
adj: $graph.adj
|
||||
} |
|
||||
until(.queue | length == 0;
|
||||
.queue[0] as $node |
|
||||
.result += [$node] |
|
||||
.queue = .queue[1:] |
|
||||
reduce ((.adj[$node] // [])[] ) as $neighbor (
|
||||
.;
|
||||
.degrees[$neighbor] -= 1 |
|
||||
if .degrees[$neighbor] == 0 then
|
||||
.queue += [$neighbor]
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
) |
|
||||
.result[]
|
||||
'
|
||||
}
|
||||
|
||||
# Check if a step's dependencies are satisfied
|
||||
# Arguments: tree_name step_name completed_steps_json
|
||||
# Returns: 0 if ready, 1 if not
|
||||
# Example: if tree_step_ready "landing-page" "add-component" '["create-project"]'; then ...
|
||||
tree_step_ready() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
local completed_steps="$3" # JSON array of completed step names
|
||||
|
||||
local deps
|
||||
deps=$(tree_get_deps "$tree_name" "$step_name") || return 1
|
||||
|
||||
if [[ -z "$deps" ]]; then
|
||||
return 0 # No dependencies, always ready
|
||||
fi
|
||||
|
||||
# Check each dependency
|
||||
while IFS= read -r dep; do
|
||||
if ! echo "$completed_steps" | jq -e --arg d "$dep" 'index($d) != null' > /dev/null; then
|
||||
return 1 # Dependency not satisfied
|
||||
fi
|
||||
done <<< "$deps"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Expand Go template variables in a string
|
||||
# Arguments: template_string vars_json outputs_json
|
||||
# Returns: expanded string
|
||||
# Example: result=$(tree_expand_template "{{ .vars.project_name }}" '{"project_name":"test"}' '{}')
|
||||
tree_expand_template() {
|
||||
local template="$1"
|
||||
local vars_json="${2:-{}}"
|
||||
local outputs_json="${3:-{}}"
|
||||
|
||||
# Build context for template expansion
|
||||
local context
|
||||
context=$(jq -n \
|
||||
--argjson vars "$vars_json" \
|
||||
--argjson outputs "$outputs_json" \
|
||||
'{vars: $vars, outputs: $outputs}')
|
||||
|
||||
# Simple template expansion using sed
|
||||
# Handle {{ .vars.NAME }} patterns
|
||||
local result="$template"
|
||||
|
||||
# Extract and replace all {{ .vars.xxx }} patterns
|
||||
while [[ "$result" =~ \{\{[[:space:]]*\.vars\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do
|
||||
local var_name="${BASH_REMATCH[1]}"
|
||||
local var_value
|
||||
var_value=$(echo "$vars_json" | jq -r ".[\"$var_name\"] // \"\"")
|
||||
result="${result//\{\{ .vars.$var_name \}\}/$var_value}"
|
||||
result="${result//\{\{.vars.$var_name\}\}/$var_value}"
|
||||
done
|
||||
|
||||
# Extract and replace all {{ .outputs.step.key }} patterns
|
||||
while [[ "$result" =~ \{\{[[:space:]]*\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do
|
||||
local step_name="${BASH_REMATCH[1]}"
|
||||
local key_name="${BASH_REMATCH[2]}"
|
||||
local out_value
|
||||
out_value=$(echo "$outputs_json" | jq -r ".[\"$step_name\"][\"$key_name\"] // \"\"")
|
||||
result="${result//\{\{ .outputs.$step_name.$key_name \}\}/$out_value}"
|
||||
result="${result//\{\{.outputs.$step_name.$key_name\}\}/$out_value}"
|
||||
done
|
||||
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
# Expand templates in a step definition
|
||||
# Arguments: step_json vars_json outputs_json
|
||||
# Returns: step JSON with templates expanded
|
||||
tree_expand_step() {
|
||||
local step_json="$1"
|
||||
local vars_json="${2:-{}}"
|
||||
local outputs_json="${3:-{}}"
|
||||
|
||||
# Convert step to string and expand templates
|
||||
local step_str
|
||||
step_str=$(echo "$step_json" | jq -c '.')
|
||||
|
||||
local expanded
|
||||
expanded=$(tree_expand_template "$step_str" "$vars_json" "$outputs_json")
|
||||
|
||||
echo "$expanded"
|
||||
}
|
||||
|
||||
# List all available trees
|
||||
# Returns: newline-separated list of tree names
|
||||
# Example: trees=$(tree_list)
|
||||
tree_list() {
|
||||
_tree_check_yq || return 1
|
||||
|
||||
for f in "$TREES_DIR"/*.yaml; do
|
||||
[[ -e "$f" ]] || continue
|
||||
basename "$f" .yaml
|
||||
done
|
||||
}
|
||||
|
||||
# List trees with descriptions
|
||||
# Returns: formatted list of trees with descriptions
|
||||
tree_list_detail() {
|
||||
_tree_check_yq || return 1
|
||||
|
||||
for f in "$TREES_DIR"/*.yaml; do
|
||||
[[ -e "$f" ]] || continue
|
||||
local name
|
||||
name=$(basename "$f" .yaml)
|
||||
local desc
|
||||
desc=$(yq -r '.description // "No description"' "$f" 2>/dev/null)
|
||||
printf " %-20s %s\n" "$name" "$desc"
|
||||
done
|
||||
}
|
||||
|
||||
# Get teardown steps
|
||||
# Arguments: tree_name
|
||||
# Returns: JSON array of teardown steps
|
||||
# Example: teardown=$(tree_get_teardown "landing-page")
|
||||
tree_get_teardown() {
|
||||
local tree_name="$1"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
echo "$tree" | jq '.teardown // []'
|
||||
}
|
||||
|
||||
# Get step action type
|
||||
# Arguments: step_json
|
||||
# Returns: action type string
|
||||
tree_step_action() {
|
||||
local step_json="$1"
|
||||
echo "$step_json" | jq -r '.action // "unknown"'
|
||||
}
|
||||
|
||||
# Get step on_error behavior
|
||||
# Arguments: step_json
|
||||
# Returns: "fail" or "continue"
|
||||
tree_step_on_error() {
|
||||
local step_json="$1"
|
||||
echo "$step_json" | jq -r '.on_error // "fail"'
|
||||
}
|
||||
|
||||
# Get output extraction rules from step
|
||||
# Arguments: step_json
|
||||
# Returns: JSON array of output rules [{name, jq_path}]
|
||||
tree_step_outputs() {
|
||||
local step_json="$1"
|
||||
|
||||
# outputs can be:
|
||||
# - array of {key: jq_path}
|
||||
# Example: [{project_id: ".data.name"}]
|
||||
echo "$step_json" | jq '
|
||||
.outputs // [] |
|
||||
map(to_entries | .[0] | {name: .key, jq_path: .value})
|
||||
'
|
||||
}
|
||||
|
||||
# Validate tree YAML
|
||||
# Arguments: tree_name
|
||||
# Returns: 0 if valid, 1 with error messages if not
|
||||
tree_validate() {
|
||||
local tree_name="$1"
|
||||
|
||||
local tree
|
||||
tree=$(tree_parse "$tree_name") || return 1
|
||||
|
||||
local errors=()
|
||||
|
||||
# Check required fields
|
||||
local name
|
||||
name=$(echo "$tree" | jq -r '.name // ""')
|
||||
if [[ -z "$name" ]]; then
|
||||
errors+=("Missing required field: name")
|
||||
fi
|
||||
|
||||
# Check that all steps have action field
|
||||
local steps_without_action
|
||||
steps_without_action=$(echo "$tree" | jq -r '.steps | to_entries | .[] | select(.value.action == null) | .key')
|
||||
if [[ -n "$steps_without_action" ]]; then
|
||||
while IFS= read -r step; do
|
||||
errors+=("Step '$step' missing required field: action")
|
||||
done <<< "$steps_without_action"
|
||||
fi
|
||||
|
||||
# Check that dependencies reference existing steps
|
||||
local all_steps
|
||||
all_steps=$(echo "$tree" | jq -r '.steps | keys')
|
||||
local invalid_deps
|
||||
invalid_deps=$(echo "$tree" | jq -r --argjson all_steps "$all_steps" '
|
||||
.steps | to_entries | .[] |
|
||||
.key as $step |
|
||||
(.value.depends_on // [])[] |
|
||||
select(. as $dep | $all_steps | index($dep) == null) |
|
||||
"\($step) depends on non-existent step: \(.)"
|
||||
')
|
||||
if [[ -n "$invalid_deps" ]]; then
|
||||
while IFS= read -r err; do
|
||||
errors+=("$err")
|
||||
done <<< "$invalid_deps"
|
||||
fi
|
||||
|
||||
# Check for cycles (execution_order will fail if there are cycles)
|
||||
if ! tree_execution_order "$tree_name" > /dev/null 2>&1; then
|
||||
errors+=("Dependency cycle detected")
|
||||
fi
|
||||
|
||||
# Report errors
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
echo "Validation errors in tree '$tree_name':" >&2
|
||||
for err in "${errors[@]}"; do
|
||||
echo " - $err" >&2
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
686
cookbooks/scripts/tree-runner.sh
Executable file
686
cookbooks/scripts/tree-runner.sh
Executable file
@ -0,0 +1,686 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Tree Runner - Execute cookbook trees with checkpoint support
|
||||
#
|
||||
# Usage:
|
||||
# ./tree-runner.sh run <tree> [--var-name value]...
|
||||
# ./tree-runner.sh resume <tree>
|
||||
# ./tree-runner.sh only <tree> <step>
|
||||
# ./tree-runner.sh status <tree>
|
||||
# ./tree-runner.sh teardown <tree>
|
||||
# ./tree-runner.sh list
|
||||
# ./tree-runner.sh clean <tree>
|
||||
#
|
||||
# Examples:
|
||||
# ./tree-runner.sh run landing-page --project-name my-test
|
||||
# ./tree-runner.sh resume landing-page
|
||||
# ./tree-runner.sh only landing-page wait-pipeline
|
||||
# ./tree-runner.sh status landing-page
|
||||
# ./tree-runner.sh teardown landing-page
|
||||
# ./tree-runner.sh list
|
||||
# ./tree-runner.sh clean landing-page
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source dependencies
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
source "$SCRIPT_DIR/lib/checkpoint.sh"
|
||||
source "$SCRIPT_DIR/lib/tree-parser.sh"
|
||||
|
||||
# Parse command
|
||||
COMMAND="${1:-}"
|
||||
|
||||
if [[ -z "$COMMAND" ]]; then
|
||||
echo "Tree Runner - Execute cookbook trees with checkpoint support"
|
||||
echo ""
|
||||
echo "Usage: $0 <command> [args]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " run <tree> [--var-name value]... Run a tree from the beginning"
|
||||
echo " resume <tree> Resume from last checkpoint"
|
||||
echo " only <tree> <step> Run only a specific step"
|
||||
echo " status <tree> Show checkpoint status"
|
||||
echo " teardown <tree> Run tree's teardown steps"
|
||||
echo " list List available trees"
|
||||
echo " clean <tree> Delete checkpoint for a tree"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 run landing-page --project-name my-test-\$(date +%s)"
|
||||
echo " $0 resume landing-page"
|
||||
echo " $0 only landing-page wait-pipeline"
|
||||
echo " $0 status landing-page"
|
||||
echo " $0 teardown landing-page"
|
||||
echo " $0 list"
|
||||
echo " $0 clean landing-page"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shift
|
||||
|
||||
# ============================================================================
|
||||
# Step Executors
|
||||
# ============================================================================
|
||||
|
||||
# Execute an API step
|
||||
# Arguments: step_json
|
||||
# Returns: response JSON
|
||||
execute_api_step() {
|
||||
local step_json="$1"
|
||||
|
||||
local method endpoint body
|
||||
method=$(echo "$step_json" | jq -r '.method // "GET"')
|
||||
endpoint=$(echo "$step_json" | jq -r '.endpoint')
|
||||
body=$(echo "$step_json" | jq -c '.body // null')
|
||||
|
||||
local response
|
||||
if [[ "$body" != "null" ]]; then
|
||||
response=$(api_call "$method" "$endpoint" "$body")
|
||||
else
|
||||
response=$(api_call "$method" "$endpoint")
|
||||
fi
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Execute a wait_pipeline step
|
||||
# Arguments: step_json
|
||||
# Returns: 0 on success, 1 on failure
|
||||
execute_wait_pipeline_step() {
|
||||
local step_json="$1"
|
||||
|
||||
local project_id max_attempts poll_interval
|
||||
project_id=$(echo "$step_json" | jq -r '.project_id')
|
||||
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 60')
|
||||
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
|
||||
|
||||
wait_for_pipeline "$project_id" "$max_attempts" "$poll_interval"
|
||||
}
|
||||
|
||||
# Execute a wait_site step
|
||||
# Arguments: step_json
|
||||
# Returns: 0 on success, 1 on failure
|
||||
execute_wait_site_step() {
|
||||
local step_json="$1"
|
||||
|
||||
local domain project_id max_attempts poll_interval
|
||||
domain=$(echo "$step_json" | jq -r '.domain')
|
||||
project_id=$(echo "$step_json" | jq -r '.project_id // ""')
|
||||
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 30')
|
||||
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
|
||||
|
||||
wait_for_site "$domain" "$max_attempts" "$poll_interval" "$project_id"
|
||||
}
|
||||
|
||||
# Execute a diagnose step
|
||||
# Arguments: step_json
|
||||
execute_diagnose_step() {
|
||||
local step_json="$1"
|
||||
|
||||
local diag_type project_id domain
|
||||
diag_type=$(echo "$step_json" | jq -r '.type // "pipeline"')
|
||||
project_id=$(echo "$step_json" | jq -r '.project_id // ""')
|
||||
domain=$(echo "$step_json" | jq -r '.domain // ""')
|
||||
|
||||
case "$diag_type" in
|
||||
pipeline)
|
||||
diagnose_pipeline_failure "$project_id"
|
||||
;;
|
||||
site)
|
||||
diagnose_site_failure "$domain" "$project_id"
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown diagnose type: $diag_type"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Execute a shell step
|
||||
# Arguments: step_json
|
||||
# Returns: command output
|
||||
execute_shell_step() {
|
||||
local step_json="$1"
|
||||
|
||||
local command
|
||||
command=$(echo "$step_json" | jq -r '.command')
|
||||
|
||||
eval "$command"
|
||||
}
|
||||
|
||||
# Extract outputs from response
|
||||
# Arguments: response_json output_rules_json
|
||||
# Returns: JSON object of extracted outputs
|
||||
extract_outputs() {
|
||||
local response="$1"
|
||||
local output_rules="$2"
|
||||
|
||||
if [[ "$output_rules" == "[]" || -z "$output_rules" ]]; then
|
||||
echo "{}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract each output using jq
|
||||
local outputs="{}"
|
||||
while IFS= read -r rule; do
|
||||
local name jq_path value
|
||||
name=$(echo "$rule" | jq -r '.name')
|
||||
jq_path=$(echo "$rule" | jq -r '.jq_path')
|
||||
|
||||
value=$(echo "$response" | jq -r "$jq_path // \"\"")
|
||||
outputs=$(echo "$outputs" | jq --arg k "$name" --arg v "$value" '. + {($k): $v}')
|
||||
done < <(echo "$output_rules" | jq -c '.[]')
|
||||
|
||||
echo "$outputs"
|
||||
}
|
||||
|
||||
# Execute a single step
|
||||
# Arguments: tree_name step_name vars_json outputs_json
|
||||
# Returns: 0 on success, 1 on failure
|
||||
# Updates outputs_json with new outputs
|
||||
execute_step() {
|
||||
local tree_name="$1"
|
||||
local step_name="$2"
|
||||
local vars_json="$3"
|
||||
local outputs_json="$4"
|
||||
|
||||
# Get and expand step definition
|
||||
local step_raw
|
||||
step_raw=$(tree_get_step "$tree_name" "$step_name") || return 1
|
||||
|
||||
local step
|
||||
step=$(tree_expand_step "$step_raw" "$vars_json" "$outputs_json")
|
||||
|
||||
local action description on_error
|
||||
action=$(tree_step_action "$step")
|
||||
description=$(echo "$step" | jq -r '.description // ""')
|
||||
on_error=$(tree_step_on_error "$step")
|
||||
|
||||
# Print step header
|
||||
if [[ -n "$description" ]]; then
|
||||
echo -e "${CYAN}Step: $step_name${NC} - $description"
|
||||
else
|
||||
echo -e "${CYAN}Step: $step_name${NC}"
|
||||
fi
|
||||
|
||||
# Mark step as started
|
||||
checkpoint_step_start "$tree_name" "$step_name"
|
||||
|
||||
local response=""
|
||||
local step_failed=0
|
||||
|
||||
case "$action" in
|
||||
api)
|
||||
response=$(execute_api_step "$step") || step_failed=1
|
||||
if [[ $step_failed -eq 0 ]]; then
|
||||
# Check for error in response
|
||||
local error
|
||||
error=$(echo "$response" | jq -r '.error // ""')
|
||||
if [[ -n "$error" && "$error" != "null" ]]; then
|
||||
print_error "API error: $error"
|
||||
step_failed=1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
wait_pipeline)
|
||||
execute_wait_pipeline_step "$step" || step_failed=1
|
||||
response="{}"
|
||||
;;
|
||||
wait_site)
|
||||
execute_wait_site_step "$step" || step_failed=1
|
||||
response="{}"
|
||||
;;
|
||||
diagnose)
|
||||
execute_diagnose_step "$step"
|
||||
response="{}"
|
||||
;;
|
||||
shell)
|
||||
response=$(execute_shell_step "$step") || step_failed=1
|
||||
# Try to parse as JSON, otherwise wrap in object
|
||||
if ! echo "$response" | jq -e '.' > /dev/null 2>&1; then
|
||||
response=$(jq -n --arg out "$response" '{output: $out}')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown action type: $action"
|
||||
checkpoint_step_fail "$tree_name" "$step_name" "Unknown action: $action"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $step_failed -eq 1 ]]; then
|
||||
checkpoint_step_fail "$tree_name" "$step_name" "Step failed"
|
||||
if [[ "$on_error" == "continue" ]]; then
|
||||
print_warning "Step failed but continuing (on_error: continue)"
|
||||
checkpoint_step_complete "$tree_name" "$step_name" "{}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract outputs if defined
|
||||
local output_rules
|
||||
output_rules=$(tree_step_outputs "$step")
|
||||
|
||||
local step_outputs
|
||||
step_outputs=$(extract_outputs "$response" "$output_rules")
|
||||
|
||||
# Save outputs to checkpoint
|
||||
checkpoint_step_complete "$tree_name" "$step_name" "$step_outputs"
|
||||
|
||||
# Return outputs for use by subsequent steps
|
||||
echo "$step_outputs"
|
||||
|
||||
print_success "Step completed: $step_name"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Build outputs JSON from checkpoint
|
||||
# Arguments: tree_name
|
||||
# Returns: outputs JSON object
|
||||
build_outputs_from_checkpoint() {
|
||||
local tree_name="$1"
|
||||
|
||||
local checkpoint
|
||||
checkpoint=$(checkpoint_load "$tree_name") || {
|
||||
echo "{}"
|
||||
return
|
||||
}
|
||||
|
||||
echo "$checkpoint" | jq '
|
||||
.steps | to_entries |
|
||||
map(select(.value.status == "completed")) |
|
||||
map({key: .key, value: .value.output}) |
|
||||
from_entries
|
||||
'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Commands
|
||||
# ============================================================================
|
||||
|
||||
# Run a tree from the beginning
|
||||
cmd_run() {
|
||||
local tree_name="${1:-}"
|
||||
shift || true
|
||||
|
||||
if [[ -z "$tree_name" ]]; then
|
||||
echo "Usage: $0 run <tree> [--var-name value]..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate tree exists
|
||||
if ! tree_parse "$tree_name" > /dev/null 2>&1; then
|
||||
print_error "Tree '$tree_name' not found"
|
||||
echo ""
|
||||
echo "Available trees:"
|
||||
tree_list_detail
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse variables from args
|
||||
local vars_json
|
||||
vars_json=$(tree_get_default_vars "$tree_name")
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--*)
|
||||
local var_name="${1#--}"
|
||||
var_name="${var_name//-/_}" # Convert dashes to underscores
|
||||
local var_value="${2:-}"
|
||||
if [[ -z "$var_value" ]]; then
|
||||
print_error "Missing value for --$var_name"
|
||||
exit 1
|
||||
fi
|
||||
vars_json=$(echo "$vars_json" | jq --arg k "$var_name" --arg v "$var_value" '. + {($k): $v}')
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown argument: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check required vars (empty string values)
|
||||
local missing_vars
|
||||
missing_vars=$(echo "$vars_json" | jq -r 'to_entries | .[] | select(.value == "") | .key')
|
||||
if [[ -n "$missing_vars" ]]; then
|
||||
print_error "Missing required variables:"
|
||||
echo "$missing_vars" | sed 's/^/ --/'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Initialize checkpoint
|
||||
local run_id
|
||||
run_id=$(checkpoint_init "$tree_name" "$vars_json")
|
||||
|
||||
print_header "Running tree: $tree_name"
|
||||
echo "Run ID: $run_id"
|
||||
echo "Variables:"
|
||||
echo "$vars_json" | jq -r 'to_entries | .[] | " \(.key): \(.value)"'
|
||||
echo ""
|
||||
|
||||
# Get execution order
|
||||
local execution_order
|
||||
execution_order=$(tree_execution_order "$tree_name")
|
||||
|
||||
# Build outputs as we go
|
||||
local outputs_json="{}"
|
||||
|
||||
# Execute steps in order
|
||||
while IFS= read -r step_name; do
|
||||
local step_outputs
|
||||
if step_outputs=$(execute_step "$tree_name" "$step_name" "$vars_json" "$outputs_json"); then
|
||||
# Merge step outputs into cumulative outputs
|
||||
outputs_json=$(echo "$outputs_json" | jq --arg step "$step_name" --argjson out "$step_outputs" '. + {($step): $out}')
|
||||
else
|
||||
print_error "Tree execution failed at step: $step_name"
|
||||
echo ""
|
||||
echo "To resume from this point:"
|
||||
echo " $0 resume $tree_name"
|
||||
echo ""
|
||||
echo "To run just this step:"
|
||||
echo " $0 only $tree_name $step_name"
|
||||
echo ""
|
||||
echo "To teardown resources:"
|
||||
echo " $0 teardown $tree_name"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
done <<< "$execution_order"
|
||||
|
||||
# Mark as completed
|
||||
checkpoint_mark_completed "$tree_name"
|
||||
|
||||
print_header "Tree completed successfully!"
|
||||
echo "Run ID: $run_id"
|
||||
echo ""
|
||||
echo "To view final state:"
|
||||
echo " $0 status $tree_name"
|
||||
echo ""
|
||||
echo "To teardown resources:"
|
||||
echo " $0 teardown $tree_name"
|
||||
}
|
||||
|
||||
# Resume from last checkpoint
|
||||
cmd_resume() {
|
||||
local tree_name="${1:-}"
|
||||
|
||||
if [[ -z "$tree_name" ]]; then
|
||||
echo "Usage: $0 resume <tree>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load checkpoint
|
||||
local checkpoint
|
||||
if ! checkpoint=$(checkpoint_load "$tree_name"); then
|
||||
print_error "No checkpoint found for tree: $tree_name"
|
||||
echo ""
|
||||
echo "Run the tree first:"
|
||||
echo " $0 run $tree_name [--var-name value]..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local status
|
||||
status=$(echo "$checkpoint" | jq -r '.status')
|
||||
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
print_success "Tree already completed. Nothing to resume."
|
||||
echo ""
|
||||
echo "To run again:"
|
||||
echo " $0 clean $tree_name && $0 run $tree_name ..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
print_header "Resuming tree: $tree_name"
|
||||
echo "Status: $status"
|
||||
|
||||
# Get vars from checkpoint
|
||||
local vars_json
|
||||
vars_json=$(echo "$checkpoint" | jq '.vars')
|
||||
|
||||
# Build outputs from completed steps
|
||||
local outputs_json
|
||||
outputs_json=$(build_outputs_from_checkpoint "$tree_name")
|
||||
|
||||
# Get completed steps
|
||||
local completed_steps
|
||||
completed_steps=$(checkpoint_completed_steps "$tree_name")
|
||||
|
||||
echo "Completed steps:"
|
||||
if [[ -n "$completed_steps" ]]; then
|
||||
echo "$completed_steps" | sed 's/^/ ✓ /'
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Get execution order
|
||||
local execution_order
|
||||
execution_order=$(tree_execution_order "$tree_name")
|
||||
|
||||
# Execute remaining steps
|
||||
while IFS= read -r step_name; do
|
||||
# Skip completed steps
|
||||
if echo "$completed_steps" | grep -q "^${step_name}$"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local step_outputs
|
||||
if step_outputs=$(execute_step "$tree_name" "$step_name" "$vars_json" "$outputs_json"); then
|
||||
outputs_json=$(echo "$outputs_json" | jq --arg step "$step_name" --argjson out "$step_outputs" '. + {($step): $out}')
|
||||
else
|
||||
print_error "Tree execution failed at step: $step_name"
|
||||
echo ""
|
||||
echo "To resume again:"
|
||||
echo " $0 resume $tree_name"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
done <<< "$execution_order"
|
||||
|
||||
checkpoint_mark_completed "$tree_name"
|
||||
|
||||
print_header "Tree completed successfully!"
|
||||
}
|
||||
|
||||
# Run only a specific step
|
||||
cmd_only() {
|
||||
local tree_name="${1:-}"
|
||||
local step_name="${2:-}"
|
||||
|
||||
if [[ -z "$tree_name" || -z "$step_name" ]]; then
|
||||
echo "Usage: $0 only <tree> <step>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate step exists
|
||||
if ! tree_get_step "$tree_name" "$step_name" > /dev/null 2>&1; then
|
||||
print_error "Step '$step_name' not found in tree '$tree_name'"
|
||||
echo ""
|
||||
echo "Available steps:"
|
||||
tree_get_steps "$tree_name" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load checkpoint (or use empty state)
|
||||
local vars_json="{}"
|
||||
local outputs_json="{}"
|
||||
|
||||
if checkpoint=$(checkpoint_load "$tree_name" 2>/dev/null); then
|
||||
vars_json=$(echo "$checkpoint" | jq '.vars')
|
||||
outputs_json=$(build_outputs_from_checkpoint "$tree_name")
|
||||
fi
|
||||
|
||||
print_header "Running single step: $step_name"
|
||||
echo "Tree: $tree_name"
|
||||
echo ""
|
||||
|
||||
local step_outputs
|
||||
if step_outputs=$(execute_step "$tree_name" "$step_name" "$vars_json" "$outputs_json"); then
|
||||
print_success "Step completed"
|
||||
echo ""
|
||||
echo "Outputs:"
|
||||
echo "$step_outputs" | jq '.'
|
||||
else
|
||||
print_error "Step failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Show checkpoint status
|
||||
cmd_status() {
|
||||
local tree_name="${1:-}"
|
||||
|
||||
if [[ -z "$tree_name" ]]; then
|
||||
echo "Usage: $0 status <tree>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checkpoint_status_detail "$tree_name"
|
||||
}
|
||||
|
||||
# Run teardown steps
|
||||
cmd_teardown() {
|
||||
local tree_name="${1:-}"
|
||||
|
||||
if [[ -z "$tree_name" ]]; then
|
||||
echo "Usage: $0 teardown <tree>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_header "Teardown: $tree_name"
|
||||
|
||||
# Load checkpoint for outputs
|
||||
local vars_json="{}"
|
||||
local outputs_json="{}"
|
||||
|
||||
if checkpoint=$(checkpoint_load "$tree_name" 2>/dev/null); then
|
||||
vars_json=$(echo "$checkpoint" | jq '.vars')
|
||||
outputs_json=$(build_outputs_from_checkpoint "$tree_name")
|
||||
else
|
||||
print_warning "No checkpoint found - teardown may not have all variables"
|
||||
fi
|
||||
|
||||
# Get teardown steps
|
||||
local teardown_steps
|
||||
teardown_steps=$(tree_get_teardown "$tree_name")
|
||||
|
||||
local teardown_count
|
||||
teardown_count=$(echo "$teardown_steps" | jq 'length')
|
||||
|
||||
if [[ "$teardown_count" -eq 0 ]]; then
|
||||
echo "No teardown steps defined for this tree."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Running $teardown_count teardown steps..."
|
||||
echo ""
|
||||
|
||||
# Execute teardown steps
|
||||
local i=0
|
||||
while IFS= read -r step_json; do
|
||||
((i++))
|
||||
|
||||
# Expand templates
|
||||
local step
|
||||
step=$(tree_expand_step "$step_json" "$vars_json" "$outputs_json")
|
||||
|
||||
local action description
|
||||
action=$(echo "$step" | jq -r '.action // "unknown"')
|
||||
description=$(echo "$step" | jq -r '.description // "Teardown step $i"')
|
||||
|
||||
echo -e "${CYAN}Teardown $i:${NC} $description"
|
||||
|
||||
case "$action" in
|
||||
api)
|
||||
execute_api_step "$step" > /dev/null && print_success "Done" || print_warning "Failed (continuing)"
|
||||
;;
|
||||
shell)
|
||||
execute_shell_step "$step" > /dev/null && print_success "Done" || print_warning "Failed (continuing)"
|
||||
;;
|
||||
*)
|
||||
print_warning "Skipping unknown action: $action"
|
||||
;;
|
||||
esac
|
||||
done < <(echo "$teardown_steps" | jq -c '.[]')
|
||||
|
||||
echo ""
|
||||
print_success "Teardown complete"
|
||||
|
||||
# Optionally clean checkpoint
|
||||
echo ""
|
||||
echo "Checkpoint preserved. To remove:"
|
||||
echo " $0 clean $tree_name"
|
||||
}
|
||||
|
||||
# List available trees
|
||||
cmd_list() {
|
||||
print_header "Available Trees"
|
||||
tree_list_detail
|
||||
|
||||
echo ""
|
||||
echo "Checkpoints:"
|
||||
local checkpoints
|
||||
checkpoints=$(checkpoint_list)
|
||||
if [[ -n "$checkpoints" ]]; then
|
||||
while IFS= read -r tree; do
|
||||
local status
|
||||
status=$(checkpoint_status "$tree")
|
||||
printf " %-20s %s\n" "$tree" "($status)"
|
||||
done <<< "$checkpoints"
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean checkpoint
|
||||
cmd_clean() {
|
||||
local tree_name="${1:-}"
|
||||
|
||||
if [[ -z "$tree_name" ]]; then
|
||||
echo "Usage: $0 clean <tree>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if checkpoint_delete "$tree_name"; then
|
||||
print_success "Checkpoint deleted for tree: $tree_name"
|
||||
else
|
||||
echo "No checkpoint found for tree: $tree_name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main dispatch
|
||||
# ============================================================================
|
||||
|
||||
case "$COMMAND" in
|
||||
run)
|
||||
cmd_run "$@"
|
||||
;;
|
||||
resume)
|
||||
cmd_resume "$@"
|
||||
;;
|
||||
only)
|
||||
cmd_only "$@"
|
||||
;;
|
||||
status)
|
||||
cmd_status "$@"
|
||||
;;
|
||||
teardown)
|
||||
cmd_teardown "$@"
|
||||
;;
|
||||
list)
|
||||
cmd_list
|
||||
;;
|
||||
clean)
|
||||
cmd_clean "$@"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $COMMAND"
|
||||
echo "Valid commands: run, resume, only, status, teardown, list, clean"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
89
cookbooks/trees/composable-app.yaml
Normal file
89
cookbooks/trees/composable-app.yaml
Normal file
@ -0,0 +1,89 @@
|
||||
name: composable-app
|
||||
description: Deploy a composable app with backend service and frontend
|
||||
version: 1
|
||||
|
||||
vars:
|
||||
project_name: "" # Required
|
||||
service_name: "api"
|
||||
frontend_name: "web"
|
||||
|
||||
steps:
|
||||
create-project:
|
||||
description: Create project with monorepo skeleton
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: /project
|
||||
body:
|
||||
name: "{{ .vars.project_name }}"
|
||||
description: "Composable app E2E test"
|
||||
outputs:
|
||||
- project_id: .data.name
|
||||
- domain: .data.domain
|
||||
|
||||
add-service:
|
||||
description: Add backend service component
|
||||
depends_on: [create-project]
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
|
||||
body:
|
||||
type: service
|
||||
name: "{{ .vars.service_name }}"
|
||||
template: service
|
||||
outputs:
|
||||
- service_path: .data.path
|
||||
- service_port: .data.port
|
||||
|
||||
add-frontend:
|
||||
description: Add frontend app component (app-react)
|
||||
depends_on: [create-project]
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
|
||||
body:
|
||||
type: app-react
|
||||
name: "{{ .vars.frontend_name }}"
|
||||
template: app-react
|
||||
outputs:
|
||||
- frontend_path: .data.path
|
||||
- frontend_port: .data.port
|
||||
|
||||
verify-components:
|
||||
description: Verify all components were added
|
||||
depends_on: [add-service, add-frontend]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
|
||||
outputs:
|
||||
- component_count: ".data.components | length"
|
||||
|
||||
wait-pipeline:
|
||||
description: Wait for CI pipeline to complete
|
||||
depends_on: [verify-components]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
poll_interval: 5
|
||||
on_error: continue
|
||||
|
||||
verify-site:
|
||||
description: Verify site is accessible
|
||||
depends_on: [wait-pipeline]
|
||||
action: wait_site
|
||||
domain: "{{ .outputs.create-project.domain }}"
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 30
|
||||
poll_interval: 5
|
||||
|
||||
test-api-health:
|
||||
description: Test API health endpoint
|
||||
depends_on: [verify-site]
|
||||
action: shell
|
||||
command: "curl -s 'https://{{ .outputs.create-project.domain }}/api/health' 2>/dev/null || echo '{\"error\":\"connection failed\"}'"
|
||||
on_error: continue
|
||||
|
||||
teardown:
|
||||
- description: Delete project
|
||||
action: api
|
||||
method: DELETE
|
||||
endpoint: "/project/{{ .outputs.create-project.project_id }}"
|
||||
58
cookbooks/trees/landing-page.yaml
Normal file
58
cookbooks/trees/landing-page.yaml
Normal file
@ -0,0 +1,58 @@
|
||||
name: landing-page
|
||||
description: Deploy a landing page using composable monorepo template
|
||||
version: 1
|
||||
|
||||
vars:
|
||||
project_name: "" # Required
|
||||
template: "app-astro"
|
||||
|
||||
steps:
|
||||
create-project:
|
||||
description: Create project with monorepo skeleton
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: /project
|
||||
body:
|
||||
name: "{{ .vars.project_name }}"
|
||||
description: "Landing page E2E test"
|
||||
outputs:
|
||||
- project_id: .data.name
|
||||
- domain: .data.domain
|
||||
|
||||
add-component:
|
||||
description: Add landing page component (app-astro)
|
||||
depends_on: [create-project]
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
|
||||
body:
|
||||
type: "{{ .vars.template }}"
|
||||
name: landing
|
||||
template: "{{ .vars.template }}"
|
||||
outputs:
|
||||
- component_path: .data.path
|
||||
- component_port: .data.port
|
||||
|
||||
wait-pipeline:
|
||||
description: Wait for CI pipeline to complete
|
||||
depends_on: [add-component]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
poll_interval: 5
|
||||
on_error: continue
|
||||
|
||||
verify-site:
|
||||
description: Verify site is accessible
|
||||
depends_on: [wait-pipeline]
|
||||
action: wait_site
|
||||
domain: "{{ .outputs.create-project.domain }}"
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 30
|
||||
poll_interval: 5
|
||||
|
||||
teardown:
|
||||
- description: Delete project
|
||||
action: api
|
||||
method: DELETE
|
||||
endpoint: "/project/{{ .outputs.create-project.project_id }}"
|
||||
141
cookbooks/trees/sdlc-flow.yaml
Normal file
141
cookbooks/trees/sdlc-flow.yaml
Normal file
@ -0,0 +1,141 @@
|
||||
name: sdlc-flow
|
||||
description: Test SDLC orchestration lifecycle endpoints
|
||||
version: 1
|
||||
|
||||
vars:
|
||||
project_id: "" # Required - existing project with SDLC initialized
|
||||
feature_slug: "" # Optional - auto-generated if empty
|
||||
|
||||
steps:
|
||||
get-state:
|
||||
description: Get SDLC state (verify endpoint works)
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/state"
|
||||
outputs:
|
||||
- version: .data.version
|
||||
|
||||
create-feature:
|
||||
description: Create a test feature
|
||||
depends_on: [get-state]
|
||||
action: shell
|
||||
command: |
|
||||
slug="{{ .vars.feature_slug }}"
|
||||
if [ -z "$slug" ]; then
|
||||
slug="sdlc-test-$(date +%s)"
|
||||
fi
|
||||
curl -s -X POST "$RDEV_API_URL/projects/{{ .vars.project_id }}/sdlc/features" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"slug\": \"$slug\", \"title\": \"SDLC E2E Test Feature\"}" | \
|
||||
jq --arg slug "$slug" '. + {created_slug: $slug}'
|
||||
outputs:
|
||||
- feature_slug: .created_slug
|
||||
|
||||
list-features:
|
||||
description: List all features
|
||||
depends_on: [create-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features"
|
||||
outputs:
|
||||
- feature_count: ".data | length"
|
||||
|
||||
get-feature:
|
||||
description: Get feature detail
|
||||
depends_on: [create-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}"
|
||||
outputs:
|
||||
- phase: .data.phase
|
||||
|
||||
get-next:
|
||||
description: Get classifier recommendation
|
||||
depends_on: [get-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/next?feature={{ .outputs.create-feature.feature_slug }}"
|
||||
outputs:
|
||||
- action: .data.action
|
||||
- message: .data.message
|
||||
|
||||
get-artifacts:
|
||||
description: Check artifact status
|
||||
depends_on: [get-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/artifacts"
|
||||
|
||||
add-task:
|
||||
description: Add a task to the feature
|
||||
depends_on: [create-feature]
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/tasks"
|
||||
body:
|
||||
title: "Test task for E2E validation"
|
||||
outputs:
|
||||
- task_id: .data.id
|
||||
|
||||
list-tasks:
|
||||
description: List feature tasks
|
||||
depends_on: [add-task]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/tasks"
|
||||
outputs:
|
||||
- task_count: ".data | length"
|
||||
|
||||
block-feature:
|
||||
description: Block the feature
|
||||
depends_on: [list-tasks]
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/block"
|
||||
body:
|
||||
reason: "E2E test blocker"
|
||||
|
||||
query-blocked:
|
||||
description: Query blocked features
|
||||
depends_on: [block-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/query/blocked"
|
||||
outputs:
|
||||
- blocked_count: ".data | length"
|
||||
|
||||
unblock-feature:
|
||||
description: Unblock the feature
|
||||
depends_on: [query-blocked]
|
||||
action: api
|
||||
method: POST
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}/unblock"
|
||||
|
||||
query-ready:
|
||||
description: Query ready features
|
||||
depends_on: [unblock-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/query/ready"
|
||||
|
||||
query-needs-approval:
|
||||
description: Query features needing approval
|
||||
depends_on: [unblock-feature]
|
||||
action: api
|
||||
method: GET
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/query/needs-approval"
|
||||
|
||||
cleanup:
|
||||
description: Delete the test feature
|
||||
depends_on: [query-ready, query-needs-approval]
|
||||
action: api
|
||||
method: DELETE
|
||||
endpoint: "/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}"
|
||||
|
||||
teardown:
|
||||
- description: Delete test feature (if still exists)
|
||||
action: shell
|
||||
command: |
|
||||
curl -s -X DELETE "$RDEV_API_URL/projects/{{ .vars.project_id }}/sdlc/features/{{ .outputs.create-feature.feature_slug }}" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" || true
|
||||
279
docs/guides/sdlc/api-reference.md
Normal file
279
docs/guides/sdlc/api-reference.md
Normal file
@ -0,0 +1,279 @@
|
||||
# SDLC API Reference
|
||||
|
||||
The rdev API provides REST endpoints for SDLC orchestration within project pods.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require API key authentication:
|
||||
|
||||
```
|
||||
X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
## Base Path
|
||||
|
||||
All SDLC endpoints are prefixed with `/projects/{id}/sdlc/`.
|
||||
|
||||
## State Endpoints
|
||||
|
||||
### GET /projects/{id}/sdlc/state
|
||||
|
||||
Get the global SDLC state for a project.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"version": 1,
|
||||
"project": {"name": "My Project"},
|
||||
"active_work": {"features": [...]},
|
||||
"blocked": [],
|
||||
"last_updated": "2026-01-15T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /projects/{id}/sdlc/next
|
||||
|
||||
Get the classifier's recommended next action.
|
||||
|
||||
**Query Parameters:**
|
||||
- `feature` - Feature slug (optional, defaults to active feature)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"action": "CREATE_SPEC",
|
||||
"feature": "auth-flow",
|
||||
"message": "Create specification document",
|
||||
"next_command": "/spec-feature auth-flow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Endpoints
|
||||
|
||||
### GET /projects/{id}/sdlc/features
|
||||
|
||||
List all features.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"slug": "auth-flow",
|
||||
"title": "User Authentication Flow",
|
||||
"phase": "draft",
|
||||
"created_at": "2026-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/features
|
||||
|
||||
Create a new feature.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"slug": "auth-flow",
|
||||
"title": "User Authentication Flow"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /projects/{id}/sdlc/features/{slug}
|
||||
|
||||
Get feature details.
|
||||
|
||||
### DELETE /projects/{id}/sdlc/features/{slug}
|
||||
|
||||
Delete a feature.
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/transition
|
||||
|
||||
Transition feature to a new phase.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"phase": "specified"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/block
|
||||
|
||||
Block a feature.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"reason": "Waiting for API design"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/unblock
|
||||
|
||||
Unblock a feature.
|
||||
|
||||
## Artifact Endpoints
|
||||
|
||||
### GET /projects/{id}/sdlc/features/{slug}/artifacts
|
||||
|
||||
Get artifact statuses for a feature.
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve
|
||||
|
||||
Approve an artifact.
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject
|
||||
|
||||
Reject an artifact.
|
||||
|
||||
## Task Endpoints
|
||||
|
||||
### GET /projects/{id}/sdlc/features/{slug}/tasks
|
||||
|
||||
List tasks for a feature.
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/tasks
|
||||
|
||||
Add a new task.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Implement login handler"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start
|
||||
|
||||
Start a task.
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete
|
||||
|
||||
Complete a task.
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block
|
||||
|
||||
Block a task.
|
||||
|
||||
## Branch Endpoints
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/branch
|
||||
|
||||
Create a feature branch.
|
||||
|
||||
### GET /projects/{id}/sdlc/features/{slug}/branch
|
||||
|
||||
Get branch status and merge checklist.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"branch": {
|
||||
"name": "feature/auth-flow",
|
||||
"base_branch": "main",
|
||||
"created_at": "2026-01-15T10:00:00Z"
|
||||
},
|
||||
"checklist": [],
|
||||
"ready": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/branch/sync
|
||||
|
||||
Sync feature branch with base.
|
||||
|
||||
## Merge and Archive Endpoints
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/merge
|
||||
|
||||
Merge a feature branch.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"strategy": "squash"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/features/{slug}/archive
|
||||
|
||||
Archive a released feature.
|
||||
|
||||
## Query Endpoints
|
||||
|
||||
### GET /projects/{id}/sdlc/query/blocked
|
||||
|
||||
List blocked features.
|
||||
|
||||
### GET /projects/{id}/sdlc/query/ready
|
||||
|
||||
List features ready for work.
|
||||
|
||||
### GET /projects/{id}/sdlc/query/needs-approval
|
||||
|
||||
List features awaiting approval.
|
||||
|
||||
## Orchestrator Endpoints
|
||||
|
||||
### POST /projects/{id}/sdlc/execute
|
||||
|
||||
Execute the next classifier action.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"feature": "auth-flow",
|
||||
"provider": "claude"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/resolve
|
||||
|
||||
Resolve a blocker.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"feature": "auth-flow"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /projects/{id}/sdlc/commit
|
||||
|
||||
Commit changes in the project.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"feature": "auth-flow",
|
||||
"message": "feat: implement login handler",
|
||||
"push": true
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All errors return:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"message": "Human readable message"
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes:
|
||||
- `not_initialized` - SDLC not initialized in project
|
||||
- `feature_not_found` - Feature does not exist
|
||||
- `feature_exists` - Feature already exists
|
||||
- `invalid_transition` - Invalid phase transition
|
||||
- `invalid_phase` - Unknown phase
|
||||
- `merge_not_ready` - Feature not ready to merge
|
||||
283
docs/guides/sdlc/cli-reference.md
Normal file
283
docs/guides/sdlc/cli-reference.md
Normal file
@ -0,0 +1,283 @@
|
||||
# SDLC CLI Reference
|
||||
|
||||
The `sdlc` CLI provides deterministic SDLC orchestration within project pods.
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--root` | Project root directory (default: auto-detect) |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
## Commands
|
||||
|
||||
### sdlc init
|
||||
|
||||
Initialize SDLC in a project.
|
||||
|
||||
```bash
|
||||
sdlc init --project "Project Name" [--type go|node|python]
|
||||
```
|
||||
|
||||
### sdlc state
|
||||
|
||||
Show the global SDLC state.
|
||||
|
||||
```bash
|
||||
sdlc state
|
||||
```
|
||||
|
||||
### sdlc next
|
||||
|
||||
Get the classifier's recommended next action.
|
||||
|
||||
```bash
|
||||
sdlc next [--feature <slug>]
|
||||
```
|
||||
|
||||
Returns the action type, message, and command to execute.
|
||||
|
||||
### Feature Management
|
||||
|
||||
#### sdlc feature create
|
||||
|
||||
Create a new feature.
|
||||
|
||||
```bash
|
||||
sdlc feature create <slug> --title "Feature Title"
|
||||
```
|
||||
|
||||
#### sdlc feature list
|
||||
|
||||
List all features.
|
||||
|
||||
```bash
|
||||
sdlc feature list
|
||||
```
|
||||
|
||||
#### sdlc feature show
|
||||
|
||||
Show feature details.
|
||||
|
||||
```bash
|
||||
sdlc feature show <slug>
|
||||
```
|
||||
|
||||
#### sdlc feature transition
|
||||
|
||||
Move feature to a new phase.
|
||||
|
||||
```bash
|
||||
sdlc feature transition <slug> <phase>
|
||||
```
|
||||
|
||||
Valid phases: draft, specified, planned, ready, implementation, review, audit, qa, merge, released
|
||||
|
||||
#### sdlc feature block
|
||||
|
||||
Block a feature with a reason.
|
||||
|
||||
```bash
|
||||
sdlc feature block <slug> --reason "Waiting for dependency"
|
||||
```
|
||||
|
||||
#### sdlc feature unblock
|
||||
|
||||
Remove all blockers from a feature.
|
||||
|
||||
```bash
|
||||
sdlc feature unblock <slug>
|
||||
```
|
||||
|
||||
#### sdlc feature delete
|
||||
|
||||
Delete a feature.
|
||||
|
||||
```bash
|
||||
sdlc feature delete <slug> [--force]
|
||||
```
|
||||
|
||||
### Artifact Management
|
||||
|
||||
#### sdlc artifact status
|
||||
|
||||
Show artifact statuses for a feature.
|
||||
|
||||
```bash
|
||||
sdlc artifact status <slug>
|
||||
```
|
||||
|
||||
#### sdlc artifact create
|
||||
|
||||
Register a new artifact.
|
||||
|
||||
```bash
|
||||
sdlc artifact create <slug> <type>
|
||||
```
|
||||
|
||||
Types: spec, design, tasks, qa_plan, review, audit, qa_results
|
||||
|
||||
#### sdlc artifact approve
|
||||
|
||||
Approve an artifact.
|
||||
|
||||
```bash
|
||||
sdlc artifact approve <slug> <type>
|
||||
```
|
||||
|
||||
#### sdlc artifact reject
|
||||
|
||||
Reject an artifact.
|
||||
|
||||
```bash
|
||||
sdlc artifact reject <slug> <type>
|
||||
```
|
||||
|
||||
### Task Management
|
||||
|
||||
#### sdlc task list
|
||||
|
||||
List tasks for a feature.
|
||||
|
||||
```bash
|
||||
sdlc task list <slug>
|
||||
```
|
||||
|
||||
#### sdlc task add
|
||||
|
||||
Add a new task.
|
||||
|
||||
```bash
|
||||
sdlc task add <slug> --title "Task title"
|
||||
```
|
||||
|
||||
#### sdlc task start
|
||||
|
||||
Start working on a task.
|
||||
|
||||
```bash
|
||||
sdlc task start <slug> <task-id>
|
||||
```
|
||||
|
||||
#### sdlc task complete
|
||||
|
||||
Mark a task as complete.
|
||||
|
||||
```bash
|
||||
sdlc task complete <slug> <task-id>
|
||||
```
|
||||
|
||||
#### sdlc task block
|
||||
|
||||
Mark a task as blocked.
|
||||
|
||||
```bash
|
||||
sdlc task block <slug> <task-id>
|
||||
```
|
||||
|
||||
### Branch Management
|
||||
|
||||
#### sdlc branch create
|
||||
|
||||
Create a feature branch.
|
||||
|
||||
```bash
|
||||
sdlc branch create <slug>
|
||||
```
|
||||
|
||||
Creates both the git branch and the branch manifest.
|
||||
|
||||
#### sdlc branch status
|
||||
|
||||
Show branch status and merge checklist.
|
||||
|
||||
```bash
|
||||
sdlc branch status <slug>
|
||||
```
|
||||
|
||||
#### sdlc branch sync
|
||||
|
||||
Sync feature branch with base branch.
|
||||
|
||||
```bash
|
||||
sdlc branch sync <slug>
|
||||
```
|
||||
|
||||
### Merge and Archive
|
||||
|
||||
#### sdlc merge
|
||||
|
||||
Merge a feature branch after all gates pass.
|
||||
|
||||
```bash
|
||||
sdlc merge <slug> [--strategy squash|merge]
|
||||
```
|
||||
|
||||
This command:
|
||||
1. Checks merge readiness (all gates passed)
|
||||
2. Checkouts the main branch
|
||||
3. Merges/squashes the feature branch
|
||||
4. Creates the commit
|
||||
5. Updates the branch manifest
|
||||
6. Transitions feature to released
|
||||
|
||||
#### sdlc archive
|
||||
|
||||
Archive a released feature.
|
||||
|
||||
```bash
|
||||
sdlc archive <slug>
|
||||
```
|
||||
|
||||
### Query Commands
|
||||
|
||||
#### sdlc query blocked
|
||||
|
||||
List blocked features.
|
||||
|
||||
```bash
|
||||
sdlc query blocked
|
||||
```
|
||||
|
||||
#### sdlc query ready
|
||||
|
||||
List features ready for work.
|
||||
|
||||
```bash
|
||||
sdlc query ready
|
||||
```
|
||||
|
||||
#### sdlc query needs-approval
|
||||
|
||||
List features awaiting approval.
|
||||
|
||||
```bash
|
||||
sdlc query needs-approval
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### sdlc config show
|
||||
|
||||
Show current configuration.
|
||||
|
||||
```bash
|
||||
sdlc config show
|
||||
```
|
||||
|
||||
#### sdlc config set
|
||||
|
||||
Set a configuration value.
|
||||
|
||||
```bash
|
||||
sdlc config set <key> <value>
|
||||
```
|
||||
|
||||
Keys:
|
||||
- `project.name`
|
||||
- `project.type`
|
||||
- `branches.main`
|
||||
- `branches.feature_prefix`
|
||||
- `compliance.require_approvals` (true/false)
|
||||
- `compliance.require_branch` (true/false)
|
||||
- `compliance.require_qa` (true/false)
|
||||
122
docs/guides/sdlc/command-catalog.md
Normal file
122
docs/guides/sdlc/command-catalog.md
Normal file
@ -0,0 +1,122 @@
|
||||
# SDLC Command Catalog
|
||||
|
||||
Claude Code commands for SDLC orchestration. These commands are available in projects with the skeleton template.
|
||||
|
||||
## Feature Lifecycle Commands
|
||||
|
||||
### /create-feature `<slug>`
|
||||
|
||||
Create a new feature in draft phase.
|
||||
|
||||
**Example:** `/create-feature auth-flow`
|
||||
|
||||
### /spec-feature `<slug>`
|
||||
|
||||
Write the specification document for a feature. Creates `.sdlc/features/<slug>/spec.md`.
|
||||
|
||||
### /design-feature `<slug>`
|
||||
|
||||
Write the design document for a feature. Creates `.sdlc/features/<slug>/design.md`.
|
||||
|
||||
### /breakdown-feature `<slug>`
|
||||
|
||||
Create the task breakdown for a feature. Creates `.sdlc/features/<slug>/tasks.md`.
|
||||
|
||||
### /create-qa-plan `<slug>`
|
||||
|
||||
Create the QA test plan for a feature. Creates `.sdlc/features/<slug>/qa-plan.md`.
|
||||
|
||||
## Implementation Commands
|
||||
|
||||
### /implement-task `<slug>` `<task-id>`
|
||||
|
||||
Implement a specific task from the feature breakdown.
|
||||
|
||||
**Flow:**
|
||||
1. Start the task via CLI
|
||||
2. Load context (spec, design, tasks)
|
||||
3. Study existing patterns
|
||||
4. Implement code changes
|
||||
5. Run tests
|
||||
6. Complete the task
|
||||
|
||||
### /review-feature `<slug>`
|
||||
|
||||
Perform code review of a feature. Creates `.sdlc/features/<slug>/review.md`.
|
||||
|
||||
### /audit-feature `<slug>`
|
||||
|
||||
Perform security audit of a feature. Creates `.sdlc/features/<slug>/audit.md`.
|
||||
|
||||
### /run-qa `<slug>`
|
||||
|
||||
Execute the QA test plan for a feature. Creates `.sdlc/features/<slug>/qa-results.md`.
|
||||
|
||||
## Merge and Archive Commands
|
||||
|
||||
### /merge-feature `<slug>`
|
||||
|
||||
Merge a completed feature branch.
|
||||
|
||||
**Prerequisites:**
|
||||
- All required artifacts approved (review, audit, qa_results)
|
||||
- No blockers
|
||||
- Feature in merge phase
|
||||
|
||||
**Flow:**
|
||||
1. Check merge readiness
|
||||
2. Verify branch status
|
||||
3. Run final tests
|
||||
4. Execute merge via `sdlc merge`
|
||||
5. Report results
|
||||
|
||||
### /archive-feature `<slug>`
|
||||
|
||||
Archive a released feature.
|
||||
|
||||
**Prerequisites:**
|
||||
- Feature must be in released phase
|
||||
|
||||
## Query Commands
|
||||
|
||||
### /sdlc-status
|
||||
|
||||
Show the current SDLC state and active features.
|
||||
|
||||
### /next-action `<slug>`
|
||||
|
||||
Get the classifier's recommended next action for a feature.
|
||||
|
||||
## Artifact Commands
|
||||
|
||||
### /approve-artifact `<slug>` `<type>`
|
||||
|
||||
Approve a feature artifact.
|
||||
|
||||
**Types:** spec, design, tasks, qa_plan, review, audit, qa_results
|
||||
|
||||
### /reject-artifact `<slug>` `<type>`
|
||||
|
||||
Reject a feature artifact.
|
||||
|
||||
## Command Arguments
|
||||
|
||||
| Argument | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `<slug>` | Feature identifier (lowercase, hyphens) | `auth-flow` |
|
||||
| `<task-id>` | Task identifier | `task-001` |
|
||||
| `<type>` | Artifact type | `spec`, `design`, `review` |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. `/create-feature my-feature`
|
||||
2. `/spec-feature my-feature` -> Approve spec
|
||||
3. `/design-feature my-feature` -> Approve design
|
||||
4. `/breakdown-feature my-feature` -> Approve tasks
|
||||
5. `/create-qa-plan my-feature` -> Approve QA plan
|
||||
6. `/implement-task my-feature task-001` (repeat for each task)
|
||||
7. `/review-feature my-feature` -> Approve review
|
||||
8. `/audit-feature my-feature` -> Approve audit
|
||||
9. `/run-qa my-feature` -> QA passes
|
||||
10. `/merge-feature my-feature`
|
||||
11. `/archive-feature my-feature`
|
||||
88
docs/guides/sdlc/getting-started.md
Normal file
88
docs/guides/sdlc/getting-started.md
Normal file
@ -0,0 +1,88 @@
|
||||
# SDLC Orchestration - Getting Started
|
||||
|
||||
This guide helps you set up and use the SDLC (Software Development Lifecycle) orchestration system in your project.
|
||||
|
||||
## Overview
|
||||
|
||||
The SDLC system provides deterministic feature lifecycle management through:
|
||||
|
||||
- **State tracking**: Features progress through defined phases
|
||||
- **Artifacts**: Required deliverables at each phase (specs, designs, reviews)
|
||||
- **Classifier**: Recommends the next action based on feature state
|
||||
- **Branch management**: Tracks feature branches with merge readiness checks
|
||||
- **Task tracking**: Break down features into implementable tasks
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Initialize SDLC in Your Project
|
||||
|
||||
From your project root:
|
||||
|
||||
```bash
|
||||
sdlc init --project "My Project"
|
||||
```
|
||||
|
||||
This creates the `.sdlc/` directory structure:
|
||||
|
||||
```
|
||||
.sdlc/
|
||||
├── config.yaml # Project configuration
|
||||
├── state.yaml # Global state
|
||||
├── features/ # Feature directories
|
||||
└── branches/ # Branch manifests
|
||||
```
|
||||
|
||||
### 2. Create Your First Feature
|
||||
|
||||
```bash
|
||||
sdlc feature create my-feature --title "My First Feature"
|
||||
```
|
||||
|
||||
This creates a feature in the `draft` phase.
|
||||
|
||||
### 3. Check What to Do Next
|
||||
|
||||
```bash
|
||||
sdlc next --feature my-feature
|
||||
```
|
||||
|
||||
The classifier returns the recommended action (e.g., `CREATE_SPEC`).
|
||||
|
||||
### 4. Progress Through Phases
|
||||
|
||||
Features move through 10 phases:
|
||||
|
||||
1. **draft** - Initial feature creation
|
||||
2. **specified** - Requirements documented and approved
|
||||
3. **planned** - Design and task breakdown complete
|
||||
4. **ready** - Ready for implementation
|
||||
5. **implementation** - Active coding
|
||||
6. **review** - Code review
|
||||
7. **audit** - Security audit
|
||||
8. **qa** - Quality assurance testing
|
||||
9. **merge** - Ready to merge
|
||||
10. **released** - Merged and complete
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.sdlc/config.yaml` to customize:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
project:
|
||||
name: "My Project"
|
||||
type: "go"
|
||||
branches:
|
||||
main: "main"
|
||||
feature_prefix: "feature/"
|
||||
compliance:
|
||||
require_approvals: true
|
||||
require_branch: true
|
||||
require_qa: true
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [CLI Reference](./cli-reference.md) - Full command documentation
|
||||
- [API Reference](./api-reference.md) - REST API endpoints
|
||||
- [Command Catalog](./command-catalog.md) - Claude Code commands for SDLC
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
19
internal/handlers/helpers_test.go
Normal file
19
internal/handlers/helpers_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/orchard9/rdev/internal/auth"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
)
|
||||
|
||||
// testAdminAuth is a chi middleware that injects an admin API key into the
|
||||
// request context so auth.RequireScope passes in tests.
|
||||
func testAdminAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := auth.WithAPIKey(r.Context(), &domain.APIKey{
|
||||
Scopes: []domain.Scope{domain.ScopeAdmin},
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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"}})
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
149
internal/sdlc/artifact_test.go
Normal file
149
internal/sdlc/artifact_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
package sdlc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewArtifact(t *testing.T) {
|
||||
tests := []struct {
|
||||
artType ArtifactType
|
||||
wantPath string
|
||||
}{
|
||||
{ArtifactSpec, "spec.md"},
|
||||
{ArtifactDesign, "design.md"},
|
||||
{ArtifactTasks, "tasks.md"},
|
||||
{ArtifactQAPlan, "qa-plan.md"},
|
||||
{ArtifactReview, "review.md"},
|
||||
{ArtifactAudit, "audit.md"},
|
||||
{ArtifactQAResults, "qa-results.md"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.artType), func(t *testing.T) {
|
||||
art := NewArtifact(tt.artType)
|
||||
if art.Status != StatusPending {
|
||||
t.Errorf("NewArtifact() status = %v, want %v", art.Status, StatusPending)
|
||||
}
|
||||
if art.Path != tt.wantPath {
|
||||
t.Errorf("NewArtifact() path = %v, want %v", art.Path, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_Approve(t *testing.T) {
|
||||
art := NewArtifact(ArtifactSpec)
|
||||
art.Approve("user@example.com")
|
||||
|
||||
if art.Status != StatusApproved {
|
||||
t.Errorf("Approve() status = %v, want %v", art.Status, StatusApproved)
|
||||
}
|
||||
if art.ApprovedBy != "user@example.com" {
|
||||
t.Errorf("Approve() approvedBy = %v, want %v", art.ApprovedBy, "user@example.com")
|
||||
}
|
||||
if art.ApprovedAt == nil {
|
||||
t.Error("Approve() approvedAt should not be nil")
|
||||
}
|
||||
if art.RejectedBy != "" {
|
||||
t.Errorf("Approve() should clear rejectedBy, got %v", art.RejectedBy)
|
||||
}
|
||||
if art.RejectedAt != nil {
|
||||
t.Error("Approve() should clear rejectedAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_Reject(t *testing.T) {
|
||||
art := NewArtifact(ArtifactSpec)
|
||||
art.Reject("reviewer@example.com")
|
||||
|
||||
if art.Status != StatusRejected {
|
||||
t.Errorf("Reject() status = %v, want %v", art.Status, StatusRejected)
|
||||
}
|
||||
if art.RejectedBy != "reviewer@example.com" {
|
||||
t.Errorf("Reject() rejectedBy = %v, want %v", art.RejectedBy, "reviewer@example.com")
|
||||
}
|
||||
if art.RejectedAt == nil {
|
||||
t.Error("Reject() rejectedAt should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_ApproveAfterReject(t *testing.T) {
|
||||
art := NewArtifact(ArtifactSpec)
|
||||
|
||||
// First reject
|
||||
art.Reject("reviewer@example.com")
|
||||
if art.Status != StatusRejected {
|
||||
t.Fatalf("expected rejected status after Reject()")
|
||||
}
|
||||
|
||||
// Then approve
|
||||
art.Approve("approver@example.com")
|
||||
if art.Status != StatusApproved {
|
||||
t.Errorf("Approve() after Reject() status = %v, want %v", art.Status, StatusApproved)
|
||||
}
|
||||
if art.RejectedBy != "" {
|
||||
t.Errorf("Approve() should clear rejectedBy, got %v", art.RejectedBy)
|
||||
}
|
||||
if art.RejectedAt != nil {
|
||||
t.Error("Approve() should clear rejectedAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_MarkDraft(t *testing.T) {
|
||||
art := NewArtifact(ArtifactSpec)
|
||||
art.MarkDraft()
|
||||
|
||||
if art.Status != StatusDraft {
|
||||
t.Errorf("MarkDraft() status = %v, want %v", art.Status, StatusDraft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_MarkPassed(t *testing.T) {
|
||||
art := NewArtifact(ArtifactQAResults)
|
||||
art.MarkPassed()
|
||||
|
||||
if art.Status != StatusPassed {
|
||||
t.Errorf("MarkPassed() status = %v, want %v", art.Status, StatusPassed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_MarkFailed(t *testing.T) {
|
||||
art := NewArtifact(ArtifactQAResults)
|
||||
art.MarkFailed()
|
||||
|
||||
if art.Status != StatusFailed {
|
||||
t.Errorf("MarkFailed() status = %v, want %v", art.Status, StatusFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_MarkNeedsFix(t *testing.T) {
|
||||
art := NewArtifact(ArtifactReview)
|
||||
art.MarkNeedsFix()
|
||||
|
||||
if art.Status != StatusNeedsFix {
|
||||
t.Errorf("MarkNeedsFix() status = %v, want %v", art.Status, StatusNeedsFix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_ApprovedAtTimestamp(t *testing.T) {
|
||||
art := NewArtifact(ArtifactSpec)
|
||||
before := time.Now().UTC()
|
||||
art.Approve("user@example.com")
|
||||
after := time.Now().UTC()
|
||||
|
||||
if art.ApprovedAt.Before(before) || art.ApprovedAt.After(after) {
|
||||
t.Errorf("ApprovedAt %v not in expected range [%v, %v]", art.ApprovedAt, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifact_RejectedAtTimestamp(t *testing.T) {
|
||||
art := NewArtifact(ArtifactSpec)
|
||||
before := time.Now().UTC()
|
||||
art.Reject("user@example.com")
|
||||
after := time.Now().UTC()
|
||||
|
||||
if art.RejectedAt.Before(before) || art.RejectedAt.After(after) {
|
||||
t.Errorf("RejectedAt %v not in expected range [%v, %v]", art.RejectedAt, before, after)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user