relay-flow 0.2.0-alpha → 0.2.2-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 +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"reflect"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestApplyTaskConfigExpectedMailboxStateUsesUnconditionalUpdate(t *testing.T) {
|
|
16
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
17
|
+
sys := &system{cli: client}
|
|
18
|
+
target := task.Target{
|
|
19
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
20
|
+
Mailbox: &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
err := sys.ApplyTaskConfig(context.Background(), target, config.RawValues{
|
|
24
|
+
"transitionTo": map[string]any{"taskStatus": "closed"},
|
|
25
|
+
})
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
if !reflect.DeepEqual(client.shown, []string{"demo-parent.1"}) {
|
|
30
|
+
t.Fatalf("show calls = %v, want one mailbox read", client.shown)
|
|
31
|
+
}
|
|
32
|
+
if len(client.updates) != 1 {
|
|
33
|
+
t.Fatalf("updates = %+v, want one unconditional status update", client.updates)
|
|
34
|
+
}
|
|
35
|
+
update := client.updates[0]
|
|
36
|
+
if update.issueID != "demo-parent.1" || update.input.Status != "closed" {
|
|
37
|
+
t.Fatalf("status update = %+v", update)
|
|
38
|
+
}
|
|
39
|
+
if update.input.Description != nil || len(update.input.AddLabels) != 0 || update.input.ClearDefer || update.input.Assignee != "" {
|
|
40
|
+
t.Fatalf("status update carried unrelated flags: %+v", update.input)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestApplyTaskConfigAlreadyAtTargetIsIdempotent(t *testing.T) {
|
|
45
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "closed"})
|
|
46
|
+
sys := &system{cli: client}
|
|
47
|
+
target := task.Target{
|
|
48
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
49
|
+
Mailbox: &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, config.RawValues{
|
|
53
|
+
"transitionTo": map[string]any{"taskStatus": "closed"},
|
|
54
|
+
}); err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
|
|
58
|
+
t.Fatalf("show calls = %v, want one target-state read", client.shown)
|
|
59
|
+
}
|
|
60
|
+
if len(client.updates) != 0 {
|
|
61
|
+
t.Fatalf("already-target status issued updates: %+v", client.updates)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestApplyTaskConfigWithoutStatusIsNoOp(t *testing.T) {
|
|
66
|
+
client := newStatusClient(map[string]string{"demo-parent": "open"})
|
|
67
|
+
sys := &system{cli: client}
|
|
68
|
+
target := task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
|
|
69
|
+
|
|
70
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, nil); err != nil {
|
|
71
|
+
t.Fatal(err)
|
|
72
|
+
}
|
|
73
|
+
if len(client.shown) != 0 || len(client.updates) != 0 {
|
|
74
|
+
t.Fatalf("no-status config caused calls: show=%v updates=%+v", client.shown, client.updates)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func TestApplyTaskConfigIncompatibleMailboxStateReturnsConflict(t *testing.T) {
|
|
79
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_review"})
|
|
80
|
+
sys := &system{cli: client}
|
|
81
|
+
target := task.Target{
|
|
82
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
83
|
+
Mailbox: &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
err := sys.ApplyTaskConfig(context.Background(), target, config.RawValues{
|
|
87
|
+
"transitionTo": map[string]any{"taskStatus": "closed"},
|
|
88
|
+
})
|
|
89
|
+
if err == nil {
|
|
90
|
+
t.Fatal("incompatible mailbox state was accepted")
|
|
91
|
+
}
|
|
92
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
93
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
94
|
+
}
|
|
95
|
+
if len(client.updates) != 0 {
|
|
96
|
+
t.Fatalf("incompatible state issued a status update: %+v", client.updates)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestApplyTaskConfigParentStatusUsesReadBeforeWrite(t *testing.T) {
|
|
101
|
+
client := newStatusClient(map[string]string{"demo-parent": "open"})
|
|
102
|
+
sys := &system{cli: client}
|
|
103
|
+
target := task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
|
|
104
|
+
|
|
105
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, config.RawValues{
|
|
106
|
+
"transitionTo": map[string]any{"parentStatus": "closed"},
|
|
107
|
+
}); err != nil {
|
|
108
|
+
t.Fatal(err)
|
|
109
|
+
}
|
|
110
|
+
if !reflect.DeepEqual(client.shown, []string{"demo-parent"}) {
|
|
111
|
+
t.Fatalf("show calls = %v, want one parent read", client.shown)
|
|
112
|
+
}
|
|
113
|
+
if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent" || client.updates[0].input.Status != "closed" {
|
|
114
|
+
t.Fatalf("parent status updates = %+v", client.updates)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func TestCompleteMailboxUsesReadBeforeWriteAndIsIdempotent(t *testing.T) {
|
|
119
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
120
|
+
sys := &system{cli: client}
|
|
121
|
+
mailbox := task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"}
|
|
122
|
+
|
|
123
|
+
if err := sys.CompleteMailbox(context.Background(), mailbox); err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
if len(client.shown) != 1 || client.shown[0] != mailbox.Key || len(client.updates) != 1 || client.updates[0].input.Status != "closed" {
|
|
127
|
+
t.Fatalf("completion calls = show:%v updates:%+v", client.shown, client.updates)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
client.shown = nil
|
|
131
|
+
client.updates = nil
|
|
132
|
+
client.issues[mailbox.Key] = bdcli.Issue{ID: mailbox.Key, Status: "closed"}
|
|
133
|
+
if err := sys.CompleteMailbox(context.Background(), mailbox); err != nil {
|
|
134
|
+
t.Fatal(err)
|
|
135
|
+
}
|
|
136
|
+
if len(client.shown) != 1 || len(client.updates) != 0 {
|
|
137
|
+
t.Fatalf("already-closed completion calls = show:%v updates:%+v", client.shown, client.updates)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func TestCompleteMailboxIncompatibleStateReturnsConflictWithoutUpdate(t *testing.T) {
|
|
142
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_review"})
|
|
143
|
+
sys := &system{cli: client}
|
|
144
|
+
mailbox := task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"}
|
|
145
|
+
|
|
146
|
+
err := sys.CompleteMailbox(context.Background(), mailbox)
|
|
147
|
+
if err == nil {
|
|
148
|
+
t.Fatal("incompatible mailbox completion was accepted")
|
|
149
|
+
}
|
|
150
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
151
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
152
|
+
}
|
|
153
|
+
if len(client.updates) != 0 {
|
|
154
|
+
t.Fatalf("incompatible completion issued update: %+v", client.updates)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func TestStatusRaceAfterReadUsesOneUnconditionalUpdate(t *testing.T) {
|
|
159
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
160
|
+
client.beforeUpdate = func(issueID string) {
|
|
161
|
+
client.issues[issueID] = bdcli.Issue{ID: issueID, Status: "in_review"}
|
|
162
|
+
}
|
|
163
|
+
sys := &system{cli: client}
|
|
164
|
+
target := task.Target{
|
|
165
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
166
|
+
Mailbox: &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, config.RawValues{
|
|
170
|
+
"transitionTo": map[string]any{"taskStatus": "closed"},
|
|
171
|
+
}); err != nil {
|
|
172
|
+
t.Fatalf("accepted read/write race returned error: %v", err)
|
|
173
|
+
}
|
|
174
|
+
if len(client.shown) != 1 || len(client.updates) != 1 {
|
|
175
|
+
t.Fatalf("race calls = show:%v updates:%+v, want one read and one write", client.shown, client.updates)
|
|
176
|
+
}
|
|
177
|
+
if client.updates[0].input.Status != "closed" {
|
|
178
|
+
t.Fatalf("race update = %+v", client.updates[0])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func TestResetForRecoveryReopensParentAndMailboxesAndClearsDefer(t *testing.T) {
|
|
183
|
+
client := newStatusClient(map[string]string{
|
|
184
|
+
"demo-parent": "blocked",
|
|
185
|
+
"demo-parent.1": "closed",
|
|
186
|
+
"demo-parent.2": "deferred",
|
|
187
|
+
})
|
|
188
|
+
sys := &system{cli: client}
|
|
189
|
+
parent := task.TicketRef{ID: "demo-parent", Key: "demo-parent", Title: "parent"}
|
|
190
|
+
mailboxes := []task.Mailbox{
|
|
191
|
+
{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
192
|
+
{ID: "demo-parent.2", Key: "demo-parent.2", Node: "review"},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if err := sys.ResetForRecovery(context.Background(), parent, mailboxes, nil); err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
if len(client.updates) != 3 {
|
|
199
|
+
t.Fatalf("recovery updates = %+v, want parent plus two mailboxes", client.updates)
|
|
200
|
+
}
|
|
201
|
+
seen := make(map[string]bdcli.UpdateInput, len(client.updates))
|
|
202
|
+
for _, update := range client.updates {
|
|
203
|
+
seen[update.issueID] = update.input
|
|
204
|
+
}
|
|
205
|
+
for _, issueID := range []string{"demo-parent", "demo-parent.1", "demo-parent.2"} {
|
|
206
|
+
input, ok := seen[issueID]
|
|
207
|
+
if !ok {
|
|
208
|
+
t.Fatalf("recovery did not reset %s: %+v", issueID, seen)
|
|
209
|
+
}
|
|
210
|
+
if input.Status != "open" || !input.ClearDefer {
|
|
211
|
+
t.Fatalf("recovery update for %s = %+v, want open and clear defer", issueID, input)
|
|
212
|
+
}
|
|
213
|
+
if input.Description != nil || len(input.AddLabels) != 0 {
|
|
214
|
+
t.Fatalf("recovery update for %s changed preserved text/labels: %+v", issueID, input)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type statusClient struct {
|
|
220
|
+
mailboxClient
|
|
221
|
+
issues map[string]bdcli.Issue
|
|
222
|
+
shown []string
|
|
223
|
+
updates []updateCall
|
|
224
|
+
beforeUpdate func(string)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func newStatusClient(statuses map[string]string) *statusClient {
|
|
228
|
+
issues := make(map[string]bdcli.Issue, len(statuses))
|
|
229
|
+
for issueID, status := range statuses {
|
|
230
|
+
issues[issueID] = bdcli.Issue{ID: issueID, Status: status}
|
|
231
|
+
}
|
|
232
|
+
return &statusClient{issues: issues}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func (f *statusClient) Show(_ context.Context, issueID string) (bdcli.Issue, error) {
|
|
236
|
+
f.shown = append(f.shown, issueID)
|
|
237
|
+
issue, ok := f.issues[issueID]
|
|
238
|
+
if !ok {
|
|
239
|
+
return bdcli.Issue{}, errors.New("unknown issue " + issueID)
|
|
240
|
+
}
|
|
241
|
+
return issue, nil
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func (f *statusClient) Update(_ context.Context, issueID string, input bdcli.UpdateInput) error {
|
|
245
|
+
f.updates = append(f.updates, updateCall{issueID: issueID, input: input})
|
|
246
|
+
if f.beforeUpdate != nil {
|
|
247
|
+
f.beforeUpdate(issueID)
|
|
248
|
+
}
|
|
249
|
+
issue := f.issues[issueID]
|
|
250
|
+
if input.Status != "" {
|
|
251
|
+
issue.Status = input.Status
|
|
252
|
+
}
|
|
253
|
+
f.issues[issueID] = issue
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var _ bdcli.Client = (*statusClient)(nil)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
fail() {
|
|
5
|
+
printf 'unsupported or malformed bd invocation: %s\n' "$*" >&2
|
|
6
|
+
exit 64
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
[ -n "${BD_LOG-}" ] || fail "BD_LOG is missing"
|
|
10
|
+
[ -n "${BEADS_DIR-}" ] || fail "BEADS_DIR is missing"
|
|
11
|
+
[ "${BEADS_DB+x}" != x ] || fail "ambient BEADS_DB leaked"
|
|
12
|
+
[ "${BD_DB+x}" != x ] || fail "ambient BD_DB leaked"
|
|
13
|
+
printf '%s|%s|%s\n' "$PWD" "$BEADS_DIR" "$*" >> "$BD_LOG"
|
|
14
|
+
|
|
15
|
+
if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --limit ] && [ "$4" = 1 ] && [ "$5" = --no-parent ] && [ "$6" = --json ]; then
|
|
16
|
+
printf '[]\n'
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --no-parent ] && [ "$4" = --limit ] && [ "$5" = 0 ] && [ "$6" = --json ]; then
|
|
20
|
+
printf '[]\n'
|
|
21
|
+
exit 0
|
|
22
|
+
fi
|
|
23
|
+
if [ "$#" -eq 9 ] && [ "$1" = list ] && [ "$2" = --no-parent ] && [ "$3" = --status ] && [ "$4" = open,in_progress,blocked,deferred ] && [ "$5" = --label-pattern ] && [ "$6" = 'wf:*' ] && [ "$7" = --limit ] && [ "$8" = 0 ] && [ "$9" = --json ]; then
|
|
24
|
+
printf '[]\n'
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
fail "$@"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func validationSystem() *system {
|
|
12
|
+
return &system{base: config.Merge(DefaultConfig())}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func TestValidateConfigMergesWorkflowAndNodeValuesLocally(t *testing.T) {
|
|
16
|
+
err := validationSystem().ValidateConfig(context.Background(), config.RawValues{
|
|
17
|
+
"filters": map[string]any{
|
|
18
|
+
"parentStatuses": []any{"open"},
|
|
19
|
+
"labels": []any{"backend"},
|
|
20
|
+
},
|
|
21
|
+
}, map[string]config.RawValues{
|
|
22
|
+
"implement": {"transitionTo": map[string]any{"taskStatus": "in_progress"}},
|
|
23
|
+
})
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestValidateConfigRejectsUnknownNodeFields(t *testing.T) {
|
|
30
|
+
err := validationSystem().ValidateConfig(context.Background(), nil, map[string]config.RawValues{
|
|
31
|
+
"implement": {"unsupported": true},
|
|
32
|
+
})
|
|
33
|
+
if err == nil || !strings.Contains(err.Error(), "unsupported") {
|
|
34
|
+
t.Fatalf("unknown node field error = %v", err)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func TestValidateConfigRejectsNullAndInvalidFilterValues(t *testing.T) {
|
|
39
|
+
for _, tc := range []struct {
|
|
40
|
+
name string
|
|
41
|
+
raw config.RawValues
|
|
42
|
+
want string
|
|
43
|
+
}{
|
|
44
|
+
{
|
|
45
|
+
name: "explicit null",
|
|
46
|
+
raw: config.RawValues{"filters": map[string]any{
|
|
47
|
+
"labels": nil,
|
|
48
|
+
}},
|
|
49
|
+
want: "explicit null",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "wrong filter type",
|
|
53
|
+
raw: config.RawValues{"filters": map[string]any{
|
|
54
|
+
"labels": "backend",
|
|
55
|
+
}},
|
|
56
|
+
want: "cannot unmarshal",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "empty filter value",
|
|
60
|
+
raw: config.RawValues{"filters": map[string]any{
|
|
61
|
+
"labels": []any{""},
|
|
62
|
+
}},
|
|
63
|
+
want: "must not be empty",
|
|
64
|
+
},
|
|
65
|
+
} {
|
|
66
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
67
|
+
err := validationSystem().ValidateConfig(context.Background(), tc.raw, nil)
|
|
68
|
+
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
69
|
+
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestValidateConfigRejectsUnsupportedStatusAndTemplate(t *testing.T) {
|
|
76
|
+
for _, tc := range []struct {
|
|
77
|
+
name string
|
|
78
|
+
raw config.RawValues
|
|
79
|
+
want string
|
|
80
|
+
}{
|
|
81
|
+
{
|
|
82
|
+
name: "legacy status",
|
|
83
|
+
raw: config.RawValues{"status": map[string]any{
|
|
84
|
+
"mailbox": "in_review",
|
|
85
|
+
}},
|
|
86
|
+
want: "field status not found",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "summary placeholder",
|
|
90
|
+
raw: config.RawValues{"templates": map[string]any{
|
|
91
|
+
"summaryComment": "summary",
|
|
92
|
+
}},
|
|
93
|
+
want: "summaryComment must contain {{summaryReport}}",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "unknown template variable",
|
|
97
|
+
raw: config.RawValues{"templates": map[string]any{
|
|
98
|
+
"mailboxDescription": "mailbox {{unknown}}",
|
|
99
|
+
}},
|
|
100
|
+
want: "unknown template variable",
|
|
101
|
+
},
|
|
102
|
+
} {
|
|
103
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
104
|
+
err := validationSystem().ValidateConfig(context.Background(), tc.raw, nil)
|
|
105
|
+
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
106
|
+
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -24,6 +24,8 @@ type fakeSystem struct {
|
|
|
24
24
|
resets []string
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
var _ task.System = (*fakeSystem)(nil)
|
|
28
|
+
|
|
27
29
|
type storedTicket struct {
|
|
28
30
|
ticket task.Ticket
|
|
29
31
|
isMailbox bool
|
|
@@ -65,6 +67,16 @@ func (f *fakeSystem) ValidateConfig(context.Context, config.RawValues, map[strin
|
|
|
65
67
|
return nil
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
func (f *fakeSystem) RenderText(_ task.TextKind, data task.TextData) (string, error) {
|
|
71
|
+
if data.SummaryReport != "" {
|
|
72
|
+
return data.SummaryReport, nil
|
|
73
|
+
}
|
|
74
|
+
if data.FeedbackReport != "" {
|
|
75
|
+
return data.FeedbackReport, nil
|
|
76
|
+
}
|
|
77
|
+
return data.NodeDescription, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
func (f *fakeSystem) EnsureMailboxes(_ context.Context, _ task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
69
81
|
for _, s := range specs {
|
|
70
82
|
if _, ok := f.mailboxes[s.Node]; !ok {
|
package/internal/task/factory.go
CHANGED
|
@@ -3,6 +3,7 @@ package task
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
|
+
"io"
|
|
6
7
|
"sort"
|
|
7
8
|
"strings"
|
|
8
9
|
"sync"
|
|
@@ -24,9 +25,12 @@ type RepoSpec struct {
|
|
|
24
25
|
// canonical physical task scope (such as Jira site/project/component) used
|
|
25
26
|
// to reject duplicate scope registration.
|
|
26
27
|
type Factory struct {
|
|
27
|
-
RequiredRepoKeys
|
|
28
|
-
TaskScopeKey
|
|
29
|
-
|
|
28
|
+
RequiredRepoKeys func() []string
|
|
29
|
+
TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
|
|
30
|
+
Auth func(context.Context, []string, io.Reader) error
|
|
31
|
+
DefaultConfig func() config.RawValues
|
|
32
|
+
ValidateTextConfig func(config.RawValues) error
|
|
33
|
+
New func(context.Context, RepoSpec) (System, error)
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
var (
|
|
@@ -60,9 +64,54 @@ func New(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
|
60
64
|
if err != nil {
|
|
61
65
|
return nil, err
|
|
62
66
|
}
|
|
67
|
+
spec.RootConfig = config.Merge(defaultConfig(f), spec.RootConfig)
|
|
63
68
|
return f.New(ctx, spec)
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
// Defaults returns a fresh copy of the selected task plugin's root config
|
|
72
|
+
// defaults for relay-flow init.
|
|
73
|
+
func Defaults(name string) (config.RawValues, error) {
|
|
74
|
+
f, err := lookup(name)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return nil, err
|
|
77
|
+
}
|
|
78
|
+
return config.Merge(defaultConfig(f)), nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ValidateTextConfig validates the selected task plugin's text templates
|
|
82
|
+
// without requiring task-system credentials or a registered repo.
|
|
83
|
+
func ValidateTextConfig(name string, cfg config.RawValues) error {
|
|
84
|
+
f, err := lookup(name)
|
|
85
|
+
if err != nil {
|
|
86
|
+
return err
|
|
87
|
+
}
|
|
88
|
+
if f.ValidateTextConfig == nil {
|
|
89
|
+
return nil
|
|
90
|
+
}
|
|
91
|
+
return f.ValidateTextConfig(config.Merge(defaultConfig(f), cfg))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func defaultConfig(f Factory) config.RawValues {
|
|
95
|
+
if f.DefaultConfig == nil {
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
98
|
+
return f.DefaultConfig()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Auth dispatches system-wide authentication to the selected task plugin.
|
|
102
|
+
// The plugin owns its flags, prompts, validation, credential format, and
|
|
103
|
+
// credential storage.
|
|
104
|
+
func Auth(ctx context.Context, name string, args []string, stdin io.Reader) error {
|
|
105
|
+
f, err := lookup(name)
|
|
106
|
+
if err != nil {
|
|
107
|
+
return err
|
|
108
|
+
}
|
|
109
|
+
if f.Auth == nil {
|
|
110
|
+
return fmt.Errorf("task: plugin %q does not support authentication", name)
|
|
111
|
+
}
|
|
112
|
+
return f.Auth(ctx, args, stdin)
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
// RequiredRepoKeys returns the repo YAML keys the named plugin requires at
|
|
67
116
|
// registration.
|
|
68
117
|
func RequiredRepoKeys(name string) ([]string, error) {
|