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.
- 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 +23 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Package jira is the built-in Tasks adapter for Jira. It talks to Jira
|
|
2
|
+
// via the acli CLI and knows nothing about runners, terminals, or agents.
|
|
3
|
+
// States are Jira status names; claims are Jira labels (wf:<workflow>).
|
|
4
|
+
package jira
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"fmt"
|
|
8
|
+
"regexp"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/acli"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func init() {
|
|
17
|
+
tasks.Register("jira", tasks.Factory{
|
|
18
|
+
UnmarshalConfig: unmarshalConfig,
|
|
19
|
+
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (tasks.Tasks, error) {
|
|
20
|
+
c, ok := cfg.(JiraConfig)
|
|
21
|
+
if !ok {
|
|
22
|
+
return nil, fmt.Errorf("internal: jira factory received %T", cfg)
|
|
23
|
+
}
|
|
24
|
+
if repoName == "" {
|
|
25
|
+
return nil, fmt.Errorf("jira adapter requires a repo name (used as the JQL component filter)")
|
|
26
|
+
}
|
|
27
|
+
return newJira(c, wfName, nodes, assignee, repoName, nil)
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// JiraConfig is the strictly-unmarshalled tasks.config for type jira.
|
|
33
|
+
type JiraConfig struct {
|
|
34
|
+
// Query is a JQL fragment (no issuetype / assignee / ORDER BY — those
|
|
35
|
+
// are appended by the adapter or rejected).
|
|
36
|
+
Query string `yaml:"query"`
|
|
37
|
+
// IssueTypes restricts the workflow to these Jira issue types.
|
|
38
|
+
IssueTypes []string `yaml:"issueTypes"`
|
|
39
|
+
// AssigneeIsAgent marks centralized mode (org server owns the queue,
|
|
40
|
+
// tickets assigned upstream to bot accounts): no assignee clause.
|
|
41
|
+
AssigneeIsAgent bool `yaml:"assigneeIsAgent"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var (
|
|
45
|
+
issueTypeRe = regexp.MustCompile(`(?i)\bissuetype\b`)
|
|
46
|
+
assigneeRe = regexp.MustCompile(`(?i)\bassignee\b`)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
func unmarshalConfig(m map[string]any) (any, error) {
|
|
50
|
+
var c JiraConfig
|
|
51
|
+
if err := strictDecode(m, &c); err != nil {
|
|
52
|
+
return nil, err
|
|
53
|
+
}
|
|
54
|
+
if strings.TrimSpace(c.Query) == "" {
|
|
55
|
+
return nil, fmt.Errorf("query must not be empty")
|
|
56
|
+
}
|
|
57
|
+
if strings.Contains(strings.ToUpper(c.Query), "ORDER BY") {
|
|
58
|
+
return nil, fmt.Errorf("query must not contain ORDER BY (always ordered by updated)")
|
|
59
|
+
}
|
|
60
|
+
if issueTypeRe.MatchString(c.Query) {
|
|
61
|
+
return nil, fmt.Errorf("query must not contain issuetype; use the issueTypes field")
|
|
62
|
+
}
|
|
63
|
+
if assigneeRe.MatchString(c.Query) {
|
|
64
|
+
return nil, fmt.Errorf("query must not contain assignee; identity comes from machine config or assigneeIsAgent")
|
|
65
|
+
}
|
|
66
|
+
if len(c.IssueTypes) == 0 {
|
|
67
|
+
return nil, fmt.Errorf("issueTypes must not be empty")
|
|
68
|
+
}
|
|
69
|
+
for _, it := range c.IssueTypes {
|
|
70
|
+
if strings.TrimSpace(it) == "" {
|
|
71
|
+
return nil, fmt.Errorf("issueTypes must not contain empty values")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return c, nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// aclier is the seam to Jira. *acli.Client satisfies it; tests fake it.
|
|
78
|
+
type aclier interface {
|
|
79
|
+
Search(jql string) ([]acli.Ticket, error)
|
|
80
|
+
View(key string) (acli.Ticket, error)
|
|
81
|
+
AddLabel(key string, existing []string, label string) error
|
|
82
|
+
Transition(key, status string) error
|
|
83
|
+
Comment(key, body string) error
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type jiraTasks struct {
|
|
87
|
+
cfg JiraConfig
|
|
88
|
+
wfName string
|
|
89
|
+
nodes map[string]config.Node
|
|
90
|
+
assignee string
|
|
91
|
+
repoComponent string
|
|
92
|
+
jql string
|
|
93
|
+
ac aclier
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// newJira builds the adapter. repoComponent is the Jira component name
|
|
97
|
+
// (the Orca repo displayName); empty in unit tests → clause omitted.
|
|
98
|
+
// ac nil → real acli client.
|
|
99
|
+
func newJira(cfg JiraConfig, wfName string, nodes map[string]config.Node, assignee, repoComponent string, ac aclier) (*jiraTasks, error) {
|
|
100
|
+
if ac == nil {
|
|
101
|
+
ac = acli.New()
|
|
102
|
+
}
|
|
103
|
+
j := &jiraTasks{cfg: cfg, wfName: wfName, nodes: nodes, assignee: assignee, repoComponent: repoComponent, ac: ac}
|
|
104
|
+
j.jql = j.buildJQL()
|
|
105
|
+
return j, nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func (j *jiraTasks) buildJQL() string {
|
|
109
|
+
quoted := make([]string, 0, len(j.cfg.IssueTypes))
|
|
110
|
+
for _, it := range j.cfg.IssueTypes {
|
|
111
|
+
quoted = append(quoted, fmt.Sprintf("%q", it))
|
|
112
|
+
}
|
|
113
|
+
q := fmt.Sprintf("(%s) AND issuetype IN (%s)", j.cfg.Query, strings.Join(quoted, ", "))
|
|
114
|
+
if j.repoComponent != "" {
|
|
115
|
+
q += fmt.Sprintf(" AND component = %q", j.repoComponent)
|
|
116
|
+
}
|
|
117
|
+
// Assignee comes from the machine config (per-person, uncommitted),
|
|
118
|
+
// never the shared workflow YAML. Centralized mode skips the clause.
|
|
119
|
+
if j.assignee != "" && !j.cfg.AssigneeIsAgent {
|
|
120
|
+
q += fmt.Sprintf(" AND assignee = %q", j.assignee)
|
|
121
|
+
}
|
|
122
|
+
return q + " ORDER BY updated"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func claimLabel(wfName string) string { return "wf:" + wfName }
|
|
126
|
+
|
|
127
|
+
// List runs the workflow's one JQL query and maps each ticket's Jira
|
|
128
|
+
// status back to a node via the `when` values ("" = unmapped). ClaimedBy
|
|
129
|
+
// is read off the wf:* labels.
|
|
130
|
+
func (j *jiraTasks) List() ([]tasks.Ticket, error) {
|
|
131
|
+
found, err := j.ac.Search(j.jql)
|
|
132
|
+
if err != nil {
|
|
133
|
+
return nil, err
|
|
134
|
+
}
|
|
135
|
+
out := make([]tasks.Ticket, 0, len(found))
|
|
136
|
+
for _, t := range found {
|
|
137
|
+
tk := tasks.Ticket{Key: t.Key, Summary: t.Summary}
|
|
138
|
+
for name, n := range j.nodes {
|
|
139
|
+
if strings.EqualFold(n.When, t.Status) {
|
|
140
|
+
tk.Node = name
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for _, l := range t.Labels {
|
|
145
|
+
if strings.HasPrefix(l, "wf:") {
|
|
146
|
+
tk.ClaimedBy = strings.TrimPrefix(l, "wf:")
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out = append(out, tk)
|
|
151
|
+
}
|
|
152
|
+
return out, nil
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Claim attaches this workflow's claim label. Labels are never removed:
|
|
156
|
+
// they are the cross-restart mutex.
|
|
157
|
+
func (j *jiraTasks) Claim(t tasks.Ticket) error {
|
|
158
|
+
cur, err := j.ac.View(t.Key)
|
|
159
|
+
if err != nil {
|
|
160
|
+
return fmt.Errorf("claim %s: view: %w", t.Key, err)
|
|
161
|
+
}
|
|
162
|
+
return j.ac.AddLabel(t.Key, cur.Labels, claimLabel(j.wfName))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Report transitions the ticket to the target node's Jira status and
|
|
166
|
+
// posts the summary as a comment. Self-loop (target status == current
|
|
167
|
+
// status) → comment only (Jira has no self-transitions).
|
|
168
|
+
func (j *jiraTasks) Report(t tasks.Ticket, outcome, targetNode, summary string) error {
|
|
169
|
+
target, ok := j.nodes[targetNode]
|
|
170
|
+
if !ok {
|
|
171
|
+
return fmt.Errorf("unknown node %q", targetNode)
|
|
172
|
+
}
|
|
173
|
+
cur, err := j.ac.View(t.Key)
|
|
174
|
+
if err != nil {
|
|
175
|
+
return fmt.Errorf("view %s: %w", t.Key, err)
|
|
176
|
+
}
|
|
177
|
+
agent := j.nodes[t.Node].Agent
|
|
178
|
+
body := fmt.Sprintf("[%s] %s (agent: %s, node: %s) reported %s → %s\n\n%s", j.wfName, t.Key, agent, t.Node, outcome, targetNode, summary)
|
|
179
|
+
if strings.EqualFold(cur.Status, target.When) {
|
|
180
|
+
return j.ac.Comment(t.Key, body)
|
|
181
|
+
}
|
|
182
|
+
if err := j.ac.Transition(t.Key, target.When); err != nil {
|
|
183
|
+
return fmt.Errorf("transition %s → %q: %w", t.Key, target.When, err)
|
|
184
|
+
}
|
|
185
|
+
return j.ac.Comment(t.Key, body)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ProjectKeyFromQuery extracts the project key from a JQL fragment (used
|
|
189
|
+
// by submit-time status validation).
|
|
190
|
+
func ProjectKeyFromQuery(query string) (string, error) {
|
|
191
|
+
re := regexp.MustCompile(`(?i)\bproject\s*=\s*("?[A-Za-z][A-Za-z0-9]*"?)`)
|
|
192
|
+
m := re.FindStringSubmatch(query)
|
|
193
|
+
if m == nil {
|
|
194
|
+
return "", fmt.Errorf("could not find 'project = <KEY>' in query %q; it is required for status validation", query)
|
|
195
|
+
}
|
|
196
|
+
return strings.Trim(m[1], `"`), nil
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// UnmarshalConfigForValidation exposes the strict config decode for
|
|
200
|
+
// submit-time validation (server needs the query/assigneeIsAgent fields).
|
|
201
|
+
func UnmarshalConfigForValidation(m map[string]any) (JiraConfig, error) {
|
|
202
|
+
c, err := unmarshalConfig(m)
|
|
203
|
+
if err != nil {
|
|
204
|
+
return JiraConfig{}, err
|
|
205
|
+
}
|
|
206
|
+
return c.(JiraConfig), nil
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// StatusValidator is the seam ValidateStates uses. *acli.Client fits.
|
|
210
|
+
type StatusValidator interface {
|
|
211
|
+
ValidateStatus(projectKey, status string) error
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ValidateStates probes every node's `when` status against the Jira
|
|
215
|
+
// project; Jira's JQL parser rejects unknown statuses with a hard error,
|
|
216
|
+
// so a typo fails at submit instead of silently matching zero tickets.
|
|
217
|
+
func ValidateStates(v StatusValidator, nodes map[string]config.Node, projectKey string) ([]string, error) {
|
|
218
|
+
seen := map[string]bool{}
|
|
219
|
+
var bad []string
|
|
220
|
+
for _, n := range nodes {
|
|
221
|
+
key := strings.ToLower(strings.TrimSpace(n.When))
|
|
222
|
+
if key == "" || seen[key] {
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
seen[key] = true
|
|
226
|
+
if err := v.ValidateStatus(projectKey, n.When); err != nil {
|
|
227
|
+
bad = append(bad, n.When)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return bad, nil
|
|
231
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/acli"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var testNodes = map[string]config.Node{
|
|
13
|
+
"coding": {Agent: "build", When: "In Progress", OnSuccess: "reviewing", OnFailure: "coding"},
|
|
14
|
+
"reviewing": {Agent: "build", When: "In Review", OnSuccess: "done", OnFailure: "coding"},
|
|
15
|
+
"done": {When: "Done"},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func TestClaimLabel(t *testing.T) {
|
|
19
|
+
if got := claimLabel("xyzTaskFlow"); got != "wf:xyzTaskFlow" {
|
|
20
|
+
t.Errorf("claimLabel = %q", got)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestBuildJQL(t *testing.T) {
|
|
25
|
+
cfg := JiraConfig{Query: "project = xyz", IssueTypes: []string{"Task"}}
|
|
26
|
+
j, err := newJira(cfg, "wf", testNodes, "Jane Doe", "repo:xyz", nil)
|
|
27
|
+
if err != nil {
|
|
28
|
+
t.Fatalf("%v", err)
|
|
29
|
+
}
|
|
30
|
+
want := `(project = xyz) AND issuetype IN ("Task") AND component = "repo:xyz" AND assignee = "Jane Doe" ORDER BY updated`
|
|
31
|
+
if j.jql != want {
|
|
32
|
+
t.Errorf("jql =\n %q\nwant\n %q", j.jql, want)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestBuildJQLCentralizedSkipsAssignee(t *testing.T) {
|
|
37
|
+
cfg := JiraConfig{Query: "project = xyz", IssueTypes: []string{"Task", "Story"}, AssigneeIsAgent: true}
|
|
38
|
+
j, err := newJira(cfg, "wf", testNodes, "", "repo:xyz", nil)
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatalf("%v", err)
|
|
41
|
+
}
|
|
42
|
+
if strings.Contains(j.jql, "assignee") {
|
|
43
|
+
t.Errorf("assigneeIsAgent must skip assignee clause: %q", j.jql)
|
|
44
|
+
}
|
|
45
|
+
if !strings.Contains(j.jql, `"Task", "Story"`) {
|
|
46
|
+
t.Errorf("issueTypes list: %q", j.jql)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestConfigValidation(t *testing.T) {
|
|
51
|
+
base := map[string]any{"query": "project = xyz", "issueTypes": []any{"Task"}}
|
|
52
|
+
goodAny, err := unmarshalConfig(base)
|
|
53
|
+
if err != nil {
|
|
54
|
+
t.Fatalf("valid config rejected: %v", err)
|
|
55
|
+
}
|
|
56
|
+
good := goodAny.(JiraConfig)
|
|
57
|
+
if good.Query != "project = xyz" || len(good.IssueTypes) != 1 {
|
|
58
|
+
t.Errorf("%+v", good)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
bad := []struct {
|
|
62
|
+
name string
|
|
63
|
+
mut func(map[string]any)
|
|
64
|
+
want string
|
|
65
|
+
}{
|
|
66
|
+
{"empty query", func(m map[string]any) { m["query"] = "" }, "query"},
|
|
67
|
+
{"no issueTypes", func(m map[string]any) { delete(m, "issueTypes") }, "issueTypes"},
|
|
68
|
+
{"ORDER BY in query", func(m map[string]any) { m["query"] = "project = xyz ORDER BY rank" }, "ORDER BY"},
|
|
69
|
+
{"issuetype in query", func(m map[string]any) { m["query"] = "project = xyz AND issuetype = Bug" }, "issuetype"},
|
|
70
|
+
{"assignee in query", func(m map[string]any) { m["query"] = "project = xyz AND assignee = bob" }, "assignee"},
|
|
71
|
+
{"unknown field", func(m map[string]any) { m["bogus"] = 1 }, "bogus"},
|
|
72
|
+
}
|
|
73
|
+
for _, tc := range bad {
|
|
74
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
75
|
+
m := map[string]any{"query": "project = xyz", "issueTypes": []any{"Task"}}
|
|
76
|
+
tc.mut(m)
|
|
77
|
+
if _, err := unmarshalConfig(m); err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
78
|
+
t.Errorf("err = %v, want mention %q", err, tc.want)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type fakeAcli struct {
|
|
85
|
+
tickets []acli.Ticket
|
|
86
|
+
labelsAdded []string
|
|
87
|
+
transitions []string
|
|
88
|
+
comments []string
|
|
89
|
+
views map[string]acli.Ticket
|
|
90
|
+
transitionErr error
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func (f *fakeAcli) Search(jql string) ([]acli.Ticket, error) { return f.tickets, nil }
|
|
94
|
+
func (f *fakeAcli) AddLabel(key string, existing []string, label string) error {
|
|
95
|
+
f.labelsAdded = append(f.labelsAdded, key+":"+label)
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
98
|
+
func (f *fakeAcli) Transition(key, status string) error {
|
|
99
|
+
f.transitions = append(f.transitions, key+":"+status)
|
|
100
|
+
return f.transitionErr
|
|
101
|
+
}
|
|
102
|
+
func (f *fakeAcli) Comment(key, body string) error {
|
|
103
|
+
f.comments = append(f.comments, key+":"+body)
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
func (f *fakeAcli) View(key string) (acli.Ticket, error) {
|
|
107
|
+
t, ok := f.views[key]
|
|
108
|
+
if !ok {
|
|
109
|
+
t = acli.Ticket{Key: key}
|
|
110
|
+
}
|
|
111
|
+
return t, nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func mustJira(t *testing.T, ac aclier) *jiraTasks {
|
|
115
|
+
t.Helper()
|
|
116
|
+
j, err := newJira(JiraConfig{Query: "project = xyz", IssueTypes: []string{"Task"}}, "wf", testNodes, "", "repo:xyz", ac)
|
|
117
|
+
if err != nil {
|
|
118
|
+
t.Fatalf("%v", err)
|
|
119
|
+
}
|
|
120
|
+
return j
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestListMapsStateToNode(t *testing.T) {
|
|
124
|
+
fa := &fakeAcli{tickets: []acli.Ticket{
|
|
125
|
+
{Key: "XYZ-1", Summary: "s", Status: "In Progress"},
|
|
126
|
+
{Key: "XYZ-2", Summary: "s", Status: "In Review", Labels: []string{"wf:otherFlow"}},
|
|
127
|
+
{Key: "XYZ-3", Summary: "s", Status: "Backlog"},
|
|
128
|
+
}}
|
|
129
|
+
j := mustJira(t, fa)
|
|
130
|
+
got, err := j.List()
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatalf("%v", err)
|
|
133
|
+
}
|
|
134
|
+
if len(got) != 3 {
|
|
135
|
+
t.Fatalf("len = %d", len(got))
|
|
136
|
+
}
|
|
137
|
+
if got[0].Node != "coding" || got[0].ClaimedBy != "" {
|
|
138
|
+
t.Errorf("t1 = %+v", got[0])
|
|
139
|
+
}
|
|
140
|
+
if got[1].Node != "reviewing" || got[1].ClaimedBy != "otherFlow" {
|
|
141
|
+
t.Errorf("t2 = %+v", got[1])
|
|
142
|
+
}
|
|
143
|
+
if got[2].Node != "" {
|
|
144
|
+
t.Errorf("unmapped state must yield empty Node: %+v", got[2])
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func TestClaim(t *testing.T) {
|
|
149
|
+
fa := &fakeAcli{views: map[string]acli.Ticket{"XYZ-1": {Key: "XYZ-1", Labels: []string{"existing"}}}}
|
|
150
|
+
j := mustJira(t, fa)
|
|
151
|
+
err := j.Claim(tasksTicket("XYZ-1"))
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatalf("%v", err)
|
|
154
|
+
}
|
|
155
|
+
if len(fa.labelsAdded) != 1 || fa.labelsAdded[0] != "XYZ-1:wf:wf" {
|
|
156
|
+
t.Errorf("labelsAdded = %v", fa.labelsAdded)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func TestReportTransitions(t *testing.T) {
|
|
161
|
+
fa := &fakeAcli{views: map[string]acli.Ticket{
|
|
162
|
+
"XYZ-1": {Key: "XYZ-1", Status: "In Progress"},
|
|
163
|
+
}}
|
|
164
|
+
j := mustJira(t, fa)
|
|
165
|
+
err := j.Report(tasksTicket("XYZ-1"), "success", "reviewing", "did the thing")
|
|
166
|
+
if err != nil {
|
|
167
|
+
t.Fatalf("%v", err)
|
|
168
|
+
}
|
|
169
|
+
if len(fa.transitions) != 1 || fa.transitions[0] != "XYZ-1:In Review" {
|
|
170
|
+
t.Errorf("transitions = %v", fa.transitions)
|
|
171
|
+
}
|
|
172
|
+
if len(fa.comments) != 1 || !strings.Contains(fa.comments[0], "did the thing") {
|
|
173
|
+
t.Errorf("comments = %v", fa.comments)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func TestReportSelfLoopCommentsOnly(t *testing.T) {
|
|
178
|
+
fa := &fakeAcli{views: map[string]acli.Ticket{
|
|
179
|
+
"XYZ-1": {Key: "XYZ-1", Status: "In Progress"},
|
|
180
|
+
}}
|
|
181
|
+
j := mustJira(t, fa)
|
|
182
|
+
// coding onFailure = coding: target state == current state.
|
|
183
|
+
err := j.Report(tasksTicket("XYZ-1"), "failure", "coding", "still broken")
|
|
184
|
+
if err != nil {
|
|
185
|
+
t.Fatalf("%v", err)
|
|
186
|
+
}
|
|
187
|
+
if len(fa.transitions) != 0 {
|
|
188
|
+
t.Errorf("self-loop must not transition: %v", fa.transitions)
|
|
189
|
+
}
|
|
190
|
+
if len(fa.comments) != 1 {
|
|
191
|
+
t.Errorf("self-loop must comment: %v", fa.comments)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func TestReportUnknownTargetNode(t *testing.T) {
|
|
196
|
+
fa := &fakeAcli{views: map[string]acli.Ticket{"XYZ-1": {Key: "XYZ-1", Status: "In Progress"}}}
|
|
197
|
+
j := mustJira(t, fa)
|
|
198
|
+
if err := j.Report(tasksTicket("XYZ-1"), "success", "nowhere", "x"); err == nil ||
|
|
199
|
+
!strings.Contains(err.Error(), "unknown node") {
|
|
200
|
+
t.Errorf("err = %v", err)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func TestProjectKeyFromQuery(t *testing.T) {
|
|
205
|
+
got, err := ProjectKeyFromQuery("project = xyz AND foo = bar")
|
|
206
|
+
if err != nil || got != "xyz" {
|
|
207
|
+
t.Errorf("got %q err %v", got, err)
|
|
208
|
+
}
|
|
209
|
+
got, err = ProjectKeyFromQuery(`PROJECT = "ABC"`)
|
|
210
|
+
if err != nil || got != "ABC" {
|
|
211
|
+
t.Errorf("got %q err %v", got, err)
|
|
212
|
+
}
|
|
213
|
+
if _, err = ProjectKeyFromQuery("foo = bar"); err == nil {
|
|
214
|
+
t.Error("expected error for missing project")
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func TestValidateStates(t *testing.T) {
|
|
219
|
+
fa := &fakeValidator{good: map[string]bool{"In Progress": true, "In Review": true}}
|
|
220
|
+
bad, err := ValidateStates(fa, testNodes, "xyz")
|
|
221
|
+
if err != nil {
|
|
222
|
+
t.Fatalf("%v", err)
|
|
223
|
+
}
|
|
224
|
+
if len(bad) != 1 || bad[0] != "Done" {
|
|
225
|
+
t.Errorf("bad = %v", bad)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type fakeValidator struct{ good map[string]bool }
|
|
230
|
+
|
|
231
|
+
func (f *fakeValidator) ValidateStatus(projectKey, status string) error {
|
|
232
|
+
if f.good[status] {
|
|
233
|
+
return nil
|
|
234
|
+
}
|
|
235
|
+
return errInvalidStatus
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
var errInvalidStatus = errorString("invalid status")
|
|
239
|
+
|
|
240
|
+
type errorString string
|
|
241
|
+
|
|
242
|
+
func (e errorString) Error() string { return string(e) }
|
|
243
|
+
|
|
244
|
+
func tasksTicket(key string) tasks.Ticket { return tasks.Ticket{Key: key} }
|
|
245
|
+
|
|
246
|
+
func TestReportTransitionFailurePropagates(t *testing.T) {
|
|
247
|
+
fa := &fakeAcli{
|
|
248
|
+
views: map[string]acli.Ticket{"XYZ-1": {Key: "XYZ-1", Status: "In Progress"}},
|
|
249
|
+
transitionErr: errorString("No allowed transitions found for given status"),
|
|
250
|
+
}
|
|
251
|
+
j := mustJira(t, fa)
|
|
252
|
+
err := j.Report(tasks.Ticket{Key: "XYZ-1"}, "success", "reviewing", "x")
|
|
253
|
+
if err == nil || !strings.Contains(err.Error(), "No allowed transitions") {
|
|
254
|
+
t.Errorf("transition failure must propagate, got %v", err)
|
|
255
|
+
}
|
|
256
|
+
if len(fa.comments) != 0 {
|
|
257
|
+
t.Errorf("no comment on failed transition: %v", fa.comments)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestDistributedJQLHasAssignee(t *testing.T) {
|
|
9
|
+
j, err := newJira(JiraConfig{Query: "project = XYZ", IssueTypes: []string{"Task"}}, "wf", testNodes, "Jane Doe", "xyz-repo", nil)
|
|
10
|
+
if err != nil {
|
|
11
|
+
t.Fatalf("%v", err)
|
|
12
|
+
}
|
|
13
|
+
if !strings.Contains(j.jql, `assignee = "Jane Doe"`) {
|
|
14
|
+
t.Errorf("distributed JQL missing assignee: %q", j.jql)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Package tasks defines the ticket-system plug point. Jira ships built in
|
|
2
|
+
// (internal/tasks/jira); beads/linear/github adapters register the same
|
|
3
|
+
// way via init() — the database/sql driver pattern: import to register,
|
|
4
|
+
// then resolve by type name from the workflow YAML's `tasks.type`.
|
|
5
|
+
package tasks
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"fmt"
|
|
9
|
+
"sort"
|
|
10
|
+
"sync"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// Ticket is one unit of work as seen by the daemon. Adapters fill Node by
|
|
16
|
+
// reverse-mapping the ticket's tracker state through the workflow's node
|
|
17
|
+
// `when` values ("" = unmapped state), and ClaimedBy from claim labels
|
|
18
|
+
// ("" = unclaimed).
|
|
19
|
+
type Ticket struct {
|
|
20
|
+
Key string
|
|
21
|
+
Summary string
|
|
22
|
+
Node string
|
|
23
|
+
ClaimedBy string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Tasks is the tracker adapter interface: list candidate tickets, claim
|
|
27
|
+
// one, report an outcome. One List call per poll cycle per workflow.
|
|
28
|
+
type Tasks interface {
|
|
29
|
+
List() ([]Ticket, error)
|
|
30
|
+
Claim(t Ticket) error
|
|
31
|
+
// Report records the agent outcome: transitions the ticket to the
|
|
32
|
+
// target node's tracker state (adapter resolves it) and posts the
|
|
33
|
+
// summary. Self-loop (target state == current) → comment only.
|
|
34
|
+
Report(t Ticket, outcome, targetNode, summary string) error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Factory builds an adapter instance for one submitted workflow.
|
|
38
|
+
type Factory struct {
|
|
39
|
+
// UnmarshalConfig strictly decodes the YAML's tasks.config map into
|
|
40
|
+
// the adapter's own config struct.
|
|
41
|
+
UnmarshalConfig func(map[string]any) (any, error)
|
|
42
|
+
// New builds the adapter. wfName is the workflow identity (claim
|
|
43
|
+
// labels); nodes carry the `when` state map; assignee is the machine
|
|
44
|
+
// user's tracker identity ("" when the adapter doesn't need it);
|
|
45
|
+
// repoName is the repo's display name (tracker-side component/label
|
|
46
|
+
// scoping; "" when the adapter doesn't scope by repo).
|
|
47
|
+
New func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (Tasks, error)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var (
|
|
51
|
+
mu sync.RWMutex
|
|
52
|
+
factories = map[string]Factory{}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// Register makes an adapter available under `type: <name>`. Called from
|
|
56
|
+
// adapter init(); panics on duplicate names (programmer error).
|
|
57
|
+
func Register(name string, f Factory) {
|
|
58
|
+
mu.Lock()
|
|
59
|
+
defer mu.Unlock()
|
|
60
|
+
if _, dup := factories[name]; dup {
|
|
61
|
+
panic("tasks: duplicate adapter registration " + name)
|
|
62
|
+
}
|
|
63
|
+
factories[name] = f
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// New resolves a tasks adapter by type name and builds an instance.
|
|
67
|
+
func New(typeName string, rawCfg map[string]any, wfName string, nodes map[string]config.Node, assignee, repoName string) (Tasks, error) {
|
|
68
|
+
mu.RLock()
|
|
69
|
+
f, ok := factories[typeName]
|
|
70
|
+
mu.RUnlock()
|
|
71
|
+
if !ok {
|
|
72
|
+
return nil, fmt.Errorf("unknown tasks type %q (registered: %v)", typeName, registered())
|
|
73
|
+
}
|
|
74
|
+
cfg, err := f.UnmarshalConfig(rawCfg)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return nil, fmt.Errorf("tasks type %q config: %w", typeName, err)
|
|
77
|
+
}
|
|
78
|
+
return f.New(cfg, wfName, nodes, assignee, repoName)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func registered() []string {
|
|
82
|
+
mu.RLock()
|
|
83
|
+
defer mu.RUnlock()
|
|
84
|
+
names := make([]string, 0, len(factories))
|
|
85
|
+
for n := range factories {
|
|
86
|
+
names = append(names, n)
|
|
87
|
+
}
|
|
88
|
+
sort.Strings(names)
|
|
89
|
+
return names
|
|
90
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
package tasks
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func testNodes() map[string]config.Node {
|
|
11
|
+
return map[string]config.Node{
|
|
12
|
+
"coding": {Agent: "build", When: "In Progress", OnSuccess: "reviewing", OnFailure: "coding"},
|
|
13
|
+
"reviewing": {Agent: "build", When: "In Review", OnSuccess: "done", OnFailure: "coding"},
|
|
14
|
+
"done": {When: "Done"},
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type fakeTasks struct {
|
|
19
|
+
listed []Ticket
|
|
20
|
+
claims []string
|
|
21
|
+
reports []string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func (f *fakeTasks) List() ([]Ticket, error) { return f.listed, nil }
|
|
25
|
+
func (f *fakeTasks) Claim(t Ticket) error {
|
|
26
|
+
f.claims = append(f.claims, t.Key)
|
|
27
|
+
return nil
|
|
28
|
+
}
|
|
29
|
+
func (f *fakeTasks) Report(t Ticket, outcome, targetNode, summary string) error {
|
|
30
|
+
f.reports = append(f.reports, t.Key+":"+outcome+":"+targetNode)
|
|
31
|
+
return nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func TestRegistryNew(t *testing.T) {
|
|
35
|
+
fake := &fakeTasks{}
|
|
36
|
+
Register("fake", Factory{
|
|
37
|
+
UnmarshalConfig: func(m map[string]any) (any, error) {
|
|
38
|
+
if m["bad"] != nil {
|
|
39
|
+
return nil, errFakeConfig
|
|
40
|
+
}
|
|
41
|
+
return m, nil
|
|
42
|
+
},
|
|
43
|
+
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (Tasks, error) {
|
|
44
|
+
if repoName != "repo:xyz" {
|
|
45
|
+
t.Errorf("repoName = %q", repoName)
|
|
46
|
+
}
|
|
47
|
+
if wfName == "" {
|
|
48
|
+
t.Error("wfName empty")
|
|
49
|
+
}
|
|
50
|
+
if nodes["coding"].When != "In Progress" {
|
|
51
|
+
t.Error("nodes not passed")
|
|
52
|
+
}
|
|
53
|
+
return fake, nil
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
tk, err := New("fake", map[string]any{"k": "v"}, "xyzFlow", testNodes(), "Jane Doe", "repo:xyz")
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatalf("%v", err)
|
|
60
|
+
}
|
|
61
|
+
tk.Claim(Ticket{Key: "XYZ-1"})
|
|
62
|
+
if len(fake.claims) != 1 || fake.claims[0] != "XYZ-1" {
|
|
63
|
+
t.Errorf("claims = %v", fake.claims)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if _, err := New("nonexistent-adapter", nil, "w", nil, "", ""); err == nil ||
|
|
67
|
+
!strings.Contains(err.Error(), "unknown tasks type") || !strings.Contains(err.Error(), "fake") {
|
|
68
|
+
t.Errorf("unknown adapter error = %v", err)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if _, err := New("fake", map[string]any{"bad": true}, "w", testNodes(), "", ""); err == nil || !strings.Contains(err.Error(), "bad config") {
|
|
72
|
+
t.Errorf("config unmarshal err = %v", err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
var errFakeConfig = errorString("bad config")
|
|
77
|
+
|
|
78
|
+
type errorString string
|
|
79
|
+
|
|
80
|
+
func (e errorString) Error() string { return string(e) }
|
|
81
|
+
|
|
82
|
+
func TestDuplicateRegisterPanics(t *testing.T) {
|
|
83
|
+
defer func() {
|
|
84
|
+
if recover() == nil {
|
|
85
|
+
t.Fatal("expected panic on duplicate registration")
|
|
86
|
+
}
|
|
87
|
+
}()
|
|
88
|
+
f := Factory{}
|
|
89
|
+
Register("dup", f)
|
|
90
|
+
Register("dup", f)
|
|
91
|
+
}
|