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.
Files changed (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /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
+ }