relay-flow 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ // Package acli wraps the Atlassian `acli` CLI. Every call is real — no
2
+ // dry-run mode: acli/Jira calls always execute (only orca CLI calls
3
+ // support dry-run).
4
+ package acli
5
+
6
+ import (
7
+ "encoding/json"
8
+ "fmt"
9
+ "log"
10
+ "os/exec"
11
+ "strings"
12
+ )
13
+
14
+ type Client struct{}
15
+
16
+ func New() *Client {
17
+ return &Client{}
18
+ }
19
+
20
+ // rawSearchResult matches acli search's minimal JSON shape: only `key`.
21
+ type rawSearchResult struct {
22
+ Key string `json:"key"`
23
+ }
24
+
25
+ // rawTicket matches acli view's JSON shape: `key` is top-level, other
26
+ // fields live under `fields`.
27
+ type rawTicket struct {
28
+ Key string `json:"key"`
29
+ Fields struct {
30
+ Summary string `json:"summary"`
31
+ Status struct {
32
+ Name string `json:"name"`
33
+ } `json:"status"`
34
+ Components []struct {
35
+ Name string `json:"name"`
36
+ } `json:"components"`
37
+ IssueType struct {
38
+ Name string `json:"name"`
39
+ } `json:"issuetype"`
40
+ Description json.RawMessage `json:"description"`
41
+ Parent struct {
42
+ Key string `json:"key"`
43
+ } `json:"parent"`
44
+ Labels []string `json:"labels"`
45
+ } `json:"fields"`
46
+ }
47
+
48
+ type Ticket struct {
49
+ Key string
50
+ Summary string
51
+ Description string
52
+ Status string
53
+ IssueType string
54
+ // Component resolves to an Orca repo by matching repo displayName.
55
+ Component string
56
+ // ParentKey is the Jira parent ticket's key (native subtask parent
57
+ // link), if any. Subtasks reuse their parent's worktree/base branch
58
+ // instead of branching from main.
59
+ ParentKey string
60
+ // Labels are the ticket's Jira labels, e.g. "baseBranch:foo" or the
61
+ // "orca-workflow:<name>" claim label.
62
+ Labels []string
63
+ }
64
+
65
+ // LabelValue returns the value portion of a "prefix:value" label on the
66
+ // ticket, if present, e.g. LabelValue("baseBranch") -> "foo" for label
67
+ // "baseBranch:foo".
68
+ func (t Ticket) LabelValue(prefix string) (string, bool) {
69
+ for _, l := range t.Labels {
70
+ if strings.HasPrefix(l, prefix+":") {
71
+ return strings.TrimPrefix(l, prefix+":"), true
72
+ }
73
+ }
74
+ return "", false
75
+ }
76
+
77
+ // adfText extracts plain text from a Jira ADF (Atlassian Document Format)
78
+ // description by concatenating every "text" node.
79
+ func adfText(raw json.RawMessage) string {
80
+ var node struct {
81
+ Text string `json:"text"`
82
+ Content []json.RawMessage `json:"content"`
83
+ }
84
+ if err := json.Unmarshal(raw, &node); err != nil {
85
+ return ""
86
+ }
87
+ var parts []string
88
+ if node.Text != "" {
89
+ parts = append(parts, node.Text)
90
+ }
91
+ for _, c := range node.Content {
92
+ if t := adfText(c); t != "" {
93
+ parts = append(parts, t)
94
+ }
95
+ }
96
+ return strings.Join(parts, " ")
97
+ }
98
+
99
+ // Search runs `acli jira workitem search --jql <jql> --fields key --json`
100
+ // to get matching ticket keys only, then fetches full details for each via
101
+ // `view`. In dry-run mode, no subprocess is executed; caller should treat
102
+ // result as empty.
103
+ func (c *Client) Search(jql string) ([]Ticket, error) {
104
+ // No --fields flag: default output always includes top-level "key",
105
+ // which is all we need here. Full details are fetched per-ticket via view.
106
+ cmd := exec.Command("acli", "jira", "workitem", "search",
107
+ "--jql", jql,
108
+ "--json")
109
+ var stderr strings.Builder
110
+ cmd.Stderr = &stderr
111
+ out, err := cmd.Output()
112
+ if err != nil {
113
+ return nil, fmt.Errorf("acli search: %w: %s", err, strings.TrimSpace(stderr.String()))
114
+ }
115
+ var raw []rawSearchResult
116
+ if err := json.Unmarshal(out, &raw); err != nil {
117
+ return nil, fmt.Errorf("acli search: parse json: %w", err)
118
+ }
119
+ tickets := make([]Ticket, 0, len(raw))
120
+ for _, r := range raw {
121
+ t, err := c.View(r.Key)
122
+ if err != nil {
123
+ log.Printf("acli search: could not fetch details for %s: %v", r.Key, err)
124
+ continue
125
+ }
126
+ tickets = append(tickets, t)
127
+ }
128
+ return tickets, nil
129
+ }
130
+
131
+ // ValidateAssignee checks that the given user (display name or accountId)
132
+ // exists by running `assignee = "<user>"` as a JQL fragment — Jira's JQL
133
+ // parser rejects unknown users with a hard error, like unknown statuses.
134
+ func (c *Client) ValidateAssignee(assignee string) error {
135
+ jql := fmt.Sprintf(`assignee = %q`, assignee)
136
+ out, err := exec.Command("acli", "jira", "workitem", "search",
137
+ "--jql", jql, "--json").CombinedOutput()
138
+ if err != nil {
139
+ return fmt.Errorf("assignee %q is not a valid Jira user: %s", assignee, strings.TrimSpace(string(out)))
140
+ }
141
+ return nil
142
+ }
143
+
144
+ // ValidateStatus checks that status is a real Jira status in the given
145
+ // project by running `status = "<status>"` as a JQL fragment — Jira's JQL
146
+ // parser rejects unknown status values with a hard error, so a typo in
147
+ // the workflow YAML ("DO Done") fails here instead of silently never
148
+ // matching at runtime. Returns nil if valid, error otherwise.
149
+ func (c *Client) ValidateStatus(projectKey, status string) error {
150
+ jql := fmt.Sprintf(`project = %s AND status = %q`, projectKey, status)
151
+ out, err := exec.Command("acli", "jira", "workitem", "search",
152
+ "--jql", jql, "--json").CombinedOutput()
153
+ if err != nil {
154
+ return fmt.Errorf("status %q is not valid in project %s: %s", status, projectKey, strings.TrimSpace(string(out)))
155
+ }
156
+ return nil
157
+ }
158
+
159
+ // view fetches full ticket details via
160
+ // `acli jira workitem view <key> --fields status,assignee,reporter,components --json`.
161
+ func (c *Client) View(key string) (Ticket, error) {
162
+ out, err := exec.Command("acli", "jira", "workitem", "view", key,
163
+ "--fields", "summary,description,status,components,issuetype,parent,labels",
164
+ "--json").Output()
165
+ if err != nil {
166
+ return Ticket{}, fmt.Errorf("acli view %s: %w", key, err)
167
+ }
168
+ var r rawTicket
169
+ if err := json.Unmarshal(out, &r); err != nil {
170
+ return Ticket{}, fmt.Errorf("acli view %s: parse json: %w", key, err)
171
+ }
172
+ var t Ticket
173
+ t.Key = r.Key
174
+ t.Summary = r.Fields.Summary
175
+ t.Description = adfText(r.Fields.Description)
176
+ t.Status = r.Fields.Status.Name
177
+ t.IssueType = r.Fields.IssueType.Name
178
+ if len(r.Fields.Components) > 0 {
179
+ t.Component = r.Fields.Components[0].Name
180
+ }
181
+ t.ParentKey = r.Fields.Parent.Key
182
+ t.Labels = r.Fields.Labels
183
+ return t, nil
184
+ }
185
+
186
+ // AddLabel adds label to the ticket's existing labels (acli's --labels
187
+ // flag replaces the set, so the caller/View result must be merged in).
188
+ func (c *Client) AddLabel(key string, existing []string, label string) error {
189
+ for _, l := range existing {
190
+ if l == label {
191
+ return nil // already present
192
+ }
193
+ }
194
+ all := append(append([]string{}, existing...), label)
195
+ return runAcli("jira", "workitem", "edit", "--key", key, "--labels", strings.Join(all, ","), "--yes", "--json")
196
+ }
197
+
198
+ func (c *Client) Transition(key, status string) error {
199
+ return runAcli("jira", "workitem", "transition", "--key", key, "--status", status, "--yes", "--json")
200
+ }
201
+
202
+ func (c *Client) Comment(key, body string) error {
203
+ return runAcli("jira", "workitem", "comment", "create", "--key", key, "--body", body, "--json")
204
+ }
205
+
206
+ func runAcli(args ...string) error {
207
+ out, err := exec.Command("acli", args...).CombinedOutput()
208
+ if err != nil {
209
+ return fmt.Errorf("acli %v: %w: %s", args, err, string(out))
210
+ }
211
+ // acli exits 0 even when the operation fails server-side — the JSON
212
+ // envelope carries per-item results with status FAILURE.
213
+ var env struct {
214
+ Results []struct {
215
+ Status string `json:"status"`
216
+ Message string `json:"message"`
217
+ ID string `json:"id"`
218
+ } `json:"results"`
219
+ SuccessCount int `json:"successCount"`
220
+ }
221
+ if jsonErr := json.Unmarshal(out, &env); jsonErr == nil && env.Results != nil {
222
+ for _, r := range env.Results {
223
+ if !strings.EqualFold(r.Status, "SUCCESS") {
224
+ return fmt.Errorf("acli %v: %s: %s", args, r.ID, r.Message)
225
+ }
226
+ }
227
+ }
228
+ return nil
229
+ }
@@ -0,0 +1,17 @@
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func TestDemoWorkflowYAML(t *testing.T) {
10
+ b, err := os.ReadFile(filepath.Join("..", "..", "..", ".workflow", "workflow.yaml"))
11
+ if err != nil {
12
+ t.Skip("demo yaml not present")
13
+ }
14
+ if _, err := Parse("demo", b); err != nil {
15
+ t.Fatalf("demo .workflow/workflow.yaml must validate: %v", err)
16
+ }
17
+ }
@@ -0,0 +1,71 @@
1
+ package config
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+
9
+ "gopkg.in/yaml.v3"
10
+ )
11
+
12
+ // MachineConfig is the per-machine, uncommitted config at
13
+ // ~/.relay-flow/config.yaml. It holds settings that are personal to
14
+ // whoever runs the server on this machine — never committed to a repo,
15
+ // unlike workflow YAMLs which the whole team shares.
16
+ type MachineConfig struct {
17
+ // Assignee is this machine user's Jira identity (display name or
18
+ // accountId). In distributed mode (workflow yaml without
19
+ // assigneeIsAgent), every workflow JQL gets `AND assignee = "<this>"`,
20
+ // so a teammate's server never touches your tickets and vice versa.
21
+ // Probe-validated against Jira at submit time.
22
+ Assignee string `yaml:"assignee"`
23
+ }
24
+
25
+ // MachineConfigPath returns ~/.relay-flow/config.yaml.
26
+ func MachineConfigPath() (string, error) {
27
+ home, err := os.UserHomeDir()
28
+ if err != nil {
29
+ return "", err
30
+ }
31
+ return filepath.Join(home, ".relay-flow", "config.yaml"), nil
32
+ }
33
+
34
+ // Save writes the machine config, creating the dir. Assignee is required.
35
+ func (m *MachineConfig) Save() error {
36
+ if strings.TrimSpace(m.Assignee) == "" {
37
+ return fmt.Errorf("assignee must not be empty")
38
+ }
39
+ p, err := MachineConfigPath()
40
+ if err != nil {
41
+ return err
42
+ }
43
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
44
+ return err
45
+ }
46
+ b, err := yaml.Marshal(m)
47
+ if err != nil {
48
+ return err
49
+ }
50
+ return os.WriteFile(p, b, 0o600) // personal identity: owner-only
51
+ }
52
+
53
+ // LoadMachineConfig reads ~/.relay-flow/config.yaml.
54
+ func LoadMachineConfig() (*MachineConfig, error) {
55
+ p, err := MachineConfigPath()
56
+ if err != nil {
57
+ return nil, err
58
+ }
59
+ b, err := os.ReadFile(p)
60
+ if err != nil {
61
+ return nil, fmt.Errorf("machine config %s not found — run `relay-flow init --assignee \"<your Jira name>\"` first", p)
62
+ }
63
+ var m MachineConfig
64
+ if err := yaml.Unmarshal(b, &m); err != nil {
65
+ return nil, fmt.Errorf("parse machine config %s: %w", p, err)
66
+ }
67
+ if strings.TrimSpace(m.Assignee) == "" {
68
+ return nil, fmt.Errorf("machine config %s: assignee must not be empty", p)
69
+ }
70
+ return &m, nil
71
+ }
@@ -0,0 +1,193 @@
1
+ // Package config loads and validates relay-flow workflow YAML files.
2
+ package config
3
+
4
+ import (
5
+ "bytes"
6
+ "fmt"
7
+ "os"
8
+ "regexp"
9
+ "strings"
10
+
11
+ "gopkg.in/yaml.v3"
12
+ )
13
+
14
+ // StringList accepts either a scalar or a list of strings in YAML and
15
+ // normalizes both forms to []string, so a single value can be written as
16
+ // `closeOn: done` instead of `closeOn: [done]`.
17
+ type StringList []string
18
+
19
+ func (s *StringList) UnmarshalYAML(value *yaml.Node) error {
20
+ if value.Kind == yaml.ScalarNode {
21
+ if value.Tag != "!!str" {
22
+ return fmt.Errorf("expected string or list of strings")
23
+ }
24
+ *s = []string{value.Value}
25
+ return nil
26
+ }
27
+ var values []string
28
+ if err := value.Decode(&values); err != nil {
29
+ return err
30
+ }
31
+ *s = values
32
+ return nil
33
+ }
34
+
35
+ // Has reports membership (case-insensitive).
36
+ func (s StringList) Has(name string) bool {
37
+ for _, value := range s {
38
+ if strings.EqualFold(value, name) {
39
+ return true
40
+ }
41
+ }
42
+ return false
43
+ }
44
+
45
+ // AdapterSpec selects a pluggable adapter (tasks or runner) by type name.
46
+ // Config is opaque to core: the adapter's factory unmarshals it strictly.
47
+ type AdapterSpec struct {
48
+ Type string `yaml:"type"`
49
+ Config map[string]any `yaml:"config"`
50
+ }
51
+
52
+ // Node is one square on the board: a tracker state (When), the agent that
53
+ // works tickets in that state, and the outcome edges to other nodes.
54
+ type Node struct {
55
+ // Agent is the OpenCode agent serving this node. Empty = terminal node
56
+ // (no agent runs; must be listed in closeOn).
57
+ Agent string `yaml:"agent"`
58
+ // When is the tracker state string that routes tickets to this node
59
+ // (poll-time condition). Unique across the file, case-insensitive.
60
+ When string `yaml:"when"`
61
+ // OnSuccess / OnFailure are the outcome edges: node names the ticket
62
+ // moves to when its agent reports success/failure. Self-loops allowed.
63
+ OnSuccess string `yaml:"onSuccess"`
64
+ OnFailure string `yaml:"onFailure"`
65
+ // NudgePrompt is sent into the ticket's existing terminal when it
66
+ // lands back on this node (bounce). Supports {{ticket}} and {{node}}
67
+ // placeholders; defaults to DefaultNudgePrompt.
68
+ NudgePrompt string `yaml:"nudgePrompt"`
69
+ }
70
+
71
+ // DefaultNudgePrompt is used when a node declares no nudgePrompt.
72
+ const DefaultNudgePrompt = "Ticket {{ticket}} is at node '{{node}}' again. Read the tracker's latest feedback, continue your work, and end your reply with the STATUS/SUMMARY block as before."
73
+
74
+ // Config is one workflow file: name, adapters, nodes, closeOn.
75
+ type Config struct {
76
+ // Name is the workflow's identity: server registry key, claim-label
77
+ // component (`wf:<name>`), CLI argument. CamelCase, unique per server.
78
+ Name string `yaml:"name"`
79
+ PollIntervalSeconds int `yaml:"pollIntervalSeconds"`
80
+ // Tasks selects the ticket-system adapter (jira, beads, ...).
81
+ Tasks AdapterSpec `yaml:"tasks"`
82
+ // Runner selects the execution backend (orca, tmux, ...).
83
+ Runner AdapterSpec `yaml:"runner"`
84
+ // CloseOn lists terminal nodes: tickets reaching them get their
85
+ // terminals closed. Nodes here must have no agent.
86
+ CloseOn StringList `yaml:"closeOn"`
87
+ // Nodes maps node names to their definitions. The graph: each agent
88
+ // node's edges point at other nodes; terminal nodes have no agent.
89
+ Nodes map[string]Node `yaml:"nodes"`
90
+ }
91
+
92
+ var namePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
93
+
94
+ // NodeForState returns the node whose When matches tracker state
95
+ // (case-insensitive), or "" if none.
96
+ func (c *Config) NodeForState(state string) string {
97
+ for name, n := range c.Nodes {
98
+ if strings.EqualFold(n.When, state) {
99
+ return name
100
+ }
101
+ }
102
+ return ""
103
+ }
104
+
105
+ // Validate cross-checks the graph so broken configs fail at submit, before
106
+ // any goroutine starts.
107
+ func (c *Config) Validate() error {
108
+ if !namePattern.MatchString(c.Name) {
109
+ return fmt.Errorf("name %q must be camelCase with no spaces", c.Name)
110
+ }
111
+ if strings.TrimSpace(c.Tasks.Type) == "" {
112
+ return fmt.Errorf("tasks.type must not be empty")
113
+ }
114
+ if strings.TrimSpace(c.Runner.Type) == "" {
115
+ return fmt.Errorf("runner.type must not be empty")
116
+ }
117
+ if len(c.Nodes) == 0 {
118
+ return fmt.Errorf("nodes must not be empty")
119
+ }
120
+ if len(c.CloseOn) == 0 {
121
+ return fmt.Errorf("closeOn must not be empty (terminal nodes that close ticket terminals)")
122
+ }
123
+ for _, co := range c.CloseOn {
124
+ n, ok := c.Nodes[co]
125
+ if !ok {
126
+ return fmt.Errorf("closeOn: unknown node %q", co)
127
+ }
128
+ if n.Agent != "" {
129
+ return fmt.Errorf("closeOn: node %q has an agent; closeOn nodes must be terminal (no agent)", co)
130
+ }
131
+ }
132
+ seenWhen := map[string]string{}
133
+ for name, n := range c.Nodes {
134
+ if strings.TrimSpace(n.When) == "" {
135
+ return fmt.Errorf("nodes[%s].when must not be empty", name)
136
+ }
137
+ key := strings.ToLower(strings.TrimSpace(n.When))
138
+ if other, dup := seenWhen[key]; dup {
139
+ return fmt.Errorf("nodes[%s].when %q duplicates nodes[%s].when", name, n.When, other)
140
+ }
141
+ seenWhen[key] = name
142
+ if n.Agent == "" {
143
+ // Agentless = human gate / pause node: no automation, no
144
+ // edges required. Whether it also closes terminals is
145
+ // controlled solely by closeOn.
146
+ continue
147
+ }
148
+ if n.OnSuccess == "" {
149
+ return fmt.Errorf("nodes[%s].onSuccess must not be empty (agent nodes need both outcome edges)", name)
150
+ }
151
+ if n.OnFailure == "" {
152
+ return fmt.Errorf("nodes[%s].onFailure must not be empty (agent nodes need both outcome edges)", name)
153
+ }
154
+ if _, ok := c.Nodes[n.OnSuccess]; !ok {
155
+ return fmt.Errorf("nodes[%s].onSuccess: unknown node %q", name, n.OnSuccess)
156
+ }
157
+ if _, ok := c.Nodes[n.OnFailure]; !ok {
158
+ return fmt.Errorf("nodes[%s].onFailure: unknown node %q", name, n.OnFailure)
159
+ }
160
+ if n.NudgePrompt == "" {
161
+ n.NudgePrompt = DefaultNudgePrompt
162
+ c.Nodes[name] = n
163
+ }
164
+ }
165
+ return nil
166
+ }
167
+
168
+ // Parse decodes and validates workflow YAML bytes. name is only used in
169
+ // error messages.
170
+ func Parse(name string, b []byte) (*Config, error) {
171
+ var c Config
172
+ dec := yaml.NewDecoder(bytes.NewReader(b))
173
+ dec.KnownFields(true)
174
+ if err := dec.Decode(&c); err != nil {
175
+ return nil, fmt.Errorf("parse config %s: %w", name, err)
176
+ }
177
+ if c.PollIntervalSeconds <= 0 {
178
+ c.PollIntervalSeconds = 15
179
+ }
180
+ if err := c.Validate(); err != nil {
181
+ return nil, fmt.Errorf("config %s: %w", name, err)
182
+ }
183
+ return &c, nil
184
+ }
185
+
186
+ // Load reads and parses a workflow YAML file.
187
+ func Load(path string) (*Config, error) {
188
+ b, err := os.ReadFile(path)
189
+ if err != nil {
190
+ return nil, fmt.Errorf("read config %s: %w", path, err)
191
+ }
192
+ return Parse(path, b)
193
+ }
@@ -0,0 +1,162 @@
1
+ package config
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ const validYAML = `
9
+ name: xyzTaskFlow
10
+ pollIntervalSeconds: 15
11
+ tasks:
12
+ type: jira
13
+ config:
14
+ query: project = xyz
15
+ issueTypes: [Task]
16
+ runner:
17
+ type: orca
18
+ closeOn: [done]
19
+ nodes:
20
+ coding:
21
+ agent: build
22
+ when: "In Progress"
23
+ onSuccess: reviewing
24
+ onFailure: coding
25
+ reviewing:
26
+ agent: build
27
+ when: "In Review"
28
+ onSuccess: done
29
+ onFailure: coding
30
+ done:
31
+ when: "Done"
32
+ `
33
+
34
+ func TestParseValid(t *testing.T) {
35
+ cfg, err := Parse("test", []byte(validYAML))
36
+ if err != nil {
37
+ t.Fatalf("valid config rejected: %v", err)
38
+ }
39
+ if cfg.Name != "xyzTaskFlow" {
40
+ t.Errorf("name = %q", cfg.Name)
41
+ }
42
+ if cfg.PollIntervalSeconds != 15 {
43
+ t.Errorf("pollIntervalSeconds = %d", cfg.PollIntervalSeconds)
44
+ }
45
+ if cfg.Tasks.Type != "jira" || cfg.Runner.Type != "orca" {
46
+ t.Errorf("tasks=%q runner=%q", cfg.Tasks.Type, cfg.Runner.Type)
47
+ }
48
+ if cfg.Tasks.Config["query"] != "project = xyz" {
49
+ t.Errorf("opaque tasks config not preserved: %v", cfg.Tasks.Config)
50
+ }
51
+ if len(cfg.Nodes) != 3 {
52
+ t.Fatalf("nodes = %v", cfg.Nodes)
53
+ }
54
+ n := cfg.Nodes["coding"]
55
+ if n.Agent != "build" || n.When != "In Progress" || n.OnSuccess != "reviewing" || n.OnFailure != "coding" {
56
+ t.Errorf("coding node = %+v", n)
57
+ }
58
+ if cfg.Nodes["done"].Agent != "" {
59
+ t.Errorf("done should have no agent")
60
+ }
61
+ if len(cfg.CloseOn) != 1 || cfg.CloseOn[0] != "done" {
62
+ t.Errorf("closeOn = %v", cfg.CloseOn)
63
+ }
64
+ }
65
+
66
+ func TestParseDefaultPollInterval(t *testing.T) {
67
+ yaml := strings.Replace(validYAML, "pollIntervalSeconds: 15\n", "", 1)
68
+ cfg, err := Parse("test", []byte(yaml))
69
+ if err != nil {
70
+ t.Fatalf("%v", err)
71
+ }
72
+ if cfg.PollIntervalSeconds != 15 {
73
+ t.Errorf("default pollIntervalSeconds = %d, want 15", cfg.PollIntervalSeconds)
74
+ }
75
+ }
76
+
77
+ func TestCloseOnScalar(t *testing.T) {
78
+ yaml := strings.Replace(validYAML, "closeOn: [done]", "closeOn: done", 1)
79
+ cfg, err := Parse("test", []byte(yaml))
80
+ if err != nil {
81
+ t.Fatalf("%v", err)
82
+ }
83
+ if len(cfg.CloseOn) != 1 || cfg.CloseOn[0] != "done" {
84
+ t.Errorf("closeOn = %v", cfg.CloseOn)
85
+ }
86
+ }
87
+
88
+ func TestParseErrors(t *testing.T) {
89
+ rep := func(old, new string) string { return strings.Replace(validYAML, old, new, 1) }
90
+ cases := []struct {
91
+ name string
92
+ yaml string
93
+ want string
94
+ }{
95
+ {"empty name", rep("name: xyzTaskFlow", "name: \"\""), "name"},
96
+ {"bad name", rep("name: xyzTaskFlow", "name: My Flow"), "camelCase"},
97
+ {"unknown top field", validYAML + "bogus: 1\n", "field bogus"},
98
+ {"unknown node field", rep("onFailure: coding\n reviewing:", "onFailure: coding\n bogus: 1\n reviewing:"), "field bogus"},
99
+ {"no tasks type", rep("type: jira", "type: \"\""), "tasks.type"},
100
+ {"no runner type", rep("type: orca", "type: \"\""), "runner.type"},
101
+ {"empty closeOn", rep("closeOn: [done]", "closeOn: []"), "closeOn"},
102
+ {"closeOn unknown node", rep("closeOn: [done]", "closeOn: [nope]"), "closeOn"},
103
+ {"closeOn node has agent", rep("closeOn: [done]", "closeOn: [coding]"), "closeOn"},
104
+
105
+ {"edge target missing", rep("onSuccess: reviewing", "onSuccess: nowhere"), "onSuccess"},
106
+ {"agent node missing onSuccess", rep(" onSuccess: reviewing\n", ""), "onSuccess"},
107
+ {"agent node missing onFailure", rep(" onFailure: coding\n reviewing", " reviewing"), "onFailure"},
108
+ {"dup when", rep("when: \"In Review\"", "when: \"In Progress\""), "duplicat"},
109
+ {"empty when", rep("when: \"In Progress\"", "when: \"\""), "when"},
110
+ {"no nodes", rep("nodes:\n", "nodes: {}\n#") + "", "nodes"},
111
+ }
112
+ for _, tc := range cases {
113
+ t.Run(tc.name, func(t *testing.T) {
114
+ yaml := tc.yaml
115
+ if tc.name == "no nodes" {
116
+ // strip the entire nodes block
117
+ yaml = validYAML[:strings.Index(validYAML, "nodes:")]
118
+ }
119
+ _, err := Parse("test", []byte(yaml))
120
+ if err == nil {
121
+ t.Fatalf("expected error containing %q, got nil", tc.want)
122
+ }
123
+ if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
124
+ t.Fatalf("error %q missing %q", err, tc.want)
125
+ }
126
+ })
127
+ }
128
+ }
129
+
130
+ func TestSelfLoopEdgeAllowed(t *testing.T) {
131
+ // coding.onFailure == coding: already in validYAML, must parse.
132
+ if _, err := Parse("test", []byte(validYAML)); err != nil {
133
+ t.Fatalf("%v", err)
134
+ }
135
+ }
136
+
137
+ func TestNodeForState(t *testing.T) {
138
+ cfg, err := Parse("test", []byte(validYAML))
139
+ if err != nil {
140
+ t.Fatalf("%v", err)
141
+ }
142
+ if got := cfg.NodeForState("in progress"); got != "coding" {
143
+ t.Errorf("NodeForState(in progress) = %q, want coding", got)
144
+ }
145
+ if got := cfg.NodeForState("Done"); got != "done" {
146
+ t.Errorf("NodeForState(Done) = %q, want done", got)
147
+ }
148
+ if got := cfg.NodeForState("Backlog"); got != "" {
149
+ t.Errorf("NodeForState(Backlog) = %q, want empty", got)
150
+ }
151
+ }
152
+
153
+ func TestDefaultNudgePrompt(t *testing.T) {
154
+ cfg, err := Parse("test", []byte(validYAML))
155
+ if err != nil {
156
+ t.Fatalf("%v", err)
157
+ }
158
+ p := cfg.Nodes["coding"].NudgePrompt
159
+ if !strings.Contains(p, "{{ticket}}") || !strings.Contains(p, "{{node}}") {
160
+ t.Errorf("default nudgePrompt missing templates: %q", p)
161
+ }
162
+ }