relay-flow 0.2.7-alpha → 0.2.9-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 (52) hide show
  1. package/README.md +76 -37
  2. package/cmd/relay-flow/commands_test.go +199 -19
  3. package/cmd/relay-flow/main.go +183 -144
  4. package/cmd/relay-flow/onboarding.go +344 -0
  5. package/cmd/relay-flow/onboarding_test.go +515 -0
  6. package/cmd/relay-flow/repo_registration.go +454 -0
  7. package/cmd/relay-flow/serve.go +5 -2
  8. package/examples/config-reference.yaml +7 -0
  9. package/go.mod +12 -8
  10. package/go.sum +24 -17
  11. package/internal/execution/goworkflows/engine_test.go +3 -3
  12. package/internal/harness/opencode/opencode_test.go +4 -4
  13. package/internal/harness/opencode/repo_setup.go +1 -1
  14. package/internal/harness/pi/prompt_test.go +3 -3
  15. package/internal/repo/service.go +47 -3
  16. package/internal/repo/service_test.go +36 -1
  17. package/internal/runner/herdr/herdr.go +103 -4
  18. package/internal/runner/herdr/herdr_test.go +72 -2
  19. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  20. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  21. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  22. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  23. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  24. package/internal/runner/orca/orca.go +68 -9
  25. package/internal/runner/orca/orca_test.go +134 -2
  26. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  27. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  28. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  29. package/internal/runner/runner.go +9 -0
  30. package/internal/server/api_test.go +65 -0
  31. package/internal/server/client.go +40 -2
  32. package/internal/server/fixture_test.go +27 -16
  33. package/internal/server/server.go +80 -4
  34. package/internal/task/beads/beads.go +7 -2
  35. package/internal/task/beads/beads_test.go +13 -0
  36. package/internal/task/factory.go +9 -7
  37. package/internal/task/jira/auth_test.go +9 -1
  38. package/internal/task/jira/filters_test.go +2 -2
  39. package/internal/task/jira/helpers_test.go +13 -0
  40. package/internal/task/jira/jira.go +233 -44
  41. package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
  42. package/internal/task/jira/rest/client.go +73 -31
  43. package/internal/task/jira/rest/client_test.go +25 -0
  44. package/internal/task/jira/status_defaults_test.go +134 -0
  45. package/internal/task/jira/templates_test.go +2 -2
  46. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  47. package/internal/task/jira/transition_defaults_test.go +8 -12
  48. package/internal/task/jira/validation_test.go +3 -1
  49. package/internal/task/registration.go +59 -0
  50. package/internal/workflow/workflow.go +4 -1
  51. package/internal/workflow/workflow_test.go +4 -4
  52. package/package.json +1 -1
@@ -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)
@@ -83,6 +85,7 @@ func New(deps Deps) http.Handler {
83
85
  mux.HandleFunc("/workflows", s.handleWorkflows)
84
86
  mux.HandleFunc("/workflows/", s.handleWorkflowByName)
85
87
  mux.HandleFunc("/repos/discover", s.handleReposDiscover)
88
+ mux.HandleFunc("/repos/ensure", s.handleRepoEnsure)
86
89
  mux.HandleFunc("/repos/task-fields", s.handleRepoTaskFields)
87
90
  mux.HandleFunc("/repos", s.handleRepos)
88
91
  mux.HandleFunc("/repos/", s.handleRepoByName)
@@ -97,6 +100,14 @@ type server struct {
97
100
  deps Deps
98
101
  }
99
102
 
103
+ // repoEnsurer is implemented by the composition root when the selected
104
+ // runner can provision a repository resource. It is intentionally separate
105
+ // from Deps so existing task/run service seams do not need a runner-specific
106
+ // method.
107
+ type repoEnsurer interface {
108
+ EnsureRepo(context.Context, runner.RepoCandidate) error
109
+ }
110
+
100
111
  // --- envelope helpers ---
101
112
 
102
113
  func writeOK(w http.ResponseWriter, status int, data any) {
@@ -245,16 +256,81 @@ func (s *server) handleReposDiscover(w http.ResponseWriter, r *http.Request) {
245
256
  writeOK(w, http.StatusOK, candidates)
246
257
  }
247
258
 
259
+ func (s *server) handleRepoEnsure(w http.ResponseWriter, r *http.Request) {
260
+ if !methodOnly(w, r, http.MethodPost) {
261
+ return
262
+ }
263
+ ensurer, ok := s.deps.(repoEnsurer)
264
+ if !ok {
265
+ mapErr(w, fmt.Errorf("%w: runner repository provisioning is unavailable", ErrInvalid))
266
+ return
267
+ }
268
+ body, err := readBody(r)
269
+ if err != nil {
270
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
271
+ return
272
+ }
273
+ var candidate runner.RepoCandidate
274
+ if err := decodeStrict(body, &candidate); err != nil {
275
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
276
+ return
277
+ }
278
+ candidate.Name = strings.TrimSpace(candidate.Name)
279
+ candidate.Path = strings.TrimSpace(candidate.Path)
280
+ if candidate.Name == "" || candidate.Path == "" {
281
+ writeErr(w, http.StatusBadRequest, "invalid", "repository name and path are required")
282
+ return
283
+ }
284
+ if err := ensurer.EnsureRepo(r.Context(), candidate); err != nil {
285
+ mapErr(w, err)
286
+ return
287
+ }
288
+ writeOK(w, http.StatusOK, candidate)
289
+ }
290
+
248
291
  func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
249
- if !methodOnly(w, r, http.MethodGet) {
292
+ if r.Method == http.MethodGet {
293
+ fields, err := s.deps.TaskRegistrationFields(r.Context(), nil)
294
+ if err != nil {
295
+ mapErr(w, err)
296
+ return
297
+ }
298
+ keys := make([]string, 0, len(fields))
299
+ for _, field := range fields {
300
+ keys = append(keys, field.Key)
301
+ }
302
+ // Keep the documented fields list for callers that only need the
303
+ // required keys, and include plugin-owned metadata for the CLI's
304
+ // initial prompt construction.
305
+ writeOK(w, http.StatusOK, map[string]any{
306
+ "fields": keys,
307
+ "registration": task.Registration{Fields: fields},
308
+ })
250
309
  return
251
310
  }
252
- fields, err := s.deps.TaskFields(r.Context())
311
+ if !methodOnly(w, r, http.MethodPost) {
312
+ return
313
+ }
314
+ body, err := readBody(r)
315
+ if err != nil {
316
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
317
+ return
318
+ }
319
+ var payload struct {
320
+ Values config.RawValues `json:"values,omitempty"`
321
+ }
322
+ if len(strings.TrimSpace(string(body))) > 0 {
323
+ if err := decodeStrict(body, &payload); err != nil {
324
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
325
+ return
326
+ }
327
+ }
328
+ fields, err := s.deps.TaskRegistrationFields(r.Context(), payload.Values)
253
329
  if err != nil {
254
330
  mapErr(w, err)
255
331
  return
256
332
  }
257
- writeOK(w, http.StatusOK, map[string]any{"fields": fields})
333
+ writeOK(w, http.StatusOK, task.Registration{Fields: fields})
258
334
  }
259
335
 
260
336
  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
  }