relay-flow 0.0.1 → 0.2.0-alpha
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/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -1,259 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|
package/internal/tasks/tasks.go
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
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
|
-
}
|