diff --git a/.doc/doki/doc/command-line.md b/.doc/doki/doc/command-line.md index 2fc2c12..c3d2ee7 100644 --- a/.doc/doki/doc/command-line.md +++ b/.doc/doki/doc/command-line.md @@ -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 +``` + +**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. diff --git a/.doc/doki/doc/customization.md b/.doc/doki/doc/customization.md index 369f2d9..cd97822 100644 --- a/.doc/doki/doc/customization.md +++ b/.doc/doki/doc/customization.md @@ -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 ` 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 diff --git a/cmd_workflow.go b/cmd_workflow.go index 420815e..fdddfba 100644 --- a/cmd_workflow.go +++ b/cmd_workflow.go @@ -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 `. +// 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 Commands: reset [target] [--scope] Reset config files to defaults install [--scope] Install a workflow from the tiki repository + describe Fetch and print a workflow's description Run 'tiki workflow --help' for details. `) @@ -178,3 +238,14 @@ Example: tiki workflow install sprint --global `) } + +func printWorkflowDescribeUsage() { + fmt.Print(`Usage: tiki workflow describe + +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 +`) +} diff --git a/cmd_workflow_test.go b/cmd_workflow_test.go index 6043a0f..d1d7f3a 100644 --- a/cmd_workflow_test.go +++ b/cmd_workflow_test.go @@ -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) + } +} diff --git a/config/default_workflow.yaml b/config/default_workflow.yaml index 011da9c..a75119e 100644 --- a/config/default_workflow.yaml +++ b/config/default_workflow.yaml @@ -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 diff --git a/config/install.go b/config/install.go index f7684aa..a950b1e 100644 --- a/config/install.go +++ b/config/install.go @@ -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 } diff --git a/config/install_test.go b/config/install_test.go index fb607ae..e496975 100644 --- a/config/install_test.go +++ b/config/install_test.go @@ -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) diff --git a/config/loader.go b/config/loader.go index ba8dde3..52bbb90 100644 --- a/config/loader.go +++ b/config/loader.go @@ -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. diff --git a/config/loader_test.go b/config/loader_test.go index c53e782..32d4613 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -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 diff --git a/config/shipped_workflows_test.go b/config/shipped_workflows_test.go new file mode 100644 index 0000000..e61f9fd --- /dev/null +++ b/config/shipped_workflows_test.go @@ -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) + } + }) + } +} diff --git a/plugin/loader.go b/plugin/loader.go index 69e77f2..6cd66ec 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -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. diff --git a/workflows/bug-tracker/workflow.yaml b/workflows/bug-tracker/workflow.yaml index d30994c..f6a4fc1 100644 --- a/workflows/bug-tracker/workflow.yaml +++ b/workflows/bug-tracker/workflow.yaml @@ -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 diff --git a/workflows/kanban/workflow.yaml b/workflows/kanban/workflow.yaml index 011da9c..b0e95c6 100644 --- a/workflows/kanban/workflow.yaml +++ b/workflows/kanban/workflow.yaml @@ -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 diff --git a/workflows/todo/workflow.yaml b/workflows/todo/workflow.yaml index 74f783a..f70c47a 100644 --- a/workflows/todo/workflow.yaml +++ b/workflows/todo/workflow.yaml @@ -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