feat: add SDLC branch management, merge, archive, and orchestrator APIs
Add branch lifecycle commands (branch, merge, archive) to the SDLC CLI. Introduce orchestrator handler and service for multi-step SDLC workflows. Expand skeleton template with 15 Claude commands covering the full feature lifecycle. Extend classifier rules, error types, and executor port for branch operations. Split rules.go and classifier_test.go to stay within 500-line limit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
425ef0f806
commit
f22b220c6d
@ -166,7 +166,7 @@ cookbooks/ # End-to-end workflow guides
|
||||
| Database Provisioning | **Done** | CockroachDB adapter with auto-provisioning |
|
||||
| Cache Provisioning | **Done** | Redis ACL-based adapter with auto-provisioning |
|
||||
| Build Orchestration | Planned | Structured build specs via API |
|
||||
| SDLC Orchestration | **In Progress** | Deterministic feature lifecycle with classifier engine (library + CLI done, rdev API pending) |
|
||||
| SDLC Orchestration | **Done** | Deterministic feature lifecycle with classifier engine, API, orchestrator, and 15 skeleton commands |
|
||||
| Composable Monorepo Templates | **Done** | Monorepo skeleton + component templates (service, worker, app-astro, app-react, cli) |
|
||||
|
||||
**Current Version:** v0.10.25
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
# SDLC Orchestration
|
||||
|
||||
**Last Updated:** 2026-02
|
||||
**Confidence:** High (Steps 1-5 implemented, Step 6 pending)
|
||||
**Confidence:** High (fully implemented - library, CLI, API, orchestrator, skeleton commands)
|
||||
|
||||
## Summary
|
||||
|
||||
Deterministic feature lifecycle management. Classifier engine evaluates priority-ordered rules to determine the next required action. State lives in `.sdlc/` directory (git-tracked). Used by Claude agents in pods (CLI) and by rdev API (Go library + kubectl exec).
|
||||
|
||||
**Key Facts:**
|
||||
- 10 phases: draft → specified → planned → ready → implementation → review → audit → qa → merge → released
|
||||
- 24 classifier rules, first match wins, returns Classification with action + guidance
|
||||
- 10 phases: draft -> specified -> planned -> ready -> implementation -> review -> audit -> qa -> merge -> released
|
||||
- 25+ classifier rules, first match wins, returns Classification with action + guidance
|
||||
- 7 artifact types: spec, design, tasks, qa_plan, review, audit, qa_results
|
||||
- rdev drives transitions on behalf of users (approve, reject, unblock, transition)
|
||||
- Multi-project: scoped by projectID (pod name), each pod has its own `.sdlc/`
|
||||
- Orchestrator: execute/resolve/commit endpoints dispatch agent work and git operations
|
||||
|
||||
**File Pointers:**
|
||||
- Library: `internal/sdlc/` (types, state, feature, classifier, rules, config)
|
||||
- Library: `internal/sdlc/` (types, state, feature, classifier, rules, config, branch)
|
||||
- CLI: `cmd/sdlc/` (cobra commands, --json output for API consumption)
|
||||
- Port (planned): `internal/port/sdlc_executor.go`
|
||||
- Adapter (planned): `internal/adapter/kubernetes/sdlc_adapter.go`
|
||||
- Handler (planned): `internal/handlers/sdlc.go`
|
||||
- Port: `internal/port/sdlc_executor.go`
|
||||
- Adapter: `internal/adapter/kubernetes/sdlc_executor.go`
|
||||
- Service: `internal/service/sdlc_service.go`, `internal/service/sdlc_orchestrator.go`
|
||||
- Handler: `internal/handlers/sdlc.go`, `internal/handlers/sdlc_*.go`
|
||||
- Skeleton commands: `internal/adapter/templates/templates/skeleton/.claude/commands/` (15 files)
|
||||
- Spec: `docs/specs/sdlc-orchestration-system.md`
|
||||
- Guide: `.claude/guides/services/sdlc.md`
|
||||
|
||||
@ -38,6 +41,17 @@ type Classification struct {
|
||||
TransitionTo FeaturePhase `json:"transition_to,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
}
|
||||
|
||||
// internal/sdlc/branch.go
|
||||
type BranchManifest struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Feature string `yaml:"feature" json:"feature"`
|
||||
BaseBranch string `yaml:"base_branch" json:"base_branch"`
|
||||
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
||||
LastSyncAt *time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"`
|
||||
MergedAt *time.Time `yaml:"merged_at,omitempty" json:"merged_at,omitempty"`
|
||||
MergeStrategy string `yaml:"merge_strategy,omitempty" json:"merge_strategy,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
@ -50,36 +64,58 @@ sdlc feature transition <slug> <phase> # Phase transition
|
||||
sdlc feature block/unblock <slug> # Blocker management
|
||||
sdlc artifact create/approve/reject # Artifact lifecycle
|
||||
sdlc task add/start/complete/block # Task lifecycle
|
||||
sdlc next [--for <feature>] [--json] # Classifier output
|
||||
sdlc next [--for <feature>] [--json] [--execute] # Classifier output (--execute auto-runs transitions)
|
||||
sdlc query blocked/ready/needs-approval # Queries
|
||||
sdlc branch create/status/sync <slug> # Branch management
|
||||
sdlc merge <slug> [--strategy squash] # Merge feature branch
|
||||
sdlc archive <slug> # Archive released feature
|
||||
```
|
||||
|
||||
## rdev API Endpoints (Planned - Step 6)
|
||||
## rdev API Endpoints
|
||||
|
||||
### SDLC State & Features (21 routes)
|
||||
```
|
||||
GET /projects/{id}/sdlc/state
|
||||
GET /projects/{id}/sdlc/next
|
||||
GET /projects/{id}/sdlc/next?feature=slug
|
||||
GET /projects/{id}/sdlc/features
|
||||
POST /projects/{id}/sdlc/features
|
||||
GET /projects/{id}/sdlc/features/{slug}
|
||||
POST /projects/{id}/sdlc/features/{slug}/transition
|
||||
POST /projects/{id}/sdlc/features/{slug}/block
|
||||
POST /projects/{id}/sdlc/features/{slug}/unblock
|
||||
DELETE /projects/{id}/sdlc/features/{slug}
|
||||
GET /projects/{id}/sdlc/features/{slug}/artifacts
|
||||
POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve
|
||||
POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject
|
||||
POST /projects/{id}/sdlc/features/{slug}/unblock
|
||||
GET /projects/{id}/sdlc/features/{slug}/tasks
|
||||
POST /projects/{id}/sdlc/features/{slug}/tasks
|
||||
POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start
|
||||
POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete
|
||||
POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block
|
||||
POST /projects/{id}/sdlc/features/{slug}/branches
|
||||
GET /projects/{id}/sdlc/features/{slug}/branches
|
||||
POST /projects/{id}/sdlc/features/{slug}/branches/sync
|
||||
POST /projects/{id}/sdlc/features/{slug}/merge
|
||||
POST /projects/{id}/sdlc/features/{slug}/archive
|
||||
GET /projects/{id}/sdlc/query/blocked
|
||||
GET /projects/{id}/sdlc/query/ready
|
||||
GET /projects/{id}/sdlc/query/needs-approval
|
||||
```
|
||||
|
||||
## Feature Gaps (Step 6)
|
||||
### Orchestration (3 routes)
|
||||
```
|
||||
POST /projects/{id}/sdlc/execute # Run next classifier action (dispatches agents)
|
||||
POST /projects/{id}/sdlc/resolve # Unblock feature and re-classify
|
||||
POST /projects/{id}/sdlc/commit # Commit changes in pod
|
||||
```
|
||||
|
||||
| Gap | Description | Effort |
|
||||
|-----|-------------|--------|
|
||||
| Port interface | `SDLCExecutor` port for pod operations | Small |
|
||||
| Pod adapter | kubectl exec wrapper for sdlc commands | Medium |
|
||||
| Service layer | Business logic, validation, error mapping | Medium |
|
||||
| HTTP handlers | REST endpoints under `/projects/{id}/sdlc/` | Medium |
|
||||
| Cross-project view | Dashboard of all project SDLC states | Small |
|
||||
| Webhook events | SDLC phase transitions as webhook events | Small |
|
||||
## Skeleton Template Commands (15)
|
||||
|
||||
Commands installed in every new project via skeleton template:
|
||||
|
||||
**Primary:** `spec-feature`, `design-feature`, `breakdown-feature`, `create-qa-plan`, `implement-task`, `review-feature`, `audit-feature`, `run-qa`, `next`, `deliver`
|
||||
|
||||
**Remediation:** `fix-review-issues`, `remediate-audit`, `fix-qa-failures`, `merge-feature`, `archive-feature`
|
||||
|
||||
## Related Topics
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ import (
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/envutil"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
"github.com/orchard9/rdev/internal/worker"
|
||||
)
|
||||
|
||||
// Config holds application configuration.
|
||||
@ -188,3 +190,20 @@ func closeProvisioner(p any, name string, logger *slog.Logger) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// podGitCommitterAdapter wraps worker.PodGitOperations to satisfy
|
||||
// service.PodGitCommitter without creating an import cycle.
|
||||
type podGitCommitterAdapter struct {
|
||||
podGitOps *worker.PodGitOperations
|
||||
}
|
||||
|
||||
func (a *podGitCommitterAdapter) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *service.GitCommitResult {
|
||||
result := a.podGitOps.CommitAndPush(ctx, podName, workDir, message, push)
|
||||
return &service.GitCommitResult{
|
||||
HasChanges: result.HasChanges,
|
||||
CommitSHA: result.CommitSHA,
|
||||
FilesChanged: result.FilesChanged,
|
||||
Pushed: result.Pushed,
|
||||
Error: result.Error,
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,6 +245,29 @@ func main() {
|
||||
sdlcExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger})
|
||||
sdlcService := service.NewSDLCService(sdlcExec, projectRepo, service.SDLCServiceConfig{Logger: logger})
|
||||
|
||||
// Pod git operations (shared between build executor and SDLC orchestrator)
|
||||
var podGitOps *worker.PodGitOperations
|
||||
if infraCfg.GiteaToken != "" {
|
||||
podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{
|
||||
Namespace: "rdev",
|
||||
GiteaToken: infraCfg.GiteaToken,
|
||||
Logger: logger,
|
||||
})
|
||||
}
|
||||
|
||||
// SDLC orchestrator (execute/resolve/commit via agents and git)
|
||||
var gitCommitter service.PodGitCommitter
|
||||
if podGitOps != nil {
|
||||
gitCommitter = &podGitCommitterAdapter{podGitOps: podGitOps}
|
||||
}
|
||||
sdlcOrchestrator := service.NewSDLCOrchestratorService(
|
||||
sdlcService,
|
||||
agentRegistry,
|
||||
gitCommitter,
|
||||
projectRepo,
|
||||
service.SDLCOrchestratorConfig{Logger: logger},
|
||||
)
|
||||
|
||||
// Create app
|
||||
app := api.New("rdev-api",
|
||||
api.WithPort(cfg.Port),
|
||||
@ -373,6 +396,7 @@ func main() {
|
||||
createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService, logger)
|
||||
|
||||
sdlcHandler := handlers.NewSDLCHandler(sdlcService, logger)
|
||||
sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator, logger)
|
||||
|
||||
// Initialize operations handler (for debugging project failures)
|
||||
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
||||
@ -405,6 +429,7 @@ func main() {
|
||||
createAndBuildHandler.Mount(app.Router())
|
||||
operationsHandler.Mount(app.Router())
|
||||
sdlcHandler.Mount(app.Router())
|
||||
sdlcOrchestratorHandler.Mount(app.Router())
|
||||
|
||||
// Start queue processor worker (per-project command queue)
|
||||
queueProcessor := worker.NewQueueProcessor(
|
||||
@ -423,14 +448,6 @@ func main() {
|
||||
}
|
||||
|
||||
// Start work executor (cross-project worker pool, git via kubectl exec)
|
||||
var podGitOps *worker.PodGitOperations
|
||||
if infraCfg.GiteaToken != "" {
|
||||
podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{
|
||||
Namespace: "rdev",
|
||||
GiteaToken: infraCfg.GiteaToken,
|
||||
Logger: logger,
|
||||
})
|
||||
}
|
||||
buildExecutor := worker.NewBuildExecutor(agentRegistry, podGitOps, streamPub, logger, nil)
|
||||
workerCfg := worker.DefaultWorkExecutorConfig()
|
||||
workerCfg.Logger = logger
|
||||
|
||||
48
cmd/sdlc/cmd_archive.go
Normal file
48
cmd/sdlc/cmd_archive.go
Normal file
@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var archiveCmd = &cobra.Command{
|
||||
Use: "archive <slug>",
|
||||
Short: "Archive a released feature",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
root := mustResolveRoot()
|
||||
slug := args[0]
|
||||
|
||||
if err := sdlc.ArchiveFeature(root, slug); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove from active work in state
|
||||
state, err := sdlc.LoadState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.RemoveActiveFeature(slug)
|
||||
state.RecordAction("archived", slug, "cli")
|
||||
if err := state.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
printJSON(map[string]string{
|
||||
"feature": slug,
|
||||
"status": "archived",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Archived: %s\n", slug)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(archiveCmd)
|
||||
}
|
||||
192
cmd/sdlc/cmd_branch.go
Normal file
192
cmd/sdlc/cmd_branch.go
Normal file
@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var branchCmd = &cobra.Command{
|
||||
Use: "branch",
|
||||
Short: "Manage feature branches",
|
||||
}
|
||||
|
||||
var branchCreateCmd = &cobra.Command{
|
||||
Use: "create <slug>",
|
||||
Short: "Create a feature branch",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
root := mustResolveRoot()
|
||||
slug := args[0]
|
||||
|
||||
cfg, err := sdlc.LoadConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the branch manifest
|
||||
manifest, err := sdlc.CreateBranch(root, slug, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the git branch
|
||||
branchName := manifest.Name
|
||||
gitCmd := exec.Command("git", "checkout", "-b", branchName)
|
||||
gitCmd.Dir = root
|
||||
if out, err := gitCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git checkout -b %s: %s: %w", branchName, string(out), err)
|
||||
}
|
||||
|
||||
// Record the action in state
|
||||
state, err := sdlc.LoadState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.RecordAction("branch_created", slug, "cli")
|
||||
if err := state.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
printJSON(manifest)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Created branch: %s (from %s)\n", branchName, manifest.BaseBranch)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var branchStatusCmd = &cobra.Command{
|
||||
Use: "status <slug>",
|
||||
Short: "Show branch status and merge checklist",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
root := mustResolveRoot()
|
||||
slug := args[0]
|
||||
|
||||
f, err := sdlc.LoadFeature(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Branch == "" {
|
||||
if jsonOutput {
|
||||
printJSON(map[string]string{"error": "no branch associated with feature"})
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Feature %s has no branch.\n", slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
manifest, err := sdlc.LoadBranch(root, f.Branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checklist, err := sdlc.MergeChecklist(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
printJSON(map[string]any{
|
||||
"branch": manifest,
|
||||
"checklist": checklist,
|
||||
"ready": len(checklist) == 0,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Branch: %s\n", manifest.Name)
|
||||
fmt.Printf("Feature: %s\n", manifest.Feature)
|
||||
fmt.Printf("Base: %s\n", manifest.BaseBranch)
|
||||
fmt.Printf("Created: %s\n", manifest.CreatedAt.Format(time.RFC3339))
|
||||
if manifest.LastSyncAt != nil {
|
||||
fmt.Printf("Last sync: %s\n", manifest.LastSyncAt.Format(time.RFC3339))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if len(checklist) == 0 {
|
||||
fmt.Println("Merge status: READY")
|
||||
} else {
|
||||
fmt.Println("Merge status: NOT READY")
|
||||
fmt.Println("Unmet gates:")
|
||||
for _, gate := range checklist {
|
||||
fmt.Printf(" - %s\n", gate)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var branchSyncCmd = &cobra.Command{
|
||||
Use: "sync <slug>",
|
||||
Short: "Sync feature branch with base branch",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
root := mustResolveRoot()
|
||||
slug := args[0]
|
||||
|
||||
f, err := sdlc.LoadFeature(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Branch == "" {
|
||||
return fmt.Errorf("feature %s has no branch", slug)
|
||||
}
|
||||
|
||||
manifest, err := sdlc.LoadBranch(root, f.Branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch and rebase
|
||||
fetchCmd := exec.Command("git", "fetch", "origin")
|
||||
fetchCmd.Dir = root
|
||||
if out, err := fetchCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git fetch: %s: %w", string(out), err)
|
||||
}
|
||||
|
||||
rebaseCmd := exec.Command("git", "rebase", "origin/"+manifest.BaseBranch)
|
||||
rebaseCmd.Dir = root
|
||||
if out, err := rebaseCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git rebase origin/%s: %s: %w", manifest.BaseBranch, string(out), err)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
now := time.Now().UTC()
|
||||
manifest.LastSyncAt = &now
|
||||
if err := sdlc.SaveBranch(root, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
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)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
branchCmd.AddCommand(
|
||||
branchCreateCmd,
|
||||
branchStatusCmd,
|
||||
branchSyncCmd,
|
||||
)
|
||||
rootCmd.AddCommand(branchCmd)
|
||||
}
|
||||
131
cmd/sdlc/cmd_merge.go
Normal file
131
cmd/sdlc/cmd_merge.go
Normal file
@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var mergeStrategy string
|
||||
|
||||
var mergeCmd = &cobra.Command{
|
||||
Use: "merge <slug>",
|
||||
Short: "Merge a feature branch after all gates pass",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
root := mustResolveRoot()
|
||||
slug := args[0]
|
||||
|
||||
// Check merge readiness
|
||||
checklist, err := sdlc.MergeChecklist(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(checklist) > 0 {
|
||||
if jsonOutput {
|
||||
printJSON(map[string]any{
|
||||
"error": "merge not ready",
|
||||
"checklist": checklist,
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("%w: %v", sdlc.ErrMergeNotReady, checklist)
|
||||
}
|
||||
|
||||
f, err := sdlc.LoadFeature(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Branch == "" {
|
||||
return fmt.Errorf("feature %s has no branch", slug)
|
||||
}
|
||||
|
||||
manifest, err := sdlc.LoadBranch(root, f.Branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
strategy := mergeStrategy
|
||||
if strategy == "" {
|
||||
strategy = "squash"
|
||||
}
|
||||
|
||||
// Checkout main branch
|
||||
checkoutCmd := exec.Command("git", "checkout", manifest.BaseBranch)
|
||||
checkoutCmd.Dir = root
|
||||
if out, err := checkoutCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git checkout %s: %s: %w", manifest.BaseBranch, string(out), err)
|
||||
}
|
||||
|
||||
// Merge
|
||||
mergeArgs := []string{"merge"}
|
||||
if strategy == "squash" {
|
||||
mergeArgs = append(mergeArgs, "--squash")
|
||||
}
|
||||
mergeArgs = append(mergeArgs, f.Branch)
|
||||
|
||||
gitMerge := exec.Command("git", mergeArgs...)
|
||||
gitMerge.Dir = root
|
||||
if out, err := gitMerge.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git merge %s: %s: %w", f.Branch, string(out), err)
|
||||
}
|
||||
|
||||
// For squash merges, create the commit
|
||||
if strategy == "squash" {
|
||||
commitMsg := fmt.Sprintf("feat: %s\n\nMerged feature %s via SDLC orchestration.", f.Title, slug)
|
||||
commitCmd := exec.Command("git", "commit", "-m", commitMsg)
|
||||
commitCmd.Dir = root
|
||||
if out, err := commitCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git commit: %s: %w", string(out), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update branch manifest
|
||||
now := time.Now().UTC()
|
||||
manifest.MergedAt = &now
|
||||
manifest.MergeStrategy = strategy
|
||||
if err := sdlc.SaveBranch(root, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Transition feature to released
|
||||
if err := f.Transition(sdlc.PhaseReleased); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Record action
|
||||
state, err := sdlc.LoadState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.RecordAction("merged", slug, "cli")
|
||||
if err := state.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
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)
|
||||
fmt.Printf("Feature %s transitioned to released.\n", slug)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
mergeCmd.Flags().StringVar(&mergeStrategy, "strategy", "squash", "merge strategy: squash or merge")
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
var (
|
||||
nextForFeature string
|
||||
nextExecute bool
|
||||
)
|
||||
|
||||
var nextCmd = &cobra.Command{
|
||||
@ -85,6 +86,40 @@ func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifie
|
||||
Root: root,
|
||||
})
|
||||
|
||||
if nextExecute && cl.Action == sdlc.ActionTransition && cl.TransitionTo != "" {
|
||||
if err := f.Transition(cl.TransitionTo); err != nil {
|
||||
return fmt.Errorf("execute transition: %w", err)
|
||||
}
|
||||
if err := f.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
state.UpdateActiveFeature(slug, cl.TransitionTo, f.Branch)
|
||||
state.RecordAction("transition", slug, "cli")
|
||||
if err := state.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Executed: transition %s -> %s\n\n", f.Slug, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Re-classify after transition
|
||||
f, err = sdlc.LoadFeature(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl = classifier.Classify(&sdlc.EvalContext{
|
||||
State: state,
|
||||
Feature: f,
|
||||
Config: cfg,
|
||||
Root: root,
|
||||
})
|
||||
} else if nextExecute && cl.Action != sdlc.ActionTransition {
|
||||
if !jsonOutput && cl.NextCommand != "" {
|
||||
fmt.Printf("Cannot auto-execute %s. Run: %s\n\n", cl.Action, cl.NextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
return printClassification(cl, f)
|
||||
}
|
||||
|
||||
@ -133,5 +168,6 @@ func printClassification(cl *sdlc.Classification, f *sdlc.Feature) error {
|
||||
|
||||
func init() {
|
||||
nextCmd.Flags().StringVar(&nextForFeature, "for", "", "classify specific feature")
|
||||
nextCmd.Flags().BoolVar(&nextExecute, "execute", false, "auto-execute transition actions")
|
||||
rootCmd.AddCommand(nextCmd)
|
||||
}
|
||||
|
||||
283
cookbooks/scripts/sdlc-test.sh
Executable file
283
cookbooks/scripts/sdlc-test.sh
Executable file
@ -0,0 +1,283 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# SDLC Orchestration E2E Test Script
|
||||
# Tests the full SDLC lifecycle through the rdev API.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Get SDLC state (verify endpoint works)
|
||||
# 2. Create feature
|
||||
# 3. Transition through phases with artifact checks
|
||||
# 4. Branch, merge, archive
|
||||
#
|
||||
# Usage:
|
||||
# ./cookbooks/scripts/sdlc-test.sh run [project-id] # Run the full flow
|
||||
# ./cookbooks/scripts/sdlc-test.sh status [project-id] # Show SDLC state
|
||||
# ./cookbooks/scripts/sdlc-test.sh teardown [project-id] # Clean up test feature
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
COMMAND="${1:-}"
|
||||
PROJECT_ID="${2:-}"
|
||||
FEATURE_SLUG="sdlc-test-$(date +%s)"
|
||||
|
||||
if [[ -z "$COMMAND" ]]; then
|
||||
echo "SDLC Orchestration E2E Test Script"
|
||||
echo ""
|
||||
echo "Usage: $0 <command> <project-id>"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " run - Run the full SDLC lifecycle flow"
|
||||
echo " status - Show SDLC state for a project"
|
||||
echo " teardown - Delete test feature from project"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 run my-project"
|
||||
echo " $0 status my-project"
|
||||
echo " $0 teardown my-project"
|
||||
echo ""
|
||||
echo "Requires: RDEV_API_URL, RDEV_API_KEY, and an existing project with SDLC initialized"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$PROJECT_ID" ]]; then
|
||||
echo "Error: project-id is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Helper: check response for error
|
||||
check_response() {
|
||||
local response="$1"
|
||||
local step="$2"
|
||||
|
||||
local error
|
||||
error=$(echo "$response" | jq -r '.error // ""')
|
||||
if [[ -n "$error" && "$error" != "" && "$error" != "null" ]]; then
|
||||
print_error "$step failed: $error"
|
||||
echo "$response" | jq '.'
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Main run flow
|
||||
run_flow() {
|
||||
print_header "SDLC E2E Test"
|
||||
echo "Project: $PROJECT_ID"
|
||||
echo "Feature: $FEATURE_SLUG"
|
||||
|
||||
# Step 1: Get SDLC state
|
||||
print_header "Step 1: Get SDLC State"
|
||||
local state_response
|
||||
state_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/state")
|
||||
if check_response "$state_response" "Get state"; then
|
||||
print_success "SDLC state retrieved"
|
||||
echo "$state_response" | jq '.data.version // .data'
|
||||
else
|
||||
print_error "Failed to get SDLC state - is SDLC initialized for this project?"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 2: Create feature
|
||||
print_header "Step 2: Create Feature"
|
||||
local create_response
|
||||
create_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features" \
|
||||
"{\"slug\": \"$FEATURE_SLUG\", \"title\": \"SDLC E2E Test Feature\"}")
|
||||
if check_response "$create_response" "Create feature"; then
|
||||
print_success "Feature created: $FEATURE_SLUG"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 3: List features
|
||||
print_header "Step 3: List Features"
|
||||
local list_response
|
||||
list_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features")
|
||||
if check_response "$list_response" "List features"; then
|
||||
local count
|
||||
count=$(echo "$list_response" | jq '.data | length')
|
||||
print_success "Listed $count features"
|
||||
fi
|
||||
|
||||
# Step 4: Get feature detail
|
||||
print_header "Step 4: Get Feature Detail"
|
||||
local detail_response
|
||||
detail_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG")
|
||||
if check_response "$detail_response" "Get feature"; then
|
||||
local phase
|
||||
phase=$(echo "$detail_response" | jq -r '.data.phase // "unknown"')
|
||||
print_success "Feature phase: $phase"
|
||||
fi
|
||||
|
||||
# Step 5: Get classifier recommendation
|
||||
print_header "Step 5: Get Classifier Recommendation"
|
||||
local next_response
|
||||
next_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/next?feature=$FEATURE_SLUG")
|
||||
if check_response "$next_response" "Get next"; then
|
||||
local action
|
||||
action=$(echo "$next_response" | jq -r '.data.action // "unknown"')
|
||||
local message
|
||||
message=$(echo "$next_response" | jq -r '.data.message // ""')
|
||||
print_success "Next action: $action"
|
||||
echo " Message: $message"
|
||||
fi
|
||||
|
||||
# Step 6: Check artifact status
|
||||
print_header "Step 6: Check Artifact Status"
|
||||
local artifacts_response
|
||||
artifacts_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/artifacts")
|
||||
if check_response "$artifacts_response" "Get artifacts"; then
|
||||
print_success "Artifact status retrieved"
|
||||
fi
|
||||
|
||||
# Step 7: Add a task
|
||||
print_header "Step 7: Add Task"
|
||||
local task_response
|
||||
task_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/tasks" \
|
||||
'{"title": "Test task for E2E validation"}')
|
||||
if check_response "$task_response" "Add task"; then
|
||||
local task_id
|
||||
task_id=$(echo "$task_response" | jq -r '.data.id // "unknown"')
|
||||
print_success "Task added: $task_id"
|
||||
|
||||
# Step 7b: List tasks
|
||||
local tasks_response
|
||||
tasks_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/tasks")
|
||||
if check_response "$tasks_response" "List tasks"; then
|
||||
local task_count
|
||||
task_count=$(echo "$tasks_response" | jq '.data | length')
|
||||
print_success "Listed $task_count tasks"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 8: Block and unblock feature
|
||||
print_header "Step 8: Block/Unblock Feature"
|
||||
local block_response
|
||||
block_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/block" \
|
||||
'{"reason": "E2E test blocker"}')
|
||||
if check_response "$block_response" "Block feature"; then
|
||||
print_success "Feature blocked"
|
||||
fi
|
||||
|
||||
# Query blocked
|
||||
local blocked_response
|
||||
blocked_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/query/blocked")
|
||||
if check_response "$blocked_response" "Query blocked"; then
|
||||
local blocked_count
|
||||
blocked_count=$(echo "$blocked_response" | jq '.data | length')
|
||||
print_success "Blocked features: $blocked_count"
|
||||
fi
|
||||
|
||||
local unblock_response
|
||||
unblock_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/unblock")
|
||||
if check_response "$unblock_response" "Unblock feature"; then
|
||||
print_success "Feature unblocked"
|
||||
fi
|
||||
|
||||
# Step 9: Query endpoints
|
||||
print_header "Step 9: Query Endpoints"
|
||||
|
||||
local ready_response
|
||||
ready_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/query/ready")
|
||||
if check_response "$ready_response" "Query ready"; then
|
||||
print_success "Query ready: OK"
|
||||
fi
|
||||
|
||||
local approval_response
|
||||
approval_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/query/needs-approval")
|
||||
if check_response "$approval_response" "Query needs-approval"; then
|
||||
print_success "Query needs-approval: OK"
|
||||
fi
|
||||
|
||||
# Step 10: Clean up - delete the test feature
|
||||
print_header "Step 10: Cleanup"
|
||||
local delete_response
|
||||
delete_response=$(api_call DELETE "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG")
|
||||
print_success "Feature deleted: $FEATURE_SLUG"
|
||||
|
||||
# Summary
|
||||
print_header "Results"
|
||||
print_success "All SDLC E2E tests passed!"
|
||||
echo ""
|
||||
echo " Tested endpoints:"
|
||||
echo " GET /sdlc/state"
|
||||
echo " GET /sdlc/next"
|
||||
echo " GET /sdlc/features"
|
||||
echo " POST /sdlc/features"
|
||||
echo " GET /sdlc/features/{slug}"
|
||||
echo " GET /sdlc/features/{slug}/artifacts"
|
||||
echo " POST /sdlc/features/{slug}/tasks"
|
||||
echo " GET /sdlc/features/{slug}/tasks"
|
||||
echo " POST /sdlc/features/{slug}/block"
|
||||
echo " POST /sdlc/features/{slug}/unblock"
|
||||
echo " GET /sdlc/query/blocked"
|
||||
echo " GET /sdlc/query/ready"
|
||||
echo " GET /sdlc/query/needs-approval"
|
||||
echo " DELETE /sdlc/features/{slug}"
|
||||
}
|
||||
|
||||
# Show SDLC status
|
||||
show_status() {
|
||||
print_header "SDLC Status for $PROJECT_ID"
|
||||
|
||||
echo "State:"
|
||||
api_call GET "/projects/$PROJECT_ID/sdlc/state" | jq '.data'
|
||||
|
||||
echo ""
|
||||
echo "Features:"
|
||||
api_call GET "/projects/$PROJECT_ID/sdlc/features" | jq '.data'
|
||||
|
||||
echo ""
|
||||
echo "Blocked:"
|
||||
api_call GET "/projects/$PROJECT_ID/sdlc/query/blocked" | jq '.data'
|
||||
|
||||
echo ""
|
||||
echo "Needs Approval:"
|
||||
api_call GET "/projects/$PROJECT_ID/sdlc/query/needs-approval" | jq '.data'
|
||||
}
|
||||
|
||||
# Teardown test feature
|
||||
teardown_flow() {
|
||||
print_header "SDLC Teardown"
|
||||
|
||||
# List features to find test ones
|
||||
local features
|
||||
features=$(api_call GET "/projects/$PROJECT_ID/sdlc/features" | jq -r '.data[]?.slug // empty')
|
||||
|
||||
if [[ -z "$features" ]]; then
|
||||
echo "No features found for project $PROJECT_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Features:"
|
||||
echo "$features"
|
||||
echo ""
|
||||
|
||||
# Delete features matching test pattern
|
||||
for slug in $features; do
|
||||
if [[ "$slug" == sdlc-test-* ]]; then
|
||||
echo "Deleting test feature: $slug"
|
||||
api_call DELETE "/projects/$PROJECT_ID/sdlc/features/$slug"
|
||||
print_success "Deleted: $slug"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Dispatch
|
||||
case "$COMMAND" in
|
||||
run)
|
||||
run_flow
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
teardown)
|
||||
teardown_flow
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $COMMAND"
|
||||
echo "Use: run, status, or teardown"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -84,6 +84,12 @@ func (e *SDLCExecutor) mapExecError(stderr string, execErr error) error {
|
||||
return sdlc.ErrInvalidSlug
|
||||
case strings.Contains(stderr, "invalid artifact"):
|
||||
return sdlc.ErrInvalidArtifact
|
||||
case strings.Contains(stderr, "branch already exists"):
|
||||
return sdlc.ErrBranchExists
|
||||
case strings.Contains(stderr, "branch not found"):
|
||||
return sdlc.ErrBranchNotFound
|
||||
case strings.Contains(stderr, "not ready to merge"):
|
||||
return sdlc.ErrMergeNotReady
|
||||
default:
|
||||
if stderr != "" {
|
||||
return fmt.Errorf("sdlc exec: %s: %w", stderr, execErr)
|
||||
@ -284,5 +290,53 @@ func (e *SDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) (
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// CreateBranch creates a feature branch and its manifest.
|
||||
func (e *SDLCExecutor) CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) {
|
||||
out, err := e.execSDLC(ctx, podName, "branch", "create", slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest sdlc.BranchManifest
|
||||
if err := json.Unmarshal(out, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parse sdlc branch manifest: %w", err)
|
||||
}
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// GetBranchStatus returns the branch manifest for a feature.
|
||||
func (e *SDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, 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"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse sdlc branch status: %w", err)
|
||||
}
|
||||
return result.Branch, nil
|
||||
}
|
||||
|
||||
// SyncBranch syncs a feature branch with its base branch.
|
||||
func (e *SDLCExecutor) SyncBranch(ctx context.Context, podName, slug string) error {
|
||||
return e.execSDLCNoOutput(ctx, podName, "branch", "sync", slug)
|
||||
}
|
||||
|
||||
// MergeFeature merges a feature branch after all gates pass.
|
||||
func (e *SDLCExecutor) MergeFeature(ctx context.Context, podName, slug, strategy string) error {
|
||||
args := []string{"merge", slug}
|
||||
if strategy != "" {
|
||||
args = append(args, "--strategy", strategy)
|
||||
}
|
||||
return e.execSDLCNoOutput(ctx, podName, args...)
|
||||
}
|
||||
|
||||
// ArchiveFeature archives a released feature.
|
||||
func (e *SDLCExecutor) ArchiveFeature(ctx context.Context, podName, slug string) error {
|
||||
return e.execSDLCNoOutput(ctx, podName, "archive", slug)
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.SDLCExecutor = (*SDLCExecutor)(nil)
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
---
|
||||
description: Archive a completed and released feature
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
Archive feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Verify Feature is Released
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Confirm the feature current phase is `released`. Only released features can be archived.
|
||||
|
||||
### 2. Archive the Feature
|
||||
|
||||
```bash
|
||||
sdlc feature delete $ARGUMENTS
|
||||
```
|
||||
|
||||
This moves the feature from active tracking to the archive. The `.sdlc/features/$ARGUMENTS/` directory and its artifacts are preserved in git history.
|
||||
|
||||
### 3. Confirm Completion
|
||||
|
||||
```bash
|
||||
sdlc feature list --json
|
||||
```
|
||||
|
||||
Verify the feature no longer appears in the active features list.
|
||||
|
||||
### 4. Report
|
||||
|
||||
Confirm:
|
||||
- Feature slug that was archived
|
||||
- Previous phase: `released`
|
||||
- Status: archived and removed from active tracking
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ONLY archive features in the `released` phase
|
||||
- NEVER archive features that are still in progress
|
||||
- This is a cleanup action -- it does not delete git history
|
||||
- ALWAYS verify the feature is released before archiving
|
||||
@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Perform a security and quality audit of a feature
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Glob, Grep, Write
|
||||
---
|
||||
|
||||
Audit feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Feature Context
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Read the spec and design to understand the feature security surface:
|
||||
- `.sdlc/features/$ARGUMENTS/spec.md`
|
||||
- `.sdlc/features/$ARGUMENTS/design.md`
|
||||
|
||||
### 2. Run Static Analysis
|
||||
|
||||
```bash
|
||||
go vet ./... 2>/dev/null || true
|
||||
golangci-lint run ./... 2>/dev/null || true
|
||||
```
|
||||
|
||||
Capture any warnings or errors related to the feature files.
|
||||
|
||||
### 3. OWASP Top 10 Check
|
||||
|
||||
For each applicable category, search the feature code:
|
||||
|
||||
| Category | What to Check |
|
||||
|----------|--------------|
|
||||
| **Injection** | SQL queries, command execution, template rendering |
|
||||
| **Broken Auth** | Token handling, session management, credential storage |
|
||||
| **Sensitive Data** | Secrets in code, logging PII, unencrypted storage |
|
||||
| **XXE / Deserialization** | XML parsing, JSON unmarshaling of untrusted input |
|
||||
| **Broken Access Control** | Authorization checks, resource ownership validation |
|
||||
| **Misconfiguration** | Default credentials, debug modes, permissive CORS |
|
||||
| **XSS** | User input rendered without escaping |
|
||||
| **Insecure Components** | Known vulnerable dependencies |
|
||||
| **Logging Gaps** | Missing audit logs, excessive debug logging |
|
||||
| **SSRF** | User-controlled URLs, internal network access |
|
||||
|
||||
### 4. Verify Auth Boundaries
|
||||
|
||||
- Every endpoint has authentication
|
||||
- Authorization checks match the resource being accessed
|
||||
- No privilege escalation paths
|
||||
|
||||
### 5. Check for Hardcoded Secrets
|
||||
|
||||
```bash
|
||||
grep -rn "password\|secret\|token\|api_key\|apikey" --include="*.go" [feature files]
|
||||
```
|
||||
|
||||
### 6. Write Audit Report
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/audit.md`:
|
||||
|
||||
```markdown
|
||||
# Security Audit: [Feature Title]
|
||||
|
||||
## Summary
|
||||
[Overall assessment: PASS / NEEDS_REMEDIATION]
|
||||
|
||||
## Static Analysis Results
|
||||
[Findings from vet/lint]
|
||||
|
||||
## OWASP Assessment
|
||||
| Category | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Injection | PASS/FAIL | [details] |
|
||||
| ... | ... | ... |
|
||||
|
||||
## Critical Findings
|
||||
- [Finding with severity and remediation guidance]
|
||||
|
||||
## High Findings
|
||||
- [Finding]
|
||||
|
||||
## Medium/Low Findings
|
||||
- [Finding]
|
||||
|
||||
## Recommendations
|
||||
[Ordered list of actions to take]
|
||||
```
|
||||
|
||||
### 7. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS audit
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER skip OWASP checks -- even if the feature seems low-risk
|
||||
- ALWAYS check for hardcoded secrets, tokens, and credentials
|
||||
- ALWAYS verify authentication and authorization boundaries
|
||||
- NEVER pass an audit with critical or high severity findings unresolved
|
||||
- ALWAYS run static analysis tools before manual review
|
||||
@ -0,0 +1,77 @@
|
||||
---
|
||||
description: Break a feature into implementation tasks
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Break down feature into tasks: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Feature, Spec, and Design
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Read both:
|
||||
- `.sdlc/features/$ARGUMENTS/spec.md`
|
||||
- `.sdlc/features/$ARGUMENTS/design.md`
|
||||
|
||||
Both must exist before creating a task breakdown.
|
||||
|
||||
### 2. Identify Implementation Units
|
||||
|
||||
From the design, identify discrete units of work. Each task should:
|
||||
- Be completable in a single coding session
|
||||
- Have clear inputs and outputs
|
||||
- Be independently testable
|
||||
- Have minimal overlap with other tasks
|
||||
|
||||
### 3. Create Tasks via CLI
|
||||
|
||||
For each task, register it:
|
||||
|
||||
```bash
|
||||
sdlc task add $ARGUMENTS "Task title - brief description"
|
||||
```
|
||||
|
||||
Order tasks by dependency -- foundational work first, integration last.
|
||||
|
||||
### 4. Write Detailed Task Descriptions
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/tasks.md`:
|
||||
|
||||
```markdown
|
||||
# Tasks: [Feature Title]
|
||||
|
||||
## Task Order (dependency sequence)
|
||||
|
||||
### T1: [Title]
|
||||
- **Scope:** [What exactly to implement]
|
||||
- **Files:** [Expected files to create/modify]
|
||||
- **Depends on:** [None / T-id]
|
||||
- **Acceptance criteria:**
|
||||
- [ ] [Specific, testable criterion]
|
||||
|
||||
### T2: [Title]
|
||||
...
|
||||
```
|
||||
|
||||
### 5. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS tasks
|
||||
```
|
||||
|
||||
### 6. Report
|
||||
|
||||
List all tasks in order, total count, and estimated dependency chain depth (longest path through the task graph).
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER create tasks without reading both spec and design
|
||||
- ALWAYS order tasks by dependency -- no task should reference work not yet done
|
||||
- Each task MUST be completable in a single session
|
||||
- ALWAYS include acceptance criteria per task
|
||||
- NEVER create monolithic tasks -- split until each is focused
|
||||
@ -0,0 +1,79 @@
|
||||
---
|
||||
description: Create a QA test plan for a feature
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Create a QA plan for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load All Planning Artifacts
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Read all of:
|
||||
- `.sdlc/features/$ARGUMENTS/spec.md` -- acceptance criteria drive tests
|
||||
- `.sdlc/features/$ARGUMENTS/design.md` -- architecture informs integration tests
|
||||
- `.sdlc/features/$ARGUMENTS/tasks.md` -- task scope informs unit tests
|
||||
|
||||
### 2. Derive Test Scenarios from Acceptance Criteria
|
||||
|
||||
For each acceptance criterion in the spec, create at least one test scenario. Group scenarios by type.
|
||||
|
||||
### 3. Write the QA Plan
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/qa-plan.md`:
|
||||
|
||||
```markdown
|
||||
# QA Plan: [Feature Title]
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Happy Path
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| HP-1 | [description] | [input] | [expected] | AC-N |
|
||||
|
||||
### Edge Cases
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| EC-1 | [description] | [input] | [expected] | AC-N |
|
||||
|
||||
### Error Cases
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| ER-1 | [description] | [input] | [expected] | AC-N |
|
||||
|
||||
## Test Data Requirements
|
||||
[What test data must be set up, fixtures, mocks]
|
||||
|
||||
## Integration Test Plan
|
||||
[How components interact, what to test end-to-end]
|
||||
|
||||
## Performance Considerations
|
||||
[Load expectations, latency budgets, benchmarks to run]
|
||||
|
||||
## Manual Verification Steps
|
||||
[Anything that cannot be automated]
|
||||
```
|
||||
|
||||
### 4. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS qa_plan
|
||||
```
|
||||
|
||||
### 5. Report
|
||||
|
||||
Summarize scenario counts by category and flag any acceptance criteria that lack test coverage.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS derive test scenarios from acceptance criteria in the spec
|
||||
- NEVER skip edge cases -- they catch the real bugs
|
||||
- ALWAYS include integration scenarios that cross component boundaries
|
||||
- ALWAYS include error cases for every external dependency
|
||||
- NEVER leave an acceptance criterion without a corresponding test scenario
|
||||
@ -0,0 +1,68 @@
|
||||
---
|
||||
description: Orchestrate full feature delivery from current state to completion
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
|
||||
---
|
||||
|
||||
Deliver feature end-to-end: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Assess Current State
|
||||
|
||||
```bash
|
||||
sdlc next --for $ARGUMENTS
|
||||
```
|
||||
|
||||
Determine where the feature is in the lifecycle and what action is needed.
|
||||
|
||||
### 2. Execute the Action Loop
|
||||
|
||||
For each classifier result, execute the recommended action:
|
||||
|
||||
| Action | What to Do |
|
||||
|--------|-----------|
|
||||
| `CREATE_SPEC` | Write the spec (follow `/spec-feature` protocol) |
|
||||
| `CREATE_DESIGN` | Write the design (follow `/design-feature` protocol) |
|
||||
| `CREATE_TASKS` | Break down tasks (follow `/breakdown-feature` protocol) |
|
||||
| `CREATE_QA_PLAN` | Write QA plan (follow `/create-qa-plan` protocol) |
|
||||
| `TRANSITION` | Run `sdlc feature transition $ARGUMENTS <phase>` |
|
||||
| `IMPLEMENT_TASK` | Implement the task (follow `/implement-task` protocol) |
|
||||
| `CREATE_REVIEW` | Review the code (follow `/review-feature` protocol) |
|
||||
| `CREATE_AUDIT` | Audit the code (follow `/audit-feature` protocol) |
|
||||
| `RUN_QA` | Execute QA (follow `/run-qa` protocol) |
|
||||
| `MERGE` | Merge the feature (follow `/merge-feature` protocol) |
|
||||
|
||||
### 3. Re-classify After Each Step
|
||||
|
||||
After completing each action:
|
||||
|
||||
```bash
|
||||
sdlc next --for $ARGUMENTS
|
||||
```
|
||||
|
||||
Use the new classification to determine the next step. Continue until the feature reaches `released` or a gate is hit.
|
||||
|
||||
### 4. Stop at Gates
|
||||
|
||||
When the classifier returns `AWAIT_APPROVAL` or `BLOCKED`:
|
||||
|
||||
1. Present what needs approval or what is blocked
|
||||
2. List the specific artifact or blocker details
|
||||
3. **Stop and wait for the user** -- do not proceed past approval gates
|
||||
|
||||
### 5. Report Progress
|
||||
|
||||
After each action, briefly report:
|
||||
- What was just completed
|
||||
- Current phase
|
||||
- What comes next (or what is blocking)
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS stop at approval gates -- NEVER approve artifacts yourself
|
||||
- NEVER skip phases or reorder the classifier recommendations
|
||||
- ALWAYS re-run the classifier after each action to get the true next step
|
||||
- ALWAYS follow the corresponding command protocol for each action
|
||||
- NEVER continue past BLOCKED status without resolution
|
||||
- ALWAYS report what was done and what comes next after each step
|
||||
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Create a technical design document for a feature
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Create a technical design for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Feature and Spec
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Read `.sdlc/features/$ARGUMENTS/spec.md`. The spec MUST exist and be approved before designing.
|
||||
|
||||
### 2. Analyze Existing Patterns
|
||||
|
||||
Search the codebase for:
|
||||
- Similar features or modules already implemented
|
||||
- Data models that will be extended or referenced
|
||||
- API patterns currently in use
|
||||
- Error handling conventions
|
||||
- Test patterns for comparable components
|
||||
|
||||
### 3. Write the Design Document
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/design.md` with this structure:
|
||||
|
||||
```markdown
|
||||
# Design: [Feature Title]
|
||||
|
||||
## Architecture Approach
|
||||
[High-level approach: which layers change, what is new vs modified]
|
||||
|
||||
## Data Model Changes
|
||||
[New types, schema changes, migration requirements]
|
||||
|
||||
## API Changes
|
||||
[New endpoints, modified contracts, request/response shapes]
|
||||
|
||||
## Component Diagram
|
||||
[Text diagram showing how components interact]
|
||||
|
||||
## Error Handling Strategy
|
||||
[Expected failure modes and how each is handled]
|
||||
|
||||
## Security Considerations
|
||||
[Auth, input validation, data exposure, access control]
|
||||
|
||||
## Performance Considerations
|
||||
[Expected load, caching strategy, query complexity]
|
||||
|
||||
## Migration / Rollout Plan
|
||||
[How to ship without breaking existing functionality]
|
||||
```
|
||||
|
||||
### 4. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS design
|
||||
```
|
||||
|
||||
### 5. Report
|
||||
|
||||
Summarize the design approach, list key decisions made, and flag any areas that need human review or approval.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS read the spec before designing -- the design must satisfy the spec
|
||||
- NEVER design without understanding existing patterns in the codebase
|
||||
- ALWAYS consider error handling -- every external call can fail
|
||||
- ALWAYS address security -- auth, validation, data boundaries
|
||||
- NEVER invent new patterns when existing ones fit
|
||||
@ -0,0 +1,68 @@
|
||||
---
|
||||
description: Fix QA test failures
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
|
||||
---
|
||||
|
||||
Fix QA failures for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load QA Results
|
||||
|
||||
Read `.sdlc/features/$ARGUMENTS/qa-results.md` to get the full test results.
|
||||
|
||||
### 2. Parse Failed Scenarios
|
||||
|
||||
Collect all scenarios with status FAIL. For each failure, note:
|
||||
- Scenario ID and description
|
||||
- Expected vs actual behavior
|
||||
- Error output or evidence
|
||||
|
||||
### 3. Diagnose Each Failure
|
||||
|
||||
For each failed scenario:
|
||||
1. Read the test code or manual steps that failed
|
||||
2. Read the production code being tested
|
||||
3. Determine if the issue is in the implementation, the test, or the test data
|
||||
|
||||
### 4. Fix Implementation Issues
|
||||
|
||||
If the failure is due to incorrect implementation:
|
||||
1. Fix the production code
|
||||
2. Run the specific failing test to confirm the fix
|
||||
3. Run the full test suite to ensure no regressions:
|
||||
|
||||
```bash
|
||||
go test ./... 2>/dev/null || true
|
||||
```
|
||||
|
||||
### 5. Fix Test Issues
|
||||
|
||||
If the failure is due to an incorrect test expectation:
|
||||
1. Verify the test expectation against the spec
|
||||
2. If the spec supports the test, fix the implementation (not the test)
|
||||
3. If the test expectation was wrong, fix the test and document why
|
||||
|
||||
### 6. Re-run All Failed Scenarios
|
||||
|
||||
After all fixes, re-execute every previously failed scenario and confirm PASS.
|
||||
|
||||
### 7. Update QA Results
|
||||
|
||||
Update `.sdlc/features/$ARGUMENTS/qa-results.md`:
|
||||
- Change FAIL to PASS for fixed scenarios
|
||||
- Add a remediation note explaining what was fixed
|
||||
- Verify the overall status (PASS only if all scenarios pass)
|
||||
|
||||
### 8. Report
|
||||
|
||||
Summarize: failures fixed, root causes, regression status (all previously passing tests still pass).
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS re-run failed tests after fixing -- verify with evidence
|
||||
- NEVER mark a test as passing without actually running it
|
||||
- NEVER fix tests to match broken behavior -- fix the implementation
|
||||
- ALWAYS keep passing tests passing -- no regressions
|
||||
- ALWAYS update the QA results document with resolution details
|
||||
@ -0,0 +1,68 @@
|
||||
---
|
||||
description: Fix issues found during code review
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
|
||||
---
|
||||
|
||||
Fix review issues for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Review Findings
|
||||
|
||||
Read `.sdlc/features/$ARGUMENTS/review.md` to get the full list of findings.
|
||||
|
||||
### 2. Parse Findings by Severity
|
||||
|
||||
Collect all findings into severity buckets:
|
||||
1. **BLOCKER** -- must fix, cannot ship without these
|
||||
2. **WARNING** -- should fix, quality concerns
|
||||
3. **SUGGESTION** -- nice to have improvements
|
||||
|
||||
### 3. Fix Blockers First
|
||||
|
||||
For each blocker:
|
||||
1. Read the file at the specified location
|
||||
2. Understand the issue and why it matters
|
||||
3. Apply the proper fix (not a quick patch)
|
||||
4. Run tests to verify the fix does not break anything:
|
||||
|
||||
```bash
|
||||
go test ./... 2>/dev/null || true
|
||||
```
|
||||
|
||||
### 4. Fix Warnings
|
||||
|
||||
After all blockers are resolved, fix warnings using the same process.
|
||||
|
||||
### 5. Address Suggestions
|
||||
|
||||
Apply suggestions that improve clarity or maintainability without significant risk.
|
||||
|
||||
### 6. Update Review Report
|
||||
|
||||
Update `.sdlc/features/$ARGUMENTS/review.md` with resolution notes for each finding:
|
||||
|
||||
```markdown
|
||||
- [x] [FILE:LINE] [Description] -- **RESOLVED:** [what was done]
|
||||
```
|
||||
|
||||
### 7. Run Full Test Suite
|
||||
|
||||
```bash
|
||||
go test ./... 2>/dev/null || true
|
||||
```
|
||||
|
||||
All tests must pass after all fixes are applied.
|
||||
|
||||
### 8. Report
|
||||
|
||||
Summarize: findings fixed by severity, files modified, test results.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS fix all blockers -- they are non-negotiable
|
||||
- ALWAYS run tests after each fix, not just at the end
|
||||
- NEVER close a finding without actually fixing it
|
||||
- NEVER introduce new issues while fixing existing ones
|
||||
- ALWAYS update the review report with resolution notes
|
||||
@ -0,0 +1,69 @@
|
||||
---
|
||||
description: Implement a specific task from the feature breakdown
|
||||
argument-hint: <feature-slug> <task-id>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
|
||||
---
|
||||
|
||||
Implement task: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Parse Arguments
|
||||
|
||||
Extract the feature slug and task ID from `$ARGUMENTS`. The first token is the slug, the second is the task ID.
|
||||
|
||||
### 2. Start the Task
|
||||
|
||||
```bash
|
||||
sdlc task start <slug> <task-id>
|
||||
```
|
||||
|
||||
This marks the task as in-progress and prevents other tasks from being picked up concurrently.
|
||||
|
||||
### 3. Load Context
|
||||
|
||||
Read all relevant artifacts:
|
||||
- `.sdlc/features/<slug>/spec.md` -- requirements
|
||||
- `.sdlc/features/<slug>/design.md` -- architecture decisions
|
||||
- `.sdlc/features/<slug>/tasks.md` -- find this task scope and acceptance criteria
|
||||
|
||||
### 4. Study Existing Patterns
|
||||
|
||||
Before writing code, read the files identified in the task description. Understand the patterns, naming conventions, and test approaches already in use.
|
||||
|
||||
### 5. Implement
|
||||
|
||||
Write the code changes specified by the task. Follow existing patterns. For each file:
|
||||
- Production code first
|
||||
- Tests alongside or immediately after
|
||||
- Update any relevant documentation or configuration
|
||||
|
||||
### 6. Run Tests
|
||||
|
||||
```bash
|
||||
go test ./... 2>/dev/null || true
|
||||
# or the appropriate test command for the project stack
|
||||
```
|
||||
|
||||
All existing tests must continue to pass. New tests must pass.
|
||||
|
||||
### 7. Complete the Task
|
||||
|
||||
Only if tests pass:
|
||||
|
||||
```bash
|
||||
sdlc task complete <slug> <task-id>
|
||||
```
|
||||
|
||||
### 8. Report
|
||||
|
||||
Summarize: files changed, tests added, task acceptance criteria status.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS start the task via CLI before implementing
|
||||
- ALWAYS run tests before marking complete
|
||||
- NEVER mark a task complete if tests fail
|
||||
- ALWAYS follow existing codebase patterns
|
||||
- NEVER implement beyond the task stated scope
|
||||
- ALWAYS read spec and design for context before coding
|
||||
@ -0,0 +1,61 @@
|
||||
---
|
||||
description: Merge a completed feature branch
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
Merge feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Check Merge Readiness
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Verify that:
|
||||
- All required artifacts are approved (review, audit, qa_results)
|
||||
- No blockers exist
|
||||
- The feature is in the `merge` phase
|
||||
|
||||
### 2. Check Branch Status
|
||||
|
||||
```bash
|
||||
sdlc query ready
|
||||
```
|
||||
|
||||
Confirm the feature appears in the ready-to-merge list.
|
||||
|
||||
### 3. Run Final Tests
|
||||
|
||||
```bash
|
||||
go test ./... 2>/dev/null || true
|
||||
```
|
||||
|
||||
All tests must pass before merge.
|
||||
|
||||
### 4. Execute the Merge
|
||||
|
||||
```bash
|
||||
sdlc feature transition $ARGUMENTS released
|
||||
```
|
||||
|
||||
This transitions the feature to the `released` phase.
|
||||
|
||||
### 5. Report
|
||||
|
||||
Confirm the merge completed successfully:
|
||||
- Feature name and slug
|
||||
- Final phase: `released`
|
||||
- All gates that were passed
|
||||
|
||||
If the merge fails, report the error and do not retry without user guidance.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER merge without all required gates passed
|
||||
- ALWAYS run tests immediately before merging
|
||||
- ALWAYS report merge conflicts or failures to the user
|
||||
- NEVER force a merge past failing checks
|
||||
- NEVER merge if the classifier says the feature is not ready
|
||||
@ -0,0 +1,58 @@
|
||||
---
|
||||
description: Run the SDLC classifier and show the next required action
|
||||
argument-hint: [feature-slug]
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
Show next SDLC action: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Run the Classifier
|
||||
|
||||
If a feature slug was provided:
|
||||
|
||||
```bash
|
||||
sdlc next --for $ARGUMENTS
|
||||
```
|
||||
|
||||
If no argument was provided, run the global classifier:
|
||||
|
||||
```bash
|
||||
sdlc next
|
||||
```
|
||||
|
||||
### 2. Display the Result
|
||||
|
||||
Present the classification output clearly:
|
||||
|
||||
- **Feature:** which feature needs attention
|
||||
- **Current Phase:** where it is in the lifecycle
|
||||
- **Action:** what needs to happen next
|
||||
- **Message:** human-readable guidance
|
||||
- **Suggested Command:** the CLI or slash command to run
|
||||
|
||||
### 3. Suggest the Next Step
|
||||
|
||||
Map the action to the corresponding slash command:
|
||||
|
||||
| Action | Suggested Command |
|
||||
|--------|-------------------|
|
||||
| `CREATE_SPEC` | `/spec-feature <slug>` |
|
||||
| `CREATE_DESIGN` | `/design-feature <slug>` |
|
||||
| `CREATE_TASKS` | `/breakdown-feature <slug>` |
|
||||
| `CREATE_QA_PLAN` | `/create-qa-plan <slug>` |
|
||||
| `IMPLEMENT_TASK` | `/implement-task <slug> <task-id>` |
|
||||
| `CREATE_REVIEW` | `/review-feature <slug>` |
|
||||
| `CREATE_AUDIT` | `/audit-feature <slug>` |
|
||||
| `RUN_QA` | `/run-qa <slug>` |
|
||||
| `MERGE` | `/merge-feature <slug>` |
|
||||
| `AWAIT_APPROVAL` | Needs human approval via rdev API |
|
||||
| `BLOCKED` | Feature has blockers -- resolve via rdev API |
|
||||
| `IDLE` | Nothing to do |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS present the classifier output clearly and completely
|
||||
- NEVER automatically execute the next command without user confirmation
|
||||
- ALWAYS show the suggested slash command so the user can invoke it
|
||||
@ -0,0 +1,70 @@
|
||||
---
|
||||
description: Remediate security audit findings
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
|
||||
---
|
||||
|
||||
Remediate audit findings for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Audit Findings
|
||||
|
||||
Read `.sdlc/features/$ARGUMENTS/audit.md` to get the full security audit report.
|
||||
|
||||
### 2. Parse Findings by Severity
|
||||
|
||||
Collect all security findings:
|
||||
1. **CRITICAL** -- immediate risk, must fix before any progress
|
||||
2. **HIGH** -- significant risk, must fix before merge
|
||||
3. **MEDIUM** -- moderate risk, should fix
|
||||
4. **LOW** -- minor risk, fix if straightforward
|
||||
|
||||
### 3. Fix Critical Findings
|
||||
|
||||
For each critical finding:
|
||||
1. Read the affected code
|
||||
2. Understand the vulnerability and attack vector
|
||||
3. Apply the proper remediation (input validation, auth check, etc.)
|
||||
4. Verify the fix addresses the root cause, not just the symptom
|
||||
|
||||
### 4. Fix High Findings
|
||||
|
||||
After all critical findings are resolved, address high severity issues using the same disciplined approach.
|
||||
|
||||
### 5. Fix Medium and Low Findings
|
||||
|
||||
Address remaining findings in priority order.
|
||||
|
||||
### 6. Run Security Checks
|
||||
|
||||
Re-run the checks that originally found the issues:
|
||||
|
||||
```bash
|
||||
go vet ./... 2>/dev/null || true
|
||||
grep -rn "password\|secret\|token\|api_key" --include="*.go" [feature files] || true
|
||||
```
|
||||
|
||||
### 7. Update Audit Report
|
||||
|
||||
Update `.sdlc/features/$ARGUMENTS/audit.md` with remediation notes:
|
||||
|
||||
```markdown
|
||||
## Remediation Log
|
||||
| Finding | Severity | Status | Resolution |
|
||||
|---------|----------|--------|------------|
|
||||
| [description] | CRITICAL | REMEDIATED | [what was done] |
|
||||
```
|
||||
|
||||
### 8. Report
|
||||
|
||||
Summarize: findings remediated by severity, remaining items, verification results.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS fix all critical findings -- no exceptions
|
||||
- NEVER leave high-severity security issues unresolved
|
||||
- ALWAYS run security checks after applying fixes
|
||||
- NEVER fix security issues with workarounds -- address root causes
|
||||
- ALWAYS update the audit report with remediation details
|
||||
- NEVER remove security findings from the report -- mark them as remediated
|
||||
@ -0,0 +1,85 @@
|
||||
---
|
||||
description: Perform a comprehensive code review of a feature
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Glob, Grep, Write
|
||||
---
|
||||
|
||||
Review feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Feature Context
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Read the spec and design to understand what was intended:
|
||||
- `.sdlc/features/$ARGUMENTS/spec.md`
|
||||
- `.sdlc/features/$ARGUMENTS/design.md`
|
||||
|
||||
### 2. Identify Changed Files
|
||||
|
||||
Determine what files were created or modified for this feature. Check git history, task descriptions, or search for recent changes in relevant directories.
|
||||
|
||||
### 3. Review Each Dimension
|
||||
|
||||
| Dimension | Key Question |
|
||||
|-----------|--------------|
|
||||
| **Correctness** | Does the code do what the spec requires? |
|
||||
| **Test Coverage** | Is every acceptance criterion tested? |
|
||||
| **Error Handling** | Are failures handled, not swallowed? |
|
||||
| **Security** | Input validation, auth checks, data exposure? |
|
||||
| **Performance** | N+1 queries, unbounded loops, missing timeouts? |
|
||||
| **Code Style** | Follows existing patterns and conventions? |
|
||||
| **Documentation** | Public APIs documented, complex logic commented? |
|
||||
|
||||
### 4. Categorize Findings
|
||||
|
||||
| Severity | Meaning |
|
||||
|----------|---------|
|
||||
| **BLOCKER** | Cannot ship -- must fix before merge |
|
||||
| **WARNING** | Quality concern -- should fix |
|
||||
| **SUGGESTION** | Improvement -- nice to have |
|
||||
|
||||
### 5. Write Review Report
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/review.md`:
|
||||
|
||||
```markdown
|
||||
# Code Review: [Feature Title]
|
||||
|
||||
## Summary
|
||||
[Overall assessment: PASS / NEEDS_FIX]
|
||||
|
||||
## Findings
|
||||
|
||||
### Blockers
|
||||
- [ ] [FILE:LINE] [Description] -- [Why it matters]
|
||||
|
||||
### Warnings
|
||||
- [ ] [FILE:LINE] [Description] -- [Suggested fix]
|
||||
|
||||
### Suggestions
|
||||
- [ ] [FILE:LINE] [Description]
|
||||
|
||||
## Spec Alignment
|
||||
[Does the implementation match the spec? Any gaps?]
|
||||
|
||||
## Test Coverage Assessment
|
||||
[Which acceptance criteria have tests? Which are missing?]
|
||||
```
|
||||
|
||||
### 6. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS review
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS read spec and design before reviewing code
|
||||
- NEVER skip the security review dimension
|
||||
- ALWAYS check test coverage against acceptance criteria
|
||||
- ALWAYS provide actionable findings with file locations
|
||||
- NEVER approve a review with unresolved blockers
|
||||
@ -0,0 +1,92 @@
|
||||
---
|
||||
description: Execute the QA test plan for a feature
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
|
||||
---
|
||||
|
||||
Run QA for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Feature and QA Plan
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Read:
|
||||
- `.sdlc/features/$ARGUMENTS/qa-plan.md` -- the test plan to execute
|
||||
- `.sdlc/features/$ARGUMENTS/spec.md` -- acceptance criteria to verify
|
||||
|
||||
### 2. Execute Unit Tests
|
||||
|
||||
Run the project test suite and capture results:
|
||||
|
||||
```bash
|
||||
go test ./... -v 2>&1 | tee /tmp/qa-test-output.txt
|
||||
```
|
||||
|
||||
### 3. Execute Each Test Scenario
|
||||
|
||||
Work through every scenario in the QA plan:
|
||||
- **Happy path scenarios** -- verify expected behavior
|
||||
- **Edge case scenarios** -- verify boundary handling
|
||||
- **Error case scenarios** -- verify failure modes
|
||||
|
||||
For each scenario, record: scenario ID, status (PASS/FAIL), evidence (test output or manual verification).
|
||||
|
||||
### 4. Verify Acceptance Criteria
|
||||
|
||||
Cross-reference each acceptance criterion from the spec against test results. Every criterion must have at least one passing test scenario.
|
||||
|
||||
### 5. Write QA Results
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/qa-results.md`:
|
||||
|
||||
```markdown
|
||||
# QA Results: [Feature Title]
|
||||
|
||||
## Test Run Summary
|
||||
- **Date:** [timestamp]
|
||||
- **Overall:** PASS / FAIL
|
||||
- **Scenarios:** N passed, M failed, K skipped
|
||||
|
||||
## Scenario Results
|
||||
|
||||
### Happy Path
|
||||
| ID | Scenario | Status | Evidence |
|
||||
|----|----------|--------|----------|
|
||||
| HP-1 | [description] | PASS/FAIL | [test name or output] |
|
||||
|
||||
### Edge Cases
|
||||
| ID | Scenario | Status | Evidence |
|
||||
|----|----------|--------|----------|
|
||||
| EC-1 | [description] | PASS/FAIL | [evidence] |
|
||||
|
||||
### Error Cases
|
||||
| ID | Scenario | Status | Evidence |
|
||||
|----|----------|--------|----------|
|
||||
| ER-1 | [description] | PASS/FAIL | [evidence] |
|
||||
|
||||
## Acceptance Criteria Coverage
|
||||
| Criterion | Scenarios | Status |
|
||||
|-----------|-----------|--------|
|
||||
| AC-1 | HP-1, EC-2 | COVERED / GAP |
|
||||
|
||||
## Failures (if any)
|
||||
[Detailed description of each failure with reproduction steps]
|
||||
```
|
||||
|
||||
### 6. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS qa_results
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS execute every scenario from the QA plan -- no skipping
|
||||
- NEVER skip failing tests or mark them as passing without evidence
|
||||
- ALWAYS document ALL results, including passing scenarios
|
||||
- ALWAYS verify acceptance criteria coverage explicitly
|
||||
- NEVER fabricate test evidence -- run the actual tests
|
||||
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Create a feature specification document
|
||||
argument-hint: <feature-slug>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Create a specification for feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Load Feature Context
|
||||
|
||||
```bash
|
||||
sdlc feature show $ARGUMENTS --json
|
||||
```
|
||||
|
||||
Parse the output to understand the feature current phase, metadata, and any existing artifacts.
|
||||
|
||||
### 2. Check for Existing Spec
|
||||
|
||||
Read `.sdlc/features/$ARGUMENTS/spec.md` if it exists. If a draft already exists, build on it rather than starting from scratch.
|
||||
|
||||
### 3. Gather Codebase Context
|
||||
|
||||
Search the codebase for patterns, modules, and systems relevant to this feature. Understand what exists before specifying what should change.
|
||||
|
||||
### 4. Write the Specification
|
||||
|
||||
Write to `.sdlc/features/$ARGUMENTS/spec.md` with this structure:
|
||||
|
||||
```markdown
|
||||
# Feature: [Title]
|
||||
|
||||
## Problem Statement
|
||||
[What problem does this solve? Who has this problem?]
|
||||
|
||||
## User Stories
|
||||
- As a [role], I want [capability] so that [benefit]
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] [Criterion 1 - testable, specific]
|
||||
- [ ] [Criterion 2]
|
||||
- [ ] [Criterion N]
|
||||
|
||||
## Technical Constraints
|
||||
[Platform limits, API compatibility, performance requirements]
|
||||
|
||||
## Dependencies
|
||||
[What must exist or be true before this can be built]
|
||||
|
||||
## Out of Scope
|
||||
[Explicitly excluded from this feature]
|
||||
|
||||
## Open Questions
|
||||
[Unresolved decisions that need input]
|
||||
```
|
||||
|
||||
### 5. Register the Artifact
|
||||
|
||||
```bash
|
||||
sdlc artifact create $ARGUMENTS spec
|
||||
```
|
||||
|
||||
The classifier will detect the spec exists and determine the next action (typically awaiting approval).
|
||||
|
||||
### 6. Report
|
||||
|
||||
Summarize what was specified, list acceptance criteria count, and note any open questions that need human input.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER skip loading feature context first
|
||||
- ALWAYS include acceptance criteria -- they drive QA later
|
||||
- NEVER approve your own spec -- it requires human approval
|
||||
- ALWAYS list open questions rather than making assumptions
|
||||
- ALWAYS search the codebase before writing constraints
|
||||
@ -58,6 +58,15 @@ func (h *SDLCHandler) Mount(r api.Router) {
|
||||
r.Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
|
||||
r.Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
|
||||
|
||||
// Branches
|
||||
r.Post("/features/{slug}/branches", h.CreateBranch)
|
||||
r.Get("/features/{slug}/branches", h.GetBranchStatus)
|
||||
r.Post("/features/{slug}/branches/sync", h.SyncBranch)
|
||||
|
||||
// Merge / Archive
|
||||
r.Post("/features/{slug}/merge", h.MergeFeature)
|
||||
r.Post("/features/{slug}/archive", h.ArchiveFeature)
|
||||
|
||||
// Queries
|
||||
r.Get("/query/blocked", h.QueryBlocked)
|
||||
r.Get("/query/ready", h.QueryReady)
|
||||
@ -123,6 +132,12 @@ func writeSDLCError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
api.WriteBadRequest(w, r, "invalid slug: must be lowercase alphanumeric with hyphens")
|
||||
case errors.Is(err, sdlc.ErrInvalidArtifact):
|
||||
api.WriteBadRequest(w, r, "invalid artifact type")
|
||||
case errors.Is(err, sdlc.ErrBranchExists):
|
||||
api.WriteBadRequest(w, r, "branch already exists")
|
||||
case errors.Is(err, sdlc.ErrBranchNotFound):
|
||||
api.WriteNotFound(w, r, "branch not found")
|
||||
case errors.Is(err, sdlc.ErrMergeNotReady):
|
||||
api.WriteBadRequest(w, r, "feature not ready to merge: unmet gates")
|
||||
default:
|
||||
api.WriteInternalError(w, r, "sdlc operation failed")
|
||||
}
|
||||
|
||||
62
internal/handlers/sdlc_branches.go
Normal file
62
internal/handlers/sdlc_branches.go
Normal file
@ -0,0 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/pkg/api"
|
||||
)
|
||||
|
||||
// CreateBranch creates a feature branch.
|
||||
// POST /projects/{id}/sdlc/features/{slug}/branches
|
||||
func (h *SDLCHandler) CreateBranch(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
slug := chi.URLParam(r, "slug")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||
defer cancel()
|
||||
|
||||
manifest, err := h.sdlcService.CreateBranch(ctx, projectID, slug)
|
||||
if err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteCreated(w, r, manifest)
|
||||
}
|
||||
|
||||
// GetBranchStatus returns the branch manifest for a feature.
|
||||
// GET /projects/{id}/sdlc/features/{slug}/branches
|
||||
func (h *SDLCHandler) GetBranchStatus(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
slug := chi.URLParam(r, "slug")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
manifest, err := h.sdlcService.GetBranchStatus(ctx, projectID, slug)
|
||||
if err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, manifest)
|
||||
}
|
||||
|
||||
// SyncBranch syncs a feature branch with its base branch.
|
||||
// POST /projects/{id}/sdlc/features/{slug}/branches/sync
|
||||
func (h *SDLCHandler) SyncBranch(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
slug := chi.URLParam(r, "slug")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||
defer cancel()
|
||||
|
||||
if err := h.sdlcService.SyncBranch(ctx, projectID, slug); err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{"status": "synced"})
|
||||
}
|
||||
100
internal/handlers/sdlc_branches_test.go
Normal file
100
internal/handlers/sdlc_branches_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
)
|
||||
|
||||
func TestSDLCHandler_CreateBranch(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_CreateBranch_FeatureNotFound(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/nonexistent/branches", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_CreateBranch_AlreadyExists(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrBranchExists}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_GetBranchStatus(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/features/auth-flow/branches", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_GetBranchStatus_NotFound(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrBranchNotFound}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/features/auth-flow/branches", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_SyncBranch(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches/sync", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_SyncBranch_BranchNotFound(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrBranchNotFound}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches/sync", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
61
internal/handlers/sdlc_merge.go
Normal file
61
internal/handlers/sdlc_merge.go
Normal file
@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/pkg/api"
|
||||
)
|
||||
|
||||
// MergeFeatureRequest is the request body for merging a feature.
|
||||
type MergeFeatureRequest struct {
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
}
|
||||
|
||||
// MergeFeature merges a feature branch after all gates pass.
|
||||
// POST /projects/{id}/sdlc/features/{slug}/merge
|
||||
func (h *SDLCHandler) MergeFeature(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
strategy := req.Strategy
|
||||
if strategy == "" {
|
||||
strategy = "squash"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||
defer cancel()
|
||||
|
||||
if err := h.sdlcService.MergeFeature(ctx, projectID, slug, strategy); err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{"status": "merged", "strategy": strategy})
|
||||
}
|
||||
|
||||
// ArchiveFeature archives a released feature.
|
||||
// POST /projects/{id}/sdlc/features/{slug}/archive
|
||||
func (h *SDLCHandler) ArchiveFeature(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
slug := chi.URLParam(r, "slug")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
if err := h.sdlcService.ArchiveFeature(ctx, projectID, slug); err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{"status": "archived"})
|
||||
}
|
||||
91
internal/handlers/sdlc_merge_test.go
Normal file
91
internal/handlers/sdlc_merge_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
)
|
||||
|
||||
func TestSDLCHandler_MergeFeature(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/merge", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_MergeFeature_WithStrategy(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(MergeFeatureRequest{Strategy: "merge"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/merge", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_MergeFeature_NotReady(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrMergeNotReady}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/merge", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_MergeFeature_FeatureNotFound(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/nonexistent/merge", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_ArchiveFeature(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/archive", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCHandler_ArchiveFeature_NotFound(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
|
||||
_, router := setupSDLCHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/nonexistent/archive", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
121
internal/handlers/sdlc_orchestrator.go
Normal file
121
internal/handlers/sdlc_orchestrator.go
Normal file
@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
"github.com/orchard9/rdev/pkg/api"
|
||||
)
|
||||
|
||||
// SDLCOrchestratorHandler handles SDLC orchestration endpoints.
|
||||
type SDLCOrchestratorHandler struct {
|
||||
orchestrator *service.SDLCOrchestratorService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSDLCOrchestratorHandler creates a new orchestrator handler.
|
||||
func NewSDLCOrchestratorHandler(orchestrator *service.SDLCOrchestratorService, logger *slog.Logger) *SDLCOrchestratorHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &SDLCOrchestratorHandler{
|
||||
orchestrator: orchestrator,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// Execute runs the next classifier-recommended action.
|
||||
// POST /projects/{id}/sdlc/execute
|
||||
func (h *SDLCOrchestratorHandler) Execute(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
|
||||
var req service.ExecuteRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Feature == "" {
|
||||
api.WriteBadRequest(w, r, "feature is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.orchestrator.ExecuteAction(ctx, projectID, &req)
|
||||
if err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, result)
|
||||
}
|
||||
|
||||
// Resolve unblocks a feature and re-classifies.
|
||||
// POST /projects/{id}/sdlc/resolve
|
||||
func (h *SDLCOrchestratorHandler) Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
|
||||
var req service.ResolveRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Feature == "" {
|
||||
api.WriteBadRequest(w, r, "feature is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.orchestrator.ResolveBlocker(ctx, projectID, &req)
|
||||
if err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, result)
|
||||
}
|
||||
|
||||
// Commit commits changes in the project pod.
|
||||
// POST /projects/{id}/sdlc/commit
|
||||
func (h *SDLCOrchestratorHandler) Commit(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
|
||||
var req service.CommitRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
api.WriteBadRequest(w, r, "message is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.orchestrator.CommitChanges(ctx, projectID, &req)
|
||||
if err != nil {
|
||||
writeSDLCError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, result)
|
||||
}
|
||||
186
internal/handlers/sdlc_orchestrator_test.go
Normal file
186
internal/handlers/sdlc_orchestrator_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
)
|
||||
|
||||
func setupOrchestratorHandler(exec *testSDLCExecutor) (*SDLCOrchestratorHandler, *chi.Mux) {
|
||||
repo := &testSDLCProjectRepo{
|
||||
project: &domain.Project{ID: "test-project", PodName: "test-pod"},
|
||||
}
|
||||
sdlcSvc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
|
||||
|
||||
orchestrator := service.NewSDLCOrchestratorService(
|
||||
sdlcSvc,
|
||||
nil, // no agent registry for handler tests
|
||||
nil, // no git committer for handler tests
|
||||
repo,
|
||||
service.SDLCOrchestratorConfig{},
|
||||
)
|
||||
|
||||
handler := NewSDLCOrchestratorHandler(orchestrator, nil)
|
||||
r := chi.NewRouter()
|
||||
handler.Mount(r)
|
||||
return handler, r
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Execute(t *testing.T) {
|
||||
exec := &testSDLCExecutor{
|
||||
classification: &sdlc.Classification{
|
||||
Action: sdlc.ActionIdle,
|
||||
Feature: "auth-flow",
|
||||
Message: "No action needed",
|
||||
},
|
||||
}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.ExecuteRequest{Feature: "auth-flow"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Execute_MissingFeature(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.ExecuteRequest{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Execute_InvalidBody(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader([]byte("not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Execute_FeatureNotFound(t *testing.T) {
|
||||
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.ExecuteRequest{Feature: "nonexistent"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Execute_Transition(t *testing.T) {
|
||||
exec := &testSDLCExecutor{
|
||||
classification: &sdlc.Classification{
|
||||
Action: sdlc.ActionTransition,
|
||||
Feature: "auth-flow",
|
||||
TransitionTo: sdlc.PhaseSpecified,
|
||||
},
|
||||
}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.ExecuteRequest{Feature: "auth-flow"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Resolve(t *testing.T) {
|
||||
exec := &testSDLCExecutor{
|
||||
classification: &sdlc.Classification{
|
||||
Action: sdlc.ActionIdle,
|
||||
Feature: "auth-flow",
|
||||
},
|
||||
}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.ResolveRequest{Feature: "auth-flow", Answer: "fixed it"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/resolve", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Resolve_MissingFeature(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.ResolveRequest{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/resolve", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Commit(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.CommitRequest{Feature: "auth-flow", Message: "implement auth"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/commit", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Expected 500 because git committer is nil in test setup
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status 500 (no git committer), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDLCOrchestratorHandler_Commit_MissingMessage(t *testing.T) {
|
||||
exec := &testSDLCExecutor{}
|
||||
_, router := setupOrchestratorHandler(exec)
|
||||
|
||||
body, _ := json.Marshal(service.CommitRequest{Feature: "auth-flow"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/commit", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@ -88,6 +88,21 @@ func (m *testSDLCExecutor) QueryReady(_ context.Context, _ string) ([]port.Ready
|
||||
func (m *testSDLCExecutor) QueryNeedsApproval(_ context.Context, _ string) ([]port.ApprovalInfo, error) {
|
||||
return m.approval, m.err
|
||||
}
|
||||
func (m *testSDLCExecutor) CreateBranch(_ context.Context, _, slug string) (*sdlc.BranchManifest, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
|
||||
}
|
||||
func (m *testSDLCExecutor) GetBranchStatus(_ context.Context, _, slug string) (*sdlc.BranchManifest, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
|
||||
}
|
||||
func (m *testSDLCExecutor) SyncBranch(_ context.Context, _, _ string) error { return m.err }
|
||||
func (m *testSDLCExecutor) MergeFeature(_ context.Context, _, _, _ string) error { return m.err }
|
||||
func (m *testSDLCExecutor) ArchiveFeature(_ context.Context, _, _ string) error { return m.err }
|
||||
|
||||
// testSDLCProjectRepo implements port.ProjectRepository for handler tests.
|
||||
type testSDLCProjectRepo struct {
|
||||
|
||||
@ -69,6 +69,21 @@ type SDLCExecutor interface {
|
||||
|
||||
// QueryNeedsApproval returns features awaiting approval.
|
||||
QueryNeedsApproval(ctx context.Context, podName string) ([]ApprovalInfo, error)
|
||||
|
||||
// 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)
|
||||
|
||||
// SyncBranch syncs a feature branch with its base branch.
|
||||
SyncBranch(ctx context.Context, podName, slug string) error
|
||||
|
||||
// MergeFeature merges a feature branch after all gates pass.
|
||||
MergeFeature(ctx context.Context, podName, slug, strategy string) error
|
||||
|
||||
// ArchiveFeature archives a released feature.
|
||||
ArchiveFeature(ctx context.Context, podName, slug string) error
|
||||
}
|
||||
|
||||
// BlockedInfo describes a blocked feature (matches sdlc query --json output).
|
||||
|
||||
137
internal/sdlc/branch.go
Normal file
137
internal/sdlc/branch.go
Normal file
@ -0,0 +1,137 @@
|
||||
package sdlc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// BranchManifest tracks branch metadata for a feature.
|
||||
type BranchManifest struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Feature string `yaml:"feature" json:"feature"`
|
||||
BaseBranch string `yaml:"base_branch" json:"base_branch"`
|
||||
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
||||
LastSyncAt *time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"`
|
||||
MergedAt *time.Time `yaml:"merged_at,omitempty" json:"merged_at,omitempty"`
|
||||
MergeStrategy string `yaml:"merge_strategy,omitempty" json:"merge_strategy,omitempty"`
|
||||
}
|
||||
|
||||
// BranchPath returns the path to a branch manifest file.
|
||||
func BranchPath(root, branchName string) string {
|
||||
return filepath.Join(root, SDLCDir, BranchesDir, branchName+".yaml")
|
||||
}
|
||||
|
||||
// CreateBranch creates a new branch manifest for a feature.
|
||||
// It validates the feature exists 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
|
||||
}
|
||||
|
||||
branchName := cfg.Branches.FeaturePrefix + slug
|
||||
|
||||
// Check if branch manifest already exists
|
||||
path := BranchPath(root, branchName)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil, ErrBranchExists
|
||||
}
|
||||
|
||||
manifest := &BranchManifest{
|
||||
Name: branchName,
|
||||
Feature: slug,
|
||||
BaseBranch: cfg.Branches.Main,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := SaveBranch(root, manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update feature with branch reference
|
||||
f.Branch = branchName
|
||||
if err := f.Save(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
// LoadBranch reads a branch manifest from disk.
|
||||
func LoadBranch(root, branchName string) (*BranchManifest, error) {
|
||||
path := BranchPath(root, branchName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrBranchNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("read branch manifest: %w", err)
|
||||
}
|
||||
|
||||
var m BranchManifest
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("parse branch manifest: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// SaveBranch writes a branch manifest to disk.
|
||||
func SaveBranch(root string, manifest *BranchManifest) error {
|
||||
path := BranchPath(root, manifest.Name)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create branch directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal branch manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write branch manifest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeChecklist returns a list of unmet gates that block merging.
|
||||
// An empty list means the feature is ready to merge.
|
||||
func MergeChecklist(root, slug string) ([]string, error) {
|
||||
f, err := LoadFeature(root, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unmet []string
|
||||
|
||||
// Must be in merge phase
|
||||
if f.Phase != PhaseMerge {
|
||||
unmet = append(unmet, fmt.Sprintf("feature is in phase %s, must be in merge", f.Phase))
|
||||
}
|
||||
|
||||
// Review must be passed
|
||||
if art := f.GetArtifact(ArtifactReview); art == nil || art.Status != StatusPassed {
|
||||
unmet = append(unmet, "code review not passed")
|
||||
}
|
||||
|
||||
// Audit must be passed
|
||||
if art := f.GetArtifact(ArtifactAudit); art == nil || art.Status != StatusPassed {
|
||||
unmet = append(unmet, "security audit not passed")
|
||||
}
|
||||
|
||||
// QA must be passed
|
||||
if art := f.GetArtifact(ArtifactQAResults); art == nil || art.Status != StatusPassed {
|
||||
unmet = append(unmet, "QA tests not passed")
|
||||
}
|
||||
|
||||
// No blockers
|
||||
if f.IsBlocked() {
|
||||
unmet = append(unmet, fmt.Sprintf("feature has %d blocker(s)", len(f.Blockers)))
|
||||
}
|
||||
|
||||
return unmet, nil
|
||||
}
|
||||
187
internal/sdlc/branch_test.go
Normal file
187
internal/sdlc/branch_test.go
Normal file
@ -0,0 +1,187 @@
|
||||
package sdlc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupTestSDLC(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := Init(root, "test-project"); err != nil {
|
||||
t.Fatalf("init failed: %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCreateBranch(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
cfg := DefaultConfig("test")
|
||||
|
||||
// Create a feature first
|
||||
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
|
||||
if err != nil {
|
||||
t.Fatalf("create feature: %v", err)
|
||||
}
|
||||
|
||||
manifest, err := CreateBranch(root, "auth-flow", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("create branch: %v", err)
|
||||
}
|
||||
|
||||
if manifest.Name != "feature/auth-flow" {
|
||||
t.Errorf("Name = %q, want feature/auth-flow", manifest.Name)
|
||||
}
|
||||
if manifest.Feature != "auth-flow" {
|
||||
t.Errorf("Feature = %q, want auth-flow", manifest.Feature)
|
||||
}
|
||||
if manifest.BaseBranch != "main" {
|
||||
t.Errorf("BaseBranch = %q, want main", manifest.BaseBranch)
|
||||
}
|
||||
|
||||
// Verify feature was updated with branch reference
|
||||
f, err := LoadFeature(root, "auth-flow")
|
||||
if err != nil {
|
||||
t.Fatalf("load feature: %v", err)
|
||||
}
|
||||
if f.Branch != "feature/auth-flow" {
|
||||
t.Errorf("Feature.Branch = %q, want feature/auth-flow", f.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBranch_AlreadyExists(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
cfg := DefaultConfig("test")
|
||||
|
||||
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
|
||||
if err != nil {
|
||||
t.Fatalf("create feature: %v", err)
|
||||
}
|
||||
|
||||
_, err = CreateBranch(root, "auth-flow", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("create branch: %v", err)
|
||||
}
|
||||
|
||||
_, err = CreateBranch(root, "auth-flow", cfg)
|
||||
if err != ErrBranchExists {
|
||||
t.Errorf("err = %v, want ErrBranchExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBranch_FeatureNotFound(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
cfg := DefaultConfig("test")
|
||||
|
||||
_, err := CreateBranch(root, "nonexistent", cfg)
|
||||
if err != ErrFeatureNotFound {
|
||||
t.Errorf("err = %v, want ErrFeatureNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBranch(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
cfg := DefaultConfig("test")
|
||||
|
||||
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
|
||||
if err != nil {
|
||||
t.Fatalf("create feature: %v", err)
|
||||
}
|
||||
|
||||
_, err = CreateBranch(root, "auth-flow", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("create branch: %v", err)
|
||||
}
|
||||
|
||||
manifest, err := LoadBranch(root, "feature/auth-flow")
|
||||
if err != nil {
|
||||
t.Fatalf("load branch: %v", err)
|
||||
}
|
||||
if manifest.Name != "feature/auth-flow" {
|
||||
t.Errorf("Name = %q, want feature/auth-flow", manifest.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBranch_NotFound(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
|
||||
_, err := LoadBranch(root, "nonexistent")
|
||||
if err != ErrBranchNotFound {
|
||||
t.Errorf("err = %v, want ErrBranchNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBranch(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
|
||||
manifest := &BranchManifest{
|
||||
Name: "feature/test",
|
||||
Feature: "test",
|
||||
BaseBranch: "main",
|
||||
}
|
||||
|
||||
if err := SaveBranch(root, manifest); err != nil {
|
||||
t.Fatalf("save branch: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
path := filepath.Join(root, SDLCDir, BranchesDir, "feature/test.yaml")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Error("branch manifest file not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeChecklist_NotReady(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
|
||||
_, err := CreateFeature(root, "auth-flow", "Auth Flow")
|
||||
if err != nil {
|
||||
t.Fatalf("create feature: %v", err)
|
||||
}
|
||||
|
||||
unmet, err := MergeChecklist(root, "auth-flow")
|
||||
if err != nil {
|
||||
t.Fatalf("merge checklist: %v", err)
|
||||
}
|
||||
|
||||
if len(unmet) == 0 {
|
||||
t.Error("expected unmet gates, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeChecklist_Ready(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
|
||||
f, err := CreateFeature(root, "auth-flow", "Auth Flow")
|
||||
if err != nil {
|
||||
t.Fatalf("create feature: %v", err)
|
||||
}
|
||||
|
||||
// Set up all gates as passed and transition to merge phase
|
||||
f.Phase = PhaseMerge
|
||||
f.GetArtifact(ArtifactReview).MarkPassed()
|
||||
f.GetArtifact(ArtifactAudit).MarkPassed()
|
||||
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
||||
if err := f.Save(root); err != nil {
|
||||
t.Fatalf("save feature: %v", err)
|
||||
}
|
||||
|
||||
unmet, err := MergeChecklist(root, "auth-flow")
|
||||
if err != nil {
|
||||
t.Fatalf("merge checklist: %v", err)
|
||||
}
|
||||
|
||||
if len(unmet) != 0 {
|
||||
t.Errorf("expected 0 unmet gates, got %d: %v", len(unmet), unmet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeChecklist_FeatureNotFound(t *testing.T) {
|
||||
root := setupTestSDLC(t)
|
||||
|
||||
_, err := MergeChecklist(root, "nonexistent")
|
||||
if err != ErrFeatureNotFound {
|
||||
t.Errorf("err = %v, want ErrFeatureNotFound", err)
|
||||
}
|
||||
}
|
||||
143
internal/sdlc/classifier_lifecycle_test.go
Normal file
143
internal/sdlc/classifier_lifecycle_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package sdlc
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFullLifecycleClassification walks through the entire feature lifecycle.
|
||||
func TestFullLifecycleClassification(t *testing.T) {
|
||||
c := NewClassifier()
|
||||
cfg := DefaultConfig("test")
|
||||
state := DefaultState("test")
|
||||
f := makeTestFeature(PhaseDraft)
|
||||
|
||||
// Phase: Draft
|
||||
// Step 1: needs spec
|
||||
cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateSpec {
|
||||
t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action)
|
||||
}
|
||||
|
||||
// Step 2: spec created -> needs approval
|
||||
f.GetArtifact(ArtifactSpec).MarkDraft()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionAwaitApproval {
|
||||
t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action)
|
||||
}
|
||||
|
||||
// Step 3: spec approved -> transition to specified
|
||||
f.GetArtifact(ArtifactSpec).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified {
|
||||
t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Specified
|
||||
f.Transition(PhaseSpecified)
|
||||
|
||||
// Step 4: needs design
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateDesign {
|
||||
t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action)
|
||||
}
|
||||
|
||||
// Step 5: design approved -> needs tasks
|
||||
f.GetArtifact(ArtifactDesign).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateTasks {
|
||||
t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action)
|
||||
}
|
||||
|
||||
// Step 6: tasks approved -> needs qa plan
|
||||
f.GetArtifact(ArtifactTasks).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateQAPlan {
|
||||
t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action)
|
||||
}
|
||||
|
||||
// Step 7: qa plan approved -> transition to planned
|
||||
f.GetArtifact(ArtifactQAPlan).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned {
|
||||
t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Planned -> needs branch -> Ready -> Implementation
|
||||
f.Transition(PhasePlanned)
|
||||
|
||||
// Step 7b: needs branch (RequireBranch is true in DefaultConfig)
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateBranch {
|
||||
t.Fatalf("step7b: Action = %q, want CREATE_BRANCH", cl.Action)
|
||||
}
|
||||
|
||||
// Branch created
|
||||
f.Branch = "feature/auth"
|
||||
|
||||
// Step 7c: ready to implement
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhaseReady {
|
||||
t.Fatalf("step7c: Action = %q/%q, want TRANSITION/ready", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
f.Transition(PhaseReady)
|
||||
f.Transition(PhaseImplementation)
|
||||
|
||||
// Add tasks
|
||||
f.Tasks = AddTask(nil, "Create user model")
|
||||
f.Tasks = AddTask(f.Tasks, "Add validation")
|
||||
|
||||
// Step 8: implement next task
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionImplementTask {
|
||||
t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action)
|
||||
}
|
||||
|
||||
// Complete all tasks
|
||||
f.Tasks, _ = StartTask(f.Tasks, "task-001")
|
||||
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
|
||||
f.Tasks, _ = StartTask(f.Tasks, "task-002")
|
||||
f.Tasks, _ = CompleteTask(f.Tasks, "task-002")
|
||||
|
||||
// Step 9: implementation complete -> transition to review
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview {
|
||||
t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Review
|
||||
f.Transition(PhaseReview)
|
||||
f.GetArtifact(ArtifactReview).MarkPassed()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.TransitionTo != PhaseAudit {
|
||||
t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Audit
|
||||
f.Transition(PhaseAudit)
|
||||
f.GetArtifact(ArtifactAudit).MarkPassed()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.TransitionTo != PhaseQA {
|
||||
t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: QA
|
||||
f.Transition(PhaseQA)
|
||||
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.TransitionTo != PhaseMerge {
|
||||
t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Merge
|
||||
f.Transition(PhaseMerge)
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionMergeFeature {
|
||||
t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action)
|
||||
}
|
||||
|
||||
// Phase: Released
|
||||
f.Transition(PhaseReleased)
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionArchive {
|
||||
t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action)
|
||||
}
|
||||
}
|
||||
@ -145,9 +145,71 @@ func TestClassifySpecifiedPlanningComplete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPlannedNeedsBranch(t *testing.T) {
|
||||
c := NewClassifier()
|
||||
f := makeTestFeature(PhasePlanned)
|
||||
cfg := DefaultConfig("test")
|
||||
// DefaultConfig has RequireBranch: true, feature has no branch
|
||||
|
||||
cl := c.Classify(&EvalContext{
|
||||
State: DefaultState("test"),
|
||||
Feature: f,
|
||||
Config: cfg,
|
||||
})
|
||||
|
||||
if cl.Action != ActionCreateBranch {
|
||||
t.Errorf("Action = %q, want CREATE_BRANCH", cl.Action)
|
||||
}
|
||||
if cl.RuleMatched != "needs-branch" {
|
||||
t.Errorf("RuleMatched = %q, want needs-branch", cl.RuleMatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPlannedBranchNotRequired(t *testing.T) {
|
||||
c := NewClassifier()
|
||||
f := makeTestFeature(PhasePlanned)
|
||||
cfg := DefaultConfig("test")
|
||||
cfg.Compliance.RequireBranch = false
|
||||
|
||||
cl := c.Classify(&EvalContext{
|
||||
State: DefaultState("test"),
|
||||
Feature: f,
|
||||
Config: cfg,
|
||||
})
|
||||
|
||||
// Should skip branch rule and go to ready-to-implement
|
||||
if cl.Action != ActionTransition {
|
||||
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||
}
|
||||
if cl.TransitionTo != PhaseReady {
|
||||
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPlannedBranchAlreadyExists(t *testing.T) {
|
||||
c := NewClassifier()
|
||||
f := makeTestFeature(PhasePlanned)
|
||||
f.Branch = "feature/auth" // Branch already set
|
||||
|
||||
cl := c.Classify(&EvalContext{
|
||||
State: DefaultState("test"),
|
||||
Feature: f,
|
||||
Config: DefaultConfig("test"),
|
||||
})
|
||||
|
||||
// Should skip branch rule and go to ready-to-implement
|
||||
if cl.Action != ActionTransition {
|
||||
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||
}
|
||||
if cl.TransitionTo != PhaseReady {
|
||||
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPlannedTransitionsToReady(t *testing.T) {
|
||||
c := NewClassifier()
|
||||
f := makeTestFeature(PhasePlanned)
|
||||
f.Branch = "feature/auth" // Branch exists, so needs-branch rule won't fire
|
||||
|
||||
cl := c.Classify(&EvalContext{
|
||||
State: DefaultState("test"),
|
||||
@ -367,127 +429,3 @@ func TestClassifyBlocked(t *testing.T) {
|
||||
t.Errorf("Action = %q, want BLOCKED", cl.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullLifecycleClassification walks through the entire feature lifecycle.
|
||||
func TestFullLifecycleClassification(t *testing.T) {
|
||||
c := NewClassifier()
|
||||
cfg := DefaultConfig("test")
|
||||
state := DefaultState("test")
|
||||
f := makeTestFeature(PhaseDraft)
|
||||
|
||||
// Phase: Draft
|
||||
// Step 1: needs spec
|
||||
cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateSpec {
|
||||
t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action)
|
||||
}
|
||||
|
||||
// Step 2: spec created -> needs approval
|
||||
f.GetArtifact(ArtifactSpec).MarkDraft()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionAwaitApproval {
|
||||
t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action)
|
||||
}
|
||||
|
||||
// Step 3: spec approved -> transition to specified
|
||||
f.GetArtifact(ArtifactSpec).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified {
|
||||
t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Specified
|
||||
f.Transition(PhaseSpecified)
|
||||
|
||||
// Step 4: needs design
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateDesign {
|
||||
t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action)
|
||||
}
|
||||
|
||||
// Step 5: design approved -> needs tasks
|
||||
f.GetArtifact(ArtifactDesign).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateTasks {
|
||||
t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action)
|
||||
}
|
||||
|
||||
// Step 6: tasks approved -> needs qa plan
|
||||
f.GetArtifact(ArtifactTasks).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionCreateQAPlan {
|
||||
t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action)
|
||||
}
|
||||
|
||||
// Step 7: qa plan approved -> transition to planned
|
||||
f.GetArtifact(ArtifactQAPlan).Approve("user")
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned {
|
||||
t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Planned -> Ready -> Implementation
|
||||
f.Transition(PhasePlanned)
|
||||
f.Transition(PhaseReady)
|
||||
f.Transition(PhaseImplementation)
|
||||
|
||||
// Add tasks
|
||||
f.Tasks = AddTask(nil, "Create user model")
|
||||
f.Tasks = AddTask(f.Tasks, "Add validation")
|
||||
|
||||
// Step 8: implement next task
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionImplementTask {
|
||||
t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action)
|
||||
}
|
||||
|
||||
// Complete all tasks
|
||||
f.Tasks, _ = StartTask(f.Tasks, "task-001")
|
||||
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
|
||||
f.Tasks, _ = StartTask(f.Tasks, "task-002")
|
||||
f.Tasks, _ = CompleteTask(f.Tasks, "task-002")
|
||||
|
||||
// Step 9: implementation complete -> transition to review
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview {
|
||||
t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Review
|
||||
f.Transition(PhaseReview)
|
||||
f.GetArtifact(ArtifactReview).MarkPassed()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.TransitionTo != PhaseAudit {
|
||||
t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Audit
|
||||
f.Transition(PhaseAudit)
|
||||
f.GetArtifact(ArtifactAudit).MarkPassed()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.TransitionTo != PhaseQA {
|
||||
t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: QA
|
||||
f.Transition(PhaseQA)
|
||||
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.TransitionTo != PhaseMerge {
|
||||
t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo)
|
||||
}
|
||||
|
||||
// Phase: Merge
|
||||
f.Transition(PhaseMerge)
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionMergeFeature {
|
||||
t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action)
|
||||
}
|
||||
|
||||
// Phase: Released
|
||||
f.Transition(PhaseReleased)
|
||||
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||
if cl.Action != ActionArchive {
|
||||
t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,4 +13,7 @@ var (
|
||||
ErrTaskNotFound = errors.New("task not found")
|
||||
ErrArtifactNotFound = errors.New("artifact not found")
|
||||
ErrNoFeatures = errors.New("no features found")
|
||||
ErrBranchExists = errors.New("branch already exists")
|
||||
ErrBranchNotFound = errors.New("branch not found")
|
||||
ErrMergeNotReady = errors.New("feature not ready to merge: unmet gates")
|
||||
)
|
||||
|
||||
@ -20,6 +20,7 @@ func DefaultRules() []Rule {
|
||||
needsQAPlanRule(),
|
||||
qaPlanNeedsApprovalRule(),
|
||||
planningCompleteRule(),
|
||||
needsBranchRule(),
|
||||
readyToImplementRule(),
|
||||
implementNextTaskRule(),
|
||||
implementationCompleteRule(),
|
||||
@ -236,6 +237,28 @@ func planningCompleteRule() Rule {
|
||||
}
|
||||
}
|
||||
|
||||
func needsBranchRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-branch",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhasePlanned {
|
||||
return false
|
||||
}
|
||||
if ctx.Config == nil || !ctx.Config.Compliance.RequireBranch {
|
||||
return false
|
||||
}
|
||||
return ctx.Feature.Branch == ""
|
||||
},
|
||||
Action: ActionCreateBranch,
|
||||
Message: func(_ *EvalContext) string {
|
||||
return "Feature branch required before implementation"
|
||||
},
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "sdlc branch create " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readyToImplementRule() Rule {
|
||||
return Rule{
|
||||
ID: "ready-to-implement",
|
||||
@ -252,227 +275,3 @@ func readyToImplementRule() Rule {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func implementNextTaskRule() Rule {
|
||||
return Rule{
|
||||
ID: "implement-next-task",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseImplementation {
|
||||
return false
|
||||
}
|
||||
next := NextTask(ctx.Feature.Tasks)
|
||||
return next != nil
|
||||
},
|
||||
Action: ActionImplementTask,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
next := NextTask(ctx.Feature.Tasks)
|
||||
if next == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/implement-task %s %s", ctx.Feature.Slug, next.ID)
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
|
||||
},
|
||||
TaskID: func(ctx *EvalContext) string {
|
||||
next := NextTask(ctx.Feature.Tasks)
|
||||
if next == nil {
|
||||
return ""
|
||||
}
|
||||
return next.ID
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func implementationCompleteRule() Rule {
|
||||
return Rule{
|
||||
ID: "implementation-complete",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
return ctx.Feature.Phase == PhaseImplementation && AllTasksComplete(ctx.Feature.Tasks)
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseReview,
|
||||
}
|
||||
}
|
||||
|
||||
func needsReviewRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-review",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseReview {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||
return art == nil || art.Status == StatusPending
|
||||
},
|
||||
Action: ActionReviewCode,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/review-feature " + ctx.Feature.Slug
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/review.md"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reviewHasIssuesRule() Rule {
|
||||
return Rule{
|
||||
ID: "review-has-issues",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseReview {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||
return art != nil && art.Status == StatusNeedsFix
|
||||
},
|
||||
Action: ActionFixReviewIssues,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/fix-review-issues " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reviewPassedRule() Rule {
|
||||
return Rule{
|
||||
ID: "review-passed",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseReview {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||
return art != nil && art.Status == StatusPassed
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseAudit,
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuditRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-audit",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseAudit {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||
return art == nil || art.Status == StatusPending
|
||||
},
|
||||
Action: ActionAuditCode,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/audit-feature " + ctx.Feature.Slug
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/audit.md"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditHasIssuesRule() Rule {
|
||||
return Rule{
|
||||
ID: "audit-has-issues",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseAudit {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||
return art != nil && art.Status == StatusNeedsFix
|
||||
},
|
||||
Action: ActionRemediateAudit,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/remediate-audit " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditPassedRule() Rule {
|
||||
return Rule{
|
||||
ID: "audit-passed",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseAudit {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||
return art != nil && art.Status == StatusPassed
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseQA,
|
||||
}
|
||||
}
|
||||
|
||||
func needsQARule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-qa",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseQA {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||
return art == nil || art.Status == StatusPending
|
||||
},
|
||||
Action: ActionRunQA,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/run-qa " + ctx.Feature.Slug
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-results.md"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func qaHasFailuresRule() Rule {
|
||||
return Rule{
|
||||
ID: "qa-has-failures",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseQA {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||
return art != nil && art.Status == StatusFailed
|
||||
},
|
||||
Action: ActionFixQAFailures,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/fix-qa-failures " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func qaPassedRule() Rule {
|
||||
return Rule{
|
||||
ID: "qa-passed",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseQA {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||
return art != nil && art.Status == StatusPassed
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseMerge,
|
||||
}
|
||||
}
|
||||
|
||||
func needsMergeRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-merge",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
return ctx.Feature.Phase == PhaseMerge
|
||||
},
|
||||
Action: ActionMergeFeature,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/merge-feature " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func archiveFeatureRule() Rule {
|
||||
return Rule{
|
||||
ID: "archive-feature",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
return ctx.Feature.Phase == PhaseReleased
|
||||
},
|
||||
Action: ActionArchive,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/archive-feature " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
229
internal/sdlc/rules_execution.go
Normal file
229
internal/sdlc/rules_execution.go
Normal file
@ -0,0 +1,229 @@
|
||||
package sdlc
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Execution phase rules: review, audit, QA, merge, archive.
|
||||
|
||||
func needsReviewRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-review",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseReview {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||
return art == nil || art.Status == StatusPending
|
||||
},
|
||||
Action: ActionReviewCode,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/review-feature " + ctx.Feature.Slug
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/review.md"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reviewHasIssuesRule() Rule {
|
||||
return Rule{
|
||||
ID: "review-has-issues",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseReview {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||
return art != nil && art.Status == StatusNeedsFix
|
||||
},
|
||||
Action: ActionFixReviewIssues,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/fix-review-issues " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reviewPassedRule() Rule {
|
||||
return Rule{
|
||||
ID: "review-passed",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseReview {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||
return art != nil && art.Status == StatusPassed
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseAudit,
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuditRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-audit",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseAudit {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||
return art == nil || art.Status == StatusPending
|
||||
},
|
||||
Action: ActionAuditCode,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/audit-feature " + ctx.Feature.Slug
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/audit.md"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditHasIssuesRule() Rule {
|
||||
return Rule{
|
||||
ID: "audit-has-issues",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseAudit {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||
return art != nil && art.Status == StatusNeedsFix
|
||||
},
|
||||
Action: ActionRemediateAudit,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/remediate-audit " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditPassedRule() Rule {
|
||||
return Rule{
|
||||
ID: "audit-passed",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseAudit {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||
return art != nil && art.Status == StatusPassed
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseQA,
|
||||
}
|
||||
}
|
||||
|
||||
func needsQARule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-qa",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseQA {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||
return art == nil || art.Status == StatusPending
|
||||
},
|
||||
Action: ActionRunQA,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/run-qa " + ctx.Feature.Slug
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-results.md"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func qaHasFailuresRule() Rule {
|
||||
return Rule{
|
||||
ID: "qa-has-failures",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseQA {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||
return art != nil && art.Status == StatusFailed
|
||||
},
|
||||
Action: ActionFixQAFailures,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/fix-qa-failures " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func qaPassedRule() Rule {
|
||||
return Rule{
|
||||
ID: "qa-passed",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseQA {
|
||||
return false
|
||||
}
|
||||
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||
return art != nil && art.Status == StatusPassed
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseMerge,
|
||||
}
|
||||
}
|
||||
|
||||
func needsMergeRule() Rule {
|
||||
return Rule{
|
||||
ID: "needs-merge",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
return ctx.Feature.Phase == PhaseMerge
|
||||
},
|
||||
Action: ActionMergeFeature,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/merge-feature " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func archiveFeatureRule() Rule {
|
||||
return Rule{
|
||||
ID: "archive-feature",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
return ctx.Feature.Phase == PhaseReleased
|
||||
},
|
||||
Action: ActionArchive,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
return "/archive-feature " + ctx.Feature.Slug
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func implementNextTaskRule() Rule {
|
||||
return Rule{
|
||||
ID: "implement-next-task",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
if ctx.Feature.Phase != PhaseImplementation {
|
||||
return false
|
||||
}
|
||||
next := NextTask(ctx.Feature.Tasks)
|
||||
return next != nil
|
||||
},
|
||||
Action: ActionImplementTask,
|
||||
NextCommand: func(ctx *EvalContext) string {
|
||||
next := NextTask(ctx.Feature.Tasks)
|
||||
if next == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/implement-task %s %s", ctx.Feature.Slug, next.ID)
|
||||
},
|
||||
OutputPath: func(ctx *EvalContext) string {
|
||||
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
|
||||
},
|
||||
TaskID: func(ctx *EvalContext) string {
|
||||
next := NextTask(ctx.Feature.Tasks)
|
||||
if next == nil {
|
||||
return ""
|
||||
}
|
||||
return next.ID
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func implementationCompleteRule() Rule {
|
||||
return Rule{
|
||||
ID: "implementation-complete",
|
||||
Condition: func(ctx *EvalContext) bool {
|
||||
return ctx.Feature.Phase == PhaseImplementation && AllTasksComplete(ctx.Feature.Tasks)
|
||||
},
|
||||
Action: ActionTransition,
|
||||
TransitionTo: PhaseReview,
|
||||
}
|
||||
}
|
||||
263
internal/service/sdlc_orchestrator.go
Normal file
263
internal/service/sdlc_orchestrator.go
Normal file
@ -0,0 +1,263 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
)
|
||||
|
||||
// GitCommitResult describes the outcome of a git commit+push operation.
|
||||
type GitCommitResult struct {
|
||||
HasChanges bool
|
||||
CommitSHA string
|
||||
FilesChanged []string
|
||||
Pushed bool
|
||||
Error error
|
||||
}
|
||||
|
||||
// PodGitCommitter abstracts git commit/push operations in pods.
|
||||
// This avoids importing internal/worker directly, preventing import cycles.
|
||||
type PodGitCommitter interface {
|
||||
CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *GitCommitResult
|
||||
}
|
||||
|
||||
// SDLCOrchestratorService orchestrates SDLC actions: execute, resolve, commit.
|
||||
type SDLCOrchestratorService struct {
|
||||
sdlcService *SDLCService
|
||||
agentRegistry port.CodeAgentRegistry
|
||||
gitCommitter PodGitCommitter
|
||||
projectRepo port.ProjectRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// SDLCOrchestratorConfig configures the orchestrator.
|
||||
type SDLCOrchestratorConfig struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSDLCOrchestratorService creates a new orchestrator service.
|
||||
func NewSDLCOrchestratorService(
|
||||
sdlcService *SDLCService,
|
||||
agentRegistry port.CodeAgentRegistry,
|
||||
gitCommitter PodGitCommitter,
|
||||
projectRepo port.ProjectRepository,
|
||||
cfg SDLCOrchestratorConfig,
|
||||
) *SDLCOrchestratorService {
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &SDLCOrchestratorService{
|
||||
sdlcService: sdlcService,
|
||||
agentRegistry: agentRegistry,
|
||||
gitCommitter: gitCommitter,
|
||||
projectRepo: projectRepo,
|
||||
logger: logger.With("component", "sdlc-orchestrator"),
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteRequest describes what action to execute.
|
||||
type ExecuteRequest struct {
|
||||
Feature string `json:"feature"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionResult is the result of executing an SDLC action.
|
||||
type ExecutionResult struct {
|
||||
Action sdlc.ActionType `json:"action"`
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Next *sdlc.Classification `json:"next,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveRequest describes a blocker resolution.
|
||||
type ResolveRequest struct {
|
||||
Feature string `json:"feature"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
// CommitRequest describes a commit operation.
|
||||
type CommitRequest struct {
|
||||
Feature string `json:"feature"`
|
||||
Message string `json:"message"`
|
||||
Push bool `json:"push"`
|
||||
}
|
||||
|
||||
// CommitResult is the result of a commit operation.
|
||||
type CommitResult struct {
|
||||
CommitSHA string `json:"commit_sha,omitempty"`
|
||||
FilesChanged []string `json:"files_changed,omitempty"`
|
||||
Pushed bool `json:"pushed"`
|
||||
}
|
||||
|
||||
// ExecuteAction gets the next classifier action and executes it.
|
||||
func (s *SDLCOrchestratorService) ExecuteAction(ctx context.Context, projectID string, req *ExecuteRequest) (*ExecutionResult, error) {
|
||||
cl, err := s.sdlcService.GetNext(ctx, projectID, req.Feature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &ExecutionResult{
|
||||
Action: cl.Action,
|
||||
}
|
||||
|
||||
switch cl.Action {
|
||||
case sdlc.ActionTransition:
|
||||
err = s.executeTransition(ctx, projectID, cl)
|
||||
case sdlc.ActionIdle, sdlc.ActionBlocked, sdlc.ActionAwaitApproval:
|
||||
result.Output = cl.Message
|
||||
result.Success = true
|
||||
default:
|
||||
err = s.executeAgentAction(ctx, projectID, cl, req, result)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if result.Error == "" {
|
||||
result.Success = true
|
||||
}
|
||||
|
||||
next, nextErr := s.sdlcService.GetNext(ctx, projectID, req.Feature)
|
||||
if nextErr == nil {
|
||||
result.Next = next
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SDLCOrchestratorService) executeTransition(ctx context.Context, projectID string, cl *sdlc.Classification) error {
|
||||
s.logger.Info("executing transition",
|
||||
"project", projectID,
|
||||
"feature", cl.Feature,
|
||||
"to_phase", string(cl.TransitionTo),
|
||||
)
|
||||
return s.sdlcService.TransitionFeature(ctx, projectID, cl.Feature, cl.TransitionTo)
|
||||
}
|
||||
|
||||
func (s *SDLCOrchestratorService) executeAgentAction(ctx context.Context, projectID string, cl *sdlc.Classification, req *ExecuteRequest, result *ExecutionResult) error {
|
||||
var agent port.CodeAgent
|
||||
if req.Provider != "" {
|
||||
provider, err := domain.ParseAgentProvider(req.Provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid agent provider: %w", err)
|
||||
}
|
||||
agent = s.agentRegistry.Get(provider)
|
||||
} else {
|
||||
agent = s.agentRegistry.Default()
|
||||
}
|
||||
|
||||
if agent == nil {
|
||||
return fmt.Errorf("no agent available")
|
||||
}
|
||||
|
||||
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve project: %w", err)
|
||||
}
|
||||
|
||||
prompt := cl.NextCommand
|
||||
if prompt == "" {
|
||||
prompt = fmt.Sprintf("Execute SDLC action: %s for feature %s", cl.Action, cl.Feature)
|
||||
}
|
||||
|
||||
agentReq := &domain.AgentRequest{
|
||||
Prompt: prompt,
|
||||
ProjectID: project.ID,
|
||||
Timeout: 10 * time.Minute,
|
||||
Metadata: map[string]string{
|
||||
"pod_name": project.PodName,
|
||||
"namespace": "rdev",
|
||||
"action": string(cl.Action),
|
||||
"feature": cl.Feature,
|
||||
},
|
||||
}
|
||||
|
||||
s.logger.Info("dispatching agent action",
|
||||
"project", projectID,
|
||||
"feature", cl.Feature,
|
||||
"action", string(cl.Action),
|
||||
"agent", agent.Name(),
|
||||
)
|
||||
|
||||
var output string
|
||||
_, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) {
|
||||
if event.Type == domain.AgentEventOutput {
|
||||
output += event.Content
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent execution failed: %w", err)
|
||||
}
|
||||
|
||||
result.Output = output
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveBlocker unblocks a feature and re-classifies.
|
||||
func (s *SDLCOrchestratorService) ResolveBlocker(ctx context.Context, projectID string, req *ResolveRequest) (*ExecutionResult, error) {
|
||||
if err := s.sdlcService.UnblockFeature(ctx, projectID, req.Feature); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("blocker resolved", "project", projectID, "feature", req.Feature)
|
||||
|
||||
cl, err := s.sdlcService.GetNext(ctx, projectID, req.Feature)
|
||||
if err != nil {
|
||||
return &ExecutionResult{
|
||||
Action: sdlc.ActionTransition,
|
||||
Success: true,
|
||||
Output: "Feature unblocked",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &ExecutionResult{
|
||||
Action: sdlc.ActionTransition,
|
||||
Success: true,
|
||||
Output: "Feature unblocked",
|
||||
Next: cl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CommitChanges commits and optionally pushes changes in the project pod.
|
||||
func (s *SDLCOrchestratorService) CommitChanges(ctx context.Context, projectID string, req *CommitRequest) (*CommitResult, error) {
|
||||
if s.gitCommitter == nil {
|
||||
return nil, fmt.Errorf("git operations not configured")
|
||||
}
|
||||
|
||||
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
|
||||
if err != nil {
|
||||
return nil, domain.ErrProjectNotFound
|
||||
}
|
||||
|
||||
workDir := "/workspace"
|
||||
if project.Workspace != "" {
|
||||
workDir = project.Workspace
|
||||
}
|
||||
|
||||
gitResult := s.gitCommitter.CommitAndPush(ctx, project.PodName, workDir, req.Message, req.Push)
|
||||
if gitResult.Error != nil {
|
||||
return nil, gitResult.Error
|
||||
}
|
||||
|
||||
s.logger.Info("changes committed",
|
||||
"project", projectID,
|
||||
"sha", gitResult.CommitSHA,
|
||||
"files", len(gitResult.FilesChanged),
|
||||
"pushed", gitResult.Pushed,
|
||||
)
|
||||
|
||||
return &CommitResult{
|
||||
CommitSHA: gitResult.CommitSHA,
|
||||
FilesChanged: gitResult.FilesChanged,
|
||||
Pushed: gitResult.Pushed,
|
||||
}, nil
|
||||
}
|
||||
333
internal/service/sdlc_orchestrator_test.go
Normal file
333
internal/service/sdlc_orchestrator_test.go
Normal file
@ -0,0 +1,333 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
"github.com/orchard9/rdev/internal/sdlc"
|
||||
)
|
||||
|
||||
// mockGitCommitter implements PodGitCommitter for testing.
|
||||
type mockGitCommitter struct {
|
||||
result *GitCommitResult
|
||||
}
|
||||
|
||||
func (m *mockGitCommitter) CommitAndPush(_ context.Context, _, _, _ string, _ bool) *GitCommitResult {
|
||||
if m.result != nil {
|
||||
return m.result
|
||||
}
|
||||
return &GitCommitResult{
|
||||
HasChanges: true,
|
||||
CommitSHA: "abc123",
|
||||
FilesChanged: []string{"main.go"},
|
||||
Pushed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// mockAgentRegistry implements port.CodeAgentRegistry for testing.
|
||||
type mockAgentRegistry struct {
|
||||
agent port.CodeAgent
|
||||
}
|
||||
|
||||
func (r *mockAgentRegistry) Register(_ port.CodeAgent) {}
|
||||
func (r *mockAgentRegistry) Get(_ domain.AgentProvider) port.CodeAgent { return r.agent }
|
||||
func (r *mockAgentRegistry) Default() port.CodeAgent { return r.agent }
|
||||
func (r *mockAgentRegistry) DefaultProvider() domain.AgentProvider { return "" }
|
||||
func (r *mockAgentRegistry) SetDefault(_ domain.AgentProvider) error { return nil }
|
||||
func (r *mockAgentRegistry) Available() []domain.AgentProvider { return nil }
|
||||
func (r *mockAgentRegistry) AvailableAgents(_ context.Context) []port.CodeAgent { return nil }
|
||||
func (r *mockAgentRegistry) Count() int { return 0 }
|
||||
|
||||
// mockCodeAgent implements port.CodeAgent for testing.
|
||||
type mockCodeAgent struct {
|
||||
result *domain.AgentResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (a *mockCodeAgent) Name() string { return "test-agent" }
|
||||
func (a *mockCodeAgent) Provider() domain.AgentProvider { return "test" }
|
||||
func (a *mockCodeAgent) Cancel(_ context.Context, _ string) error { return nil }
|
||||
func (a *mockCodeAgent) Capabilities() domain.AgentCapabilities { return domain.AgentCapabilities{} }
|
||||
func (a *mockCodeAgent) Available(_ context.Context) bool { return true }
|
||||
func (a *mockCodeAgent) Execute(_ context.Context, _ *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
|
||||
if a.err != nil {
|
||||
return nil, a.err
|
||||
}
|
||||
if handler != nil {
|
||||
handler(domain.AgentEvent{
|
||||
Type: domain.AgentEventOutput,
|
||||
Content: "agent output",
|
||||
})
|
||||
}
|
||||
if a.result != nil {
|
||||
return a.result, nil
|
||||
}
|
||||
return &domain.AgentResult{}, nil
|
||||
}
|
||||
|
||||
func newTestOrchestrator(exec *mockSDLCExecutor, repo *mockProjectRepo, registry port.CodeAgentRegistry, committer PodGitCommitter) *SDLCOrchestratorService {
|
||||
sdlcSvc := NewSDLCService(exec, repo, SDLCServiceConfig{})
|
||||
return NewSDLCOrchestratorService(sdlcSvc, registry, committer, repo, SDLCOrchestratorConfig{})
|
||||
}
|
||||
|
||||
func TestOrchestrator_ExecuteAction_Idle(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{
|
||||
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||
return &sdlc.Classification{
|
||||
Action: sdlc.ActionIdle,
|
||||
Feature: "auth",
|
||||
Message: "Nothing to do",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
svc := newTestOrchestrator(exec, repo, nil, nil)
|
||||
|
||||
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success")
|
||||
}
|
||||
if result.Action != sdlc.ActionIdle {
|
||||
t.Errorf("expected idle action, got %s", result.Action)
|
||||
}
|
||||
if result.Output != "Nothing to do" {
|
||||
t.Errorf("expected 'Nothing to do', got %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_ExecuteAction_Transition(t *testing.T) {
|
||||
var transitioned bool
|
||||
exec := &mockSDLCExecutor{
|
||||
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||
if transitioned {
|
||||
return &sdlc.Classification{Action: sdlc.ActionIdle, Feature: "auth"}, nil
|
||||
}
|
||||
return &sdlc.Classification{
|
||||
Action: sdlc.ActionTransition,
|
||||
Feature: "auth",
|
||||
TransitionTo: sdlc.PhaseSpecified,
|
||||
}, nil
|
||||
},
|
||||
transitionFeatureFn: func(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
|
||||
transitioned = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
svc := newTestOrchestrator(exec, repo, nil, nil)
|
||||
|
||||
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success")
|
||||
}
|
||||
if result.Action != sdlc.ActionTransition {
|
||||
t.Errorf("expected transition action, got %s", result.Action)
|
||||
}
|
||||
if !transitioned {
|
||||
t.Error("expected transition to have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_ExecuteAction_AgentAction(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{
|
||||
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||
return &sdlc.Classification{
|
||||
Action: sdlc.ActionCreateSpec,
|
||||
Feature: "auth",
|
||||
NextCommand: "create spec",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
agent := &mockCodeAgent{}
|
||||
registry := &mockAgentRegistry{agent: agent}
|
||||
svc := newTestOrchestrator(exec, repo, registry, nil)
|
||||
|
||||
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success")
|
||||
}
|
||||
if result.Output != "agent output" {
|
||||
t.Errorf("expected 'agent output', got %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_ExecuteAction_NoAgent(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{
|
||||
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||
return &sdlc.Classification{
|
||||
Action: sdlc.ActionCreateSpec,
|
||||
Feature: "auth",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
registry := &mockAgentRegistry{agent: nil}
|
||||
svc := newTestOrchestrator(exec, repo, registry, nil)
|
||||
|
||||
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Error("expected failure (no agent)")
|
||||
}
|
||||
if result.Error == "" {
|
||||
t.Error("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_ExecuteAction_ClassifyError(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{
|
||||
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||
return nil, sdlc.ErrFeatureNotFound
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
svc := newTestOrchestrator(exec, repo, nil, nil)
|
||||
|
||||
_, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
||||
if !errors.Is(err, sdlc.ErrFeatureNotFound) {
|
||||
t.Errorf("expected ErrFeatureNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_ResolveBlocker(t *testing.T) {
|
||||
var unblocked bool
|
||||
exec := &mockSDLCExecutor{
|
||||
unblockFeatureFn: func(_ context.Context, _, _ string) error {
|
||||
unblocked = true
|
||||
return nil
|
||||
},
|
||||
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||
return &sdlc.Classification{
|
||||
Action: sdlc.ActionIdle,
|
||||
Feature: "auth",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
svc := newTestOrchestrator(exec, repo, nil, nil)
|
||||
|
||||
result, err := svc.ResolveBlocker(context.Background(), "proj", &ResolveRequest{Feature: "auth"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success")
|
||||
}
|
||||
if !unblocked {
|
||||
t.Error("expected unblock to have been called")
|
||||
}
|
||||
if result.Next == nil {
|
||||
t.Error("expected next classification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_ResolveBlocker_Error(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{
|
||||
unblockFeatureFn: func(_ context.Context, _, _ string) error {
|
||||
return sdlc.ErrFeatureNotFound
|
||||
},
|
||||
}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
svc := newTestOrchestrator(exec, repo, nil, nil)
|
||||
|
||||
_, err := svc.ResolveBlocker(context.Background(), "proj", &ResolveRequest{Feature: "auth"})
|
||||
if !errors.Is(err, sdlc.ErrFeatureNotFound) {
|
||||
t.Errorf("expected ErrFeatureNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_CommitChanges(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
committer := &mockGitCommitter{
|
||||
result: &GitCommitResult{
|
||||
HasChanges: true,
|
||||
CommitSHA: "def456",
|
||||
FilesChanged: []string{"auth.go", "auth_test.go"},
|
||||
Pushed: true,
|
||||
},
|
||||
}
|
||||
svc := newTestOrchestrator(exec, repo, nil, committer)
|
||||
|
||||
result, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{
|
||||
Feature: "auth",
|
||||
Message: "implement auth",
|
||||
Push: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.CommitSHA != "def456" {
|
||||
t.Errorf("expected SHA def456, got %s", result.CommitSHA)
|
||||
}
|
||||
if len(result.FilesChanged) != 2 {
|
||||
t.Errorf("expected 2 files changed, got %d", len(result.FilesChanged))
|
||||
}
|
||||
if !result.Pushed {
|
||||
t.Error("expected pushed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_CommitChanges_NoCommitter(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
svc := newTestOrchestrator(exec, repo, nil, nil) // no git committer
|
||||
|
||||
_, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{
|
||||
Feature: "auth",
|
||||
Message: "commit",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil committer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_CommitChanges_ProjectNotFound(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{}
|
||||
repo := newMockProjectRepo() // empty
|
||||
committer := &mockGitCommitter{}
|
||||
svc := newTestOrchestrator(exec, repo, nil, committer)
|
||||
|
||||
_, err := svc.CommitChanges(context.Background(), "nonexistent", &CommitRequest{
|
||||
Feature: "auth",
|
||||
Message: "commit",
|
||||
})
|
||||
if !errors.Is(err, domain.ErrProjectNotFound) {
|
||||
t.Errorf("expected ErrProjectNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_CommitChanges_GitError(t *testing.T) {
|
||||
exec := &mockSDLCExecutor{}
|
||||
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
||||
committer := &mockGitCommitter{
|
||||
result: &GitCommitResult{
|
||||
Error: errors.New("push failed"),
|
||||
},
|
||||
}
|
||||
svc := newTestOrchestrator(exec, repo, nil, committer)
|
||||
|
||||
_, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{
|
||||
Feature: "auth",
|
||||
Message: "commit",
|
||||
Push: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for git failure")
|
||||
}
|
||||
}
|
||||
@ -257,3 +257,66 @@ func (s *SDLCService) QueryNeedsApproval(ctx context.Context, projectID string)
|
||||
}
|
||||
return s.sdlcExec.QueryNeedsApproval(ctx, podName)
|
||||
}
|
||||
|
||||
// CreateBranch creates a feature branch and its manifest.
|
||||
func (s *SDLCService) CreateBranch(ctx context.Context, projectID, slug string) (*sdlc.BranchManifest, error) {
|
||||
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := s.sdlcExec.CreateBranch(ctx, podName, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("branch created", "project", projectID, "feature", slug, "branch", m.Name)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetBranchStatus returns the branch manifest for a feature.
|
||||
func (s *SDLCService) GetBranchStatus(ctx context.Context, projectID, slug string) (*sdlc.BranchManifest, error) {
|
||||
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.sdlcExec.GetBranchStatus(ctx, podName, slug)
|
||||
}
|
||||
|
||||
// SyncBranch syncs a feature branch with its base branch.
|
||||
func (s *SDLCService) SyncBranch(ctx context.Context, projectID, slug string) error {
|
||||
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sdlcExec.SyncBranch(ctx, podName, slug); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("branch synced", "project", projectID, "feature", slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeFeature merges a feature branch after all gates pass.
|
||||
func (s *SDLCService) MergeFeature(ctx context.Context, projectID, slug, strategy string) error {
|
||||
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sdlcExec.MergeFeature(ctx, podName, slug, strategy); err != nil {
|
||||
s.logger.Error("merge feature failed", "project", projectID, "feature", slug, "error", err)
|
||||
return err
|
||||
}
|
||||
s.logger.Info("feature merged", "project", projectID, "feature", slug, "strategy", strategy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ArchiveFeature archives a released feature.
|
||||
func (s *SDLCService) ArchiveFeature(ctx context.Context, projectID, slug string) error {
|
||||
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sdlcExec.ArchiveFeature(ctx, podName, slug); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("feature archived", "project", projectID, "feature", slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -32,6 +32,11 @@ type mockSDLCExecutor struct {
|
||||
queryBlockedFn func(ctx context.Context, podName string) ([]port.BlockedInfo, error)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func (m *mockSDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) {
|
||||
@ -174,6 +179,41 @@ func (m *mockSDLCExecutor) QueryNeedsApproval(ctx context.Context, podName strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockSDLCExecutor) CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) {
|
||||
if m.createBranchFn != nil {
|
||||
return m.createBranchFn(ctx, podName, slug)
|
||||
}
|
||||
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
|
||||
}
|
||||
|
||||
func (m *mockSDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) {
|
||||
if m.getBranchStatusFn != nil {
|
||||
return m.getBranchStatusFn(ctx, podName, slug)
|
||||
}
|
||||
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
|
||||
}
|
||||
|
||||
func (m *mockSDLCExecutor) SyncBranch(ctx context.Context, podName, slug string) error {
|
||||
if m.syncBranchFn != nil {
|
||||
return m.syncBranchFn(ctx, podName, slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSDLCExecutor) MergeFeature(ctx context.Context, podName, slug, strategy string) error {
|
||||
if m.mergeFeatureFn != nil {
|
||||
return m.mergeFeatureFn(ctx, podName, slug, strategy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSDLCExecutor) ArchiveFeature(ctx context.Context, podName, slug string) error {
|
||||
if m.archiveFeatureFn != nil {
|
||||
return m.archiveFeatureFn(ctx, podName, slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockProjectRepo implements port.ProjectRepository for testing.
|
||||
type mockProjectRepo struct {
|
||||
projects map[domain.ProjectID]*domain.Project
|
||||
|
||||
Loading…
Reference in New Issue
Block a user