relay-flow 0.2.6-alpha → 0.2.8-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.
@@ -12,9 +12,11 @@ import (
12
12
  "net/http"
13
13
  "strings"
14
14
 
15
+ "github.com/rajpopat27/relay-flow/internal/config"
15
16
  "github.com/rajpopat27/relay-flow/internal/repo"
16
17
  "github.com/rajpopat27/relay-flow/internal/run"
17
18
  "github.com/rajpopat27/relay-flow/internal/runner"
19
+ "github.com/rajpopat27/relay-flow/internal/task"
18
20
  "github.com/rajpopat27/relay-flow/internal/workflow"
19
21
  )
20
22
 
@@ -41,7 +43,7 @@ type Deps interface {
41
43
 
42
44
  // Repos
43
45
  DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error)
44
- TaskFields(ctx context.Context) ([]string, error)
46
+ TaskRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error)
45
47
  RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error)
46
48
  ListRepos(ctx context.Context) ([]repo.Info, error)
47
49
  GetRepo(ctx context.Context, name string) (repo.Info, error)
@@ -246,15 +248,48 @@ func (s *server) handleReposDiscover(w http.ResponseWriter, r *http.Request) {
246
248
  }
247
249
 
248
250
  func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
249
- if !methodOnly(w, r, http.MethodGet) {
251
+ if r.Method == http.MethodGet {
252
+ fields, err := s.deps.TaskRegistrationFields(r.Context(), nil)
253
+ if err != nil {
254
+ mapErr(w, err)
255
+ return
256
+ }
257
+ keys := make([]string, 0, len(fields))
258
+ for _, field := range fields {
259
+ keys = append(keys, field.Key)
260
+ }
261
+ // Keep the documented fields list for callers that only need the
262
+ // required keys, and include plugin-owned metadata for the CLI's
263
+ // initial prompt construction.
264
+ writeOK(w, http.StatusOK, map[string]any{
265
+ "fields": keys,
266
+ "registration": task.Registration{Fields: fields},
267
+ })
268
+ return
269
+ }
270
+ if !methodOnly(w, r, http.MethodPost) {
271
+ return
272
+ }
273
+ body, err := readBody(r)
274
+ if err != nil {
275
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
250
276
  return
251
277
  }
252
- fields, err := s.deps.TaskFields(r.Context())
278
+ var payload struct {
279
+ Values config.RawValues `json:"values,omitempty"`
280
+ }
281
+ if len(strings.TrimSpace(string(body))) > 0 {
282
+ if err := decodeStrict(body, &payload); err != nil {
283
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
284
+ return
285
+ }
286
+ }
287
+ fields, err := s.deps.TaskRegistrationFields(r.Context(), payload.Values)
253
288
  if err != nil {
254
289
  mapErr(w, err)
255
290
  return
256
291
  }
257
- writeOK(w, http.StatusOK, map[string]any{"fields": fields})
292
+ writeOK(w, http.StatusOK, task.Registration{Fields: fields})
258
293
  }
259
294
 
260
295
  func (s *server) handleRepos(w http.ResponseWriter, r *http.Request) {
@@ -81,10 +81,15 @@ func DefaultConfig() config.RawValues {
81
81
  }}
82
82
  }
83
83
 
84
+ func registrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
85
+ return []task.RegistrationField{{Key: "beadsDir", Title: "Beads workspace"}}, nil
86
+ }
87
+
84
88
  func init() {
85
89
  task.Register("beads", task.Factory{
86
- RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
- TaskScopeKey: beadsTaskScopeKey,
90
+ RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
91
+ RegistrationFields: registrationFields,
92
+ TaskScopeKey: beadsTaskScopeKey,
88
93
  RegistrationKey: func(spec task.RepoRegistrationSpec) (string, error) {
89
94
  label, err := RepositoryLabel(spec.Name)
90
95
  if err != nil {
@@ -29,6 +29,19 @@ func TestBeadsFactoryIsRegisteredWithBeadsDirRequirement(t *testing.T) {
29
29
  }
30
30
  }
31
31
 
32
+ func TestBeadsRegistrationDoesNotExposeJiraStatusFields(t *testing.T) {
33
+ fields, err := task.RegistrationFields(context.Background(), "beads", nil)
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if len(fields) != 1 || fields[0].Key != "beadsDir" {
38
+ t.Fatalf("registration fields = %#v, want only beadsDir", fields)
39
+ }
40
+ if len(fields[0].Options) != 0 || fields[0].Default != "" {
41
+ t.Fatalf("Beads registration field unexpectedly has status choices: %#v", fields[0])
42
+ }
43
+ }
44
+
32
45
  func TestBeadsConfigRejectsUnknownFields(t *testing.T) {
33
46
  for _, field := range []string{"unknownField", "project", "component"} {
34
47
  t.Run(field, func(t *testing.T) {
@@ -36,13 +36,15 @@ type RepoRegistrationSpec struct {
36
36
  // the complete registration identity when a plugin permits sharing a
37
37
  // physical scope with distinct logical repositories.
38
38
  type Factory struct {
39
- RequiredRepoKeys func() []string
40
- TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
41
- RegistrationKey func(RepoRegistrationSpec) (string, error)
42
- Auth func(context.Context, []string, io.Reader) error
43
- DefaultConfig func() config.RawValues
44
- ValidateTextConfig func(config.RawValues) error
45
- New func(context.Context, RepoSpec) (System, error)
39
+ RequiredRepoKeys func() []string
40
+ TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
41
+ RegistrationKey func(RepoRegistrationSpec) (string, error)
42
+ RegistrationFields func(context.Context, config.RawValues) ([]RegistrationField, error)
43
+ ValidateRegistration func(context.Context, RepoRegistrationSpec) error
44
+ Auth func(context.Context, []string, io.Reader) error
45
+ DefaultConfig func() config.RawValues
46
+ ValidateTextConfig func(config.RawValues) error
47
+ New func(context.Context, RepoSpec) (System, error)
46
48
  }
47
49
 
48
50
  var (
@@ -75,7 +75,15 @@ func TestAuthWritesLoadsAndNewUsesJiraOwnedCredentials(t *testing.T) {
75
75
  }
76
76
  if _, err := task.New(context.Background(), "jira", task.RepoSpec{
77
77
  Name: "payments", RootConfig: machine.TaskConfig,
78
- RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
78
+ RepoConfig: config.RawValues{
79
+ "project": "PAY",
80
+ "component": "api",
81
+ "statusDefaults": map[string]any{
82
+ "start": defaultStartParentStatus,
83
+ "work": defaultWorkTaskStatus,
84
+ "end": defaultEndParentStatus,
85
+ },
86
+ },
79
87
  }); err != nil {
80
88
  t.Fatalf("task.New did not load Jira-owned credentials: %v", err)
81
89
  }
@@ -284,7 +284,7 @@ func TestCompileFilterDoesNotUseEffectiveAssigneeAsFilter(t *testing.T) {
284
284
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
285
285
  Name: "payments",
286
286
  RootConfig: config.RawValues{"assignee": "root@example.com"},
287
- RepoConfig: config.RawValues{"project": "PAY", "component": "api", "assignee": "Repo.Bot@Example.com"},
287
+ RepoConfig: testRepoConfig(config.RawValues{"assignee": "Repo.Bot@Example.com"}),
288
288
  })
289
289
  if err != nil {
290
290
  t.Fatal(err)
@@ -304,7 +304,7 @@ func TestCompileFilterWorkflowAssigneesOverrideEffectiveAssignee(t *testing.T) {
304
304
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
305
305
  Name: "payments",
306
306
  RootConfig: config.RawValues{"assignee": "root@example.com"},
307
- RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
307
+ RepoConfig: testRepoConfig(nil),
308
308
  })
309
309
  if err != nil {
310
310
  t.Fatal(err)
@@ -3,6 +3,7 @@ package jira
3
3
  import (
4
4
  "context"
5
5
 
6
+ "github.com/rajpopat27/relay-flow/internal/config"
6
7
  "github.com/rajpopat27/relay-flow/internal/task"
7
8
  "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
8
9
  )
@@ -67,6 +68,18 @@ func (notFakedError) Error() string { return "not faked" }
67
68
 
68
69
  var errNotFaked = notFakedError{}
69
70
 
71
+ func testStatusDefaults() config.RawValues {
72
+ return config.RawValues{"statusDefaults": map[string]any{
73
+ "start": defaultStartParentStatus,
74
+ "work": defaultWorkTaskStatus,
75
+ "end": defaultEndParentStatus,
76
+ }}
77
+ }
78
+
79
+ func testRepoConfig(values config.RawValues) config.RawValues {
80
+ return config.Merge(config.RawValues{"project": "PAY", "component": "api"}, testStatusDefaults(), values)
81
+ }
82
+
70
83
  // newSystemForTest builds the adapter around the test-local REST seam.
71
84
  func newSystemForTest(fake *fakeJira) (task.System, error) {
72
85
  return newSystemForCLI(&fakeClient{fake: fake})
@@ -4,6 +4,7 @@ package jira
4
4
 
5
5
  import (
6
6
  "context"
7
+ "errors"
7
8
  "fmt"
8
9
  "log/slog"
9
10
  "regexp"
@@ -19,12 +20,21 @@ import (
19
20
 
20
21
  // Config is the adapter-owned typed config for every scope.
21
22
  type Config struct {
22
- Assignee string `yaml:"assignee,omitempty"`
23
- Project string `yaml:"project,omitempty"`
24
- Component string `yaml:"component,omitempty"`
25
- Filters Filters `yaml:"filters,omitempty"`
26
- Transition TransitionTo `yaml:"transitionTo,omitempty"`
27
- Templates Templates `yaml:"templates,omitempty"`
23
+ Assignee string `yaml:"assignee,omitempty"`
24
+ Project string `yaml:"project,omitempty"`
25
+ Component string `yaml:"component,omitempty"`
26
+ Filters Filters `yaml:"filters,omitempty"`
27
+ Transition TransitionTo `yaml:"transitionTo,omitempty"`
28
+ StatusDefaults StatusDefaults `yaml:"statusDefaults,omitempty"`
29
+ Templates Templates `yaml:"templates,omitempty"`
30
+ }
31
+
32
+ // StatusDefaults are repository-scoped Jira lifecycle statuses selected from
33
+ // the statuses available to the configured project.
34
+ type StatusDefaults struct {
35
+ Start string `yaml:"start,omitempty"`
36
+ Work string `yaml:"work,omitempty"`
37
+ End string `yaml:"end,omitempty"`
28
38
  }
29
39
 
30
40
  // Templates are Jira-owned task-system descriptions and comments.
@@ -103,10 +113,134 @@ var (
103
113
  // claimLabel is the permanent workflow claim label.
104
114
  func claimLabel(workflow string) string { return "wf:" + workflow }
105
115
 
116
+ func jiraRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
117
+ fields := []task.RegistrationField{
118
+ {Key: "project", Title: "Jira project"},
119
+ {Key: "component", Title: "Jira component", Derived: true},
120
+ }
121
+ project := registrationValue(values, "project")
122
+ if strings.TrimSpace(project) == "" {
123
+ return fields, nil
124
+ }
125
+ creds, err := loadCredentialsDefault()
126
+ if err != nil {
127
+ return nil, fmt.Errorf("jira credentials: %w", err)
128
+ }
129
+ client, err := sharedClient(creds.Site, creds.Email, creds.Token)
130
+ if err != nil {
131
+ return nil, err
132
+ }
133
+ statusProvider, ok := any(client).(jirarest.StatusProvider)
134
+ if !ok {
135
+ return nil, errors.New("jira client does not support project status discovery")
136
+ }
137
+ statuses, err := statusProvider.Statuses(ctx, strings.TrimSpace(project))
138
+ if err != nil {
139
+ return nil, fmt.Errorf("discover Jira statuses for project %q: %w", project, err)
140
+ }
141
+ if len(statuses) == 0 {
142
+ return nil, fmt.Errorf("discover Jira statuses for project %q: no statuses returned", project)
143
+ }
144
+ return append(fields,
145
+ statusRegistrationField("statusDefaults.start", "Start status", statuses, defaultStartParentStatus),
146
+ statusRegistrationField("statusDefaults.work", "Work status", statuses, defaultWorkTaskStatus),
147
+ statusRegistrationField("statusDefaults.end", "End status", statuses, defaultEndParentStatus),
148
+ ), nil
149
+ }
150
+
151
+ func statusRegistrationField(key, title string, statuses []string, conventional string) task.RegistrationField {
152
+ defaultValue := ""
153
+ for _, status := range statuses {
154
+ if status == conventional {
155
+ defaultValue = conventional
156
+ break
157
+ }
158
+ }
159
+ return task.RegistrationField{
160
+ Key: key, Title: title, Options: append([]string(nil), statuses...), Default: defaultValue,
161
+ }
162
+ }
163
+
164
+ func validateJiraRegistration(ctx context.Context, spec task.RepoRegistrationSpec) error {
165
+ if hasStatusDefaults(spec.RootConfig) {
166
+ return errors.New("Jira statusDefaults must be configured per repository")
167
+ }
168
+ var repoCfg Config
169
+ if err := config.DecodeStrict(spec.RepoConfig, &repoCfg); err != nil {
170
+ return fmt.Errorf("repo config: %w", err)
171
+ }
172
+ if strings.TrimSpace(repoCfg.Project) == "" {
173
+ return errors.New("Jira project is required")
174
+ }
175
+ if strings.TrimSpace(repoCfg.Component) == "" {
176
+ return errors.New("Jira component is required")
177
+ }
178
+ if repoCfg.Component != spec.Name {
179
+ return fmt.Errorf("Jira component %q must match repository name %q", repoCfg.Component, spec.Name)
180
+ }
181
+ if !hasStatusDefaults(spec.RepoConfig) {
182
+ return errors.New("statusDefaults.start, statusDefaults.work, and statusDefaults.end are required")
183
+ }
184
+ selected := repoCfg.StatusDefaults
185
+ if selected.Start == "" || selected.Work == "" || selected.End == "" {
186
+ return errors.New("statusDefaults.start, statusDefaults.work, and statusDefaults.end are required")
187
+ }
188
+ creds, err := loadCredentialsDefault()
189
+ if err != nil {
190
+ return fmt.Errorf("Jira credentials: %w", err)
191
+ }
192
+ client, err := sharedClient(creds.Site, creds.Email, creds.Token)
193
+ if err != nil {
194
+ return err
195
+ }
196
+ statusProvider, ok := any(client).(jirarest.StatusProvider)
197
+ if !ok {
198
+ return errors.New("jira client does not support project status discovery")
199
+ }
200
+ statuses, err := statusProvider.Statuses(ctx, repoCfg.Project)
201
+ if err != nil {
202
+ return fmt.Errorf("discover Jira statuses for project %q: %w", repoCfg.Project, err)
203
+ }
204
+ for key, value := range map[string]string{
205
+ "start": selected.Start, "work": selected.Work, "end": selected.End,
206
+ } {
207
+ if !contains(statuses, value) {
208
+ return fmt.Errorf("statusDefaults.%s %q is not available in Jira project %s", key, value, repoCfg.Project)
209
+ }
210
+ }
211
+ return nil
212
+ }
213
+
214
+ func registrationValue(values config.RawValues, key string) string {
215
+ if value, ok := values[key].(string); ok {
216
+ return strings.TrimSpace(value)
217
+ }
218
+ parts := strings.Split(key, ".")
219
+ var current any = map[string]any(values)
220
+ for _, part := range parts {
221
+ m, ok := current.(map[string]any)
222
+ if !ok {
223
+ if raw, ok := current.(config.RawValues); ok {
224
+ m = map[string]any(raw)
225
+ } else {
226
+ return ""
227
+ }
228
+ }
229
+ current, ok = m[part]
230
+ if !ok {
231
+ return ""
232
+ }
233
+ }
234
+ value, _ := current.(string)
235
+ return strings.TrimSpace(value)
236
+ }
237
+
106
238
  func init() {
107
239
  task.Register("jira", task.Factory{
108
- RequiredRepoKeys: func() []string { return []string{"project", "component"} },
109
- DefaultConfig: DefaultConfig,
240
+ RequiredRepoKeys: func() []string { return []string{"project", "component"} },
241
+ RegistrationFields: jiraRegistrationFields,
242
+ ValidateRegistration: validateJiraRegistration,
243
+ DefaultConfig: DefaultConfig,
110
244
  ValidateTextConfig: func(raw config.RawValues) error {
111
245
  var cfg Config
112
246
  if err := config.DecodeStrict(raw, &cfg); err != nil {
@@ -190,6 +324,12 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
190
324
  if spec.Name == "" {
191
325
  return nil, fmt.Errorf("jira: repo name is required")
192
326
  }
327
+ if hasStatusDefaults(spec.RootConfig) {
328
+ return nil, errors.New("jira statusDefaults must be configured per repository")
329
+ }
330
+ if !hasStatusDefaults(spec.RepoConfig) {
331
+ return nil, errors.New("jira repository taskConfig.statusDefaults requires start, work, and end values")
332
+ }
193
333
  merged := config.Merge(DefaultConfig(), spec.RootConfig, spec.RepoConfig)
194
334
  var cfg Config
195
335
  if err := config.DecodeStrict(merged, &cfg); err != nil {
@@ -203,25 +343,37 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
203
343
  return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
204
344
  }
205
345
  }
206
- s := &system{cli: cli, repoName: spec.Name, base: merged, effective: cfg}
346
+ s := &system{
347
+ cli: cli,
348
+ repoName: spec.Name,
349
+ base: merged,
350
+ effective: cfg,
351
+ }
207
352
  if err := s.validateTransition(ctx, "repo config", cfg.Project, cfg.Transition); err != nil {
208
353
  return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
209
354
  }
210
- for _, status := range []string{defaultStartParentStatus, defaultEndParentStatus, "To Do"} {
211
- if err := cli.ValidateStatus(ctx, cfg.Project, status); err != nil {
212
- return nil, fmt.Errorf("jira repo %q default status %q: %w", spec.Name, status, err)
213
- }
355
+ if err := validateStatusDefaults(ctx, cli, "repo config", cfg.Project, cfg.StatusDefaults); err != nil {
356
+ return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
214
357
  }
215
- // project/component are required repo keys enforced at registration
216
- // (RequiredRepoKeys); construction also probes external Jira names.
358
+ // project/component are required repo keys enforced at registration. Do not
359
+ // probe conventional lifecycle names here: repository registration stores
360
+ // the project-specific defaults, and projects may not contain those names.
217
361
  return s, nil
218
362
  }
219
363
 
220
364
  // newSystemForCLI constructs a system around an explicit Jira client seam.
221
365
  func newSystemForCLI(cli jirarest.Client) (task.System, error) {
222
366
  return newSystem(context.Background(), cli, task.RepoSpec{
223
- Name: "payments",
224
- RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
367
+ Name: "payments",
368
+ RepoConfig: config.RawValues{
369
+ "project": "PAY",
370
+ "component": "api",
371
+ "statusDefaults": map[string]any{
372
+ "start": defaultStartParentStatus,
373
+ "work": defaultWorkTaskStatus,
374
+ "end": defaultEndParentStatus,
375
+ },
376
+ },
225
377
  })
226
378
  }
227
379
 
@@ -234,6 +386,25 @@ func decodeConfig(raw config.RawValues) (Config, error) {
234
386
  return cfg, nil
235
387
  }
236
388
 
389
+ func hasStatusDefaults(raw config.RawValues) bool {
390
+ _, ok := raw["statusDefaults"]
391
+ return ok
392
+ }
393
+
394
+ func validateStatusDefaults(ctx context.Context, cli jirarest.Client, scope, project string, defaults StatusDefaults) error {
395
+ if strings.TrimSpace(defaults.Start) == "" || strings.TrimSpace(defaults.Work) == "" || strings.TrimSpace(defaults.End) == "" {
396
+ return fmt.Errorf("%s statusDefaults.start, statusDefaults.work, and statusDefaults.end are required", scope)
397
+ }
398
+ for field, status := range map[string]string{
399
+ "start": defaults.Start, "work": defaults.Work, "end": defaults.End,
400
+ } {
401
+ if err := cli.ValidateStatus(ctx, project, status); err != nil {
402
+ return fmt.Errorf("%s statusDefaults.%s %q: %w", scope, field, status, err)
403
+ }
404
+ }
405
+ return nil
406
+ }
407
+
237
408
  func validateTemplates(templates Templates) error {
238
409
  for name, tmpl := range map[string]string{
239
410
  "mailboxDescription": templates.MailboxDescription,
@@ -392,6 +563,9 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
392
563
  if err := validateTemplates(workflowCfg.Templates); err != nil {
393
564
  return fmt.Errorf("workflow taskConfig.templates: %w", err)
394
565
  }
566
+ if hasStatusDefaults(workflowTaskConfig) {
567
+ return errors.New("workflow taskConfig.statusDefaults must be configured per repository")
568
+ }
395
569
  if err := s.validateAssignee(ctx, "workflow", workflowCfg.Project, workflowCfg.Assignee); err != nil {
396
570
  return err
397
571
  }
@@ -404,6 +578,9 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
404
578
  }
405
579
  sort.Strings(nodes)
406
580
  for _, n := range nodes {
581
+ if hasStatusDefaults(nodeTaskConfigs[n]) {
582
+ return fmt.Errorf("node %q taskConfig.statusDefaults must be configured per repository", n)
583
+ }
407
584
  cfg, err := decodeConfig(config.Merge(s.base, workflowTaskConfig, nodeTaskConfigs[n]))
408
585
  if err != nil {
409
586
  return fmt.Errorf("node %q taskConfig: %w", n, err)
@@ -450,11 +627,9 @@ func (s *system) validateTransition(ctx context.Context, scope, project string,
450
627
  return nil
451
628
  }
452
629
 
453
- // LifecycleDefaults exposure: the deterministic Jira transition defaults per
454
- // lifecycle point (spec: Jira transition defaults are deterministic). Run
455
- // orchestration merges these raw values under the effective node config
456
- // before ApplyTaskConfig, so the effective precedence is
457
- // built-in default < root < repo < workflow < node.
630
+ // LifecycleDefaults exposes the repository-selected Jira transition values.
631
+ // Run orchestration merges these raw values under the effective node config
632
+ // before ApplyTaskConfig, so explicit workflow/node transition values win.
458
633
  //
459
634
  // The methods also carry the inherited root/repository transitionTo and
460
635
  // assignee values. ApplyTaskConfig receives only the workflow/node config the
@@ -463,29 +638,43 @@ func (s *system) validateTransition(ctx context.Context, scope, project string,
463
638
  // silently discarded. internal/task/beads implements the same rule; keep the
464
639
  // two adapters aligned.
465
640
 
466
- // StartDefaults defaults the parent to In Progress.
641
+ // StartDefaults uses the repository-selected parent start status.
467
642
  func (s *system) StartDefaults() config.RawValues {
468
- return s.lifecycleDefaults(transitionDefault("parentStatus", defaultStartParentStatus))
643
+ return s.lifecycleDefaults(transitionDefault("parentStatus", s.startStatus()))
469
644
  }
470
645
 
471
- // WorkDefaults defaults the mailbox task status to In Progress; the parent
646
+ // WorkDefaults uses the repository-selected active mailbox status; the parent
472
647
  // is left unchanged when parentStatus is omitted.
473
648
  func (s *system) WorkDefaults() config.RawValues {
474
- return s.lifecycleDefaults(transitionDefault("taskStatus", defaultWorkTaskStatus))
649
+ return s.lifecycleDefaults(transitionDefault("taskStatus", s.workStatus()))
475
650
  }
476
651
 
477
- // EndDefaults defaults the parent to Done.
652
+ // EndDefaults uses the repository-selected parent completion status.
478
653
  func (s *system) EndDefaults() config.RawValues {
479
- return s.lifecycleDefaults(transitionDefault("parentStatus", defaultEndParentStatus))
654
+ return s.lifecycleDefaults(transitionDefault("parentStatus", s.endStatus()))
655
+ }
656
+
657
+ func (s *system) startStatus() string { return s.effective.StatusDefaults.Start }
658
+
659
+ func (s *system) workStatus() string { return s.effective.StatusDefaults.Work }
660
+
661
+ func (s *system) endStatus() string { return s.effective.StatusDefaults.End }
662
+
663
+ // Recovery/restart needs an initial mailbox state. It uses the same
664
+ // repository-selected start status as a fresh run; there is no separate
665
+ // hard-coded Jira reset status.
666
+ func (s *system) mailboxResetStatus() string {
667
+ return s.startStatus()
480
668
  }
481
669
 
482
670
  func transitionDefault(key, value string) config.RawValues {
483
671
  return config.RawValues{"transitionTo": map[string]any{key: value}}
484
672
  }
485
673
 
486
- // lifecycleDefaults layers the inherited root/repository operation values over
487
- // the built-in lifecycle default. Only the keys ApplyTaskConfig consumes are
488
- // carried, so unrelated configuration never enters durable activity inputs.
674
+ // lifecycleDefaults layers inherited root/repository operation values over
675
+ // the selected repository lifecycle value. Only the keys ApplyTaskConfig
676
+ // consumes are carried, so unrelated configuration never enters durable
677
+ // activity inputs.
489
678
  func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
490
679
  inherited := config.RawValues{}
491
680
  for _, key := range []string{"transitionTo", "assignee"} {
@@ -550,11 +739,10 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
550
739
  // --- Transitions ---
551
740
 
552
741
  // ApplyTaskConfig applies the adapter-owned taskConfig to the parent and
553
- // optional mailbox. Deterministic defaults: an omitted work-node taskStatus
554
- // defaults the mailbox to In Progress and leaves the parent unchanged; an
555
- // omitted parent-only parentStatus defaults to In Progress. Run
556
- // orchestration merges EndDefaults into the end node's config before this
557
- // call, so end processing transitions the parent to Done when omitted.
742
+ // optional mailbox. An omitted work-node taskStatus uses the repository's
743
+ // selected work status and leaves the parent unchanged; an omitted
744
+ // parent-only parentStatus uses the selected start status. Run orchestration
745
+ // merges EndDefaults into the end node's config before that call.
558
746
  func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskConfig config.RawValues) error {
559
747
  cfg, err := decodeConfig(taskConfig)
560
748
  if err != nil {
@@ -564,7 +752,7 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
564
752
  if target.Mailbox != nil {
565
753
  status := tr.TaskStatus
566
754
  if status == "" {
567
- status = defaultWorkTaskStatus
755
+ status = s.workStatus()
568
756
  }
569
757
  if err := s.transition(ctx, target.Mailbox.Key, status, cfg.Assignee); err != nil {
570
758
  return err
@@ -577,7 +765,7 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
577
765
  // Parent-only target (start/end lifecycle processing).
578
766
  status := tr.ParentStatus
579
767
  if status == "" {
580
- status = defaultStartParentStatus
768
+ status = s.startStatus()
581
769
  }
582
770
  return s.transition(ctx, target.Parent.Key, status, "")
583
771
  }
@@ -598,9 +786,10 @@ func isConflict(err error) bool {
598
786
  strings.Contains(msg, "cannot") || strings.Contains(msg, "invalid"))
599
787
  }
600
788
 
601
- // CompleteMailbox marks the mailbox Done using task-system semantics.
789
+ // CompleteMailbox uses the repository's selected end status. There is no
790
+ // separate mailbox-completion status in the Jira registration contract.
602
791
  func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
603
- return s.transition(ctx, mailbox.Key, "Done", "")
792
+ return s.transition(ctx, mailbox.Key, s.endStatus(), "")
604
793
  }
605
794
 
606
795
  // --- Comments ---
@@ -651,7 +840,7 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
651
840
  // transition and therefore retry without blind status writes.
652
841
  func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox) error {
653
842
  for _, mailbox := range mailboxes {
654
- if err := s.transition(ctx, mailbox.Key, "To Do", ""); err != nil {
843
+ if err := s.transition(ctx, mailbox.Key, s.mailboxResetStatus(), ""); err != nil {
655
844
  return fmt.Errorf("reopen mailbox %s for restart: %w", mailbox.Key, err)
656
845
  }
657
846
  }
@@ -660,11 +849,11 @@ func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes
660
849
 
661
850
  // --- Recovery ---
662
851
 
663
- // ResetForRecovery resets mailbox subtasks to To Do while preserving
664
- // comments, labels, and history. No parent rollback runs.
852
+ // ResetForRecovery resets mailbox subtasks to the configured initial status
853
+ // while preserving comments, labels, and history. No parent rollback runs.
665
854
  func (s *system) ResetForRecovery(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
666
855
  for _, mb := range mailboxes {
667
- if err := s.transition(ctx, mb.Key, "To Do", ""); err != nil {
856
+ if err := s.transition(ctx, mb.Key, s.mailboxResetStatus(), ""); err != nil {
668
857
  return fmt.Errorf("reset mailbox %s: %w", mb.Key, err)
669
858
  }
670
859
  }
@@ -16,7 +16,7 @@ import (
16
16
  //
17
17
  // The adapter therefore returns its inherited transitionTo/assignee values as
18
18
  // part of its lifecycle defaults, producing the effective precedence
19
- // built-in default < root < repo < workflow < node.
19
+ // registered lifecycle value < root/repo inherited operation value < workflow < node.
20
20
  //
21
21
  // internal/task/beads/lifecycle_inheritance_test.go is the sibling of this
22
22
  // file and asserts the same invariants against the Beads adapter. Keep the two
@@ -26,7 +26,7 @@ import (
26
26
  // scopes that never appear in an ApplyTaskConfig argument.
27
27
  func repoScopedSystem(t *testing.T, fake *fakeJira, repoValues config.RawValues) task.System {
28
28
  t.Helper()
29
- repoConfig := config.Merge(config.RawValues{"project": "PAY", "component": "api"}, repoValues)
29
+ repoConfig := config.Merge(testRepoConfig(nil), repoValues)
30
30
  sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
31
31
  Name: "payments",
32
32
  RepoConfig: repoConfig,
@@ -66,7 +66,7 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
66
66
  t.Fatal(err)
67
67
  }
68
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",
69
+ t.Fatalf("parent transitions = %v, want the inherited repo value to beat the registered lifecycle value",
70
70
  fake.parentTransitions)
71
71
  }
72
72
  })
@@ -82,7 +82,7 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
82
82
  t.Fatal(err)
83
83
  }
84
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",
85
+ t.Fatalf("mailbox transitions = %v, want the inherited repo value to beat the registered lifecycle value",
86
86
  fake.taskTransitions)
87
87
  }
88
88
  })
@@ -113,7 +113,7 @@ func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
113
113
  node config.RawValues
114
114
  want string
115
115
  }{
116
- {name: "repo beats built-in default", want: "Repo Status"},
116
+ {name: "repo operation value beats lifecycle value", want: "Repo Status"},
117
117
  {
118
118
  name: "workflow beats repo",
119
119
  workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Workflow Status"}},