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,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 {
@@ -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 func() []string
28
- TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
29
- New func(context.Context, RepoSpec) (System, error)
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) {