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.
Files changed (72) hide show
  1. package/README.md +155 -22
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +74 -0
  4. package/cmd/relay-flow/main.go +25 -0
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +4 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  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 +105 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine_test.go +33 -15
  16. package/internal/execution/goworkflows/fakes_test.go +50 -4
  17. package/internal/execution/goworkflows/interpreter.go +36 -29
  18. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  19. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  20. package/internal/execution/goworkflows/recovery_test.go +2 -2
  21. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  22. package/internal/harness/contract_test.go +10 -0
  23. package/internal/harness/factory.go +25 -3
  24. package/internal/harness/harness.go +30 -4
  25. package/internal/harness/opencode/opencode.go +125 -10
  26. package/internal/harness/opencode/opencode_test.go +183 -0
  27. package/internal/harness/opencode/repo_setup.go +361 -0
  28. package/internal/harness/plugin_selection_test.go +5 -5
  29. package/internal/recover/recover.go +11 -6
  30. package/internal/repo/service.go +10 -0
  31. package/internal/repo/service_test.go +51 -2
  32. package/internal/run/run.go +6 -4
  33. package/internal/task/beads/bdcli/bdcli.go +323 -0
  34. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  35. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  36. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  37. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  38. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  39. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  40. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  41. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  42. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  43. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  44. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  45. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  46. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  47. package/internal/task/beads/beads.go +840 -0
  48. package/internal/task/beads/beads_test.go +609 -0
  49. package/internal/task/beads/comments_test.go +242 -0
  50. package/internal/task/beads/config_compatibility_test.go +163 -0
  51. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  52. package/internal/task/beads/repo_composition_test.go +232 -0
  53. package/internal/task/beads/runtime_config_test.go +81 -0
  54. package/internal/task/beads/status_compatibility_test.go +233 -0
  55. package/internal/task/beads/status_test.go +257 -0
  56. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  57. package/internal/task/beads/validation_test.go +110 -0
  58. package/internal/task/contract_test.go +10 -0
  59. package/internal/task/factory.go +37 -4
  60. package/internal/task/jira/auth.go +27 -1
  61. package/internal/task/jira/auth_test.go +54 -1
  62. package/internal/task/jira/filters_test.go +60 -0
  63. package/internal/task/jira/jira.go +171 -29
  64. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  65. package/internal/task/jira/rest/adf.go +119 -82
  66. package/internal/task/jira/rest/adf_test.go +60 -0
  67. package/internal/task/jira/rest/client_test.go +1 -1
  68. package/internal/task/jira/templates_test.go +118 -0
  69. package/internal/task/jira/transition_defaults_test.go +4 -2
  70. package/internal/task/task.go +32 -0
  71. package/internal/workflow/report_test.go +45 -0
  72. 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 = "In Progress"
45
- defaultWorkTaskStatus = "In Progress"
46
- defaultEndParentStatus = "Done"
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
- cfg, err := decodeConfig(workflowTaskConfig)
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 && !contains(f.Assignees, strField(t.Fields, "assignee")) {
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; omitted values inherit the default, explicit
319
- // values win (map merge with defaults as the lower layer).
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 config.RawValues{"transitionTo": map[string]any{"parentStatus": defaultStartParentStatus}}
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 config.RawValues{"transitionTo": map[string]any{"taskStatus": defaultWorkTaskStatus}}
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 config.RawValues{"transitionTo": map[string]any{"parentStatus": defaultEndParentStatus}}
469
+ return s.lifecycleDefaults(transitionDefault("parentStatus", defaultEndParentStatus))
335
470
  }
336
471
 
337
- // endConfig applies the deterministic end default: omitted parentStatus
338
- // becomes Done.
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
- func withTransitionDefault(cfg config.RawValues, key, value string) config.RawValues {
344
- out := config.RawValues{}
345
- for k, v := range cfg {
346
- out[k] = v
347
- }
348
- merged := map[string]any{}
349
- if tr, ok := out["transitionTo"].(map[string]any); ok {
350
- for k, v := range tr {
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
- if _, ok := merged[key]; !ok {
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
+ }