relay-flow 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/relay-flow.js +50 -0
- package/cmd/relay-flow/main.go +250 -0
- package/go.mod +5 -0
- package/go.sum +3 -0
- package/internal/acli/acli.go +229 -0
- package/internal/config/demo_test.go +17 -0
- package/internal/config/machine.go +71 -0
- package/internal/config/schema.go +193 -0
- package/internal/config/schema_test.go +162 -0
- package/internal/daemon/daemon.go +218 -0
- package/internal/daemon/daemon_test.go +204 -0
- package/internal/discovery/discovery.go +122 -0
- package/internal/discovery/discovery_test.go +62 -0
- package/internal/opencode/opencode.go +26 -0
- package/internal/orcacli/orcacli.go +264 -0
- package/internal/runner/orca/README.md +64 -0
- package/internal/runner/orca/orca.go +243 -0
- package/internal/runner/orca/orca_test.go +201 -0
- package/internal/runner/runner.go +81 -0
- package/internal/runner/runner_test.go +64 -0
- package/internal/server/client.go +126 -0
- package/internal/server/server.go +342 -0
- package/internal/server/server_test.go +195 -0
- package/internal/tasks/jira/README.md +69 -0
- package/internal/tasks/jira/component_test.go +16 -0
- package/internal/tasks/jira/decode.go +24 -0
- package/internal/tasks/jira/jira.go +231 -0
- package/internal/tasks/jira/jira_test.go +259 -0
- package/internal/tasks/jira/jql_test.go +16 -0
- package/internal/tasks/tasks.go +90 -0
- package/internal/tasks/tasks_test.go +91 -0
- package/package.json +27 -0
|
@@ -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
|
+
}
|