mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
workflow description
This commit is contained in:
parent
11b4ae2a7b
commit
f7ca8b44fa
14 changed files with 453 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
config/shipped_workflows_test.go
Normal file
47
config/shipped_workflows_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue