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,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
+ }