relay-flow 0.2.7-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.
@@ -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"}},
@@ -35,6 +35,14 @@ type CreatedSubtask struct {
35
35
  Key string
36
36
  }
37
37
 
38
+ // StatusProvider discovers the actual statuses configured for a Jira project.
39
+ // It is intentionally separate from Client so existing adapter test doubles
40
+ // and replaceable REST clients do not need provider-specific discovery unless
41
+ // registration needs it.
42
+ type StatusProvider interface {
43
+ Statuses(context.Context, string) ([]string, error)
44
+ }
45
+
38
46
  type Client interface {
39
47
  ValidateCredentials(context.Context) error
40
48
  Search(context.Context, string) ([]byte, error)
@@ -70,6 +78,7 @@ type HTTPClient struct {
70
78
  transitions map[string]transitionInfo
71
79
  accounts map[string]string
72
80
  statuses map[string]map[string]bool
81
+ statusNames map[string][]string
73
82
  subtaskTypes map[string]string
74
83
  }
75
84
 
@@ -97,6 +106,7 @@ func New(site, email, token string) (*HTTPClient, error) {
97
106
  transitions: map[string]transitionInfo{},
98
107
  accounts: map[string]string{},
99
108
  statuses: map[string]map[string]bool{},
109
+ statusNames: map[string][]string{},
100
110
  subtaskTypes: map[string]string{},
101
111
  }, nil
102
112
  }
@@ -186,43 +196,72 @@ func (c *HTTPClient) accountID(ctx context.Context, project, assignee string) (s
186
196
  return "", fmt.Errorf("assignee %q is not assignable in project %s", assignee, project)
187
197
  }
188
198
 
199
+ // Statuses returns the status names configured for all issue types in a Jira
200
+ // project, preserving Jira's response order and removing case-insensitive
201
+ // duplicates. The same discovery populates the subtask issue-type cache used
202
+ // when mailboxes are created.
203
+ func (c *HTTPClient) Statuses(ctx context.Context, project string) ([]string, error) {
204
+ _, names, err := c.loadStatuses(ctx, project)
205
+ if err != nil {
206
+ return nil, err
207
+ }
208
+ return append([]string(nil), names...), nil
209
+ }
210
+
189
211
  func (c *HTTPClient) ValidateStatus(ctx context.Context, project, status string) error {
212
+ statuses, _, err := c.loadStatuses(ctx, project)
213
+ if err != nil {
214
+ return err
215
+ }
216
+ if !statuses[strings.ToLower(status)] {
217
+ return fmt.Errorf("status %q is not valid in project %s", status, project)
218
+ }
219
+ return nil
220
+ }
221
+
222
+ func (c *HTTPClient) loadStatuses(ctx context.Context, project string) (map[string]bool, []string, error) {
190
223
  c.mu.Lock()
191
224
  statuses, ok := c.statuses[project]
225
+ names := append([]string(nil), c.statusNames[project]...)
192
226
  c.mu.Unlock()
193
- if !ok {
194
- var issueTypes []struct {
195
- ID string `json:"id"`
196
- Subtask bool `json:"subtask"`
197
- Statuses []struct {
198
- Name string `json:"name"`
199
- } `json:"statuses"`
200
- }
201
- path := "/rest/api/3/project/" + url.PathEscape(project) + "/statuses"
202
- if err := c.request(ctx, http.MethodGet, path, nil, nil, &issueTypes, true); err != nil {
203
- return err
204
- }
205
- statuses = map[string]bool{}
206
- subtaskType := ""
207
- for _, issueType := range issueTypes {
208
- if issueType.Subtask && subtaskType == "" {
209
- subtaskType = issueType.ID
210
- }
211
- for _, candidate := range issueType.Statuses {
212
- statuses[strings.ToLower(candidate.Name)] = true
227
+ if ok {
228
+ return statuses, names, nil
229
+ }
230
+ var issueTypes []struct {
231
+ ID string `json:"id"`
232
+ Subtask bool `json:"subtask"`
233
+ Statuses []struct {
234
+ Name string `json:"name"`
235
+ } `json:"statuses"`
236
+ }
237
+ path := "/rest/api/3/project/" + url.PathEscape(project) + "/statuses"
238
+ if err := c.request(ctx, http.MethodGet, path, nil, nil, &issueTypes, true); err != nil {
239
+ return nil, nil, err
240
+ }
241
+ statuses = map[string]bool{}
242
+ names = make([]string, 0)
243
+ subtaskType := ""
244
+ for _, issueType := range issueTypes {
245
+ if issueType.Subtask && subtaskType == "" {
246
+ subtaskType = issueType.ID
247
+ }
248
+ for _, candidate := range issueType.Statuses {
249
+ name := strings.TrimSpace(candidate.Name)
250
+ if name == "" || statuses[strings.ToLower(name)] {
251
+ continue
213
252
  }
253
+ statuses[strings.ToLower(name)] = true
254
+ names = append(names, name)
214
255
  }
215
- c.mu.Lock()
216
- c.statuses[project] = statuses
217
- if subtaskType != "" {
218
- c.subtaskTypes[project] = subtaskType
219
- }
220
- c.mu.Unlock()
221
256
  }
222
- if !statuses[strings.ToLower(status)] {
223
- return fmt.Errorf("status %q is not valid in project %s", status, project)
257
+ c.mu.Lock()
258
+ c.statuses[project] = statuses
259
+ c.statusNames[project] = append([]string(nil), names...)
260
+ if subtaskType != "" {
261
+ c.subtaskTypes[project] = subtaskType
224
262
  }
225
- return nil
263
+ c.mu.Unlock()
264
+ return statuses, names, nil
226
265
  }
227
266
 
228
267
  func (c *HTTPClient) View(ctx context.Context, key string) ([]byte, error) {
@@ -251,7 +290,7 @@ func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label
251
290
  subtaskType := c.subtaskTypes[project]
252
291
  c.mu.Unlock()
253
292
  if subtaskType == "" {
254
- if err := c.ValidateStatus(ctx, project, "To Do"); err != nil {
293
+ if _, _, err := c.loadStatuses(ctx, project); err != nil {
255
294
  return nil, err
256
295
  }
257
296
  c.mu.Lock()
@@ -290,7 +329,10 @@ func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label
290
329
  }
291
330
  created = append(created, result.Issues...)
292
331
  for _, issue := range result.Issues {
293
- c.setIssueState(issue.Key, "Sub-task", "To Do")
332
+ // Jira chooses the subtask's initial status from the project
333
+ // workflow. Leave the status unknown so the first transition reads
334
+ // the actual state instead of assuming a conventional name.
335
+ c.setIssueState(issue.Key, "Sub-task", "")
294
336
  }
295
337
  }
296
338
  return created, nil
@@ -7,6 +7,7 @@ import (
7
7
  "io"
8
8
  "net/http"
9
9
  "net/http/httptest"
10
+ "reflect"
10
11
  "strings"
11
12
  "sync"
12
13
  "testing"
@@ -93,6 +94,30 @@ func TestNewRejectsNonHTTPSRemoteSite(t *testing.T) {
93
94
  }
94
95
  }
95
96
 
97
+ func TestStatusesDiscoversProjectWorkflowStatuses(t *testing.T) {
98
+ s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
99
+ if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/project/PAY/statuses" {
100
+ http.NotFound(w, r)
101
+ return
102
+ }
103
+ writeJSON(w, []any{
104
+ map[string]any{"id": "100", "subtask": true, "statuses": []any{
105
+ map[string]any{"name": "Open"},
106
+ map[string]any{"name": "Working"},
107
+ map[string]any{"name": "Closed"},
108
+ }},
109
+ })
110
+ })
111
+ statuses, err := s.client(t).Statuses(context.Background(), "PAY")
112
+ if err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ want := []string{"Open", "Working", "Closed"}
116
+ if !reflect.DeepEqual(statuses, want) {
117
+ t.Fatalf("statuses = %v, want %v", statuses, want)
118
+ }
119
+ }
120
+
96
121
  func TestSearchPaginatesAndRequestsIssueLinks(t *testing.T) {
97
122
  s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
98
123
  if r.URL.Path != "/rest/api/3/search/jql" || r.Method != http.MethodPost {