From 68bd0570c9fffcfd4d230d79842ba1e4e749d72a Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 17 Dec 2025 22:46:44 -0800 Subject: [PATCH] refactor: Remove YAML workflow system (bd-r6a.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the entire YAML-based workflow system in preparation for redesigning templates as native Beads. Removed: - cmd/bd/templates/workflows/*.yaml - cmd/bd/workflow.go - internal/types/workflow.go (WorkflowTemplate types) The new design will use regular Beads with a template label, and a clone-with-substitution operation for instantiation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/templates/workflows/version-bump.yaml | 385 -------- cmd/bd/workflow.go | 925 ------------------- internal/types/workflow.go | 70 -- 3 files changed, 1380 deletions(-) delete mode 100644 cmd/bd/templates/workflows/version-bump.yaml delete mode 100644 cmd/bd/workflow.go delete mode 100644 internal/types/workflow.go diff --git a/cmd/bd/templates/workflows/version-bump.yaml b/cmd/bd/templates/workflows/version-bump.yaml deleted file mode 100644 index e18ffd45..00000000 --- a/cmd/bd/templates/workflows/version-bump.yaml +++ /dev/null @@ -1,385 +0,0 @@ -schema_version: 1 - -name: version-bump -description: | - Bumps version across all beads components and coordinates the release. - Creates tasks for file updates, verification, git operations, CI monitoring, - and local installation. - -defaults: - priority: 1 - type: task - -variables: - - name: version - description: "New semantic version (e.g., 0.31.0)" - required: true - pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" - - - name: previous_version - description: "Current version (auto-detected from version.go)" - required: false - default_command: "grep 'Version = ' cmd/bd/version.go | sed 's/.*\"\\(.*\\)\".*/\\1/'" - -preflight: - - command: "git diff-index --quiet HEAD --" - message: "Working directory must be clean (no uncommitted changes)" - - command: "which jq >/dev/null 2>&1" - message: "jq must be installed for JSON file updates" - - command: "which gh >/dev/null 2>&1" - message: "GitHub CLI (gh) must be installed" - -epic: - title: "Release v{{version}}" - description: | - ## Version Bump Workflow - - Coordinating release from {{previous_version}} to {{version}}. - - ### Components Updated - - Go CLI (cmd/bd/version.go) - - Claude Plugin (.claude-plugin/*.json) - - MCP Server (integrations/beads-mcp/) - - npm Package (npm-package/package.json) - - Git hooks (cmd/bd/templates/hooks/) - - ### Release Channels - - GitHub Releases (GoReleaser) - - PyPI (beads-mcp) - - npm (@beads/cli) - - Homebrew (homebrew-beads tap) - priority: 1 - labels: - - release - - v{{version}} - -tasks: - - id: update-version-go - title: "Update cmd/bd/version.go to {{version}}" - description: | - Update the Version constant in cmd/bd/version.go: - ```go - Version = "{{version}}" - ``` - verification: - command: "grep -q 'Version = \"{{version}}\"' cmd/bd/version.go" - - - id: update-changelog - title: "Update CHANGELOG.md for {{version}}" - description: | - 1. Change `## [Unreleased]` to `## [{{version}}] - {{today}}` - 2. Add new empty `## [Unreleased]` section at top - 3. Ensure all changes since {{previous_version}} are documented - - - id: update-info-go - title: "Add {{version}} to info.go release notes" - description: | - Update cmd/bd/info.go versionChanges map with release notes for {{version}}. - Include any workflow-impacting changes for --whats-new output. - depends_on: - - update-changelog - - - id: update-plugin-json - title: "Update .claude-plugin/plugin.json to {{version}}" - description: | - Update version field in .claude-plugin/plugin.json: - ```json - "version": "{{version}}" - ``` - verification: - command: "jq -e '.version == \"{{version}}\"' .claude-plugin/plugin.json" - - - id: update-marketplace-json - title: "Update .claude-plugin/marketplace.json to {{version}}" - description: | - Update version field in .claude-plugin/marketplace.json: - ```json - "version": "{{version}}" - ``` - verification: - command: "jq -e '.plugins[0].version == \"{{version}}\"' .claude-plugin/marketplace.json" - - - id: update-pyproject - title: "Update integrations/beads-mcp/pyproject.toml to {{version}}" - description: | - Update version in pyproject.toml: - ```toml - version = "{{version}}" - ``` - verification: - command: "grep -q 'version = \"{{version}}\"' integrations/beads-mcp/pyproject.toml" - - - id: update-python-init - title: "Update beads_mcp/__init__.py to {{version}}" - description: | - Update __version__ in integrations/beads-mcp/src/beads_mcp/__init__.py: - ```python - __version__ = "{{version}}" - ``` - verification: - command: "grep -q '__version__ = \"{{version}}\"' integrations/beads-mcp/src/beads_mcp/__init__.py" - - - id: update-npm-package - title: "Update npm-package/package.json to {{version}}" - description: | - Update version field in npm-package/package.json: - ```json - "version": "{{version}}" - ``` - verification: - command: "jq -e '.version == \"{{version}}\"' npm-package/package.json" - - - id: update-hook-templates - title: "Update hook templates to {{version}}" - description: | - Update bd-hooks-version comment in all 4 hook templates: - - cmd/bd/templates/hooks/pre-commit - - cmd/bd/templates/hooks/post-merge - - cmd/bd/templates/hooks/pre-push - - cmd/bd/templates/hooks/post-checkout - - Each should have: - ```bash - # bd-hooks-version: {{version}} - ``` - verification: - command: "grep -l 'bd-hooks-version: {{version}}' cmd/bd/templates/hooks/* | wc -l | grep -q '4'" - - - id: verify-all-versions - title: "Run check-versions.sh - all must pass" - description: | - Run the version consistency check: - ```bash - ./scripts/check-versions.sh - ``` - - All versions must match {{version}}. - depends_on: - - update-version-go - - update-plugin-json - - update-marketplace-json - - update-pyproject - - update-python-init - - update-npm-package - - update-hook-templates - verification: - command: "./scripts/check-versions.sh" - - - id: git-commit-tag - title: "Commit changes and create v{{version}} tag" - description: | - ```bash - git add -A - git commit -m "chore: Bump version to {{version}}" - git tag -a v{{version}} -m "Release v{{version}}" - ``` - depends_on: - - verify-all-versions - - update-changelog - - update-info-go - verification: - command: "git describe --tags --exact-match HEAD 2>/dev/null | grep -q 'v{{version}}'" - - - id: git-push - title: "Push commit and tag to origin" - description: | - ```bash - git push origin main - git push origin v{{version}} - ``` - - This triggers GitHub Actions: - - GoReleaser build - - PyPI publish - - npm publish - depends_on: - - git-commit-tag - verification: - command: "git ls-remote origin refs/tags/v{{version}} | grep -q 'v{{version}}'" - - - id: monitor-goreleaser - title: "Monitor GoReleaser CI job" - description: | - Watch the GoReleaser action: - https://github.com/steveyegge/beads/actions/workflows/release.yml - - Should complete in ~10 minutes and create: - - GitHub Release with binaries for all platforms - - Checksums and signatures - - Check status: - ```bash - gh run list --workflow=release.yml -L 1 - gh run watch # to monitor live - ``` - - Verify release exists: - ```bash - gh release view v{{version}} - ``` - priority: 2 - depends_on: - - git-push - verification: - command: "gh release view v{{version}} --json tagName -q .tagName | grep -q 'v{{version}}'" - - - id: monitor-pypi - title: "Monitor PyPI publish" - description: | - Watch the PyPI publish action: - https://github.com/steveyegge/beads/actions/workflows/pypi-publish.yml - - Verify at: https://pypi.org/project/beads-mcp/{{version}}/ - - Check: - ```bash - pip index versions beads-mcp 2>/dev/null | grep -q '{{version}}' - ``` - priority: 2 - depends_on: - - git-push - verification: - command: "pip index versions beads-mcp 2>/dev/null | grep -q '{{version}}' || curl -s https://pypi.org/pypi/beads-mcp/json | jq -e '.releases[\"{{version}}\"]'" - - - id: monitor-npm - title: "Monitor npm publish" - description: | - Watch the npm publish action: - https://github.com/steveyegge/beads/actions/workflows/npm-publish.yml - - Verify at: https://www.npmjs.com/package/@anthropics/claude-code-beads-plugin/v/{{version}} - - Check: - ```bash - npm view @anthropics/claude-code-beads-plugin@{{version}} version - ``` - priority: 2 - depends_on: - - git-push - verification: - command: "npm view @anthropics/claude-code-beads-plugin@{{version}} version 2>/dev/null | grep -q '{{version}}'" - - - id: update-homebrew - title: "Update Homebrew formula" - description: | - After GoReleaser completes, the Homebrew tap should be auto-updated. - - If manual update needed: - ```bash - ./scripts/update-homebrew.sh v{{version}} - ``` - - Or manually update steveyegge/homebrew-beads with new SHA256. - - Verify: - ```bash - brew update - brew info beads - ``` - priority: 2 - depends_on: - - monitor-goreleaser - - - id: local-go-install - title: "Install {{version}} Go binary locally" - description: | - Rebuild and install the Go binary: - ```bash - go install ./cmd/bd - # OR - make install - ``` - - Verify: - ```bash - bd --version - ``` - depends_on: - - monitor-goreleaser - verification: - command: "bd --version 2>&1 | grep -q '{{version}}'" - - - id: restart-daemon - title: "Restart beads daemon" - description: | - Kill any running daemons so they pick up the new version: - ```bash - bd daemons killall - ``` - - Start fresh daemon: - ```bash - bd list # triggers daemon start - ``` - - Verify daemon version: - ```bash - bd version --daemon - ``` - depends_on: - - local-go-install - verification: - command: "bd version --daemon 2>&1 | grep -q '{{version}}'" - - - id: local-mcp-install - title: "Install {{version}} MCP server locally" - description: | - Upgrade the MCP server (after PyPI publish): - ```bash - pip install --upgrade beads-mcp - # OR if using uv: - uv tool upgrade beads-mcp - ``` - - Verify: - ```bash - pip show beads-mcp | grep Version - ``` - depends_on: - - monitor-pypi - verification: - command: "pip show beads-mcp 2>/dev/null | grep -q 'Version: {{version}}'" - - - id: update-hooks - title: "Update git hooks" - description: | - Install the updated hooks: - ```bash - bd hooks install - ``` - - Verify hook version: - ```bash - grep 'bd-hooks-version' .git/hooks/pre-commit - ``` - depends_on: - - local-go-install - verification: - command: "grep -q 'bd-hooks-version: {{version}}' .git/hooks/pre-commit 2>/dev/null || echo 'Hooks may not be installed - verify manually'" - - - id: final-verification - title: "Final release verification" - description: | - Verify all release artifacts are accessible: - - - [ ] `bd --version` shows {{version}} - - [ ] `bd version --daemon` shows {{version}} - - [ ] GitHub release exists: https://github.com/steveyegge/beads/releases/tag/v{{version}} - - [ ] `brew upgrade beads && bd --version` shows {{version}} (if using Homebrew) - - [ ] `pip show beads-mcp` shows {{version}} - - [ ] npm package available at {{version}} - - [ ] `bd info --whats-new` shows {{version}} notes - - Run final checks: - ```bash - bd --version - bd version --daemon - pip show beads-mcp | grep Version - bd info --whats-new - ``` - depends_on: - - restart-daemon - - local-mcp-install - - update-hooks - - monitor-npm - - update-homebrew diff --git a/cmd/bd/workflow.go b/cmd/bd/workflow.go deleted file mode 100644 index 5b96d72c..00000000 --- a/cmd/bd/workflow.go +++ /dev/null @@ -1,925 +0,0 @@ -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 ", - 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 [--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 (unless skipped) - skipPreflight, _ := cmd.Flags().GetBool("skip-preflight") - if len(wf.Preflight) > 0 && !skipPreflight { - 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() - } else if skipPreflight && len(wf.Preflight) > 0 { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("%s Skipping preflight checks\n\n", yellow("⚠")) - } - - // 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 ", - 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 ", - 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") - workflowCreateCmd.Flags().Bool("skip-preflight", false, "Skip preflight checks (use with caution)") - - 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 -} diff --git a/internal/types/workflow.go b/internal/types/workflow.go deleted file mode 100644 index 9895c3b0..00000000 --- a/internal/types/workflow.go +++ /dev/null @@ -1,70 +0,0 @@ -package types - -// WorkflowTemplate represents a workflow definition loaded from YAML -type WorkflowTemplate struct { - SchemaVersion int `yaml:"schema_version" json:"schema_version"` - Name string `yaml:"name" json:"name"` - Description string `yaml:"description" json:"description"` - Defaults WorkflowDefaults `yaml:"defaults" json:"defaults"` - Variables []WorkflowVariable `yaml:"variables" json:"variables"` - Preflight []PreflightCheck `yaml:"preflight" json:"preflight"` - Epic WorkflowEpic `yaml:"epic" json:"epic"` - Tasks []WorkflowTask `yaml:"tasks" json:"tasks"` -} - -// WorkflowDefaults contains default values for task fields -type WorkflowDefaults struct { - Priority int `yaml:"priority" json:"priority"` - Type string `yaml:"type" json:"type"` -} - -// WorkflowVariable defines a variable that can be substituted in the template -type WorkflowVariable struct { - Name string `yaml:"name" json:"name"` - Description string `yaml:"description" json:"description"` - Required bool `yaml:"required" json:"required"` - Pattern string `yaml:"pattern" json:"pattern"` // Optional regex validation - DefaultValue string `yaml:"default" json:"default"` // Static default - DefaultCommand string `yaml:"default_command" json:"default_command"` // Command to run for default -} - -// PreflightCheck is a check that must pass before workflow creation -type PreflightCheck struct { - Command string `yaml:"command" json:"command"` - Message string `yaml:"message" json:"message"` -} - -// WorkflowEpic defines the parent epic for the workflow -type WorkflowEpic struct { - Title string `yaml:"title" json:"title"` - Description string `yaml:"description" json:"description"` - Priority int `yaml:"priority" json:"priority"` - Labels []string `yaml:"labels" json:"labels"` -} - -// WorkflowTask defines a single task in the workflow -type WorkflowTask struct { - ID string `yaml:"id" json:"id"` - Title string `yaml:"title" json:"title"` - Description string `yaml:"description" json:"description"` - Type string `yaml:"type" json:"type"` - Priority int `yaml:"priority" json:"priority"` - Estimate int `yaml:"estimate" json:"estimate"` // Minutes - DependsOn []string `yaml:"depends_on" json:"depends_on"` - Verification *Verification `yaml:"verification" json:"verification"` -} - -// Verification defines how to verify a task was completed successfully -type Verification struct { - Command string `yaml:"command" json:"command"` - ExpectExit *int `yaml:"expect_exit" json:"expect_exit"` - ExpectStdout string `yaml:"expect_stdout" json:"expect_stdout"` -} - -// WorkflowInstance represents a created workflow (epic + tasks) -type WorkflowInstance struct { - EpicID string `json:"epic_id"` - TemplateName string `json:"template_name"` - Variables map[string]string `json:"variables"` - TaskMap map[string]string `json:"task_map"` // template task ID -> actual issue ID -}