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,242 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"reflect"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestHasCommentFindsMarkerOnParentOrMailbox(t *testing.T) {
|
|
14
|
+
for _, tc := range []struct {
|
|
15
|
+
name string
|
|
16
|
+
target task.Target
|
|
17
|
+
issueID string
|
|
18
|
+
wantFound bool
|
|
19
|
+
}{
|
|
20
|
+
{
|
|
21
|
+
name: "parent summary",
|
|
22
|
+
target: task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}},
|
|
23
|
+
issueID: "demo-parent",
|
|
24
|
+
wantFound: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "mailbox feedback",
|
|
28
|
+
target: task.Target{
|
|
29
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
30
|
+
Mailbox: &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
31
|
+
},
|
|
32
|
+
issueID: "demo-parent.1",
|
|
33
|
+
wantFound: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "missing marker",
|
|
37
|
+
target: task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}},
|
|
38
|
+
issueID: "demo-parent",
|
|
39
|
+
wantFound: false,
|
|
40
|
+
},
|
|
41
|
+
} {
|
|
42
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
43
|
+
client := &commentClient{comments: map[string][]bdcli.Comment{
|
|
44
|
+
"demo-parent": {
|
|
45
|
+
{ID: "comment-parent", Text: "parent note\n<!-- visit-parent:summary -->"},
|
|
46
|
+
},
|
|
47
|
+
"demo-parent.1": {
|
|
48
|
+
{ID: "comment-mailbox", Text: "feedback\n<!-- visit-mailbox:feedback -->"},
|
|
49
|
+
},
|
|
50
|
+
}}
|
|
51
|
+
marker := "visit-parent:summary"
|
|
52
|
+
if tc.name == "mailbox feedback" {
|
|
53
|
+
marker = "visit-mailbox:feedback"
|
|
54
|
+
}
|
|
55
|
+
if tc.name == "missing marker" {
|
|
56
|
+
marker = "visit-missing:summary"
|
|
57
|
+
}
|
|
58
|
+
sys := &system{cli: client}
|
|
59
|
+
got, err := sys.HasComment(context.Background(), tc.target, marker)
|
|
60
|
+
if err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
if got != tc.wantFound {
|
|
64
|
+
t.Fatalf("HasComment = %v, want %v", got, tc.wantFound)
|
|
65
|
+
}
|
|
66
|
+
if !reflect.DeepEqual(client.listed, []string{tc.issueID}) {
|
|
67
|
+
t.Fatalf("comment lookup IDs = %v, want [%s]", client.listed, tc.issueID)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestCommentAvoidsDuplicateMarkerAndPreservesMultilineBody(t *testing.T) {
|
|
74
|
+
marker := "visit-1:summary"
|
|
75
|
+
target := task.Target{
|
|
76
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
77
|
+
Mailbox: &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
78
|
+
}
|
|
79
|
+
client := &commentClient{comments: map[string][]bdcli.Comment{
|
|
80
|
+
"demo-parent.1": {{ID: "existing", Text: "already stored\n<!-- " + marker + " -->"}},
|
|
81
|
+
}}
|
|
82
|
+
sys := &system{cli: client}
|
|
83
|
+
|
|
84
|
+
if err := sys.Comment(context.Background(), target, "SUMMARY:\nfirst line\nsecond line", marker); err != nil {
|
|
85
|
+
t.Fatal(err)
|
|
86
|
+
}
|
|
87
|
+
if len(client.added) != 0 {
|
|
88
|
+
t.Fatalf("duplicate marker caused comment write: %+v", client.added)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
client.comments[target.Mailbox.Key] = nil
|
|
92
|
+
body := "SUMMARY:\nfirst line\nsecond line"
|
|
93
|
+
if err := sys.Comment(context.Background(), target, body, marker); err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
if len(client.added) != 1 {
|
|
97
|
+
t.Fatalf("comment writes = %+v, want one", client.added)
|
|
98
|
+
}
|
|
99
|
+
if client.added[0].issueID != target.Mailbox.Key {
|
|
100
|
+
t.Fatalf("comment destination = %q, want %q", client.added[0].issueID, target.Mailbox.Key)
|
|
101
|
+
}
|
|
102
|
+
wantBody := body + "\n\n<!-- " + marker + " -->"
|
|
103
|
+
if client.added[0].body != wantBody {
|
|
104
|
+
t.Fatalf("comment body = %q, want %q", client.added[0].body, wantBody)
|
|
105
|
+
}
|
|
106
|
+
if !strings.Contains(client.added[0].body, "first line\nsecond line") {
|
|
107
|
+
t.Fatalf("multiline content was not preserved: %q", client.added[0].body)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func TestCommentUsesSummaryAndSelectedFeedbackMailboxDestinations(t *testing.T) {
|
|
112
|
+
client := &commentClient{comments: map[string][]bdcli.Comment{}}
|
|
113
|
+
sys := &system{cli: client}
|
|
114
|
+
parent := task.TicketRef{ID: "demo-parent", Key: "demo-parent"}
|
|
115
|
+
current := &task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"}
|
|
116
|
+
selectedNext := &task.Mailbox{ID: "demo-parent.2", Key: "demo-parent.2", Node: "review"}
|
|
117
|
+
|
|
118
|
+
if err := sys.Comment(context.Background(), task.Target{Parent: parent, Mailbox: current}, "summary", "visit-1:summary"); err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
if err := sys.Comment(context.Background(), task.Target{Parent: parent, Mailbox: selectedNext}, "feedback", "visit-1:feedback"); err != nil {
|
|
122
|
+
t.Fatal(err)
|
|
123
|
+
}
|
|
124
|
+
got := []string{client.added[0].issueID, client.added[1].issueID}
|
|
125
|
+
want := []string{current.Key, selectedNext.Key}
|
|
126
|
+
if !reflect.DeepEqual(got, want) {
|
|
127
|
+
t.Fatalf("comment destinations = %v, want %v", got, want)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func TestBeadsRenderTextExposesSupportedTemplateValues(t *testing.T) {
|
|
132
|
+
sys := &system{effective: Config{Templates: Templates{
|
|
133
|
+
MailboxDescription: "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}",
|
|
134
|
+
SummaryComment: "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}|{{summaryReport}}",
|
|
135
|
+
FeedbackComment: "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}|{{feedbackReport}}",
|
|
136
|
+
}}}
|
|
137
|
+
data := task.TextData{
|
|
138
|
+
RunID: "run-1",
|
|
139
|
+
Ticket: "demo-parent",
|
|
140
|
+
Workflow: "implementation",
|
|
141
|
+
Repo: "payments",
|
|
142
|
+
Node: "review",
|
|
143
|
+
NodeType: "hitl",
|
|
144
|
+
Agent: "reviewer",
|
|
145
|
+
NodeDescription: "Review the change.",
|
|
146
|
+
NextSteps: "implement; end",
|
|
147
|
+
SuccessRoutes: "end",
|
|
148
|
+
FailureRoutes: "implement",
|
|
149
|
+
Mailbox: "demo-parent.2",
|
|
150
|
+
SourceNode: "review",
|
|
151
|
+
TargetNode: "implement",
|
|
152
|
+
SummaryReport: "COMPLETED:\nreviewed",
|
|
153
|
+
FeedbackReport: "REQUIRED ACTIONS:\nfix it",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
description, err := sys.RenderText(task.TextMailboxDescription, data)
|
|
157
|
+
if err != nil {
|
|
158
|
+
t.Fatal(err)
|
|
159
|
+
}
|
|
160
|
+
for _, want := range []string{
|
|
161
|
+
"run-1", "demo-parent", "implementation", "payments", "review", "hitl", "reviewer",
|
|
162
|
+
"Review the change.", "implement; end", "end", "implement", "demo-parent.2",
|
|
163
|
+
} {
|
|
164
|
+
if !strings.Contains(description, want) {
|
|
165
|
+
t.Fatalf("mailbox description missing %q: %q", want, description)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
summary, err := sys.RenderText(task.TextSummaryComment, data)
|
|
170
|
+
if err != nil || !strings.Contains(summary, data.SummaryReport) || !strings.Contains(summary, "review|implement|demo-parent.2") {
|
|
171
|
+
t.Fatalf("summary = %q, %v", summary, err)
|
|
172
|
+
}
|
|
173
|
+
feedback, err := sys.RenderText(task.TextFeedbackComment, data)
|
|
174
|
+
if err != nil || !strings.Contains(feedback, data.FeedbackReport) || !strings.Contains(feedback, "review|implement|demo-parent.2") {
|
|
175
|
+
t.Fatalf("feedback = %q, %v", feedback, err)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func TestBeadsTemplateValidationRequiresReportPlaceholders(t *testing.T) {
|
|
180
|
+
for _, tc := range []struct {
|
|
181
|
+
name string
|
|
182
|
+
templates Templates
|
|
183
|
+
wantError string
|
|
184
|
+
}{
|
|
185
|
+
{
|
|
186
|
+
name: "summary report",
|
|
187
|
+
templates: Templates{
|
|
188
|
+
SummaryComment: "summary",
|
|
189
|
+
FeedbackComment: "feedback {{feedbackReport}}",
|
|
190
|
+
},
|
|
191
|
+
wantError: "summaryComment must contain {{summaryReport}}",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "feedback report",
|
|
195
|
+
templates: Templates{
|
|
196
|
+
SummaryComment: "summary {{summaryReport}}",
|
|
197
|
+
FeedbackComment: "feedback",
|
|
198
|
+
},
|
|
199
|
+
wantError: "feedbackComment must contain {{feedbackReport}}",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "unknown variable",
|
|
203
|
+
templates: Templates{
|
|
204
|
+
MailboxDescription: "mailbox {{mailboxInstructions}}",
|
|
205
|
+
SummaryComment: "summary {{summaryReport}}",
|
|
206
|
+
FeedbackComment: "feedback {{feedbackReport}}",
|
|
207
|
+
},
|
|
208
|
+
wantError: "unknown template variable {{mailboxInstructions}}",
|
|
209
|
+
},
|
|
210
|
+
} {
|
|
211
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
212
|
+
err := validateTemplates(tc.templates)
|
|
213
|
+
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
|
214
|
+
t.Fatalf("validateTemplates error = %v, want %q", err, tc.wantError)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type commentClient struct {
|
|
221
|
+
mailboxClient
|
|
222
|
+
comments map[string][]bdcli.Comment
|
|
223
|
+
listed []string
|
|
224
|
+
added []addedComment
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type addedComment struct {
|
|
228
|
+
issueID string
|
|
229
|
+
body string
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func (f *commentClient) ListComments(_ context.Context, issueID string) ([]bdcli.Comment, error) {
|
|
233
|
+
f.listed = append(f.listed, issueID)
|
|
234
|
+
return append([]bdcli.Comment(nil), f.comments[issueID]...), nil
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func (f *commentClient) AddComment(_ context.Context, issueID, body string) error {
|
|
238
|
+
f.added = append(f.added, addedComment{issueID: issueID, body: body})
|
|
239
|
+
return nil
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
var _ bdcli.Client = (*commentClient)(nil)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestBeadsConfigAcceptsSharedTransitionAndAssignee(t *testing.T) {
|
|
11
|
+
err := task.ValidateTextConfig("beads", config.RawValues{
|
|
12
|
+
"assignee": "owner@example.com",
|
|
13
|
+
"filters": map[string]any{
|
|
14
|
+
"parentStatuses": []any{"open"},
|
|
15
|
+
"issueTypes": []any{"epic"},
|
|
16
|
+
"labels": []any{"relay-ready"},
|
|
17
|
+
"assignees": []any{"owner@example.com"},
|
|
18
|
+
},
|
|
19
|
+
"transitionTo": map[string]any{
|
|
20
|
+
"parentStatus": "in_progress",
|
|
21
|
+
"taskStatus": "closed",
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatalf("shared transitionTo and assignee configuration was rejected: %v", err)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestBeadsConfigRejectsLegacyStatusVocabulary(t *testing.T) {
|
|
30
|
+
for _, field := range []string{"parent", "mailbox"} {
|
|
31
|
+
t.Run(field, func(t *testing.T) {
|
|
32
|
+
err := task.ValidateTextConfig("beads", config.RawValues{
|
|
33
|
+
"status": map[string]any{field: "open"},
|
|
34
|
+
})
|
|
35
|
+
if err == nil {
|
|
36
|
+
t.Fatalf("legacy status.%s configuration was accepted", field)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestBeadsConfigRejectsJiraOnlyFields(t *testing.T) {
|
|
43
|
+
for _, field := range []string{"project", "component"} {
|
|
44
|
+
t.Run(field, func(t *testing.T) {
|
|
45
|
+
err := task.ValidateTextConfig("beads", config.RawValues{field: "not-supported"})
|
|
46
|
+
if err == nil {
|
|
47
|
+
t.Fatalf("Jira-only field %q was accepted", field)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func TestBeadsCompileFilterUsesInheritedTopLevelAssignee(t *testing.T) {
|
|
54
|
+
sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
|
|
55
|
+
"assignee": "Repo.Bot@Example.com",
|
|
56
|
+
})}
|
|
57
|
+
match, err := sys.CompileFilter(nil)
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
60
|
+
}
|
|
61
|
+
if !match(task.Ticket{Fields: map[string]any{"assignee": "repo.bot@example.COM"}}) {
|
|
62
|
+
t.Fatal("top-level assignee did not become the default filter")
|
|
63
|
+
}
|
|
64
|
+
if match(task.Ticket{Fields: map[string]any{"assignee": "other@example.com"}}) {
|
|
65
|
+
t.Fatal("ticket assigned to another user matched the default assignee filter")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func TestBeadsExplicitAssigneeFilterOverridesInheritedDefault(t *testing.T) {
|
|
70
|
+
sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
|
|
71
|
+
"assignee": "repo@example.com",
|
|
72
|
+
})}
|
|
73
|
+
match, err := sys.CompileFilter(config.RawValues{
|
|
74
|
+
"assignee": "workflow@example.com",
|
|
75
|
+
"filters": map[string]any{
|
|
76
|
+
"assignees": []any{"selected@example.com"},
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
81
|
+
}
|
|
82
|
+
if !match(task.Ticket{Fields: map[string]any{"assignee": "SELECTED@example.com"}}) {
|
|
83
|
+
t.Fatal("explicit assignee filter rejected the selected assignee")
|
|
84
|
+
}
|
|
85
|
+
for _, assignee := range []string{"repo@example.com", "workflow@example.com"} {
|
|
86
|
+
if match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
|
|
87
|
+
t.Fatalf("inherited assignee %q overrode explicit assignee filter", assignee)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestBeadsConfigPreservesRootRepoWorkflowNodePrecedence(t *testing.T) {
|
|
93
|
+
root := config.RawValues{
|
|
94
|
+
"assignee": "root@example.com",
|
|
95
|
+
"filters": map[string]any{
|
|
96
|
+
"parentStatuses": []any{"root"},
|
|
97
|
+
"labels": []any{"root-label"},
|
|
98
|
+
},
|
|
99
|
+
"transitionTo": map[string]any{
|
|
100
|
+
"parentStatus": "open",
|
|
101
|
+
"taskStatus": "open",
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
repo := config.RawValues{
|
|
105
|
+
"assignee": "repo@example.com",
|
|
106
|
+
"filters": map[string]any{
|
|
107
|
+
"parentStatuses": []any{"repo"},
|
|
108
|
+
},
|
|
109
|
+
"transitionTo": map[string]any{
|
|
110
|
+
"parentStatus": "in_progress",
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
workflow := config.RawValues{
|
|
114
|
+
"assignee": "workflow@example.com",
|
|
115
|
+
"filters": map[string]any{
|
|
116
|
+
"issueTypes": []any{"workflow-type"},
|
|
117
|
+
},
|
|
118
|
+
"transitionTo": map[string]any{
|
|
119
|
+
"taskStatus": "in_progress",
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
node := config.RawValues{
|
|
123
|
+
"assignee": "node@example.com",
|
|
124
|
+
"filters": map[string]any{
|
|
125
|
+
"labels": []any{"node-label"},
|
|
126
|
+
},
|
|
127
|
+
"transitionTo": map[string]any{
|
|
128
|
+
"taskStatus": "closed",
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
merged := config.Merge(root, repo, workflow, node)
|
|
133
|
+
if merged["assignee"] != "node@example.com" {
|
|
134
|
+
t.Fatalf("merged assignee = %v, want node value", merged["assignee"])
|
|
135
|
+
}
|
|
136
|
+
transition, ok := merged["transitionTo"].(map[string]any)
|
|
137
|
+
if !ok {
|
|
138
|
+
t.Fatalf("merged transitionTo = %#v, want map", merged["transitionTo"])
|
|
139
|
+
}
|
|
140
|
+
if transition["parentStatus"] != "in_progress" {
|
|
141
|
+
t.Fatalf("merged parent status = %v, want repo value", transition["parentStatus"])
|
|
142
|
+
}
|
|
143
|
+
if transition["taskStatus"] != "closed" {
|
|
144
|
+
t.Fatalf("merged task status = %v, want node value", transition["taskStatus"])
|
|
145
|
+
}
|
|
146
|
+
filters, ok := merged["filters"].(map[string]any)
|
|
147
|
+
if !ok {
|
|
148
|
+
t.Fatalf("merged filters = %#v, want map", merged["filters"])
|
|
149
|
+
}
|
|
150
|
+
if got := filters["parentStatuses"].([]any); len(got) != 1 || got[0] != "repo" {
|
|
151
|
+
t.Fatalf("merged parent statuses = %#v, want repo value", filters["parentStatuses"])
|
|
152
|
+
}
|
|
153
|
+
if got := filters["issueTypes"].([]any); len(got) != 1 || got[0] != "workflow-type" {
|
|
154
|
+
t.Fatalf("merged issue types = %#v, want workflow value", filters["issueTypes"])
|
|
155
|
+
}
|
|
156
|
+
if got := filters["labels"].([]any); len(got) != 1 || got[0] != "node-label" {
|
|
157
|
+
t.Fatalf("merged labels = %#v, want node value", filters["labels"])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if err := task.ValidateTextConfig("beads", merged); err != nil {
|
|
161
|
+
t.Fatalf("effective root/repo/workflow/node configuration was rejected: %v", err)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Inherited root/repository taskConfig must have the same effect at runtime
|
|
13
|
+
// that validation implies. ApplyTaskConfig receives only the configuration the
|
|
14
|
+
// caller assembles (lifecycle defaults merged under workflow/node values), so
|
|
15
|
+
// an adapter that keeps inherited values to itself accepts configuration,
|
|
16
|
+
// validates it, and then silently discards it.
|
|
17
|
+
//
|
|
18
|
+
// The adapter therefore returns its inherited transitionTo/assignee values as
|
|
19
|
+
// part of its lifecycle defaults, producing the effective precedence
|
|
20
|
+
// built-in default < root < repo < workflow < node.
|
|
21
|
+
//
|
|
22
|
+
// internal/task/jira/lifecycle_inheritance_test.go is the sibling of this file
|
|
23
|
+
// and asserts the same invariants against the Jira adapter. Keep the two in
|
|
24
|
+
// sync: a change that breaks inheritance in one adapter must fail here too.
|
|
25
|
+
|
|
26
|
+
// repoScopedSystem builds the adapter with root/repository-scoped values, the
|
|
27
|
+
// scopes that never appear in an ApplyTaskConfig argument.
|
|
28
|
+
func repoScopedSystem(client bdcli.Client, repoValues config.RawValues) *system {
|
|
29
|
+
return &system{cli: client, base: config.Merge(DefaultConfig(), repoValues)}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// operationConfig reproduces what the caller assembles before ApplyTaskConfig.
|
|
33
|
+
func operationConfig(defaults, workflow, node config.RawValues) config.RawValues {
|
|
34
|
+
return config.Merge(defaults, config.Merge(workflow, node))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func lifecycleDefaultsOf(t *testing.T, sys task.System) task.LifecycleDefaults {
|
|
38
|
+
t.Helper()
|
|
39
|
+
d, ok := sys.(task.LifecycleDefaults)
|
|
40
|
+
if !ok {
|
|
41
|
+
t.Fatal("adapter does not expose task.LifecycleDefaults; inherited values cannot reach runtime")
|
|
42
|
+
}
|
|
43
|
+
return d
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
|
|
47
|
+
t.Run("repo parentStatus reaches start", func(t *testing.T) {
|
|
48
|
+
client := newStatusClient(map[string]string{"demo-parent": "open"})
|
|
49
|
+
sys := repoScopedSystem(client, config.RawValues{
|
|
50
|
+
"transitionTo": map[string]any{"parentStatus": "blocked"},
|
|
51
|
+
})
|
|
52
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).StartDefaults(), nil, nil)
|
|
53
|
+
|
|
54
|
+
if err := sys.ApplyTaskConfig(context.Background(), endTarget(), cfg); err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
if len(client.updates) != 1 || client.updates[0].input.Status != "blocked" {
|
|
58
|
+
t.Fatalf("updates = %+v, want the inherited repo value to beat the built-in default", client.updates)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
t.Run("repo taskStatus reaches a work node", func(t *testing.T) {
|
|
63
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
64
|
+
sys := repoScopedSystem(client, config.RawValues{
|
|
65
|
+
"transitionTo": map[string]any{"taskStatus": "blocked"},
|
|
66
|
+
})
|
|
67
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
|
|
68
|
+
|
|
69
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
70
|
+
t.Fatal(err)
|
|
71
|
+
}
|
|
72
|
+
if len(client.updates) != 1 || client.updates[0].input.Status != "blocked" {
|
|
73
|
+
t.Fatalf("updates = %+v, want the inherited repo value to beat the built-in default", client.updates)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func TestLifecycleDefaultsCarryInheritedAssignee(t *testing.T) {
|
|
79
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
80
|
+
sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
|
|
81
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
|
|
82
|
+
|
|
83
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
84
|
+
t.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
if len(client.updates) != 1 {
|
|
87
|
+
t.Fatalf("updates = %+v, want one combined update", client.updates)
|
|
88
|
+
}
|
|
89
|
+
update := client.updates[0].input
|
|
90
|
+
if update.Status != "in_progress" || update.Assignee != "repo-bot@example.com" {
|
|
91
|
+
t.Fatalf("update = %+v, want the built-in status plus the inherited repo assignee", update)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
|
|
96
|
+
for _, tc := range []struct {
|
|
97
|
+
name string
|
|
98
|
+
workflow config.RawValues
|
|
99
|
+
node config.RawValues
|
|
100
|
+
want string
|
|
101
|
+
}{
|
|
102
|
+
{name: "repo beats built-in default", want: "blocked"},
|
|
103
|
+
{
|
|
104
|
+
name: "workflow beats repo",
|
|
105
|
+
workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "hooked"}},
|
|
106
|
+
want: "hooked",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "node beats workflow",
|
|
110
|
+
workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "hooked"}},
|
|
111
|
+
node: config.RawValues{"transitionTo": map[string]any{"taskStatus": "deferred"}},
|
|
112
|
+
want: "deferred",
|
|
113
|
+
},
|
|
114
|
+
} {
|
|
115
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
116
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
117
|
+
sys := repoScopedSystem(client, config.RawValues{
|
|
118
|
+
"transitionTo": map[string]any{"taskStatus": "blocked"},
|
|
119
|
+
})
|
|
120
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), tc.workflow, tc.node)
|
|
121
|
+
|
|
122
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
123
|
+
t.Fatal(err)
|
|
124
|
+
}
|
|
125
|
+
if len(client.updates) != 1 || client.updates[0].input.Status != tc.want {
|
|
126
|
+
t.Fatalf("updates = %+v, want [%s]", client.updates, tc.want)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// With nothing inherited, the built-in lifecycle values still apply: the
|
|
133
|
+
// parent opens the run in_progress, a work mailbox moves to in_progress, and
|
|
134
|
+
// the parent closes at end.
|
|
135
|
+
func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
|
|
136
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
|
|
137
|
+
sys := repoScopedSystem(client, nil)
|
|
138
|
+
d := lifecycleDefaultsOf(t, sys)
|
|
139
|
+
ctx := context.Background()
|
|
140
|
+
|
|
141
|
+
if err := sys.ApplyTaskConfig(ctx, endTarget(), operationConfig(d.StartDefaults(), nil, nil)); err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
if err := sys.ApplyTaskConfig(ctx, statusTarget("demo-parent.1"), operationConfig(d.WorkDefaults(), nil, nil)); err != nil {
|
|
145
|
+
t.Fatal(err)
|
|
146
|
+
}
|
|
147
|
+
if err := sys.ApplyTaskConfig(ctx, endTarget(), operationConfig(d.EndDefaults(), nil, nil)); err != nil {
|
|
148
|
+
t.Fatal(err)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
want := []struct{ issueID, status string }{
|
|
152
|
+
{"demo-parent", "in_progress"},
|
|
153
|
+
{"demo-parent.1", "in_progress"},
|
|
154
|
+
{"demo-parent", "closed"},
|
|
155
|
+
}
|
|
156
|
+
if len(client.updates) != len(want) {
|
|
157
|
+
t.Fatalf("updates = %+v, want %d lifecycle updates", client.updates, len(want))
|
|
158
|
+
}
|
|
159
|
+
for i, expected := range want {
|
|
160
|
+
got := client.updates[i]
|
|
161
|
+
if got.issueID != expected.issueID || got.input.Status != expected.status {
|
|
162
|
+
t.Fatalf("update %d = %+v, want %s -> %s", i, got, expected.issueID, expected.status)
|
|
163
|
+
}
|
|
164
|
+
if got.input.Assignee != "" {
|
|
165
|
+
t.Fatalf("update %d assigned %q with no assignee configured", i, got.input.Assignee)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|