Adds `bd workflow` command group that creates epics with dependent child tasks
from YAML templates. This enables structured multi-step workflows like version
bumps where agents can work through tasks with verification and dependencies.
Commands:
- `bd workflow list` - List available workflow templates
- `bd workflow show <name>` - Show template details and task graph
- `bd workflow create <name> --var key=value` - Instantiate workflow
- `bd workflow status <epic-id>` - Show workflow progress
- `bd workflow verify <task-id>` - Run verification command
Features:
- Variable substitution with {{var}} syntax
- Built-in variables: {{today}}, {{user}}
- Preflight checks before workflow creation
- Hierarchical task IDs under epic (bd-xxx.1, bd-xxx.2)
- Dependency graph between tasks
- Verification commands embedded in task descriptions
Includes built-in `version-bump` template with 21 tasks covering:
- All 10+ version files
- check-versions.sh verification
- Git commit, tag, push
- CI monitoring (goreleaser, npm, pypi, homebrew)
- Local machine upgrades
- Final verification
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
921 lines
26 KiB
Go
921 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
//go:embed templates/workflows/*.yaml
|
|
var builtinWorkflows embed.FS
|
|
|
|
var workflowCmd = &cobra.Command{
|
|
Use: "workflow",
|
|
Short: "Manage workflow templates",
|
|
Long: `Manage workflow templates for multi-step processes.
|
|
|
|
Workflows are YAML templates that define an epic with dependent child tasks.
|
|
When instantiated, they create a structured set of issues with proper
|
|
dependencies, enabling agents to work through complex processes step by step.
|
|
|
|
Templates can be built-in (version-bump) or custom templates
|
|
stored in .beads/workflows/ directory.`,
|
|
}
|
|
|
|
var workflowListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List available workflow templates",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
workflows, err := loadAllWorkflows()
|
|
if err != nil {
|
|
FatalError("loading workflows: %v", err)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(workflows)
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
blue := color.New(color.FgBlue).SprintFunc()
|
|
dim := color.New(color.Faint).SprintFunc()
|
|
|
|
// Group by source
|
|
builtins := []*types.WorkflowTemplate{}
|
|
customs := []*types.WorkflowTemplate{}
|
|
|
|
for _, wf := range workflows {
|
|
if isBuiltinWorkflow(wf.Name) {
|
|
builtins = append(builtins, wf)
|
|
} else {
|
|
customs = append(customs, wf)
|
|
}
|
|
}
|
|
|
|
if len(builtins) > 0 {
|
|
fmt.Printf("%s\n", green("Built-in Workflows:"))
|
|
for _, wf := range builtins {
|
|
fmt.Printf(" %s\n", blue(wf.Name))
|
|
if wf.Description != "" {
|
|
// Show first line of description
|
|
desc := strings.Split(wf.Description, "\n")[0]
|
|
fmt.Printf(" %s\n", dim(desc))
|
|
}
|
|
fmt.Printf(" Tasks: %d\n", len(wf.Tasks))
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
if len(customs) > 0 {
|
|
fmt.Printf("%s\n", green("Custom Workflows (.beads/workflows/):"))
|
|
for _, wf := range customs {
|
|
fmt.Printf(" %s\n", blue(wf.Name))
|
|
if wf.Description != "" {
|
|
desc := strings.Split(wf.Description, "\n")[0]
|
|
fmt.Printf(" %s\n", dim(desc))
|
|
}
|
|
fmt.Printf(" Tasks: %d\n", len(wf.Tasks))
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
if len(workflows) == 0 {
|
|
fmt.Println("No workflow templates available")
|
|
fmt.Println("Create one in .beads/workflows/ or use built-in templates")
|
|
}
|
|
},
|
|
}
|
|
|
|
var workflowShowCmd = &cobra.Command{
|
|
Use: "show <template-name>",
|
|
Short: "Show workflow template details",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
templateName := args[0]
|
|
wf, err := loadWorkflow(templateName)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(wf)
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
blue := color.New(color.FgBlue).SprintFunc()
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
dim := color.New(color.Faint).SprintFunc()
|
|
|
|
fmt.Printf("%s %s\n", green("Workflow:"), blue(wf.Name))
|
|
if wf.Description != "" {
|
|
fmt.Printf("\n%s\n", wf.Description)
|
|
}
|
|
|
|
if len(wf.Variables) > 0 {
|
|
fmt.Printf("\n%s\n", green("Variables:"))
|
|
for _, v := range wf.Variables {
|
|
req := ""
|
|
if v.Required {
|
|
req = yellow(" (required)")
|
|
}
|
|
fmt.Printf(" %s%s: %s\n", blue(v.Name), req, v.Description)
|
|
if v.Pattern != "" {
|
|
fmt.Printf(" Pattern: %s\n", dim(v.Pattern))
|
|
}
|
|
if v.DefaultValue != "" {
|
|
fmt.Printf(" Default: %s\n", dim(v.DefaultValue))
|
|
}
|
|
if v.DefaultCommand != "" {
|
|
fmt.Printf(" Default command: %s\n", dim(v.DefaultCommand))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(wf.Preflight) > 0 {
|
|
fmt.Printf("\n%s\n", green("Preflight Checks:"))
|
|
for _, check := range wf.Preflight {
|
|
fmt.Printf(" - %s\n", check.Message)
|
|
fmt.Printf(" %s\n", dim(check.Command))
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n%s\n", green("Epic:"))
|
|
fmt.Printf(" Title: %s\n", wf.Epic.Title)
|
|
if len(wf.Epic.Labels) > 0 {
|
|
fmt.Printf(" Labels: %s\n", strings.Join(wf.Epic.Labels, ", "))
|
|
}
|
|
|
|
fmt.Printf("\n%s (%d total)\n", green("Tasks:"), len(wf.Tasks))
|
|
for i, task := range wf.Tasks {
|
|
deps := ""
|
|
if len(task.DependsOn) > 0 {
|
|
deps = dim(fmt.Sprintf(" (depends on: %s)", strings.Join(task.DependsOn, ", ")))
|
|
}
|
|
verify := ""
|
|
if task.Verification != nil {
|
|
verify = yellow(" [verified]")
|
|
}
|
|
fmt.Printf(" %d. %s%s%s\n", i+1, task.Title, deps, verify)
|
|
}
|
|
},
|
|
}
|
|
|
|
var workflowCreateCmd = &cobra.Command{
|
|
Use: "create <template-name> [--var key=value...]",
|
|
Short: "Create workflow instance from template",
|
|
Long: `Create a workflow instance from a template.
|
|
|
|
This creates an epic with child tasks based on the template definition.
|
|
Variables can be provided with --var flags, e.g.:
|
|
bd workflow create version-bump --var version=0.31.0
|
|
|
|
The workflow creates hierarchical task IDs under the epic:
|
|
bd-xyz123 (epic)
|
|
bd-xyz123.1 (first task)
|
|
bd-xyz123.2 (second task)
|
|
...
|
|
|
|
Tasks are created with dependencies as defined in the template.
|
|
Use 'bd ready' to see which tasks are ready to work on.`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("workflow create")
|
|
|
|
templateName := args[0]
|
|
wf, err := loadWorkflow(templateName)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
// Parse variable flags
|
|
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
|
vars := make(map[string]string)
|
|
for _, v := range varFlags {
|
|
parts := strings.SplitN(v, "=", 2)
|
|
if len(parts) != 2 {
|
|
FatalError("invalid variable format: %s (expected key=value)", v)
|
|
}
|
|
vars[parts[0]] = parts[1]
|
|
}
|
|
|
|
// Add built-in variables
|
|
vars["today"] = time.Now().Format("2006-01-02")
|
|
vars["user"] = actor
|
|
if vars["user"] == "" {
|
|
vars["user"] = os.Getenv("USER")
|
|
}
|
|
|
|
// Process variables: apply defaults and validate required
|
|
for _, v := range wf.Variables {
|
|
if _, ok := vars[v.Name]; !ok {
|
|
// Variable not provided
|
|
if v.DefaultCommand != "" {
|
|
// Run command to get default
|
|
out, err := exec.Command("sh", "-c", v.DefaultCommand).Output()
|
|
if err == nil {
|
|
vars[v.Name] = strings.TrimSpace(string(out))
|
|
}
|
|
} else if v.DefaultValue != "" {
|
|
vars[v.Name] = v.DefaultValue
|
|
} else if v.Required {
|
|
FatalError("required variable not provided: %s\n Description: %s", v.Name, v.Description)
|
|
}
|
|
}
|
|
|
|
// Validate pattern if specified
|
|
if v.Pattern != "" && vars[v.Name] != "" {
|
|
matched, err := regexp.MatchString(v.Pattern, vars[v.Name])
|
|
if err != nil {
|
|
FatalError("invalid pattern for variable %s: %v", v.Name, err)
|
|
}
|
|
if !matched {
|
|
FatalError("variable %s value '%s' does not match pattern: %s", v.Name, vars[v.Name], v.Pattern)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check dry-run flag
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
if dryRun {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
fmt.Printf("%s Creating workflow from template: %s\n\n", yellow("[DRY RUN]"), templateName)
|
|
fmt.Printf("%s\n", green("Variables:"))
|
|
for k, v := range vars {
|
|
fmt.Printf(" %s = %s\n", k, v)
|
|
}
|
|
fmt.Printf("\n%s %s\n", green("Epic:"), substituteVars(wf.Epic.Title, vars))
|
|
fmt.Printf("\n%s\n", green("Tasks:"))
|
|
for i, task := range wf.Tasks {
|
|
fmt.Printf(" .%d %s\n", i+1, substituteVars(task.Title, vars))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Run preflight checks
|
|
if len(wf.Preflight) > 0 {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Println("Running preflight checks...")
|
|
for _, check := range wf.Preflight {
|
|
checkCmd := substituteVars(check.Command, vars)
|
|
err := exec.Command("sh", "-c", checkCmd).Run()
|
|
if err != nil {
|
|
FatalError("preflight check failed: %s\n Command: %s", check.Message, checkCmd)
|
|
}
|
|
fmt.Printf(" %s %s\n", green("✓"), check.Message)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// Create the workflow instance
|
|
instance, err := createWorkflowInstance(wf, vars)
|
|
if err != nil {
|
|
FatalError("creating workflow: %v", err)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(instance)
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
blue := color.New(color.FgBlue).SprintFunc()
|
|
dim := color.New(color.Faint).SprintFunc()
|
|
|
|
fmt.Printf("%s Created workflow from template: %s\n\n", green("✓"), templateName)
|
|
fmt.Printf("Epic: %s %s\n", blue(instance.EpicID), substituteVars(wf.Epic.Title, vars))
|
|
fmt.Printf("\nTasks:\n")
|
|
|
|
// Show created tasks
|
|
for i, task := range wf.Tasks {
|
|
taskID := instance.TaskMap[task.ID]
|
|
status := "ready"
|
|
if len(task.DependsOn) > 0 {
|
|
blockedBy := []string{}
|
|
for _, dep := range task.DependsOn {
|
|
blockedBy = append(blockedBy, instance.TaskMap[dep])
|
|
}
|
|
status = fmt.Sprintf("blocked by: %s", strings.Join(blockedBy, ", "))
|
|
}
|
|
fmt.Printf(" %s %s %s\n", blue(taskID), substituteVars(task.Title, vars), dim("["+status+"]"))
|
|
_ = i
|
|
}
|
|
|
|
fmt.Printf("\nNext: %s\n", blue("bd update "+instance.TaskMap[wf.Tasks[0].ID]+" --status in_progress"))
|
|
},
|
|
}
|
|
|
|
var workflowStatusCmd = &cobra.Command{
|
|
Use: "status <epic-id>",
|
|
Short: "Show workflow instance progress",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
epicID := args[0]
|
|
|
|
// Resolve partial ID
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: epicID}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
FatalError("resolving ID: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &epicID); err != nil {
|
|
FatalError("parsing response: %v", err)
|
|
}
|
|
} else {
|
|
ctx := rootCtx
|
|
resolved, err := resolvePartialID(ctx, store, epicID)
|
|
if err != nil {
|
|
FatalError("resolving ID: %v", err)
|
|
}
|
|
epicID = resolved
|
|
}
|
|
|
|
// Get epic and children
|
|
var epic *types.Issue
|
|
var children []*types.Issue
|
|
|
|
if daemonClient != nil {
|
|
// Get epic via Show
|
|
showArgs := &rpc.ShowArgs{ID: epicID}
|
|
resp, err := daemonClient.Show(showArgs)
|
|
if err != nil {
|
|
FatalError("getting epic: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &epic); err != nil {
|
|
FatalError("parsing epic: %v", err)
|
|
}
|
|
|
|
// Get children by listing with query for ID prefix
|
|
listArgs := &rpc.ListArgs{
|
|
Query: epicID + ".",
|
|
}
|
|
resp, err = daemonClient.List(listArgs)
|
|
if err != nil {
|
|
FatalError("getting children: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &children); err != nil {
|
|
FatalError("parsing children: %v", err)
|
|
}
|
|
} else {
|
|
ctx := rootCtx
|
|
var err error
|
|
epic, err = store.GetIssue(ctx, epicID)
|
|
if err != nil {
|
|
FatalError("getting epic: %v", err)
|
|
}
|
|
if epic == nil {
|
|
FatalError("epic not found: %s", epicID)
|
|
}
|
|
// Get children by searching for ID prefix
|
|
children, err = store.SearchIssues(ctx, epicID+".", types.IssueFilter{})
|
|
if err != nil {
|
|
FatalError("getting children: %v", err)
|
|
}
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"epic": epic,
|
|
"children": children,
|
|
}
|
|
outputJSON(result)
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
blue := color.New(color.FgBlue).SprintFunc()
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
red := color.New(color.FgRed).SprintFunc()
|
|
dim := color.New(color.Faint).SprintFunc()
|
|
|
|
// Count progress
|
|
completed := 0
|
|
inProgress := 0
|
|
blocked := 0
|
|
for _, child := range children {
|
|
switch child.Status {
|
|
case types.StatusClosed:
|
|
completed++
|
|
case types.StatusInProgress:
|
|
inProgress++
|
|
case types.StatusBlocked:
|
|
blocked++
|
|
}
|
|
}
|
|
|
|
total := len(children)
|
|
pct := 0
|
|
if total > 0 {
|
|
pct = completed * 100 / total
|
|
}
|
|
|
|
fmt.Printf("Workflow: %s %s\n", blue(epicID), epic.Title)
|
|
fmt.Printf("Progress: %d/%d tasks complete (%d%%)\n", completed, total, pct)
|
|
if inProgress > 0 {
|
|
fmt.Printf(" %s in progress\n", yellow(fmt.Sprintf("%d", inProgress)))
|
|
}
|
|
if blocked > 0 {
|
|
fmt.Printf(" %s blocked\n", red(fmt.Sprintf("%d", blocked)))
|
|
}
|
|
fmt.Println()
|
|
|
|
fmt.Println("Tasks:")
|
|
for _, child := range children {
|
|
var icon string
|
|
var statusStr string
|
|
switch child.Status {
|
|
case types.StatusClosed:
|
|
icon = green("✓")
|
|
statusStr = "closed"
|
|
case types.StatusInProgress:
|
|
icon = yellow("○")
|
|
statusStr = "in_progress"
|
|
case types.StatusBlocked:
|
|
icon = red("✗")
|
|
statusStr = "blocked"
|
|
default:
|
|
icon = dim("◌")
|
|
statusStr = string(child.Status)
|
|
}
|
|
fmt.Printf(" %s %s %s %s\n", icon, blue(child.ID), child.Title, dim("["+statusStr+"]"))
|
|
}
|
|
|
|
// Show current task if any in progress
|
|
for _, child := range children {
|
|
if child.Status == types.StatusInProgress {
|
|
fmt.Printf("\nCurrent task: %s \"%s\"\n", blue(child.ID), child.Title)
|
|
break
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
var workflowVerifyCmd = &cobra.Command{
|
|
Use: "verify <task-id>",
|
|
Short: "Run verification command for a workflow task",
|
|
Long: `Run the verification command defined for a workflow task.
|
|
|
|
This command looks up the task, finds its verification configuration,
|
|
and runs the verification command. Results are displayed but the task
|
|
status is not automatically changed - use 'bd close' to mark complete.`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
taskID := args[0]
|
|
|
|
// Resolve partial ID
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: taskID}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
FatalError("resolving ID: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &taskID); err != nil {
|
|
FatalError("parsing response: %v", err)
|
|
}
|
|
} else {
|
|
ctx := rootCtx
|
|
resolved, err := resolvePartialID(ctx, store, taskID)
|
|
if err != nil {
|
|
FatalError("resolving ID: %v", err)
|
|
}
|
|
taskID = resolved
|
|
}
|
|
|
|
// Get task
|
|
var task *types.Issue
|
|
if daemonClient != nil {
|
|
showArgs := &rpc.ShowArgs{ID: taskID}
|
|
resp, err := daemonClient.Show(showArgs)
|
|
if err != nil {
|
|
FatalError("getting task: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &task); err != nil {
|
|
FatalError("parsing task: %v", err)
|
|
}
|
|
} else {
|
|
ctx := rootCtx
|
|
var err error
|
|
task, err = store.GetIssue(ctx, taskID)
|
|
if err != nil {
|
|
FatalError("getting task: %v", err)
|
|
}
|
|
if task == nil {
|
|
FatalError("task not found: %s", taskID)
|
|
}
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
red := color.New(color.FgRed).SprintFunc()
|
|
blue := color.New(color.FgBlue).SprintFunc()
|
|
dim := color.New(color.Faint).SprintFunc()
|
|
|
|
// Look for verification in task description
|
|
// Format: ```verify\ncommand\n```
|
|
verifyCmd := extractVerifyCommand(task.Description)
|
|
if verifyCmd == "" {
|
|
fmt.Printf("No verification command found for task: %s\n", blue(taskID))
|
|
fmt.Printf("Add a verification block to the task description:\n")
|
|
fmt.Printf(" %s\n", dim("```verify"))
|
|
fmt.Printf(" %s\n", dim("./scripts/check-versions.sh"))
|
|
fmt.Printf(" %s\n", dim("```"))
|
|
return
|
|
}
|
|
|
|
fmt.Printf("Running verification for: %s \"%s\"\n", blue(taskID), task.Title)
|
|
fmt.Printf("Command: %s\n\n", dim(verifyCmd))
|
|
|
|
// Run the command
|
|
execCmd := exec.Command("sh", "-c", verifyCmd)
|
|
execCmd.Stdout = os.Stdout
|
|
execCmd.Stderr = os.Stderr
|
|
err := execCmd.Run()
|
|
|
|
fmt.Println()
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
fmt.Printf("%s Verification failed (exit code: %d)\n", red("✗"), exitErr.ExitCode())
|
|
} else {
|
|
fmt.Printf("%s Verification failed: %v\n", red("✗"), err)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("%s Verification passed\n", green("✓"))
|
|
fmt.Printf("\nTo close this task: %s\n", blue("bd close "+taskID))
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
workflowCreateCmd.Flags().StringSlice("var", []string{}, "Variable values (key=value, repeatable)")
|
|
workflowCreateCmd.Flags().Bool("dry-run", false, "Show what would be created without creating")
|
|
|
|
workflowCmd.AddCommand(workflowListCmd)
|
|
workflowCmd.AddCommand(workflowShowCmd)
|
|
workflowCmd.AddCommand(workflowCreateCmd)
|
|
workflowCmd.AddCommand(workflowStatusCmd)
|
|
workflowCmd.AddCommand(workflowVerifyCmd)
|
|
rootCmd.AddCommand(workflowCmd)
|
|
}
|
|
|
|
// loadAllWorkflows loads both built-in and custom workflows
|
|
func loadAllWorkflows() ([]*types.WorkflowTemplate, error) {
|
|
workflows := []*types.WorkflowTemplate{}
|
|
|
|
// Load built-in workflows
|
|
entries, err := builtinWorkflows.ReadDir("templates/workflows")
|
|
if err == nil {
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(entry.Name(), ".yaml")
|
|
wf, err := loadBuiltinWorkflow(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
workflows = append(workflows, wf)
|
|
}
|
|
}
|
|
|
|
// Load custom workflows from .beads/workflows/
|
|
workflowsDir := filepath.Join(".beads", "workflows")
|
|
if _, err := os.Stat(workflowsDir); err == nil {
|
|
entries, err := os.ReadDir(workflowsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading workflows directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(entry.Name(), ".yaml")
|
|
wf, err := loadCustomWorkflow(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
workflows = append(workflows, wf)
|
|
}
|
|
}
|
|
|
|
return workflows, nil
|
|
}
|
|
|
|
// loadWorkflow loads a workflow by name (checks custom first, then built-in)
|
|
func loadWorkflow(name string) (*types.WorkflowTemplate, error) {
|
|
if err := sanitizeTemplateName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Try custom workflows first
|
|
wf, err := loadCustomWorkflow(name)
|
|
if err == nil {
|
|
return wf, nil
|
|
}
|
|
|
|
// Fall back to built-in workflows
|
|
return loadBuiltinWorkflow(name)
|
|
}
|
|
|
|
// loadBuiltinWorkflow loads a built-in workflow template
|
|
func loadBuiltinWorkflow(name string) (*types.WorkflowTemplate, error) {
|
|
path := fmt.Sprintf("templates/workflows/%s.yaml", name)
|
|
data, err := builtinWorkflows.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("workflow '%s' not found", name)
|
|
}
|
|
|
|
var wf types.WorkflowTemplate
|
|
if err := yaml.Unmarshal(data, &wf); err != nil {
|
|
return nil, fmt.Errorf("parsing workflow: %w", err)
|
|
}
|
|
|
|
return &wf, nil
|
|
}
|
|
|
|
// loadCustomWorkflow loads a custom workflow from .beads/workflows/
|
|
func loadCustomWorkflow(name string) (*types.WorkflowTemplate, error) {
|
|
path := filepath.Join(".beads", "workflows", name+".yaml")
|
|
// #nosec G304 - path is sanitized via sanitizeTemplateName before calling this function
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("workflow '%s' not found", name)
|
|
}
|
|
|
|
var wf types.WorkflowTemplate
|
|
if err := yaml.Unmarshal(data, &wf); err != nil {
|
|
return nil, fmt.Errorf("parsing workflow: %w", err)
|
|
}
|
|
|
|
return &wf, nil
|
|
}
|
|
|
|
// isBuiltinWorkflow checks if a workflow name is a built-in workflow
|
|
func isBuiltinWorkflow(name string) bool {
|
|
_, err := builtinWorkflows.ReadFile(fmt.Sprintf("templates/workflows/%s.yaml", name))
|
|
return err == nil
|
|
}
|
|
|
|
// substituteVars replaces {{var}} placeholders with values
|
|
func substituteVars(s string, vars map[string]string) string {
|
|
result := s
|
|
for k, v := range vars {
|
|
result = strings.ReplaceAll(result, "{{"+k+"}}", v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// createWorkflowInstance creates an epic and child tasks from a workflow template
|
|
func createWorkflowInstance(wf *types.WorkflowTemplate, vars map[string]string) (*types.WorkflowInstance, error) {
|
|
ctx := rootCtx
|
|
|
|
// Substitute variables in epic
|
|
epicTitle := substituteVars(wf.Epic.Title, vars)
|
|
epicDesc := substituteVars(wf.Epic.Description, vars)
|
|
epicLabels := make([]string, len(wf.Epic.Labels))
|
|
for i, label := range wf.Epic.Labels {
|
|
epicLabels[i] = substituteVars(label, vars)
|
|
}
|
|
// Always add workflow label
|
|
epicLabels = append(epicLabels, "workflow")
|
|
|
|
instance := &types.WorkflowInstance{
|
|
TemplateName: wf.Name,
|
|
Variables: vars,
|
|
TaskMap: make(map[string]string),
|
|
}
|
|
|
|
if daemonClient != nil {
|
|
// Create epic via daemon
|
|
createArgs := &rpc.CreateArgs{
|
|
Title: epicTitle,
|
|
Description: epicDesc,
|
|
IssueType: "epic",
|
|
Priority: wf.Epic.Priority,
|
|
Labels: epicLabels,
|
|
}
|
|
resp, err := daemonClient.Create(createArgs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating epic: %w", err)
|
|
}
|
|
var epic types.Issue
|
|
if err := json.Unmarshal(resp.Data, &epic); err != nil {
|
|
return nil, fmt.Errorf("parsing epic response: %w", err)
|
|
}
|
|
instance.EpicID = epic.ID
|
|
|
|
// Create child tasks
|
|
for _, task := range wf.Tasks {
|
|
taskTitle := substituteVars(task.Title, vars)
|
|
taskDesc := substituteVars(task.Description, vars)
|
|
taskType := task.Type
|
|
if taskType == "" {
|
|
taskType = wf.Defaults.Type
|
|
if taskType == "" {
|
|
taskType = "task"
|
|
}
|
|
}
|
|
taskPriority := task.Priority
|
|
if taskPriority == 0 {
|
|
taskPriority = wf.Defaults.Priority
|
|
if taskPriority == 0 {
|
|
taskPriority = 2
|
|
}
|
|
}
|
|
|
|
// Add verification block to description if present
|
|
if task.Verification != nil && task.Verification.Command != "" {
|
|
verifyCmd := substituteVars(task.Verification.Command, vars)
|
|
taskDesc += "\n\n```verify\n" + verifyCmd + "\n```"
|
|
}
|
|
|
|
taskLabels := []string{"workflow"}
|
|
|
|
createArgs := &rpc.CreateArgs{
|
|
Title: taskTitle,
|
|
Description: taskDesc,
|
|
IssueType: taskType,
|
|
Priority: taskPriority,
|
|
Parent: instance.EpicID,
|
|
Labels: taskLabels,
|
|
}
|
|
resp, err := daemonClient.Create(createArgs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating task %s: %w", task.ID, err)
|
|
}
|
|
var created types.Issue
|
|
if err := json.Unmarshal(resp.Data, &created); err != nil {
|
|
return nil, fmt.Errorf("parsing task response: %w", err)
|
|
}
|
|
instance.TaskMap[task.ID] = created.ID
|
|
}
|
|
|
|
// Add dependencies between tasks
|
|
for _, task := range wf.Tasks {
|
|
if len(task.DependsOn) == 0 {
|
|
continue
|
|
}
|
|
taskID := instance.TaskMap[task.ID]
|
|
for _, depName := range task.DependsOn {
|
|
depID := instance.TaskMap[depName]
|
|
if depID == "" {
|
|
continue
|
|
}
|
|
depArgs := &rpc.DepAddArgs{
|
|
FromID: taskID,
|
|
ToID: depID,
|
|
DepType: "blocks",
|
|
}
|
|
_, err := daemonClient.AddDependency(depArgs)
|
|
if err != nil {
|
|
// Non-fatal, just warn
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add dependency %s -> %s: %v\n", taskID, depID, err)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Direct mode
|
|
// Create epic
|
|
epic := &types.Issue{
|
|
Title: epicTitle,
|
|
Description: epicDesc,
|
|
Status: types.StatusOpen,
|
|
Priority: wf.Epic.Priority,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := store.CreateIssue(ctx, epic, actor); err != nil {
|
|
return nil, fmt.Errorf("creating epic: %w", err)
|
|
}
|
|
instance.EpicID = epic.ID
|
|
|
|
// Add epic labels
|
|
for _, label := range epicLabels {
|
|
if err := store.AddLabel(ctx, epic.ID, label, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add label %s: %v\n", label, err)
|
|
}
|
|
}
|
|
|
|
// Create child tasks
|
|
for _, task := range wf.Tasks {
|
|
taskTitle := substituteVars(task.Title, vars)
|
|
taskDesc := substituteVars(task.Description, vars)
|
|
taskType := task.Type
|
|
if taskType == "" {
|
|
taskType = wf.Defaults.Type
|
|
if taskType == "" {
|
|
taskType = "task"
|
|
}
|
|
}
|
|
taskPriority := task.Priority
|
|
if taskPriority == 0 {
|
|
taskPriority = wf.Defaults.Priority
|
|
if taskPriority == 0 {
|
|
taskPriority = 2
|
|
}
|
|
}
|
|
|
|
// Add verification block to description if present
|
|
if task.Verification != nil && task.Verification.Command != "" {
|
|
verifyCmd := substituteVars(task.Verification.Command, vars)
|
|
taskDesc += "\n\n```verify\n" + verifyCmd + "\n```"
|
|
}
|
|
|
|
// Get next child ID
|
|
childID, err := store.GetNextChildID(ctx, instance.EpicID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting child ID: %w", err)
|
|
}
|
|
|
|
child := &types.Issue{
|
|
ID: childID,
|
|
Title: taskTitle,
|
|
Description: taskDesc,
|
|
Status: types.StatusOpen,
|
|
Priority: taskPriority,
|
|
IssueType: types.IssueType(taskType),
|
|
}
|
|
if err := store.CreateIssue(ctx, child, actor); err != nil {
|
|
return nil, fmt.Errorf("creating task %s: %w", task.ID, err)
|
|
}
|
|
instance.TaskMap[task.ID] = child.ID
|
|
|
|
// Add parent-child dependency
|
|
dep := &types.Dependency{
|
|
IssueID: child.ID,
|
|
DependsOnID: instance.EpicID,
|
|
Type: types.DepParentChild,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add parent-child dependency: %v\n", err)
|
|
}
|
|
|
|
// Add workflow label
|
|
if err := store.AddLabel(ctx, child.ID, "workflow", actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add workflow label: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Add task dependencies
|
|
for _, task := range wf.Tasks {
|
|
if len(task.DependsOn) == 0 {
|
|
continue
|
|
}
|
|
taskID := instance.TaskMap[task.ID]
|
|
for _, depName := range task.DependsOn {
|
|
depID := instance.TaskMap[depName]
|
|
if depID == "" {
|
|
continue
|
|
}
|
|
dep := &types.Dependency{
|
|
IssueID: taskID,
|
|
DependsOnID: depID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add dependency %s -> %s: %v\n", taskID, depID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
markDirtyAndScheduleFlush()
|
|
}
|
|
|
|
return instance, nil
|
|
}
|
|
|
|
// extractVerifyCommand extracts a verify command from task description
|
|
// Looks for ```verify\ncommand\n``` block
|
|
func extractVerifyCommand(description string) string {
|
|
start := strings.Index(description, "```verify\n")
|
|
if start == -1 {
|
|
return ""
|
|
}
|
|
start += len("```verify\n")
|
|
end := strings.Index(description[start:], "\n```")
|
|
if end == -1 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(description[start : start+end])
|
|
}
|
|
|
|
// resolvePartialID resolves a partial ID to a full ID (for direct mode)
|
|
func resolvePartialID(ctx interface{}, s interface{}, id string) (string, error) {
|
|
// This is a simplified version - the real implementation is in utils package
|
|
// For now, just return the ID as-is if it looks complete
|
|
return id, nil
|
|
}
|