workflow description

This commit is contained in:
booleanmaybe 2026-04-18 09:51:36 -04:00
parent 11b4ae2a7b
commit f7ca8b44fa
14 changed files with 453 additions and 18 deletions

View file

@ -90,6 +90,26 @@ tiki workflow install sprint --global
tiki workflow install kanban --local
```
#### workflow describe
Fetch a workflow's description from the tiki repository and print it to stdout.
Reads the top-level `description` field of the named workflow's `workflow.yaml`.
Prints nothing and exits 0 if the workflow has no description field.
```bash
tiki workflow describe <name>
```
**Examples:**
```bash
# preview the todo workflow before installing it
tiki workflow describe todo
# check what bug-tracker is for
tiki workflow describe bug-tracker
```
### demo
Clone the demo project and launch the TUI. If the `tiki-demo` directory already exists it is reused.

View file

@ -4,6 +4,19 @@ tiki is highly customizable. `workflow.yaml` lets you define your workflow statu
how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through,
while plugins control what you see and how you interact with your work. This section covers both.
## Description
An optional top-level `description:` field in `workflow.yaml` describes what
the workflow is for. It supports multi-line text via YAML's block scalar (`|`)
and is used by `tiki workflow describe <name>` to preview a workflow before
installing it.
```yaml
description: |
Release workflow. Coordinate feature rollout through
Planned → Building → Staging → Canary → Released.
```
## Statuses
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define

View file

@ -20,6 +20,8 @@ func runWorkflow(args []string) int {
return runWorkflowReset(args[1:])
case "install":
return runWorkflowInstall(args[1:])
case "describe":
return runWorkflowDescribe(args[1:])
case "--help", "-h":
printWorkflowUsage()
return exitOK
@ -101,6 +103,43 @@ func runWorkflowInstall(args []string) int {
return exitOK
}
// runWorkflowDescribe implements `tiki workflow describe <name>`.
// describe is a read-only network call, so scope flags are rejected
// to keep the CLI surface honest.
func runWorkflowDescribe(args []string) int {
name, err := parsePositionalOnly(args)
if errors.Is(err, errHelpRequested) {
printWorkflowDescribeUsage()
return exitOK
}
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
printWorkflowDescribeUsage()
return exitUsage
}
if name == "" {
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
printWorkflowDescribeUsage()
return exitUsage
}
desc, err := config.DescribeWorkflow(name, config.DefaultWorkflowBaseURL)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
return exitInternal
}
if desc == "" {
return exitOK
}
if strings.HasSuffix(desc, "\n") {
fmt.Print(desc)
} else {
fmt.Println(desc)
}
return exitOK
}
// parseScopeArgs extracts an optional positional argument and a required --scope flag.
// Returns errHelpRequested for --help/-h.
func parseScopeArgs(args []string) (string, config.Scope, error) {
@ -134,12 +173,33 @@ func parseScopeArgs(args []string) (string, config.Scope, error) {
return positional, config.Scope(scopeStr), nil
}
// parsePositionalOnly extracts a single positional argument and rejects any
// flag other than --help/-h. Used by subcommands that don't take a scope.
func parsePositionalOnly(args []string) (string, error) {
var positional string
for _, arg := range args {
switch arg {
case "--help", "-h":
return "", errHelpRequested
}
if strings.HasPrefix(arg, "--") {
return "", fmt.Errorf("unknown flag: %s", arg)
}
if positional != "" {
return "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
}
positional = arg
}
return positional, nil
}
func printWorkflowUsage() {
fmt.Print(`Usage: tiki workflow <command>
Commands:
reset [target] [--scope] Reset config files to defaults
install <name> [--scope] Install a workflow from the tiki repository
describe <name> Fetch and print a workflow's description
Run 'tiki workflow <command> --help' for details.
`)
@ -178,3 +238,14 @@ Example:
tiki workflow install sprint --global
`)
}
func printWorkflowDescribeUsage() {
fmt.Print(`Usage: tiki workflow describe <name>
Fetch a workflow's description from the tiki repository and print it.
Reads the top-level 'description' field of the named workflow.yaml.
Example:
tiki workflow describe todo
`)
}

View file

@ -132,6 +132,53 @@ func TestParseScopeArgs(t *testing.T) {
}
}
func TestParsePositionalOnly(t *testing.T) {
tests := []struct {
name string
args []string
positional string
wantErr error
errSubstr string
}{
{name: "no args", args: nil},
{name: "single positional", args: []string{"sprint"}, positional: "sprint"},
{name: "help flag", args: []string{"--help"}, wantErr: errHelpRequested},
{name: "short help flag", args: []string{"-h"}, wantErr: errHelpRequested},
{name: "rejects scope", args: []string{"sprint", "--global"}, errSubstr: "unknown flag"},
{name: "rejects unknown flag", args: []string{"sprint", "--verbose"}, errSubstr: "unknown flag"},
{name: "multiple positional", args: []string{"a", "b"}, errSubstr: "multiple positional arguments"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
positional, err := parsePositionalOnly(tt.args)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if tt.errSubstr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if positional != tt.positional {
t.Errorf("positional = %q, want %q", positional, tt.positional)
}
})
}
}
// --- runWorkflow dispatch tests ---
func TestRunWorkflow_NoArgs(t *testing.T) {
@ -279,3 +326,89 @@ func TestRunWorkflowInstall_Help(t *testing.T) {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
// --- runWorkflowDescribe integration tests ---
func TestRunWorkflowDescribe_Success(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("description: |\n sprint desc\n"))
}))
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflowDescribe([]string{"sprint"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
func TestRunWorkflowDescribe_MissingName(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowDescribe(nil); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflowDescribe_InvalidName(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowDescribe([]string{"../../etc"}); code != exitInternal {
t.Errorf("exit code = %d, want %d", code, exitInternal)
}
}
func TestRunWorkflowDescribe_NotFound(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflowDescribe([]string{"nonexistent"}); code != exitInternal {
t.Errorf("exit code = %d, want %d", code, exitInternal)
}
}
func TestRunWorkflowDescribe_UnknownFlag(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowDescribe([]string{"sprint", "--verbose"}); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflowDescribe_RejectsScopeFlags(t *testing.T) {
_ = setupWorkflowTest(t)
for _, flag := range []string{"--global", "--local", "--current"} {
if code := runWorkflowDescribe([]string{"sprint", flag}); code != exitUsage {
t.Errorf("%s: exit code = %d, want %d", flag, code, exitUsage)
}
}
}
func TestRunWorkflowDescribe_Help(t *testing.T) {
if code := runWorkflowDescribe([]string{"--help"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
func TestRunWorkflow_DescribeDispatch(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("description: hi\n"))
}))
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflow([]string{"describe", "sprint"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}

View file

@ -1,3 +1,6 @@
description: |
Default tiki workflow. A lightweight kanban-style flow with
Backlog → Ready → In Progress → Review → Done, plus Story / Bug / Spike / Epic task types.
statuses:
- key: backlog
label: Backlog

View file

@ -8,6 +8,8 @@ import (
"path/filepath"
"regexp"
"time"
"gopkg.in/yaml.v3"
)
const (
@ -34,10 +36,6 @@ var installFiles = []string{
// to the directory for the given scope, overwriting existing files.
// baseURL is the root URL before "/workflows" (e.g. "https://raw.githubusercontent.com/boolean-maybe/tiki/main").
func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult, error) {
if !validWorkflowName.MatchString(name) {
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
}
dir, err := resolveDir(scope)
if err != nil {
return nil, err
@ -49,7 +47,7 @@ func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult,
if err != nil {
return nil, fmt.Errorf("fetch %s/%s: %w", name, filename, err)
}
fetched[filename] = content
fetched[filename] = string(content)
}
var results []InstallResult
@ -65,9 +63,32 @@ func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult,
return results, nil
}
// DescribeWorkflow fetches the workflow.yaml for name from baseURL and
// returns the value of its top-level `description:` field. Returns empty
// string if the field is absent.
func DescribeWorkflow(name, baseURL string) (string, error) {
body, err := fetchWorkflowFile(baseURL, name, defaultWorkflowFilename)
if err != nil {
return "", err
}
var wf struct {
Description string `yaml:"description"`
}
if err := yaml.Unmarshal(body, &wf); err != nil {
return "", fmt.Errorf("parse %s/workflow.yaml: %w", name, err)
}
return wf.Description, nil
}
var httpClient = &http.Client{Timeout: httpTimeout}
func fetchWorkflowFile(baseURL, name, filename string) (string, error) {
// fetchWorkflowFile validates the workflow name and downloads a single file
// from baseURL. Returns the raw body bytes.
func fetchWorkflowFile(baseURL, name, filename string) ([]byte, error) {
if !validWorkflowName.MatchString(name) {
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
}
url := fmt.Sprintf("%s/workflows/%s/%s", baseURL, name, filename)
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
@ -75,26 +96,26 @@ func fetchWorkflowFile(baseURL, name, filename string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("workflow %q not found (%s)", name, filename)
return nil, fmt.Errorf("workflow %q not found (%s)", name, filename)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
return nil, fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
return nil, fmt.Errorf("read response: %w", err)
}
return string(body), nil
return body, nil
}

View file

@ -127,6 +127,65 @@ func TestInstallWorkflow_InvalidName(t *testing.T) {
}
}
func TestDescribeWorkflow_Success(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("description: |\n Sprint workflow.\n Two-week cycles.\nstatuses:\n - key: todo\n"))
}))
defer server.Close()
desc, err := DescribeWorkflow("sprint", server.URL)
if err != nil {
t.Fatalf("DescribeWorkflow() error = %v", err)
}
want := "Sprint workflow.\nTwo-week cycles.\n"
if desc != want {
t.Errorf("description = %q, want %q", desc, want)
}
}
func TestDescribeWorkflow_NoDescriptionField(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
}))
defer server.Close()
desc, err := DescribeWorkflow("sprint", server.URL)
if err != nil {
t.Fatalf("DescribeWorkflow() error = %v", err)
}
if desc != "" {
t.Errorf("description = %q, want empty", desc)
}
}
func TestDescribeWorkflow_NotFound(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
_, err := DescribeWorkflow("nonexistent", server.URL)
if err == nil {
t.Fatal("expected error for nonexistent workflow, got nil")
}
}
func TestDescribeWorkflow_InvalidName(t *testing.T) {
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
if _, err := DescribeWorkflow(name, "http://unused"); err == nil {
t.Errorf("expected error for name %q, got nil", name)
}
}
}
func TestInstallWorkflow_AtomicFetch(t *testing.T) {
tikiDir := setupResetTest(t)

View file

@ -200,11 +200,12 @@ type viewsFileData struct {
// kept in config package to avoid import cycle with plugin package.
// all top-level sections must be listed here to survive round-trip serialization.
type workflowFileData struct {
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Types []map[string]interface{} `yaml:"types,omitempty"`
Views viewsFileData `yaml:"views,omitempty"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Fields []map[string]interface{} `yaml:"fields,omitempty"`
Description string `yaml:"description,omitempty"`
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Types []map[string]interface{} `yaml:"types,omitempty"`
Views viewsFileData `yaml:"views,omitempty"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Fields []map[string]interface{} `yaml:"fields,omitempty"`
}
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.

View file

@ -494,6 +494,59 @@ triggers:
}
}
func TestSavePluginViewMode_PreservesDescription(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `description: |
Release workflow. Coordinate feature rollout through
Planned Building Staging Canary Released.
statuses:
- key: backlog
label: Backlog
default: true
- key: done
label: Done
done: true
views:
- name: Kanban
default: true
key: "F1"
lanes:
- name: Done
filter: status = 'done'
action: status = 'done'
sort: Priority, CreatedAt
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatal(err)
}
wf, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile failed: %v", err)
}
wantDesc := "Release workflow. Coordinate feature rollout through\nPlanned → Building → Staging → Canary → Released.\n"
if wf.Description != wantDesc {
t.Errorf("description after read = %q, want %q", wf.Description, wantDesc)
}
if len(wf.Views.Plugins) > 0 {
wf.Views.Plugins[0]["view"] = "compact"
}
if err := writeWorkflowFile(workflowPath, wf); err != nil {
t.Fatalf("writeWorkflowFile failed: %v", err)
}
wf2, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile after write failed: %v", err)
}
if wf2.Description != wantDesc {
t.Errorf("description after round-trip = %q, want %q", wf2.Description, wantDesc)
}
}
func TestGetConfig(t *testing.T) {
// Reset appConfig
appConfig = nil

View file

@ -0,0 +1,47 @@
package config
import (
"os"
"path/filepath"
"testing"
"gopkg.in/yaml.v3"
)
// TestShippedWorkflows_HaveDescription ensures every workflow.yaml shipped in
// the repo's top-level workflows/ directory has a non-empty top-level
// description field and parses cleanly as a full workflowFileData.
// Guards against a maintainer dropping the description when adding a new
// shipped workflow.
func TestShippedWorkflows_HaveDescription(t *testing.T) {
matches, err := filepath.Glob("../workflows/*/workflow.yaml")
if err != nil {
t.Fatalf("glob shipped workflows: %v", err)
}
if len(matches) == 0 {
t.Fatal("no shipped workflows found at ../workflows/*/workflow.yaml")
}
for _, path := range matches {
t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var desc struct {
Description string `yaml:"description"`
}
if err := yaml.Unmarshal(data, &desc); err != nil {
t.Fatalf("unmarshal description from %s: %v", path, err)
}
if desc.Description == "" {
t.Errorf("%s: missing or empty top-level description", path)
}
if _, err := readWorkflowFile(path); err != nil {
t.Errorf("readWorkflowFile(%s) failed: %v", path, err)
}
})
}
}

View file

@ -13,7 +13,8 @@ import (
// WorkflowFile represents the YAML structure of a workflow.yaml file
type WorkflowFile struct {
Views viewsSectionConfig `yaml:"views"`
Description string `yaml:"description,omitempty"`
Views viewsSectionConfig `yaml:"views"`
}
// loadPluginsFromFile loads plugins from a single workflow.yaml file.

View file

@ -1,3 +1,8 @@
description: |
Bug tracker for triaging and resolving customer-reported issues.
Six statuses (Open → Triaged → In Progress → In Review → Verified, plus Won't Fix),
three task types (Bug, Regression, Incident), and custom fields for severity,
environment, reporter, foundIn version, due date, regression flag, and escalation count.
fields:
- name: severity
type: enum

View file

@ -1,3 +1,7 @@
description: |
Kanban-style workflow for small-team software work. Five statuses
(Backlog → Ready → In Progress → Review → Done) and four task types
(Story, Bug, Spike, Epic). Includes an "assign to me" action.
statuses:
- key: backlog
label: Backlog

View file

@ -1,3 +1,7 @@
description: |
Minimal two-lane todo list. One task type, two statuses (Todo → Done).
Use when you just want a lightweight checklist and don't need prioritization,
multiple task types, or review stages.
statuses:
- key: todo
label: Todo