diff --git a/CHANGELOG.md b/CHANGELOG.md index f25558fc..0233d0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - CI scripts for publishing to Homebrew tap and AUR ### Plugins +- ticket-workflow 1.0.0: Workflow templates for repeatable multi-step processes (new plugin) - ticket-edit 1.0.0: Open ticket in $EDITOR (extracted from core) - ticket-ls 1.0.0: List tickets with optional filters (extracted from core); `ticket-list` symlink for alias - ticket-query 1.0.0: Output tickets as JSON, optionally filtered with jq (extracted from core) diff --git a/README.md b/README.md index 2d7017e5..07d751ed 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ Bundled plugins (ticket-extras): ls|list [--status=X] [-a X] [-T X] List tickets query [jq-filter] Output tickets as JSON, optionally filtered (requires jq) migrate-beads Import tickets from .beads/issues.jsonl (requires jq) + workflow list List available workflow templates + workflow run Instantiate workflow as tickets + --var key=value Set a variable (repeatable) + --dry-run Preview without creating tickets Searches parent directories for .tickets/ (override with TICKETS_DIR env var) Supports partial ID matching (e.g., 'tk show 5c4' matches 'nw-5c46') diff --git a/features/environment.py b/features/environment.py index 8a64bada..be971e8f 100644 --- a/features/environment.py +++ b/features/environment.py @@ -29,6 +29,9 @@ def before_scenario(context, scenario): context.stderr = '' context.returncode = None + # Isolate workflow tests from real user workflows + os.environ['TK_WORKFLOW_USER_DIR'] = os.path.join(context.test_dir, '.config', 'ticket', 'workflows') + def after_scenario(context, scenario): """Clean up temporary directories after each scenario.""" diff --git a/features/steps/workflow_steps.py b/features/steps/workflow_steps.py new file mode 100644 index 00000000..e7ad63b5 --- /dev/null +++ b/features/steps/workflow_steps.py @@ -0,0 +1,165 @@ +"""Step definitions for workflow plugin BDD tests.""" + +import re as _re +from pathlib import Path + +from behave import given, use_step_matcher + +use_step_matcher("re") + + +def create_workflow_file(context, name, content): + """Helper to create a workflow TOML file in .tickets/workflows/.""" + workflows_dir = Path(context.test_dir) / '.tickets' / 'workflows' + workflows_dir.mkdir(parents=True, exist_ok=True) + wf_path = workflows_dir / f'{name}.toml' + wf_path.write_text(content) + return wf_path + + +@given(r'a workflow file "(?P[^"]+)" with description "(?P[^"]+)"') +def step_workflow_file_with_desc(context, name, desc): + """Create a minimal workflow file with a description.""" + content = f'''workflow = "{name}" +description = "{desc}" +version = 1 + +[[steps]] +id = "step1" +title = "Step 1" +''' + create_workflow_file(context, name, content) + + +@given(r'a workflow file "(?P[^"]+)" with steps') +def step_workflow_file_with_steps(context, name): + """Create a workflow file with steps from a table.""" + lines = [f'workflow = "{name}"', f'description = "{name} workflow"', 'version = 1', ''] + + # Check for variables referenced in titles ({{var}}) + var_names = set() + for row in context.table: + for m in _re.finditer(r'\{\{(\w+)\}\}', row['title']): + var_names.add(m.group(1)) + + for var_name in var_names: + lines.append(f'[vars.{var_name}]') + lines.append(f'description = "{var_name}"') + lines.append(f'required = true') + lines.append('') + + for row in context.table: + lines.append('[[steps]]') + lines.append(f'id = "{row["id"]}"') + lines.append(f'title = "{row["title"]}"') + if row['needs'].strip(): + needs = ', '.join(f'"{n.strip()}"' for n in row['needs'].split(',')) + lines.append(f'needs = [{needs}]') + lines.append('') + + create_workflow_file(context, name, '\n'.join(lines)) + + +@given(r'a simple workflow file "(?P[^"]+)" with (?P\d+) steps') +def step_simple_workflow_file(context, name, count): + """Create a simple workflow file with N steps, no variables.""" + lines = [f'workflow = "{name}"', f'description = "{name} workflow"', 'version = 1', ''] + for i in range(1, int(count) + 1): + lines.append('[[steps]]') + lines.append(f'id = "step{i}"') + lines.append(f'title = "Step {i}"') + if i > 1: + lines.append(f'needs = ["step{i-1}"]') + lines.append('') + create_workflow_file(context, name, '\n'.join(lines)) + + +@given(r'a workflow file "(?P[^"]+)" with required variable "(?P[^"]+)"') +def step_workflow_with_required_var(context, name, var): + """Create a workflow with a required variable.""" + content = f'''workflow = "{name}" +description = "{name} workflow" +version = 1 + +[vars.{var}] +required = true + +[[steps]] +id = "step1" +title = "Step with {{{{{var}}}}}" +''' + create_workflow_file(context, name, content) + + +@given(r'a workflow file "(?P[^"]+)" with variable "(?P[^"]+)" pattern "(?P[^"]+)"') +def step_workflow_with_var_pattern(context, name, var, pattern): + """Create a workflow with a pattern-validated variable.""" + content = f'''workflow = "{name}" +description = "{name} workflow" +version = 1 + +[vars.{var}] +required = true +pattern = "{pattern}" + +[[steps]] +id = "step1" +title = "Step with {{{{{var}}}}}" +''' + create_workflow_file(context, name, content) + + +@given(r'a workflow file "(?P[^"]+)" with variable "(?P[^"]+)" enum "(?P[^"]+)"') +def step_workflow_with_var_enum(context, name, var, vals): + """Create a workflow with an enum-validated variable.""" + enum_list = ', '.join(f'"{v.strip()}"' for v in vals.split(',')) + content = f'''workflow = "{name}" +description = "{name} workflow" +version = 1 + +[vars.{var}] +required = true +enum = [{enum_list}] + +[[steps]] +id = "step1" +title = "Step with {{{{{var}}}}}" +''' + create_workflow_file(context, name, content) + + +@given(r'a workflow file "(?P[^"]+)" with multiline description and variable "(?P[^"]+)"') +def step_workflow_with_multiline(context, name, var): + """Create a workflow with a triple-quoted multiline description.""" + content = f'''workflow = "{name}" +description = """ +This is a multiline description for {{{{{var}}}}}. +It spans multiple lines. +""" +version = 1 + +[vars.{var}] +required = true + +[[steps]] +id = "step1" +title = "Do something for {{{{{var}}}}}" +''' + create_workflow_file(context, name, content) + + +@given(r'a workflow file "(?P[^"]+)" with variable "(?P[^"]+)" default "(?P[^"]+)" in step title "(?P[^"]+)"') +def step_workflow_with_var_default(context, name, var, default, title): + """Create a workflow with a variable that has a default value.""" + content = f'''workflow = "{name}" +description = "{name} workflow" +version = 1 + +[vars.{var}] +default = "{default}" + +[[steps]] +id = "step1" +title = "{title}" +''' + create_workflow_file(context, name, content) diff --git a/features/ticket_workflow.feature b/features/ticket_workflow.feature new file mode 100644 index 00000000..a30f7548 --- /dev/null +++ b/features/ticket_workflow.feature @@ -0,0 +1,100 @@ +Feature: Workflow Templates + As a user + I want to define reusable workflow templates + So that I can create repeatable multi-step processes as tickets + + Scenario: List workflows with none available + Given a clean tickets directory + When I run "ticket workflow list" + Then the command should succeed + And the output should contain "No workflows found" + + Scenario: List workflows from project directory + Given a clean tickets directory + And a workflow file "release" with description "Standard release" + When I run "ticket workflow list" + Then the command should succeed + And the output should contain "release" + And the output should contain "Standard release" + And the output should contain "project" + + Scenario: Workflow run with dry-run + Given a clean tickets directory + And a workflow file "release" with steps + | id | title | needs | + | bump | Bump version to {{version}} | | + | test | Run tests | bump | + | publish | Publish {{version}} | test | + When I run "ticket workflow run release --var version=1.0.0 --dry-run" + Then the command should succeed + And the output should contain "Dry run" + And the output should contain "Bump version to 1.0.0" + And the output should contain "Publish 1.0.0" + + Scenario: Workflow run creates parent and child tickets + Given a clean tickets directory + And a simple workflow file "deploy" with 2 steps + When I run "ticket workflow run deploy" + Then the command should succeed + And the output should contain "Created parent:" + And the output should contain "Created step:" + + Scenario: Workflow run creates dependencies between steps + Given a clean tickets directory + And a workflow file "release" with steps + | id | title | needs | + | build | Build project | | + | deploy | Deploy | build | + When I run "ticket workflow run release" + Then the command should succeed + And the output should contain "Created parent:" + And the output should contain "2 steps" + + Scenario: Missing required variable fails + Given a clean tickets directory + And a workflow file "release" with required variable "version" + When I run "ticket workflow run release" + Then the command should fail + And the output should contain "missing required variables" + And the output should contain "version" + + Scenario: Pattern validation rejects bad values + Given a clean tickets directory + And a workflow file "release" with variable "version" pattern "^[0-9]+\.[0-9]+\.[0-9]+$" + When I run "ticket workflow run release --var version=abc" + Then the command should fail + And the output should contain "does not match pattern" + + Scenario: Enum validation rejects bad values + Given a clean tickets directory + And a workflow file "deploy" with variable "env" enum "staging,production" + When I run "ticket workflow run deploy --var env=dev" + Then the command should fail + And the output should contain "not in allowed values" + + Scenario: Default variable values are used + Given a clean tickets directory + And a workflow file "deploy" with variable "env" default "staging" in step title "Deploy to {{env}}" + When I run "ticket workflow run deploy --dry-run" + Then the command should succeed + And the output should contain "Deploy to staging" + + Scenario: Workflow not found + Given a clean tickets directory + When I run "ticket workflow run nonexistent" + Then the command should fail + And the output should contain "workflow 'nonexistent' not found" + + Scenario: Multiline triple-quoted strings are parsed + Given a clean tickets directory + And a workflow file "tdd" with multiline description and variable "name" + When I run "ticket workflow run tdd --var name=auth --dry-run" + Then the command should succeed + And the output should contain "multiline description for auth" + And the output should contain "spans multiple lines" + + Scenario: No subcommand shows usage + Given a clean tickets directory + When I run "ticket workflow" + Then the command should fail + And the output should contain "Usage" diff --git a/openspec/changes/workflow-plugin/.openspec.yaml b/openspec/changes/workflow-plugin/.openspec.yaml new file mode 100644 index 00000000..f1842c5f --- /dev/null +++ b/openspec/changes/workflow-plugin/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-07 diff --git a/openspec/changes/workflow-plugin/design.md b/openspec/changes/workflow-plugin/design.md new file mode 100644 index 00000000..3c5299d2 --- /dev/null +++ b/openspec/changes/workflow-plugin/design.md @@ -0,0 +1,62 @@ +## Context + +`tk` is a single-file bash script with a plugin system. Plugins are executables named `ticket-<cmd>` in PATH. The existing plugins (ticket-query, ticket-ls, ticket-edit, ticket-migrate-beads) demonstrate the pattern: receive `TICKETS_DIR` and `TK_SCRIPT` env vars, call back to core via `$TK_SCRIPT super <cmd>`. + +Beads provides formulas (TOML workflow templates) and molecules (instantiated workflows). We want the template functionality without the molecule tracking overhead. + +## Goals / Non-Goals + +**Goals:** +- TOML workflow definitions compatible with beads formula syntax (s/formula/workflow/) +- `tk workflow list` to discover available workflows from project and user directories +- `tk workflow run <name> --var key=value` to instantiate a workflow as tickets with dependencies +- Variable substitution in step titles and descriptions using `{{var}}` syntax + +**Non-Goals:** +- Gates (human/timer/GitHub async coordination) +- Wisps (ephemeral operations) +- Molecule tracking (no persistent workflow instance state beyond the created tickets) +- Bond points / aspect composition +- Step hooks (on_complete actions) +- TOML library dependency — we parse a constrained subset with awk/sed + +## Decisions + +### 1. Single plugin file with subcommands + +The plugin `ticket-workflow` handles `list` and `run` as subcommands via `$1` dispatch. This keeps the plugin self-contained. + +Alternative: Separate `ticket-workflow-list` and `ticket-workflow-run` plugins. Rejected because `tk workflow list` naturally routes to `ticket-workflow` with `list` as an argument — the plugin system already handles this. + +### 2. TOML parsing with awk/sed (no external deps) + +We parse a constrained TOML subset sufficient for workflow definitions: top-level key-value pairs, `[vars.*]` sections, and `[[steps]]` array tables. This keeps the zero-dependency philosophy. + +Alternative: Require `tomlq` or Python. Rejected to maintain the coreutils-only requirement. + +### 3. Search path: project then user + +Workflows are searched in order: +1. `.tickets/workflows/*.toml` (project-level, version controlled) +2. `~/.config/ticket/workflows/*.toml` (user-level, personal) + +This mirrors the beads formula search path pattern. Project workflows take precedence. + +### 4. Workflow instantiation creates a parent ticket + child tickets + +`tk workflow run release --var version=1.0.0` creates: +- A parent ticket with the workflow name/description as title +- Child tickets for each step, with `--parent` set to the parent ID +- Dependencies between child tickets based on `needs` declarations + +This maps directly to existing `tk create` and `tk dep` commands. + +### 5. Variable validation at run time + +Required variables without defaults cause an error if not provided via `--var`. Pattern and enum constraints are validated before any tickets are created. This prevents partial workflow instantiation. + +## Risks / Trade-offs + +- **[Constrained TOML parsing]** → Only supports the subset needed for workflows. Malformed TOML outside this subset may produce confusing errors. Mitigation: clear error messages for parse failures, document supported syntax. +- **[No molecule state]** → Once tickets are created, there's no record linking them back to the workflow template. Mitigation: the parent-child relationship and a tag (workflow name) on created tickets provides sufficient traceability. +- **[Step type field ignored]** → We parse `type` on steps but don't enforce gate/human semantics. All steps become regular tickets. Mitigation: document that `type` is informational only. diff --git a/openspec/changes/workflow-plugin/proposal.md b/openspec/changes/workflow-plugin/proposal.md new file mode 100644 index 00000000..6258de15 --- /dev/null +++ b/openspec/changes/workflow-plugin/proposal.md @@ -0,0 +1,27 @@ +## Why + +The beads workflow system (formulas/molecules) provides useful templated workflow creation, but beads requires SQLite sync and a background daemon. Since `tk` already replaces beads for issue tracking, it should also support workflow templates natively — allowing users to define repeatable multi-step processes (releases, feature development, etc.) and instantiate them as tickets with proper dependency graphs. + +## What Changes + +- New `ticket-workflow` plugin providing `tk workflow list` and `tk workflow run` commands +- Workflow definitions in TOML format (adapted from beads formula format, replacing "formula" with "workflow") +- Workflow search path: `.tickets/workflows/` (project-level) then `~/.config/ticket/workflows/` (user-level) +- `tk workflow run <name>` creates parent + child tickets with dependencies, supporting variable substitution via `--var key=value` +- No gates, wisps, or molecule tracking — just template instantiation into tickets + +## Capabilities + +### New Capabilities +- `workflow-templates`: TOML-based workflow definition format with variables, step types, and dependency declarations +- `workflow-commands`: CLI commands for listing available workflows and instantiating them as tickets + +### Modified Capabilities + +## Impact + +- New plugin file: `plugins/ticket-workflow` +- New config directories: `.tickets/workflows/` and `~/.config/ticket/workflows/` +- Depends on core `tk create` and `tk dep` commands for ticket/dependency creation +- No changes to core script +- No new external dependencies beyond bash/sed/awk (TOML parsing done with awk/sed) diff --git a/openspec/changes/workflow-plugin/specs/workflow-commands/spec.md b/openspec/changes/workflow-plugin/specs/workflow-commands/spec.md new file mode 100644 index 00000000..ef2f5762 --- /dev/null +++ b/openspec/changes/workflow-plugin/specs/workflow-commands/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Workflow list command +The `tk workflow list` command SHALL display all available workflows from both project and user directories, showing the workflow name and description. + +#### Scenario: List with workflows available +- **WHEN** the user runs `tk workflow list` and workflows exist in the search path +- **THEN** the system SHALL print each workflow's name and description, one per line + +#### Scenario: List with no workflows +- **WHEN** the user runs `tk workflow list` and no workflow files exist in any search path +- **THEN** the system SHALL print a message indicating no workflows were found + +#### Scenario: List shows source location +- **WHEN** workflows exist in both project and user directories +- **THEN** the list SHALL indicate the source (project vs user) for each workflow + +### Requirement: Workflow run command +The `tk workflow run <name>` command SHALL instantiate a workflow by creating tickets for all steps with proper dependencies. + +#### Scenario: Basic workflow instantiation +- **WHEN** the user runs `tk workflow run release --var version=1.0.0` and a `release` workflow with 3 steps exists +- **THEN** the system SHALL create a parent ticket titled with the workflow description and 3 child tickets, printing each created ticket ID + +#### Scenario: Dependencies created between steps +- **WHEN** a workflow has step "deploy" with `needs = ["build", "test"]` +- **THEN** the created "deploy" ticket SHALL have dependencies on both the "build" and "test" tickets + +#### Scenario: Parent-child relationship +- **WHEN** a workflow is instantiated +- **THEN** all step tickets SHALL be created with `--parent` set to the parent (workflow root) ticket ID + +#### Scenario: Variable substitution in created tickets +- **WHEN** a workflow step has `title = "Bump version to {{version}}"` and `--var version=2.0.0` is provided +- **THEN** the created ticket SHALL have the title "Bump version to 2.0.0" + +#### Scenario: Workflow not found +- **WHEN** the user runs `tk workflow run nonexistent` +- **THEN** the system SHALL exit with an error indicating the workflow was not found + +#### Scenario: Dry run mode +- **WHEN** the user runs `tk workflow run release --var version=1.0.0 --dry-run` +- **THEN** the system SHALL print what tickets would be created without actually creating them + +### Requirement: Workflow run variable syntax +The `tk workflow run` command SHALL accept variables via `--var key=value` flags, supporting multiple variables. + +#### Scenario: Multiple variables +- **WHEN** the user runs `tk workflow run deploy --var version=1.0.0 --var env=production` +- **THEN** both `{{version}}` and `{{env}}` SHALL be substituted in step titles and descriptions + +#### Scenario: No variables needed +- **WHEN** a workflow has no required variables and the user runs `tk workflow run simple` +- **THEN** the system SHALL create tickets without requiring any `--var` flags + +### Requirement: Plugin metadata +The plugin SHALL include proper metadata comments for `tk help` discovery. + +#### Scenario: Help listing +- **WHEN** the user runs `tk help` +- **THEN** the workflow plugin SHALL appear with its description in the plugins section diff --git a/openspec/changes/workflow-plugin/specs/workflow-templates/spec.md b/openspec/changes/workflow-plugin/specs/workflow-templates/spec.md new file mode 100644 index 00000000..0dca85c7 --- /dev/null +++ b/openspec/changes/workflow-plugin/specs/workflow-templates/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Workflow definition format +The system SHALL support workflow definitions in TOML files with the following top-level fields: +- `workflow` (string, required): workflow name +- `description` (string, optional): human-readable description +- `version` (integer, optional): schema version +- `type` (string, optional): informational type field (e.g., "workflow") + +#### Scenario: Valid workflow file +- **WHEN** a file `.tickets/workflows/release.toml` contains `workflow = "release"` and `version = 1` +- **THEN** the system SHALL recognize it as a valid workflow definition + +#### Scenario: Missing workflow name +- **WHEN** a TOML file in the workflows directory lacks a `workflow = "..."` line +- **THEN** the system SHALL skip that file and not list it as an available workflow + +### Requirement: Workflow variables +The system SHALL support variable definitions under `[vars.<name>]` sections with the following fields: +- `description` (string, optional): human-readable description +- `required` (boolean, optional): whether the variable must be provided +- `default` (string, optional): default value if not provided +- `pattern` (string, optional): regex pattern for validation +- `enum` (array, optional): list of allowed values + +#### Scenario: Required variable provided +- **WHEN** a workflow defines `[vars.version]` with `required = true` and the user provides `--var version=1.0.0` +- **THEN** the system SHALL substitute `{{version}}` with `1.0.0` in all step titles and descriptions + +#### Scenario: Required variable missing +- **WHEN** a workflow defines `[vars.version]` with `required = true` and the user does not provide `--var version=...` +- **THEN** the system SHALL exit with an error listing the missing required variable + +#### Scenario: Variable with default +- **WHEN** a workflow defines `[vars.env]` with `default = "staging"` and the user does not provide `--var env=...` +- **THEN** the system SHALL use `"staging"` as the value for `{{env}}` + +#### Scenario: Variable with pattern constraint +- **WHEN** a workflow defines `[vars.version]` with `pattern = "^\d+\.\d+\.\d+$"` and the user provides `--var version=abc` +- **THEN** the system SHALL exit with an error indicating the value does not match the pattern + +#### Scenario: Variable with enum constraint +- **WHEN** a workflow defines `[vars.env]` with `enum = ["staging", "production"]` and the user provides `--var env=dev` +- **THEN** the system SHALL exit with an error indicating the value is not in the allowed set + +### Requirement: Workflow steps +The system SHALL support step definitions in `[[steps]]` array tables with the following fields: +- `id` (string, required): unique step identifier +- `title` (string, required): step title, supports `{{var}}` substitution +- `description` (string, optional): step description, supports `{{var}}` substitution +- `needs` (array, optional): list of step IDs this step depends on +- `type` (string, optional): informational step type + +#### Scenario: Step with dependencies +- **WHEN** a workflow defines step "implement" with `needs = ["design"]` +- **THEN** the system SHALL create a dependency from the "implement" ticket to the "design" ticket + +#### Scenario: Step with variable substitution in title +- **WHEN** a step has `title = "Deploy {{version}}"` and the variable `version` is `2.0.0` +- **THEN** the created ticket SHALL have title "Deploy 2.0.0" + +### Requirement: Workflow search path +The system SHALL search for workflow files in the following order: +1. `.tickets/workflows/*.toml` (project-level) +2. `~/.config/ticket/workflows/*.toml` (user-level) + +Project-level workflows SHALL take precedence over user-level workflows with the same name. + +#### Scenario: Project workflow found +- **WHEN** `.tickets/workflows/release.toml` exists +- **THEN** it SHALL be listed and available for `tk workflow run release` + +#### Scenario: User workflow found +- **WHEN** `~/.config/ticket/workflows/deploy.toml` exists and no project-level `deploy.toml` exists +- **THEN** it SHALL be listed and available for `tk workflow run deploy` + +#### Scenario: Project overrides user workflow +- **WHEN** both `.tickets/workflows/release.toml` and `~/.config/ticket/workflows/release.toml` exist +- **THEN** the project-level version SHALL be used diff --git a/openspec/changes/workflow-plugin/tasks.md b/openspec/changes/workflow-plugin/tasks.md new file mode 100644 index 00000000..365128e0 --- /dev/null +++ b/openspec/changes/workflow-plugin/tasks.md @@ -0,0 +1,39 @@ +## 1. Plugin Scaffold + +- [x] 1.1 Create `plugins/ticket-workflow` with shebang, metadata comments, and subcommand dispatch (list/run) +- [x] 1.2 Add usage/help output for `tk workflow` with no args or invalid subcommand + +## 2. TOML Parser + +- [x] 2.1 Implement awk/sed TOML parser for workflow files: extract top-level fields (workflow, description, version, type) +- [x] 2.2 Parse `[vars.<name>]` sections with description, required, default, pattern, enum fields +- [x] 2.3 Parse `[[steps]]` array tables with id, title, description, needs, type fields + +## 3. Workflow Discovery + +- [x] 3.1 Implement search path logic: `.tickets/workflows/*.toml` then `~/.config/ticket/workflows/*.toml` +- [x] 3.2 Implement `tk workflow list` — display name, description, and source for each workflow +- [x] 3.3 Handle project-overrides-user precedence for same-named workflows + +## 4. Variable Handling + +- [x] 4.1 Parse `--var key=value` flags from command line arguments +- [x] 4.2 Validate required variables are provided, apply defaults for optional ones +- [x] 4.3 Validate pattern constraints (regex match) +- [x] 4.4 Validate enum constraints (value in allowed set) +- [x] 4.5 Implement `{{var}}` substitution in step titles and descriptions + +## 5. Workflow Instantiation + +- [x] 5.1 Create parent ticket from workflow name/description using `$TK_SCRIPT super create` +- [x] 5.2 Create child tickets for each step with `--parent` set to parent ID +- [x] 5.3 Add dependencies between child tickets based on `needs` declarations using `$TK_SCRIPT super dep` +- [x] 5.4 Print summary of created tickets +- [x] 5.5 Implement `--dry-run` flag to preview without creating + +## 6. Testing & Documentation + +- [x] 6.1 Add behave scenarios for `tk workflow list` and `tk workflow run` +- [x] 6.2 Create an example workflow file (e.g., `release.toml`) for testing +- [x] 6.3 Update README.md usage section with workflow commands +- [x] 6.4 Update CHANGELOG.md diff --git a/plugins/ticket-workflow b/plugins/ticket-workflow new file mode 100755 index 00000000..6dc17ef0 --- /dev/null +++ b/plugins/ticket-workflow @@ -0,0 +1,611 @@ +#!/usr/bin/env bash +# tk-plugin: Workflow templates for repeatable multi-step processes +# tk-plugin-version: 1.0.0 +set -euo pipefail + +# Search paths for workflow definitions +# If TICKETS_DIR is set, use it; otherwise check .tickets/ in cwd +if [[ -n "${TICKETS_DIR:-}" ]]; then + project_workflows_dir="${TICKETS_DIR}/workflows" +elif [[ -d ".tickets/workflows" ]]; then + project_workflows_dir="$PWD/.tickets/workflows" +else + project_workflows_dir="" +fi +user_workflows_dir="${TK_WORKFLOW_USER_DIR:-${HOME}/.config/ticket/workflows}" + +usage() { + cat >&2 <<'EOF' +Usage: tk workflow <command> [args] + +Commands: + list List available workflow templates + run <name> [options] Instantiate a workflow as tickets + --var key=value Set a variable (repeatable) + --dry-run Preview without creating tickets + +Workflow files are TOML, stored in: + .tickets/workflows/ (project-level, checked into git) + ~/.config/ticket/workflows/ (user-level, personal) + +Example (.tickets/workflows/release.toml): + + workflow = "release" + description = "Standard release workflow" + version = 1 + + [vars.version] + description = "Release version" + required = true + pattern = "^\d+\.\d+\.\d+$" + + [vars.env] + description = "Target environment" + default = "staging" + enum = ["staging", "production"] + + [[steps]] + id = "bump-version" + title = "Bump version to {{version}}" + + [[steps]] + id = "changelog" + title = "Update CHANGELOG" + needs = ["bump-version"] + + [[steps]] + id = "test" + title = "Run full test suite" + needs = ["changelog"] + + [[steps]] + id = "publish" + title = "Publish {{version}} to {{env}}" + needs = ["test"] + type = "human" + +Variable fields: + required = true Error if not provided via --var + default = "value" Used when --var not provided + pattern = "regex" Validate value against regex + enum = ["a", "b"] Validate value is in list + +Step fields: + id = "name" Unique step identifier (required) + title = "text" Step title, supports {{var}} (required) + description = "text" Step description, supports {{var}} + needs = ["step-id"] Dependencies (wait for these steps) + type = "human" Informational step type +EOF + exit 1 +} + +# ============================================================================ +# TOML Parser — constrained subset for workflow definitions +# ============================================================================ + +# Parse a workflow TOML file, outputting structured data to stdout. +# Format: key-value lines for top-level fields, vars, and steps. +# TOP:key=value +# VAR:name:field=value +# STEP:index:field=value +parse_workflow() { + local file="$1" + awk ' + BEGIN { + step_idx = -1; section = "top"; var_name = "" + in_multiline = 0; ml_key = ""; ml_val = "" + US = sprintf("%c", 31) # Unit separator for encoding newlines + } + + # Accumulate multiline triple-quoted strings + in_multiline { + if (/"""/) { + # Closing triple-quote found + line = $0 + sub(/""".*$/, "", line) + ml_val = ml_val line + # Remove leading/trailing unit separators + if (substr(ml_val, 1, 1) == US) ml_val = substr(ml_val, 2) + if (substr(ml_val, length(ml_val)) == US) ml_val = substr(ml_val, 1, length(ml_val) - 1) + in_multiline = 0 + # Emit the accumulated value + if (section == "top") { + print "TOP:" ml_key "=" ml_val + } else if (section == "var") { + print "VAR:" var_name ":" ml_key "=" ml_val + } else if (section == "step") { + print "STEP:" step_idx ":" ml_key "=" ml_val + } + next + } + ml_val = ml_val $0 US + next + } + + # Skip comments and blank lines + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + + # [[steps]] array table + /^\[\[steps\]\]/ { + section = "step" + step_idx++ + next + } + + # [vars.<name>] section + /^\[vars\./ { + section = "var" + var_name = $0 + gsub(/^\[vars\./, "", var_name) + gsub(/\].*$/, "", var_name) + next + } + + # Any other section header — skip + /^\[/ { section = "skip"; next } + + # Key = value lines + /=/ { + key = $0 + sub(/[[:space:]]*=.*/, "", key) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + + val = $0 + sub(/^[^=]*=[[:space:]]*/, "", val) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", val) + + # Check for triple-quoted multiline string + if (val ~ /^"""/) { + # Remove opening triple-quote + sub(/^"""/, "", val) + # Check if closing triple-quote is on the same line + if (val ~ /"""$/) { + sub(/"""$/, "", val) + } else { + # Start multiline accumulation + in_multiline = 1 + ml_key = key + ml_val = val US + next + } + } + + # Strip surrounding single quotes + if (val ~ /^".*"$/) { + val = substr(val, 2, length(val) - 2) + } + + if (section == "top") { + print "TOP:" key "=" val + } else if (section == "var") { + print "VAR:" var_name ":" key "=" val + } else if (section == "step") { + print "STEP:" step_idx ":" key "=" val + } + } + ' "$file" +} + +# ============================================================================ +# Workflow Discovery +# ============================================================================ + +# Find all workflow files, project-level first, deduplicating by name. +# Outputs: source\tname\tfile_path (tab-separated) +discover_workflows() { + local seen_names="" + + _scan_dir() { + local dir="$1" source="$2" + [[ -d "$dir" ]] || return 0 + local f + for f in "$dir"/*.toml; do + [[ -f "$f" ]] || continue + local name + name=$(basename "$f" .toml) + # Check if already seen (project takes precedence) + case " $seen_names " in + *" $name "*) continue ;; + esac + # Verify it has a workflow= line + if grep -q '^workflow[[:space:]]*=' "$f" 2>/dev/null; then + seen_names="$seen_names $name" + printf '%s\t%s\t%s\n' "$source" "$name" "$f" + fi + done + } + + [[ -n "${project_workflows_dir:-}" ]] && _scan_dir "$project_workflows_dir" "project" + _scan_dir "$user_workflows_dir" "user" +} + +# Find a specific workflow file by name. Returns the file path. +find_workflow() { + local name="$1" + local result + result=$(discover_workflows | awk -F'\t' -v name="$name" '$2 == name { print $3; exit }') + if [[ -z "$result" ]]; then + echo "Error: workflow '$name' not found" >&2 + echo "Search paths:" >&2 + [[ -n "${project_workflows_dir:-}" ]] && echo " ${project_workflows_dir}/" >&2 + echo " ${user_workflows_dir}/" >&2 + return 1 + fi + echo "$result" +} + +# Decode unit separator (ASCII 31) back to newlines in TOML multiline values. +decode_ml() { + printf '%s' "$1" | tr "$(printf '\037')" '\n' +} + +# ============================================================================ +# Key-Value Store (bash 3.2 compatible replacement for associative arrays) +# Uses a newline-separated string of key=value pairs. +# Values containing newlines are stored with \x1F (unit separator) encoding. +# ============================================================================ + +# Get value for key from store. Returns 1 if not found. +# Decodes \x1F back to newlines in the returned value. +kv_get() { + local store="$1" key="$2" + local line + while IFS= read -r line; do + case "$line" in + "${key}="*) + decode_ml "${line#*=}" + return 0 + ;; + esac + done <<< "$store" + return 1 +} + +# Set key=value in store (prints updated store). +kv_set() { + local store="$1" key="$2" val="$3" + local found=0 result="" line + if [[ -n "$store" ]]; then + while IFS= read -r line; do + case "$line" in + "${key}="*) result="${result}${key}=${val}"$'\n'; found=1 ;; + *) [[ -n "$line" ]] && result="${result}${line}"$'\n' ;; + esac + done <<< "$store" + fi + if [[ $found -eq 0 ]]; then + result="${result}${key}=${val}"$'\n' + fi + echo "$result" +} + +# Check if key exists in store. +kv_has() { + local store="$1" key="$2" + kv_get "$store" "$key" >/dev/null 2>&1 +} + +# List all keys in store. +kv_keys() { + local store="$1" line + [[ -z "$store" ]] && return 0 + while IFS= read -r line; do + [[ -n "$line" ]] && echo "${line%%=*}" + done <<< "$store" +} + +# ============================================================================ +# Variable Handling +# ============================================================================ + +# Substitute {{var}} placeholders in a string using a kv store. +substitute_vars() { + local text="$1" var_store="$2" + local key val + for key in $(kv_keys "$var_store"); do + val=$(kv_get "$var_store" "$key") || continue + text="${text//\{\{${key}\}\}/${val}}" + done + printf '%s' "$text" +} + +# Validate variables against workflow definitions. +# Sets global var_store with validated+defaulted variables. +validate_vars() { + local parsed_data="$1" + # var_store is global, already set by caller + + # Collect unique variable names from parsed data + local var_names="" line vname + while IFS= read -r line; do + case "$line" in + VAR:*:*) + vname="${line#VAR:}" + vname="${vname%%:*}" + case " $var_names " in + *" $vname "*) ;; + *) var_names="$var_names $vname" ;; + esac + ;; + esac + done <<< "$parsed_data" + + local missing="" + + for vname in $var_names; do + local required="" default="" pattern="" enum_vals="" + + # Extract properties for this variable + while IFS= read -r line; do + case "$line" in + "VAR:${vname}:required="*) required="${line#VAR:${vname}:required=}" ;; + "VAR:${vname}:default="*) default="${line#VAR:${vname}:default=}" ;; + "VAR:${vname}:pattern="*) pattern="${line#VAR:${vname}:pattern=}" ;; + "VAR:${vname}:enum="*) enum_vals="${line#VAR:${vname}:enum=}" ;; + esac + done <<< "$parsed_data" + + # Apply default if not provided + if ! kv_has "$var_store" "$vname"; then + if [[ -n "$default" ]]; then + var_store=$(kv_set "$var_store" "$vname" "$default") + elif [[ "$required" == "true" ]]; then + missing="$missing $vname" + continue + fi + fi + + # Skip validation if variable still not set + kv_has "$var_store" "$vname" || continue + + local val + val=$(kv_get "$var_store" "$vname") + + # Pattern validation + if [[ -n "$pattern" ]]; then + pattern="${pattern#\"}" + pattern="${pattern%\"}" + if ! echo "$val" | grep -qE "$pattern"; then + echo "Error: variable '$vname' value '$val' does not match pattern '$pattern'" >&2 + return 1 + fi + fi + + # Enum validation + if [[ -n "$enum_vals" ]]; then + local stripped="${enum_vals#\[}" + stripped="${stripped%\]}" + local valid=0 + local IFS=',' + local item + for item in $stripped; do + item="${item## }" + item="${item%% }" + item="${item#\"}" + item="${item%\"}" + [[ "$item" == "$val" ]] && { valid=1; break; } + done + if [[ $valid -eq 0 ]]; then + echo "Error: variable '$vname' value '$val' not in allowed values: ${enum_vals}" >&2 + return 1 + fi + fi + done + + if [[ -n "$missing" ]]; then + echo "Error: missing required variables:${missing}" >&2 + echo "Hint: pass them with$(for v in $missing; do printf ' --var %s=<value>' "$v"; done)" >&2 + return 1 + fi +} + +# ============================================================================ +# Commands +# ============================================================================ + +cmd_list() { + local workflows + workflows=$(discover_workflows) + + if [[ -z "$workflows" ]]; then + echo "No workflows found" + echo "Search paths:" + [[ -n "${project_workflows_dir:-}" ]] && echo " ${project_workflows_dir}/" + echo " ${user_workflows_dir}/" + return 0 + fi + + while IFS=$'\t' read -r source name filepath; do + local desc="" + desc=$(parse_workflow "$filepath" | awk -F= '/^TOP:description=/ { print substr($0, index($0,"=")+1); exit }') + printf "%-20s %-10s %s\n" "$name" "($source)" "${desc:-(no description)}" + done <<< "$workflows" +} + +cmd_run() { + # Parse arguments + local run_name="" dry_run=0 + var_store="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --var) + [[ $# -lt 2 ]] && { echo "Error: --var requires a key=value argument" >&2; return 1; } + local kv="$2" k="${2%%=*}" v="${2#*=}" + [[ "$k" == "$2" ]] && { echo "Error: --var requires key=value format, got '$2'" >&2; return 1; } + var_store=$(kv_set "$var_store" "$k" "$v") + shift 2 + ;; + --var=*) + local kv="${1#--var=}" k="${kv%%=*}" v="${kv#*=}" + var_store=$(kv_set "$var_store" "$k" "$v") + shift + ;; + --dry-run) dry_run=1; shift ;; + -*) echo "Error: unknown option '$1'" >&2; return 1 ;; + *) + [[ -z "$run_name" ]] && run_name="$1" || { echo "Error: unexpected argument '$1'" >&2; return 1; } + shift + ;; + esac + done + + if [[ -z "$run_name" ]]; then + echo "Usage: tk workflow run <name> [--var key=value] [--dry-run]" >&2 + return 1 + fi + + # Find the workflow file + local wf_file + wf_file=$(find_workflow "$run_name") || return 1 + + # Parse it + local parsed + parsed=$(parse_workflow "$wf_file") + + # Get workflow metadata + local wf_name="" wf_desc="" + local line + while IFS= read -r line; do + case "$line" in + TOP:workflow=*) wf_name="${line#TOP:workflow=}" ;; + TOP:description=*) wf_desc=$(decode_ml "${line#TOP:description=}") ;; + esac + done <<< "$parsed" + + # Validate variables (sets var_store with defaults) + validate_vars "$parsed" || return 1 + + # Collect steps into indexed arrays + local step_ids="" step_titles="" step_descs="" step_needs="" + local max_step=-1 + while IFS= read -r line; do + case "$line" in + STEP:*:id=*) + local idx="${line#STEP:}" val + idx="${idx%%:*}" + val="${line#STEP:${idx}:id=}" + step_ids=$(kv_set "$step_ids" "$idx" "$val") + [[ $idx -gt $max_step ]] && max_step=$idx + ;; + STEP:*:title=*) + local idx="${line#STEP:}" + idx="${idx%%:*}" + step_titles=$(kv_set "$step_titles" "$idx" "${line#STEP:${idx}:title=}") + ;; + STEP:*:description=*) + local idx="${line#STEP:}" + idx="${idx%%:*}" + step_descs=$(kv_set "$step_descs" "$idx" "${line#STEP:${idx}:description=}") + ;; + STEP:*:needs=*) + local idx="${line#STEP:}" + idx="${idx%%:*}" + step_needs=$(kv_set "$step_needs" "$idx" "${line#STEP:${idx}:needs=}") + ;; + esac + done <<< "$parsed" + + if [[ $max_step -lt 0 ]]; then + echo "Error: workflow '$run_name' has no steps" >&2 + return 1 + fi + + # Substitute variables in workflow description for parent title + local parent_title="${wf_desc:-$wf_name}" + parent_title=$(substitute_vars "$parent_title" "$var_store") + + if [[ $dry_run -eq 1 ]]; then + echo "Dry run: workflow '$run_name'" + echo "" + echo "Would create parent ticket: $parent_title" + echo "" + echo "Steps:" + local i + for (( i = 0; i <= max_step; i++ )); do + local sid stitle needs_raw needs_display="" + sid=$(kv_get "$step_ids" "$i") + stitle=$(kv_get "$step_titles" "$i") + stitle=$(substitute_vars "$stitle" "$var_store") + needs_raw=$(kv_get "$step_needs" "$i" 2>/dev/null) || true + [[ -n "$needs_raw" ]] && needs_display=" (needs: ${needs_raw})" + echo " ${sid}: ${stitle}${needs_display}" + done + return 0 + fi + + # Ensure .tickets directory exists (create handles this, but we need TICKETS_DIR set for dep) + if [[ -z "${TICKETS_DIR:-}" ]]; then + mkdir -p .tickets + TICKETS_DIR="$PWD/.tickets" + export TICKETS_DIR + fi + + # Create parent ticket + local parent_id + parent_id=$("$TK_SCRIPT" super create "$parent_title" --type "epic" --tags "workflow:${wf_name}") + echo "Created parent: $parent_id - $parent_title" + + # Create child tickets, mapping step IDs to ticket IDs + local step_to_ticket="" + local i + for (( i = 0; i <= max_step; i++ )); do + local sid stitle sdesc child_id + sid=$(kv_get "$step_ids" "$i") + stitle=$(kv_get "$step_titles" "$i") + stitle=$(substitute_vars "$stitle" "$var_store") + sdesc=$(kv_get "$step_descs" "$i" 2>/dev/null) || true + [[ -n "$sdesc" ]] && sdesc=$(substitute_vars "$sdesc" "$var_store") + + local child_args=("$stitle" --parent "$parent_id" --tags "workflow:${wf_name}") + [[ -n "$sdesc" ]] && child_args+=(-d "$sdesc") + child_id=$("$TK_SCRIPT" super create "${child_args[@]}") + step_to_ticket=$(kv_set "$step_to_ticket" "$sid" "$child_id") + echo " Created step: $child_id - $stitle" + done + + # Add dependencies + for (( i = 0; i <= max_step; i++ )); do + local sid needs_raw + sid=$(kv_get "$step_ids" "$i") + needs_raw=$(kv_get "$step_needs" "$i" 2>/dev/null) || true + [[ -z "$needs_raw" ]] && continue + + # Parse needs array: ["a", "b"] or [a, b] + local stripped="${needs_raw#\[}" + stripped="${stripped%\]}" + local IFS=',' dep_name + for dep_name in $stripped; do + dep_name="${dep_name## }" + dep_name="${dep_name%% }" + dep_name="${dep_name#\"}" + dep_name="${dep_name%\"}" + local dep_ticket_id + if dep_ticket_id=$(kv_get "$step_to_ticket" "$dep_name" 2>/dev/null); then + local this_ticket_id + this_ticket_id=$(kv_get "$step_to_ticket" "$sid") + "$TK_SCRIPT" super dep "$this_ticket_id" "$dep_ticket_id" >/dev/null + else + echo " Warning: step '$sid' needs unknown step '$dep_name'" >&2 + fi + done + done + + echo "" + echo "Workflow '$wf_name' created with $((max_step + 1)) steps (parent: $parent_id)" +} + +# ============================================================================ +# Main Dispatch +# ============================================================================ + +subcmd="${1:-}" +shift || true + +case "$subcmd" in + list) cmd_list ;; + run) cmd_run "$@" ;; + *) usage ;; +esac