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.
- package/README.md +191 -33
- package/cmd/relay-flow/commands_test.go +82 -11
- package/cmd/relay-flow/main.go +18 -86
- package/cmd/relay-flow/repo_registration.go +235 -0
- package/cmd/relay-flow/serve.go +2 -2
- package/examples/config-reference.yaml +7 -0
- package/examples/default-story-workflow.yaml +34 -19
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/repo/service.go +13 -0
- package/internal/repo/service_test.go +14 -1
- package/internal/server/api_test.go +46 -0
- package/internal/server/client.go +30 -2
- package/internal/server/fixture_test.go +20 -16
- package/internal/server/server.go +39 -4
- package/internal/task/beads/beads.go +7 -2
- package/internal/task/beads/beads_test.go +13 -0
- package/internal/task/factory.go +9 -7
- package/internal/task/jira/auth_test.go +9 -1
- package/internal/task/jira/filters_test.go +2 -2
- package/internal/task/jira/helpers_test.go +13 -0
- package/internal/task/jira/jira.go +233 -44
- package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
- package/internal/task/jira/rest/client.go +73 -31
- package/internal/task/jira/rest/client_test.go +25 -0
- package/internal/task/jira/status_defaults_test.go +134 -0
- package/internal/task/jira/templates_test.go +2 -2
- package/internal/task/jira/transition_defaults_test.go +8 -12
- package/internal/task/jira/validation_test.go +3 -1
- package/internal/task/registration.go +59 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
87
|
-
|
|
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) {
|
package/internal/task/factory.go
CHANGED
|
@@ -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
|
|
40
|
-
TaskScopeKey
|
|
41
|
-
RegistrationKey
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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{
|
|
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{"
|
|
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:
|
|
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
|
|
23
|
-
Project
|
|
24
|
-
Component
|
|
25
|
-
Filters
|
|
26
|
-
Transition
|
|
27
|
-
|
|
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:
|
|
109
|
-
|
|
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{
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
//
|
|
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:
|
|
224
|
-
RepoConfig: config.RawValues{
|
|
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
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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
|
|
641
|
+
// StartDefaults uses the repository-selected parent start status.
|
|
467
642
|
func (s *system) StartDefaults() config.RawValues {
|
|
468
|
-
return s.lifecycleDefaults(transitionDefault("parentStatus",
|
|
643
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", s.startStatus()))
|
|
469
644
|
}
|
|
470
645
|
|
|
471
|
-
// WorkDefaults
|
|
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",
|
|
649
|
+
return s.lifecycleDefaults(transitionDefault("taskStatus", s.workStatus()))
|
|
475
650
|
}
|
|
476
651
|
|
|
477
|
-
// EndDefaults
|
|
652
|
+
// EndDefaults uses the repository-selected parent completion status.
|
|
478
653
|
func (s *system) EndDefaults() config.RawValues {
|
|
479
|
-
return s.lifecycleDefaults(transitionDefault("parentStatus",
|
|
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
|
|
487
|
-
// the
|
|
488
|
-
// carried, so unrelated configuration never enters durable
|
|
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.
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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"}},
|