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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// 3.13: filter tests per specs/repo-workflow-routing "Workflow filters are
|
|
14
|
+
// structured and locally evaluable": the adapter compiles workflow
|
|
15
|
+
// taskConfig.filters into matchers over normalized ticket fields; matching
|
|
16
|
+
// is in-memory against one repo poll batch (no per-workflow re-query).
|
|
17
|
+
// Package-local so it can use the adapter's test seams without exporting
|
|
18
|
+
// production API.
|
|
19
|
+
|
|
20
|
+
// pollCountingSystem wraps the adapter's Poll to prove matchers never
|
|
21
|
+
// trigger another fetch.
|
|
22
|
+
type pollCountingSystem struct {
|
|
23
|
+
task.System
|
|
24
|
+
polls int
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (p *pollCountingSystem) Poll(ctx context.Context) ([]task.Ticket, error) {
|
|
28
|
+
p.polls++
|
|
29
|
+
return p.System.Poll(ctx)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestCompileFilterMatchesNormalizedFields(t *testing.T) {
|
|
33
|
+
sys := newSystemWithFake(t, &fakeACLI{})
|
|
34
|
+
|
|
35
|
+
match, err := sys.CompileFilter(config.RawValues{
|
|
36
|
+
"filters": map[string]any{
|
|
37
|
+
"parentStatuses": []any{"To Do"},
|
|
38
|
+
"issueTypes": []any{"Task"},
|
|
39
|
+
"labels": []any{"coding"},
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ticket := task.Ticket{
|
|
47
|
+
ID: "1", Key: "PAY-101",
|
|
48
|
+
Fields: map[string]any{
|
|
49
|
+
"status": "To Do",
|
|
50
|
+
"issueType": "Task",
|
|
51
|
+
"labels": []string{"coding", "backend"},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
if !match(ticket) {
|
|
55
|
+
t.Fatal("matching ticket rejected by compiled filter")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestCompileFilterRejectsNonMatching(t *testing.T) {
|
|
60
|
+
sys := newSystemWithFake(t, &fakeACLI{})
|
|
61
|
+
match, err := sys.CompileFilter(config.RawValues{
|
|
62
|
+
"filters": map[string]any{"parentStatuses": []any{"To Do"}},
|
|
63
|
+
})
|
|
64
|
+
if err != nil {
|
|
65
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
66
|
+
}
|
|
67
|
+
for _, tc := range []struct {
|
|
68
|
+
name string
|
|
69
|
+
fields map[string]any
|
|
70
|
+
}{
|
|
71
|
+
{"wrong status", map[string]any{"status": "Done", "issueType": "Task"}},
|
|
72
|
+
{"missing status", map[string]any{"issueType": "Task"}},
|
|
73
|
+
} {
|
|
74
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
75
|
+
if match(task.Ticket{Key: "PAY-1", Fields: tc.fields}) {
|
|
76
|
+
t.Fatal("non-matching ticket accepted")
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestCompileFilterRejectsUnknownField(t *testing.T) {
|
|
83
|
+
sys := newSystemWithFake(t, &fakeACLI{})
|
|
84
|
+
if _, err := sys.CompileFilter(config.RawValues{
|
|
85
|
+
"filters": map[string]any{"jql": "project = PAY"},
|
|
86
|
+
}); err == nil {
|
|
87
|
+
t.Fatal("arbitrary query/filter field accepted; workflow validation must reject it")
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestMatchingIsInMemoryNoRequery(t *testing.T) {
|
|
92
|
+
// One repo poll fetches the batch; the compiled matcher then evaluates
|
|
93
|
+
// every ticket in memory with no per-workflow re-query.
|
|
94
|
+
fake := &fakeACLI{searchJSON: []byte(`[{"id":"1","key":"PAY-1","fields":{"summary":"a","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"2","key":"PAY-2","fields":{"summary":"b","status":{"name":"Done"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"3","key":"PAY-3","fields":{"summary":"c","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}}]`)}
|
|
95
|
+
base := newSystemWithFake(t, fake)
|
|
96
|
+
counting := &pollCountingSystem{System: base}
|
|
97
|
+
|
|
98
|
+
match, err := counting.CompileFilter(config.RawValues{
|
|
99
|
+
"filters": map[string]any{"parentStatuses": []any{"To Do"}},
|
|
100
|
+
})
|
|
101
|
+
if err != nil {
|
|
102
|
+
t.Fatal(err)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The single repo poll.
|
|
106
|
+
batch, err := counting.Poll(context.Background())
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatal(err)
|
|
109
|
+
}
|
|
110
|
+
if counting.polls != 1 {
|
|
111
|
+
t.Fatalf("polls = %d, want exactly 1 repo poll", counting.polls)
|
|
112
|
+
}
|
|
113
|
+
if len(batch) == 0 {
|
|
114
|
+
t.Fatal("poll returned no batch to match against")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Matching the batch must not trigger another poll.
|
|
118
|
+
var matched int
|
|
119
|
+
for _, tk := range batch {
|
|
120
|
+
if match(tk) {
|
|
121
|
+
matched++
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if counting.polls != 1 {
|
|
125
|
+
t.Fatalf("matching triggered %d extra task-system polls; matching must be in-memory", counting.polls-1)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func TestJiraJSONNormalization(t *testing.T) {
|
|
130
|
+
// The adapter normalizes Jira search JSON (status, issue type, labels,
|
|
131
|
+
// assignee) into task.Ticket.Fields. Package-local so it can call the
|
|
132
|
+
// adapter's unexported normalization directly.
|
|
133
|
+
// acli emits a BARE ARRAY of issue objects (no REST envelope), and the
|
|
134
|
+
// normalized assignee is the user's email address — the stable identity
|
|
135
|
+
// workflow filters match on, not the human-readable display name.
|
|
136
|
+
raw := []byte(`[{"id":"1","key":"PAY-101","fields":{"summary":"parent","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":["coding"],"assignee":{"displayName":"Relay Bot","emailAddress":"relay@bot"}}}]`)
|
|
137
|
+
|
|
138
|
+
tickets, err := normalizeSearchResponse(raw)
|
|
139
|
+
if err != nil {
|
|
140
|
+
t.Fatalf("normalization failed: %v", err)
|
|
141
|
+
}
|
|
142
|
+
if len(tickets) != 1 {
|
|
143
|
+
t.Fatalf("tickets = %d, want 1", len(tickets))
|
|
144
|
+
}
|
|
145
|
+
tk := tickets[0]
|
|
146
|
+
if tk.Fields["status"] != "To Do" {
|
|
147
|
+
t.Fatalf("status = %v", tk.Fields["status"])
|
|
148
|
+
}
|
|
149
|
+
if tk.Fields["issueType"] != "Task" {
|
|
150
|
+
t.Fatalf("issueType = %v", tk.Fields["issueType"])
|
|
151
|
+
}
|
|
152
|
+
labels, ok := tk.Fields["labels"].([]string)
|
|
153
|
+
if !ok || len(labels) != 1 || labels[0] != "coding" {
|
|
154
|
+
t.Fatalf("labels = %v", tk.Fields["labels"])
|
|
155
|
+
}
|
|
156
|
+
// Raw Jira assignee object is normalized to its email address.
|
|
157
|
+
if tk.Fields["assignee"] != "relay@bot" {
|
|
158
|
+
t.Fatalf("assignee = %v, want normalized email address", tk.Fields["assignee"])
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func TestJiraJSONNormalizationParsesRealAcliSearchShape(t *testing.T) {
|
|
163
|
+
// 9.9: parse the REAL acli wire shape captured live from
|
|
164
|
+
// acli jira workitem search --jql ... --fields key,summary,status,issuetype,labels,assignee --json
|
|
165
|
+
// Fixture stored under testdata/ so regressions in the parser are
|
|
166
|
+
// caught against the actual acli contract, not a hand-written envelope.
|
|
167
|
+
raw, err := os.ReadFile("testdata/acli_search.json")
|
|
168
|
+
if err != nil {
|
|
169
|
+
t.Fatalf("read acli fixture: %v", err)
|
|
170
|
+
}
|
|
171
|
+
tickets, err := normalizeSearchResponse(raw)
|
|
172
|
+
if err != nil {
|
|
173
|
+
t.Fatalf("normalization of real acli output failed: %v", err)
|
|
174
|
+
}
|
|
175
|
+
if len(tickets) == 0 {
|
|
176
|
+
t.Fatal("real acli fixture yielded no tickets")
|
|
177
|
+
}
|
|
178
|
+
for _, tk := range tickets {
|
|
179
|
+
if tk.Key == "" || tk.ID == "" {
|
|
180
|
+
t.Fatalf("ticket missing id/key: %+v", tk)
|
|
181
|
+
}
|
|
182
|
+
if tk.Fields["status"] == "" || tk.Fields["issueType"] == "" {
|
|
183
|
+
t.Fatalf("ticket %s missing status/issueType: %+v", tk.Key, tk.Fields)
|
|
184
|
+
}
|
|
185
|
+
if _, ok := tk.Fields["labels"].([]string); !ok {
|
|
186
|
+
t.Fatalf("ticket %s labels not normalized to []string: %+v", tk.Key, tk.Fields)
|
|
187
|
+
}
|
|
188
|
+
// The fixture is captured with assignee=currentUser(), so every
|
|
189
|
+
// entry carries an email-form normalized assignee.
|
|
190
|
+
a, _ := tk.Fields["assignee"].(string)
|
|
191
|
+
if a == "" || !strings.Contains(a, "@") {
|
|
192
|
+
t.Fatalf("ticket %s assignee = %q, want an email address", tk.Key, a)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
|
|
198
|
+
// 9.9: workflow assignee filters match the normalized EMAIL identity,
|
|
199
|
+
// not the display name. e2e workflow.yaml filters on the user's email.
|
|
200
|
+
sys := newSystemWithFake(t, &fakeACLI{})
|
|
201
|
+
match, err := sys.CompileFilter(config.RawValues{
|
|
202
|
+
"filters": map[string]any{"assignees": []any{"raj.popat@example.com"}},
|
|
203
|
+
})
|
|
204
|
+
if err != nil {
|
|
205
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
206
|
+
}
|
|
207
|
+
if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "raj.popat@example.com"}}) {
|
|
208
|
+
t.Fatal("email assignee rejected")
|
|
209
|
+
}
|
|
210
|
+
if match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "Raj Popat"}}) {
|
|
211
|
+
t.Fatal("display name accepted; filters must match the normalized email")
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
|
|
216
|
+
// Assignee is a supported structured filter dimension
|
|
217
|
+
// (repo-workflow-routing: "parent statuses, issue types, labels, and
|
|
218
|
+
// assignee"); it matches the normalized ticket's assignee field.
|
|
219
|
+
// NOTE: the filter key `assignees` follows the same naming as
|
|
220
|
+
// parentStatuses/issueTypes/labels (plural); the docs do not pin the key.
|
|
221
|
+
sys := newSystemWithFake(t, &fakeACLI{})
|
|
222
|
+
match, err := sys.CompileFilter(config.RawValues{
|
|
223
|
+
"filters": map[string]any{"assignees": []any{"relay-bot@example.com"}},
|
|
224
|
+
})
|
|
225
|
+
if err != nil {
|
|
226
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
227
|
+
}
|
|
228
|
+
if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "relay-bot@example.com"}}) {
|
|
229
|
+
t.Fatal("matching assignee rejected")
|
|
230
|
+
}
|
|
231
|
+
if match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "someone-else@example.com"}}) {
|
|
232
|
+
t.Fatal("non-matching assignee accepted")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// fakeClient adapts the test-local fakeACLI (transitions + raw search batch)
|
|
10
|
+
// to the production acli.Client seam.
|
|
11
|
+
type fakeClient struct {
|
|
12
|
+
fake *fakeACLI
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func (f *fakeClient) Search(context.Context, string) ([]byte, error) {
|
|
16
|
+
return f.fake.searchJSON, nil
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (f *fakeClient) ValidateAssignee(context.Context, string) error { return nil }
|
|
20
|
+
|
|
21
|
+
func (f *fakeClient) ValidateStatus(context.Context, string, string) error { return nil }
|
|
22
|
+
|
|
23
|
+
func (f *fakeClient) View(context.Context, string) ([]byte, error) {
|
|
24
|
+
return []byte(`{"fields":{"labels":[],"subtasks":[]}}`), nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (f *fakeClient) CreateSubtask(context.Context, string, string, string) (string, string, error) {
|
|
28
|
+
return "", "", errNotFaked
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func (f *fakeClient) Assign(_ context.Context, key, assignee string) error {
|
|
32
|
+
f.fake.assignments = append(f.fake.assignments, key+":"+assignee)
|
|
33
|
+
f.fake.events = append(f.fake.events, "assign")
|
|
34
|
+
return f.fake.assignErr
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func (f *fakeClient) Transition(_ context.Context, key, status string) error {
|
|
38
|
+
f.fake.events = append(f.fake.events, "transition")
|
|
39
|
+
f.fake.transition(key, status)
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (f *fakeClient) EnsureLabel(context.Context, string, string) error { return nil }
|
|
44
|
+
|
|
45
|
+
func (f *fakeClient) UpdateDescription(context.Context, string, string) error { return nil }
|
|
46
|
+
|
|
47
|
+
func (f *fakeClient) ListComments(context.Context, string) ([]string, error) { return nil, nil }
|
|
48
|
+
|
|
49
|
+
func (f *fakeClient) AddComment(context.Context, string, string) error { return nil }
|
|
50
|
+
|
|
51
|
+
type notFakedError struct{}
|
|
52
|
+
|
|
53
|
+
func (notFakedError) Error() string { return "not faked" }
|
|
54
|
+
|
|
55
|
+
var errNotFaked = notFakedError{}
|
|
56
|
+
|
|
57
|
+
// newSystemForTest builds the adapter around the test-local fake ACLI seam.
|
|
58
|
+
func newSystemForTest(fake *fakeACLI) (task.System, error) {
|
|
59
|
+
return newSystemForCLI(&fakeClient{fake: fake})
|
|
60
|
+
}
|