relay-flow 0.2.1-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 +155 -22
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +74 -0
- package/cmd/relay-flow/main.go +25 -0
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +4 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- 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 +105 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +50 -4
- package/internal/execution/goworkflows/interpreter.go +36 -29
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/recovery_test.go +2 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -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/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/run.go +6 -4
- 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/empty.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 +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/jira.go +171 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +4 -2
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"context"
|
|
7
7
|
"fmt"
|
|
8
8
|
"log/slog"
|
|
9
|
+
"regexp"
|
|
9
10
|
"sort"
|
|
10
11
|
"strings"
|
|
11
12
|
"sync"
|
|
@@ -23,6 +24,14 @@ type Config struct {
|
|
|
23
24
|
Component string `yaml:"component,omitempty"`
|
|
24
25
|
Filters Filters `yaml:"filters,omitempty"`
|
|
25
26
|
Transition TransitionTo `yaml:"transitionTo,omitempty"`
|
|
27
|
+
Templates Templates `yaml:"templates,omitempty"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Templates are Jira-owned task-system descriptions and comments.
|
|
31
|
+
type Templates struct {
|
|
32
|
+
MailboxDescription string `yaml:"mailboxDescription"`
|
|
33
|
+
SummaryComment string `yaml:"summaryComment"`
|
|
34
|
+
FeedbackComment string `yaml:"feedbackComment"`
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
// Filters are the structured, locally evaluable workflow ticket matchers.
|
|
@@ -41,11 +50,47 @@ type TransitionTo struct {
|
|
|
41
50
|
|
|
42
51
|
// Deterministic transition defaults for omitted values.
|
|
43
52
|
const (
|
|
44
|
-
defaultStartParentStatus
|
|
45
|
-
defaultWorkTaskStatus
|
|
46
|
-
defaultEndParentStatus
|
|
53
|
+
defaultStartParentStatus = "In Progress"
|
|
54
|
+
defaultWorkTaskStatus = "In Progress"
|
|
55
|
+
defaultEndParentStatus = "Done"
|
|
56
|
+
defaultMailboxDescription = `Parent ticket: {{ticket}}
|
|
57
|
+
Workflow: {{workflow}}
|
|
58
|
+
Node: {{node}}
|
|
59
|
+
Node type: {{nodeType}}
|
|
60
|
+
Agent: {{agent}}
|
|
61
|
+
Mailbox: {{mailbox}}
|
|
62
|
+
|
|
63
|
+
Node work:
|
|
64
|
+
{{nodeDescription}}
|
|
65
|
+
|
|
66
|
+
Read this mailbox's comments for feedback from previous nodes.`
|
|
67
|
+
defaultSummaryComment = `Summary for {{node}}
|
|
68
|
+
|
|
69
|
+
{{summaryReport}}`
|
|
70
|
+
defaultFeedbackComment = `Feedback from {{sourceNode}} to {{targetNode}} mailbox {{mailbox}}
|
|
71
|
+
|
|
72
|
+
{{feedbackReport}}`
|
|
47
73
|
)
|
|
48
74
|
|
|
75
|
+
var textVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
76
|
+
|
|
77
|
+
var knownTextVars = map[string]bool{
|
|
78
|
+
"runID": true, "ticket": true, "workflow": true,
|
|
79
|
+
"repo": true, "node": true, "nodeType": true, "agent": true,
|
|
80
|
+
"nodeDescription": true, "nextSteps": true, "successRoutes": true,
|
|
81
|
+
"failureRoutes": true, "mailbox": true, "sourceNode": true,
|
|
82
|
+
"targetNode": true, "summaryReport": true, "feedbackReport": true,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// DefaultConfig supplies Jira task-system text defaults for relay-flow init.
|
|
86
|
+
func DefaultConfig() config.RawValues {
|
|
87
|
+
return config.RawValues{"templates": map[string]any{
|
|
88
|
+
"mailboxDescription": defaultMailboxDescription,
|
|
89
|
+
"summaryComment": defaultSummaryComment,
|
|
90
|
+
"feedbackComment": defaultFeedbackComment,
|
|
91
|
+
}}
|
|
92
|
+
}
|
|
93
|
+
|
|
49
94
|
var (
|
|
50
95
|
clientsMu sync.Mutex
|
|
51
96
|
clients = map[string]struct {
|
|
@@ -60,6 +105,14 @@ func claimLabel(workflow string) string { return "wf:" + workflow }
|
|
|
60
105
|
func init() {
|
|
61
106
|
task.Register("jira", task.Factory{
|
|
62
107
|
RequiredRepoKeys: func() []string { return []string{"project", "component"} },
|
|
108
|
+
DefaultConfig: DefaultConfig,
|
|
109
|
+
ValidateTextConfig: func(raw config.RawValues) error {
|
|
110
|
+
var cfg Config
|
|
111
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
return validateTemplates(cfg.Templates)
|
|
115
|
+
},
|
|
63
116
|
TaskScopeKey: func(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
64
117
|
var root, repoCfg Config
|
|
65
118
|
if err := config.DecodeStrict(rootConfig, &root); err != nil {
|
|
@@ -130,11 +183,14 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
|
|
|
130
183
|
if spec.Name == "" {
|
|
131
184
|
return nil, fmt.Errorf("jira: repo name is required")
|
|
132
185
|
}
|
|
133
|
-
merged := config.Merge(spec.RootConfig, spec.RepoConfig)
|
|
186
|
+
merged := config.Merge(DefaultConfig(), spec.RootConfig, spec.RepoConfig)
|
|
134
187
|
var cfg Config
|
|
135
188
|
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
136
189
|
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
137
190
|
}
|
|
191
|
+
if err := validateTemplates(cfg.Templates); err != nil {
|
|
192
|
+
return nil, fmt.Errorf("jira repo %q taskConfig.templates: %w", spec.Name, err)
|
|
193
|
+
}
|
|
138
194
|
if cfg.Assignee != "" {
|
|
139
195
|
if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
|
|
140
196
|
return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
|
|
@@ -171,6 +227,55 @@ func decodeConfig(raw config.RawValues) (Config, error) {
|
|
|
171
227
|
return cfg, nil
|
|
172
228
|
}
|
|
173
229
|
|
|
230
|
+
func validateTemplates(templates Templates) error {
|
|
231
|
+
for name, tmpl := range map[string]string{
|
|
232
|
+
"mailboxDescription": templates.MailboxDescription,
|
|
233
|
+
"summaryComment": templates.SummaryComment,
|
|
234
|
+
"feedbackComment": templates.FeedbackComment,
|
|
235
|
+
} {
|
|
236
|
+
for _, match := range textVarPattern.FindAllStringSubmatch(tmpl, -1) {
|
|
237
|
+
if !knownTextVars[match[1]] {
|
|
238
|
+
return fmt.Errorf("%s: unknown template variable {{%s}}", name, match[1])
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
|
|
243
|
+
return fmt.Errorf("summaryComment must contain {{summaryReport}}")
|
|
244
|
+
}
|
|
245
|
+
if !strings.Contains(templates.FeedbackComment, "{{feedbackReport}}") {
|
|
246
|
+
return fmt.Errorf("feedbackComment must contain {{feedbackReport}}")
|
|
247
|
+
}
|
|
248
|
+
return nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// RenderText renders Jira-owned mailbox descriptions and comments.
|
|
252
|
+
func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, error) {
|
|
253
|
+
var tmpl string
|
|
254
|
+
switch kind {
|
|
255
|
+
case task.TextMailboxDescription:
|
|
256
|
+
tmpl = s.effective.Templates.MailboxDescription
|
|
257
|
+
case task.TextSummaryComment:
|
|
258
|
+
tmpl = s.effective.Templates.SummaryComment
|
|
259
|
+
case task.TextFeedbackComment:
|
|
260
|
+
tmpl = s.effective.Templates.FeedbackComment
|
|
261
|
+
default:
|
|
262
|
+
return "", fmt.Errorf("jira: unknown task text kind %q", kind)
|
|
263
|
+
}
|
|
264
|
+
values := map[string]string{
|
|
265
|
+
"runID": data.RunID, "ticket": data.Ticket,
|
|
266
|
+
"workflow": data.Workflow, "repo": data.Repo, "node": data.Node,
|
|
267
|
+
"nodeType": data.NodeType, "agent": data.Agent,
|
|
268
|
+
"nodeDescription": data.NodeDescription, "nextSteps": data.NextSteps,
|
|
269
|
+
"successRoutes": data.SuccessRoutes, "failureRoutes": data.FailureRoutes,
|
|
270
|
+
"mailbox": data.Mailbox, "sourceNode": data.SourceNode,
|
|
271
|
+
"targetNode": data.TargetNode, "summaryReport": data.SummaryReport,
|
|
272
|
+
"feedbackReport": data.FeedbackReport,
|
|
273
|
+
}
|
|
274
|
+
return textVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
|
|
275
|
+
return values[textVarPattern.FindStringSubmatch(match)[1]]
|
|
276
|
+
}), nil
|
|
277
|
+
}
|
|
278
|
+
|
|
174
279
|
// --- Poll / filters ---
|
|
175
280
|
|
|
176
281
|
// buildJQL scopes the parent poll by project/component and active statuses.
|
|
@@ -212,11 +317,15 @@ func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
|
|
|
212
317
|
// CompileFilter compiles workflow taskConfig.filters into an in-memory
|
|
213
318
|
// matcher over normalized ticket fields. Unknown filter fields are rejected.
|
|
214
319
|
func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
|
|
215
|
-
|
|
320
|
+
merged := config.Merge(s.base, workflowTaskConfig)
|
|
321
|
+
cfg, err := decodeConfig(merged)
|
|
216
322
|
if err != nil {
|
|
217
323
|
return nil, err
|
|
218
324
|
}
|
|
219
325
|
f := cfg.Filters
|
|
326
|
+
if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
|
|
327
|
+
f.Assignees = []string{cfg.Assignee}
|
|
328
|
+
}
|
|
220
329
|
return func(t task.Ticket) bool {
|
|
221
330
|
if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
|
|
222
331
|
return false
|
|
@@ -232,13 +341,26 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
232
341
|
}
|
|
233
342
|
}
|
|
234
343
|
}
|
|
235
|
-
if len(f.Assignees) > 0 && !
|
|
344
|
+
if len(f.Assignees) > 0 && !containsFold(f.Assignees, strField(t.Fields, "assignee")) {
|
|
236
345
|
return false
|
|
237
346
|
}
|
|
238
347
|
return true
|
|
239
348
|
}, nil
|
|
240
349
|
}
|
|
241
350
|
|
|
351
|
+
func hasAssigneeFilter(raw config.RawValues) bool {
|
|
352
|
+
switch filters := raw["filters"].(type) {
|
|
353
|
+
case map[string]any:
|
|
354
|
+
_, ok := filters["assignees"]
|
|
355
|
+
return ok
|
|
356
|
+
case config.RawValues:
|
|
357
|
+
_, ok := filters["assignees"]
|
|
358
|
+
return ok
|
|
359
|
+
default:
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
242
364
|
// --- Claim ---
|
|
243
365
|
|
|
244
366
|
// Claim adds wf:<workflow> using the claims already inspected by routing.
|
|
@@ -257,6 +379,9 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
257
379
|
if err != nil {
|
|
258
380
|
return fmt.Errorf("workflow taskConfig: %w", err)
|
|
259
381
|
}
|
|
382
|
+
if err := validateTemplates(workflowCfg.Templates); err != nil {
|
|
383
|
+
return fmt.Errorf("workflow taskConfig.templates: %w", err)
|
|
384
|
+
}
|
|
260
385
|
if err := s.validateAssignee(ctx, "workflow", workflowCfg.Project, workflowCfg.Assignee); err != nil {
|
|
261
386
|
return err
|
|
262
387
|
}
|
|
@@ -273,6 +398,9 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
273
398
|
if err != nil {
|
|
274
399
|
return fmt.Errorf("node %q taskConfig: %w", n, err)
|
|
275
400
|
}
|
|
401
|
+
if err := validateTemplates(cfg.Templates); err != nil {
|
|
402
|
+
return fmt.Errorf("node %q taskConfig.templates: %w", n, err)
|
|
403
|
+
}
|
|
276
404
|
if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Assignee); err != nil {
|
|
277
405
|
return err
|
|
278
406
|
}
|
|
@@ -315,47 +443,47 @@ func (s *system) validateTransition(ctx context.Context, scope, project string,
|
|
|
315
443
|
// LifecycleDefaults exposure: the deterministic Jira transition defaults per
|
|
316
444
|
// lifecycle point (spec: Jira transition defaults are deterministic). Run
|
|
317
445
|
// orchestration merges these raw values under the effective node config
|
|
318
|
-
// before ApplyTaskConfig
|
|
319
|
-
//
|
|
446
|
+
// before ApplyTaskConfig, so the effective precedence is
|
|
447
|
+
// built-in default < root < repo < workflow < node.
|
|
448
|
+
//
|
|
449
|
+
// The methods also carry the inherited root/repository transitionTo and
|
|
450
|
+
// assignee values. ApplyTaskConfig receives only the workflow/node config the
|
|
451
|
+
// caller assembles, so returning inherited values here is what makes a
|
|
452
|
+
// repo-scoped value take effect at runtime instead of being validated and then
|
|
453
|
+
// silently discarded. internal/task/beads implements the same rule; keep the
|
|
454
|
+
// two adapters aligned.
|
|
320
455
|
|
|
321
456
|
// StartDefaults defaults the parent to In Progress.
|
|
322
457
|
func (s *system) StartDefaults() config.RawValues {
|
|
323
|
-
return
|
|
458
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", defaultStartParentStatus))
|
|
324
459
|
}
|
|
325
460
|
|
|
326
461
|
// WorkDefaults defaults the mailbox task status to In Progress; the parent
|
|
327
462
|
// is left unchanged when parentStatus is omitted.
|
|
328
463
|
func (s *system) WorkDefaults() config.RawValues {
|
|
329
|
-
return
|
|
464
|
+
return s.lifecycleDefaults(transitionDefault("taskStatus", defaultWorkTaskStatus))
|
|
330
465
|
}
|
|
331
466
|
|
|
332
467
|
// EndDefaults defaults the parent to Done.
|
|
333
468
|
func (s *system) EndDefaults() config.RawValues {
|
|
334
|
-
return
|
|
469
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", defaultEndParentStatus))
|
|
335
470
|
}
|
|
336
471
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
func endConfig(cfg config.RawValues) config.RawValues {
|
|
340
|
-
return withTransitionDefault(cfg, "parentStatus", defaultEndParentStatus)
|
|
472
|
+
func transitionDefault(key, value string) config.RawValues {
|
|
473
|
+
return config.RawValues{"transitionTo": map[string]any{key: value}}
|
|
341
474
|
}
|
|
342
475
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
merged[k] = v
|
|
476
|
+
// lifecycleDefaults layers the inherited root/repository operation values over
|
|
477
|
+
// the built-in lifecycle default. Only the keys ApplyTaskConfig consumes are
|
|
478
|
+
// carried, so unrelated configuration never enters durable activity inputs.
|
|
479
|
+
func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
|
|
480
|
+
inherited := config.RawValues{}
|
|
481
|
+
for _, key := range []string{"transitionTo", "assignee"} {
|
|
482
|
+
if value, ok := s.base[key]; ok {
|
|
483
|
+
inherited[key] = value
|
|
352
484
|
}
|
|
353
485
|
}
|
|
354
|
-
|
|
355
|
-
merged[key] = value
|
|
356
|
-
}
|
|
357
|
-
out["transitionTo"] = merged
|
|
358
|
-
return out
|
|
486
|
+
return config.Merge(builtin, inherited)
|
|
359
487
|
}
|
|
360
488
|
|
|
361
489
|
// --- Mailboxes ---
|
|
@@ -527,6 +655,15 @@ func contains(xs []string, want string) bool {
|
|
|
527
655
|
return false
|
|
528
656
|
}
|
|
529
657
|
|
|
658
|
+
func containsFold(xs []string, want string) bool {
|
|
659
|
+
for _, x := range xs {
|
|
660
|
+
if strings.EqualFold(x, want) {
|
|
661
|
+
return true
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
|
|
530
667
|
func strField(fields map[string]any, key string) string {
|
|
531
668
|
s, _ := fields[key].(string)
|
|
532
669
|
return s
|
|
@@ -536,3 +673,8 @@ func strSliceField(fields map[string]any, key string) []string {
|
|
|
536
673
|
out, _ := fields[key].([]string)
|
|
537
674
|
return out
|
|
538
675
|
}
|
|
676
|
+
|
|
677
|
+
var (
|
|
678
|
+
_ task.System = (*system)(nil)
|
|
679
|
+
_ task.LifecycleDefaults = (*system)(nil)
|
|
680
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
package jira
|
|
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
|
+
)
|
|
10
|
+
|
|
11
|
+
// Inherited root/repository taskConfig must have the same effect at runtime
|
|
12
|
+
// that validation implies. ApplyTaskConfig receives only the configuration the
|
|
13
|
+
// caller assembles (lifecycle defaults merged under workflow/node values), so
|
|
14
|
+
// an adapter that keeps inherited values to itself accepts configuration,
|
|
15
|
+
// validates it against the live task system, and then silently discards it.
|
|
16
|
+
//
|
|
17
|
+
// The adapter therefore returns its inherited transitionTo/assignee values as
|
|
18
|
+
// part of its lifecycle defaults, producing the effective precedence
|
|
19
|
+
// built-in default < root < repo < workflow < node.
|
|
20
|
+
//
|
|
21
|
+
// internal/task/beads/lifecycle_inheritance_test.go is the sibling of this
|
|
22
|
+
// file and asserts the same invariants against the Beads adapter. Keep the two
|
|
23
|
+
// in sync: a change that breaks inheritance in one adapter must fail here too.
|
|
24
|
+
|
|
25
|
+
// repoScopedSystem builds the adapter with root/repository-scoped values, the
|
|
26
|
+
// scopes that never appear in an ApplyTaskConfig argument.
|
|
27
|
+
func repoScopedSystem(t *testing.T, fake *fakeJira, repoValues config.RawValues) task.System {
|
|
28
|
+
t.Helper()
|
|
29
|
+
repoConfig := config.Merge(config.RawValues{"project": "PAY", "component": "api"}, repoValues)
|
|
30
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
|
|
31
|
+
Name: "payments",
|
|
32
|
+
RepoConfig: repoConfig,
|
|
33
|
+
})
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatalf("adapter construction failed: %v", err)
|
|
36
|
+
}
|
|
37
|
+
return sys
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// operationConfig reproduces what the caller assembles before ApplyTaskConfig.
|
|
41
|
+
func operationConfig(defaults, workflow, node config.RawValues) config.RawValues {
|
|
42
|
+
return config.Merge(defaults, config.Merge(workflow, node))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func lifecycleDefaultsOf(t *testing.T, sys task.System) task.LifecycleDefaults {
|
|
46
|
+
t.Helper()
|
|
47
|
+
d, ok := sys.(task.LifecycleDefaults)
|
|
48
|
+
if !ok {
|
|
49
|
+
t.Fatal("adapter does not expose task.LifecycleDefaults; inherited values cannot reach runtime")
|
|
50
|
+
}
|
|
51
|
+
return d
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
|
|
55
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
56
|
+
mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
|
|
57
|
+
|
|
58
|
+
t.Run("repo parentStatus reaches start", func(t *testing.T) {
|
|
59
|
+
fake := &fakeJira{}
|
|
60
|
+
sys := repoScopedSystem(t, fake, config.RawValues{
|
|
61
|
+
"transitionTo": map[string]any{"parentStatus": "In Review"},
|
|
62
|
+
})
|
|
63
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).StartDefaults(), nil, nil)
|
|
64
|
+
|
|
65
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, cfg); err != nil {
|
|
66
|
+
t.Fatal(err)
|
|
67
|
+
}
|
|
68
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Review" {
|
|
69
|
+
t.Fatalf("parent transitions = %v, want the inherited repo value to beat the built-in default",
|
|
70
|
+
fake.parentTransitions)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
t.Run("repo taskStatus reaches a work node", func(t *testing.T) {
|
|
75
|
+
fake := &fakeJira{}
|
|
76
|
+
sys := repoScopedSystem(t, fake, config.RawValues{
|
|
77
|
+
"transitionTo": map[string]any{"taskStatus": "In Review"},
|
|
78
|
+
})
|
|
79
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
|
|
80
|
+
|
|
81
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Review" {
|
|
85
|
+
t.Fatalf("mailbox transitions = %v, want the inherited repo value to beat the built-in default",
|
|
86
|
+
fake.taskTransitions)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestLifecycleDefaultsCarryInheritedAssignee(t *testing.T) {
|
|
92
|
+
fake := &fakeJira{}
|
|
93
|
+
sys := repoScopedSystem(t, fake, config.RawValues{"assignee": "repo-bot@example.com"})
|
|
94
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
95
|
+
mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
|
|
96
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
|
|
97
|
+
|
|
98
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
|
|
99
|
+
t.Fatal(err)
|
|
100
|
+
}
|
|
101
|
+
if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:repo-bot@example.com" {
|
|
102
|
+
t.Fatalf("assignments = %v, want the inherited repo assignee applied to the mailbox", fake.assignments)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
|
|
107
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
108
|
+
mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
|
|
109
|
+
|
|
110
|
+
for _, tc := range []struct {
|
|
111
|
+
name string
|
|
112
|
+
workflow config.RawValues
|
|
113
|
+
node config.RawValues
|
|
114
|
+
want string
|
|
115
|
+
}{
|
|
116
|
+
{name: "repo beats built-in default", want: "Repo Status"},
|
|
117
|
+
{
|
|
118
|
+
name: "workflow beats repo",
|
|
119
|
+
workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Workflow Status"}},
|
|
120
|
+
want: "Workflow Status",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "node beats workflow",
|
|
124
|
+
workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Workflow Status"}},
|
|
125
|
+
node: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Node Status"}},
|
|
126
|
+
want: "Node Status",
|
|
127
|
+
},
|
|
128
|
+
} {
|
|
129
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
130
|
+
fake := &fakeJira{}
|
|
131
|
+
sys := repoScopedSystem(t, fake, config.RawValues{
|
|
132
|
+
"transitionTo": map[string]any{"taskStatus": "Repo Status"},
|
|
133
|
+
})
|
|
134
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), tc.workflow, tc.node)
|
|
135
|
+
|
|
136
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
|
|
137
|
+
t.Fatal(err)
|
|
138
|
+
}
|
|
139
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != tc.want {
|
|
140
|
+
t.Fatalf("mailbox transitions = %v, want [%s]", fake.taskTransitions, tc.want)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// With nothing inherited, the built-in lifecycle values still apply.
|
|
147
|
+
func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
|
|
148
|
+
fake := &fakeJira{}
|
|
149
|
+
sys := repoScopedSystem(t, fake, nil)
|
|
150
|
+
d := lifecycleDefaultsOf(t, sys)
|
|
151
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
152
|
+
mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
|
|
153
|
+
|
|
154
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, operationConfig(d.StartDefaults(), nil, nil)); err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, operationConfig(d.WorkDefaults(), nil, nil)); err != nil {
|
|
158
|
+
t.Fatal(err)
|
|
159
|
+
}
|
|
160
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, operationConfig(d.EndDefaults(), nil, nil)); err != nil {
|
|
161
|
+
t.Fatal(err)
|
|
162
|
+
}
|
|
163
|
+
if len(fake.parentTransitions) != 2 || fake.parentTransitions[0] != defaultStartParentStatus || fake.parentTransitions[1] != defaultEndParentStatus {
|
|
164
|
+
t.Fatalf("parent transitions = %v, want [%s %s]", fake.parentTransitions, defaultStartParentStatus, defaultEndParentStatus)
|
|
165
|
+
}
|
|
166
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != defaultWorkTaskStatus {
|
|
167
|
+
t.Fatalf("mailbox transitions = %v, want [%s]", fake.taskTransitions, defaultWorkTaskStatus)
|
|
168
|
+
}
|
|
169
|
+
if len(fake.assignments) != 0 {
|
|
170
|
+
t.Fatalf("assignments = %v, want none when no assignee is configured", fake.assignments)
|
|
171
|
+
}
|
|
172
|
+
}
|