relay-flow 0.2.0-alpha → 0.2.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -6,13 +6,15 @@ import (
|
|
|
6
6
|
"context"
|
|
7
7
|
"fmt"
|
|
8
8
|
"log/slog"
|
|
9
|
+
"regexp"
|
|
9
10
|
"sort"
|
|
10
11
|
"strings"
|
|
12
|
+
"sync"
|
|
11
13
|
|
|
12
14
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
15
|
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
14
16
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
|
-
"github.com/rajpopat27/relay-flow/internal/task/jira/
|
|
17
|
+
jirarest "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
// Config is the adapter-owned typed config for every scope.
|
|
@@ -20,9 +22,16 @@ type Config struct {
|
|
|
20
22
|
Assignee string `yaml:"assignee,omitempty"`
|
|
21
23
|
Project string `yaml:"project,omitempty"`
|
|
22
24
|
Component string `yaml:"component,omitempty"`
|
|
23
|
-
Site string `yaml:"site,omitempty"`
|
|
24
25
|
Filters Filters `yaml:"filters,omitempty"`
|
|
25
26
|
Transition TransitionTo `yaml:"transitionTo,omitempty"`
|
|
27
|
+
Templates Templates `yaml:"templates,omitempty"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Templates are Jira-owned task-system descriptions and comments.
|
|
31
|
+
type Templates struct {
|
|
32
|
+
MailboxDescription string `yaml:"mailboxDescription"`
|
|
33
|
+
SummaryComment string `yaml:"summaryComment"`
|
|
34
|
+
FeedbackComment string `yaml:"feedbackComment"`
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
// Filters are the structured, locally evaluable workflow ticket matchers.
|
|
@@ -41,9 +50,53 @@ type TransitionTo struct {
|
|
|
41
50
|
|
|
42
51
|
// Deterministic transition defaults for omitted values.
|
|
43
52
|
const (
|
|
44
|
-
defaultStartParentStatus
|
|
45
|
-
defaultWorkTaskStatus
|
|
46
|
-
defaultEndParentStatus
|
|
53
|
+
defaultStartParentStatus = "In Progress"
|
|
54
|
+
defaultWorkTaskStatus = "In Progress"
|
|
55
|
+
defaultEndParentStatus = "Done"
|
|
56
|
+
defaultMailboxDescription = `Parent ticket: {{ticket}}
|
|
57
|
+
Workflow: {{workflow}}
|
|
58
|
+
Node: {{node}}
|
|
59
|
+
Node type: {{nodeType}}
|
|
60
|
+
Agent: {{agent}}
|
|
61
|
+
Mailbox: {{mailbox}}
|
|
62
|
+
|
|
63
|
+
Node work:
|
|
64
|
+
{{nodeDescription}}
|
|
65
|
+
|
|
66
|
+
Read this mailbox's comments for feedback from previous nodes.`
|
|
67
|
+
defaultSummaryComment = `Summary for {{node}}
|
|
68
|
+
|
|
69
|
+
{{summaryReport}}`
|
|
70
|
+
defaultFeedbackComment = `Feedback from {{sourceNode}} to {{targetNode}} mailbox {{mailbox}}
|
|
71
|
+
|
|
72
|
+
{{feedbackReport}}`
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
var textVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
76
|
+
|
|
77
|
+
var knownTextVars = map[string]bool{
|
|
78
|
+
"runID": true, "ticket": true, "workflow": true,
|
|
79
|
+
"repo": true, "node": true, "nodeType": true, "agent": true,
|
|
80
|
+
"nodeDescription": true, "nextSteps": true, "successRoutes": true,
|
|
81
|
+
"failureRoutes": true, "mailbox": true, "sourceNode": true,
|
|
82
|
+
"targetNode": true, "summaryReport": true, "feedbackReport": true,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// DefaultConfig supplies Jira task-system text defaults for relay-flow init.
|
|
86
|
+
func DefaultConfig() config.RawValues {
|
|
87
|
+
return config.RawValues{"templates": map[string]any{
|
|
88
|
+
"mailboxDescription": defaultMailboxDescription,
|
|
89
|
+
"summaryComment": defaultSummaryComment,
|
|
90
|
+
"feedbackComment": defaultFeedbackComment,
|
|
91
|
+
}}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var (
|
|
95
|
+
clientsMu sync.Mutex
|
|
96
|
+
clients = map[string]struct {
|
|
97
|
+
token string
|
|
98
|
+
client *jirarest.HTTPClient
|
|
99
|
+
}{}
|
|
47
100
|
)
|
|
48
101
|
|
|
49
102
|
// claimLabel is the permanent workflow claim label.
|
|
@@ -52,6 +105,14 @@ func claimLabel(workflow string) string { return "wf:" + workflow }
|
|
|
52
105
|
func init() {
|
|
53
106
|
task.Register("jira", task.Factory{
|
|
54
107
|
RequiredRepoKeys: func() []string { return []string{"project", "component"} },
|
|
108
|
+
DefaultConfig: DefaultConfig,
|
|
109
|
+
ValidateTextConfig: func(raw config.RawValues) error {
|
|
110
|
+
var cfg Config
|
|
111
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
return validateTemplates(cfg.Templates)
|
|
115
|
+
},
|
|
55
116
|
TaskScopeKey: func(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
56
117
|
var root, repoCfg Config
|
|
57
118
|
if err := config.DecodeStrict(rootConfig, &root); err != nil {
|
|
@@ -65,34 +126,73 @@ func init() {
|
|
|
65
126
|
if proj == "" || comp == "" {
|
|
66
127
|
return "", fmt.Errorf("jira task scope requires repo project and component")
|
|
67
128
|
}
|
|
68
|
-
|
|
129
|
+
creds, err := loadCredentialsDefault()
|
|
130
|
+
if err != nil {
|
|
131
|
+
return "", fmt.Errorf("jira credentials: %w", err)
|
|
132
|
+
}
|
|
133
|
+
return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
|
|
69
134
|
},
|
|
135
|
+
Auth: auth,
|
|
70
136
|
New: func(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
71
|
-
|
|
137
|
+
merged := config.Merge(spec.RootConfig, spec.RepoConfig)
|
|
138
|
+
var cfg Config
|
|
139
|
+
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
140
|
+
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
141
|
+
}
|
|
142
|
+
creds, err := loadCredentialsDefault()
|
|
143
|
+
if err != nil {
|
|
144
|
+
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
145
|
+
}
|
|
146
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
147
|
+
if err != nil {
|
|
148
|
+
return nil, err
|
|
149
|
+
}
|
|
150
|
+
return newSystem(ctx, client, spec)
|
|
72
151
|
},
|
|
73
152
|
})
|
|
74
153
|
}
|
|
75
154
|
|
|
155
|
+
func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
|
|
156
|
+
key := strings.TrimRight(site, "/") + "\x00" + email
|
|
157
|
+
clientsMu.Lock()
|
|
158
|
+
defer clientsMu.Unlock()
|
|
159
|
+
if cached := clients[key]; cached.client != nil && cached.token == token {
|
|
160
|
+
return cached.client, nil
|
|
161
|
+
}
|
|
162
|
+
client, err := jirarest.New(site, email, token)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return nil, err
|
|
165
|
+
}
|
|
166
|
+
clients[key] = struct {
|
|
167
|
+
token string
|
|
168
|
+
client *jirarest.HTTPClient
|
|
169
|
+
}{token: token, client: client}
|
|
170
|
+
return client, nil
|
|
171
|
+
}
|
|
172
|
+
|
|
76
173
|
// system is the repo-bound Jira task.System. It is safe for concurrent use;
|
|
77
|
-
// the
|
|
174
|
+
// the REST client owns connection reuse, caches, and request limiting.
|
|
78
175
|
type system struct {
|
|
79
|
-
cli
|
|
176
|
+
cli jirarest.Client
|
|
80
177
|
repoName string
|
|
81
178
|
base config.RawValues
|
|
82
179
|
effective Config // root+repo merged
|
|
83
180
|
}
|
|
84
181
|
|
|
85
|
-
func newSystem(ctx context.Context, cli
|
|
182
|
+
func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
|
|
86
183
|
if spec.Name == "" {
|
|
87
184
|
return nil, fmt.Errorf("jira: repo name is required")
|
|
88
185
|
}
|
|
89
|
-
merged := config.Merge(spec.RootConfig, spec.RepoConfig)
|
|
186
|
+
merged := config.Merge(DefaultConfig(), spec.RootConfig, spec.RepoConfig)
|
|
90
187
|
var cfg Config
|
|
91
188
|
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
92
189
|
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
93
190
|
}
|
|
191
|
+
if err := validateTemplates(cfg.Templates); err != nil {
|
|
192
|
+
return nil, fmt.Errorf("jira repo %q taskConfig.templates: %w", spec.Name, err)
|
|
193
|
+
}
|
|
94
194
|
if cfg.Assignee != "" {
|
|
95
|
-
if err := cli.ValidateAssignee(ctx, cfg.Assignee); err != nil {
|
|
195
|
+
if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
|
|
96
196
|
return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
|
|
97
197
|
}
|
|
98
198
|
}
|
|
@@ -110,11 +210,10 @@ func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*syste
|
|
|
110
210
|
return s, nil
|
|
111
211
|
}
|
|
112
212
|
|
|
113
|
-
// newSystemForCLI constructs a system around an explicit
|
|
114
|
-
func newSystemForCLI(cli
|
|
213
|
+
// newSystemForCLI constructs a system around an explicit Jira client seam.
|
|
214
|
+
func newSystemForCLI(cli jirarest.Client) (task.System, error) {
|
|
115
215
|
return newSystem(context.Background(), cli, task.RepoSpec{
|
|
116
216
|
Name: "payments",
|
|
117
|
-
RootConfig: config.RawValues{},
|
|
118
217
|
RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
119
218
|
})
|
|
120
219
|
}
|
|
@@ -128,6 +227,55 @@ func decodeConfig(raw config.RawValues) (Config, error) {
|
|
|
128
227
|
return cfg, nil
|
|
129
228
|
}
|
|
130
229
|
|
|
230
|
+
func validateTemplates(templates Templates) error {
|
|
231
|
+
for name, tmpl := range map[string]string{
|
|
232
|
+
"mailboxDescription": templates.MailboxDescription,
|
|
233
|
+
"summaryComment": templates.SummaryComment,
|
|
234
|
+
"feedbackComment": templates.FeedbackComment,
|
|
235
|
+
} {
|
|
236
|
+
for _, match := range textVarPattern.FindAllStringSubmatch(tmpl, -1) {
|
|
237
|
+
if !knownTextVars[match[1]] {
|
|
238
|
+
return fmt.Errorf("%s: unknown template variable {{%s}}", name, match[1])
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
|
|
243
|
+
return fmt.Errorf("summaryComment must contain {{summaryReport}}")
|
|
244
|
+
}
|
|
245
|
+
if !strings.Contains(templates.FeedbackComment, "{{feedbackReport}}") {
|
|
246
|
+
return fmt.Errorf("feedbackComment must contain {{feedbackReport}}")
|
|
247
|
+
}
|
|
248
|
+
return nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// RenderText renders Jira-owned mailbox descriptions and comments.
|
|
252
|
+
func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, error) {
|
|
253
|
+
var tmpl string
|
|
254
|
+
switch kind {
|
|
255
|
+
case task.TextMailboxDescription:
|
|
256
|
+
tmpl = s.effective.Templates.MailboxDescription
|
|
257
|
+
case task.TextSummaryComment:
|
|
258
|
+
tmpl = s.effective.Templates.SummaryComment
|
|
259
|
+
case task.TextFeedbackComment:
|
|
260
|
+
tmpl = s.effective.Templates.FeedbackComment
|
|
261
|
+
default:
|
|
262
|
+
return "", fmt.Errorf("jira: unknown task text kind %q", kind)
|
|
263
|
+
}
|
|
264
|
+
values := map[string]string{
|
|
265
|
+
"runID": data.RunID, "ticket": data.Ticket,
|
|
266
|
+
"workflow": data.Workflow, "repo": data.Repo, "node": data.Node,
|
|
267
|
+
"nodeType": data.NodeType, "agent": data.Agent,
|
|
268
|
+
"nodeDescription": data.NodeDescription, "nextSteps": data.NextSteps,
|
|
269
|
+
"successRoutes": data.SuccessRoutes, "failureRoutes": data.FailureRoutes,
|
|
270
|
+
"mailbox": data.Mailbox, "sourceNode": data.SourceNode,
|
|
271
|
+
"targetNode": data.TargetNode, "summaryReport": data.SummaryReport,
|
|
272
|
+
"feedbackReport": data.FeedbackReport,
|
|
273
|
+
}
|
|
274
|
+
return textVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
|
|
275
|
+
return values[textVarPattern.FindStringSubmatch(match)[1]]
|
|
276
|
+
}), nil
|
|
277
|
+
}
|
|
278
|
+
|
|
131
279
|
// --- Poll / filters ---
|
|
132
280
|
|
|
133
281
|
// buildJQL scopes the parent poll by project/component and active statuses.
|
|
@@ -169,11 +317,15 @@ func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
|
|
|
169
317
|
// CompileFilter compiles workflow taskConfig.filters into an in-memory
|
|
170
318
|
// matcher over normalized ticket fields. Unknown filter fields are rejected.
|
|
171
319
|
func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
|
|
172
|
-
|
|
320
|
+
merged := config.Merge(s.base, workflowTaskConfig)
|
|
321
|
+
cfg, err := decodeConfig(merged)
|
|
173
322
|
if err != nil {
|
|
174
323
|
return nil, err
|
|
175
324
|
}
|
|
176
325
|
f := cfg.Filters
|
|
326
|
+
if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
|
|
327
|
+
f.Assignees = []string{cfg.Assignee}
|
|
328
|
+
}
|
|
177
329
|
return func(t task.Ticket) bool {
|
|
178
330
|
if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
|
|
179
331
|
return false
|
|
@@ -189,36 +341,32 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
189
341
|
}
|
|
190
342
|
}
|
|
191
343
|
}
|
|
192
|
-
if len(f.Assignees) > 0 && !
|
|
344
|
+
if len(f.Assignees) > 0 && !containsFold(f.Assignees, strField(t.Fields, "assignee")) {
|
|
193
345
|
return false
|
|
194
346
|
}
|
|
195
347
|
return true
|
|
196
348
|
}, nil
|
|
197
349
|
}
|
|
198
350
|
|
|
351
|
+
func hasAssigneeFilter(raw config.RawValues) bool {
|
|
352
|
+
switch filters := raw["filters"].(type) {
|
|
353
|
+
case map[string]any:
|
|
354
|
+
_, ok := filters["assignees"]
|
|
355
|
+
return ok
|
|
356
|
+
case config.RawValues:
|
|
357
|
+
_, ok := filters["assignees"]
|
|
358
|
+
return ok
|
|
359
|
+
default:
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
199
364
|
// --- Claim ---
|
|
200
365
|
|
|
201
|
-
// Claim adds wf:<workflow>
|
|
202
|
-
//
|
|
366
|
+
// Claim adds wf:<workflow> using the claims already inspected by routing.
|
|
367
|
+
// Jira's label-add operation is idempotent.
|
|
203
368
|
func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
|
|
204
|
-
|
|
205
|
-
if err != nil {
|
|
206
|
-
return err
|
|
207
|
-
}
|
|
208
|
-
labels, err := labelsOf(raw)
|
|
209
|
-
if err != nil {
|
|
210
|
-
return err
|
|
211
|
-
}
|
|
212
|
-
want := claimLabel(workflow)
|
|
213
|
-
for _, l := range labels {
|
|
214
|
-
if l == want {
|
|
215
|
-
return nil // idempotent
|
|
216
|
-
}
|
|
217
|
-
if strings.HasPrefix(l, "wf:") {
|
|
218
|
-
return fmt.Errorf("ticket %s already claimed by %s", ticket.Key, l)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return s.cli.EnsureLabel(ctx, ticket.Key, want)
|
|
369
|
+
return s.cli.EnsureLabel(ctx, ticket.Key, claimLabel(workflow))
|
|
222
370
|
}
|
|
223
371
|
|
|
224
372
|
// --- Config validation ---
|
|
@@ -231,7 +379,10 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
231
379
|
if err != nil {
|
|
232
380
|
return fmt.Errorf("workflow taskConfig: %w", err)
|
|
233
381
|
}
|
|
234
|
-
if err :=
|
|
382
|
+
if err := validateTemplates(workflowCfg.Templates); err != nil {
|
|
383
|
+
return fmt.Errorf("workflow taskConfig.templates: %w", err)
|
|
384
|
+
}
|
|
385
|
+
if err := s.validateAssignee(ctx, "workflow", workflowCfg.Project, workflowCfg.Assignee); err != nil {
|
|
235
386
|
return err
|
|
236
387
|
}
|
|
237
388
|
if err := s.validateTransition(ctx, "workflow", workflowCfg.Project, workflowCfg.Transition); err != nil {
|
|
@@ -247,7 +398,10 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
247
398
|
if err != nil {
|
|
248
399
|
return fmt.Errorf("node %q taskConfig: %w", n, err)
|
|
249
400
|
}
|
|
250
|
-
if err :=
|
|
401
|
+
if err := validateTemplates(cfg.Templates); err != nil {
|
|
402
|
+
return fmt.Errorf("node %q taskConfig.templates: %w", n, err)
|
|
403
|
+
}
|
|
404
|
+
if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Assignee); err != nil {
|
|
251
405
|
return err
|
|
252
406
|
}
|
|
253
407
|
if err := s.validateTransition(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Transition); err != nil {
|
|
@@ -257,11 +411,11 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
257
411
|
return nil
|
|
258
412
|
}
|
|
259
413
|
|
|
260
|
-
func (s *system) validateAssignee(ctx context.Context, scope, assignee string) error {
|
|
414
|
+
func (s *system) validateAssignee(ctx context.Context, scope, project, assignee string) error {
|
|
261
415
|
if assignee == "" {
|
|
262
416
|
return nil
|
|
263
417
|
}
|
|
264
|
-
if err := s.cli.ValidateAssignee(ctx, assignee); err != nil {
|
|
418
|
+
if err := s.cli.ValidateAssignee(ctx, project, assignee); err != nil {
|
|
265
419
|
return fmt.Errorf("%s assignee %q: %w", scope, assignee, err)
|
|
266
420
|
}
|
|
267
421
|
return nil
|
|
@@ -289,47 +443,47 @@ func (s *system) validateTransition(ctx context.Context, scope, project string,
|
|
|
289
443
|
// LifecycleDefaults exposure: the deterministic Jira transition defaults per
|
|
290
444
|
// lifecycle point (spec: Jira transition defaults are deterministic). Run
|
|
291
445
|
// orchestration merges these raw values under the effective node config
|
|
292
|
-
// before ApplyTaskConfig
|
|
293
|
-
//
|
|
446
|
+
// before ApplyTaskConfig, so the effective precedence is
|
|
447
|
+
// built-in default < root < repo < workflow < node.
|
|
448
|
+
//
|
|
449
|
+
// The methods also carry the inherited root/repository transitionTo and
|
|
450
|
+
// assignee values. ApplyTaskConfig receives only the workflow/node config the
|
|
451
|
+
// caller assembles, so returning inherited values here is what makes a
|
|
452
|
+
// repo-scoped value take effect at runtime instead of being validated and then
|
|
453
|
+
// silently discarded. internal/task/beads implements the same rule; keep the
|
|
454
|
+
// two adapters aligned.
|
|
294
455
|
|
|
295
456
|
// StartDefaults defaults the parent to In Progress.
|
|
296
457
|
func (s *system) StartDefaults() config.RawValues {
|
|
297
|
-
return
|
|
458
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", defaultStartParentStatus))
|
|
298
459
|
}
|
|
299
460
|
|
|
300
461
|
// WorkDefaults defaults the mailbox task status to In Progress; the parent
|
|
301
462
|
// is left unchanged when parentStatus is omitted.
|
|
302
463
|
func (s *system) WorkDefaults() config.RawValues {
|
|
303
|
-
return
|
|
464
|
+
return s.lifecycleDefaults(transitionDefault("taskStatus", defaultWorkTaskStatus))
|
|
304
465
|
}
|
|
305
466
|
|
|
306
467
|
// EndDefaults defaults the parent to Done.
|
|
307
468
|
func (s *system) EndDefaults() config.RawValues {
|
|
308
|
-
return
|
|
469
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", defaultEndParentStatus))
|
|
309
470
|
}
|
|
310
471
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
func endConfig(cfg config.RawValues) config.RawValues {
|
|
314
|
-
return withTransitionDefault(cfg, "parentStatus", defaultEndParentStatus)
|
|
472
|
+
func transitionDefault(key, value string) config.RawValues {
|
|
473
|
+
return config.RawValues{"transitionTo": map[string]any{key: value}}
|
|
315
474
|
}
|
|
316
475
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
merged[k] = v
|
|
476
|
+
// lifecycleDefaults layers the inherited root/repository operation values over
|
|
477
|
+
// the built-in lifecycle default. Only the keys ApplyTaskConfig consumes are
|
|
478
|
+
// carried, so unrelated configuration never enters durable activity inputs.
|
|
479
|
+
func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
|
|
480
|
+
inherited := config.RawValues{}
|
|
481
|
+
for _, key := range []string{"transitionTo", "assignee"} {
|
|
482
|
+
if value, ok := s.base[key]; ok {
|
|
483
|
+
inherited[key] = value
|
|
326
484
|
}
|
|
327
485
|
}
|
|
328
|
-
|
|
329
|
-
merged[key] = value
|
|
330
|
-
}
|
|
331
|
-
out["transitionTo"] = merged
|
|
332
|
-
return out
|
|
486
|
+
return config.Merge(builtin, inherited)
|
|
333
487
|
}
|
|
334
488
|
|
|
335
489
|
// --- Mailboxes ---
|
|
@@ -347,28 +501,38 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
|
|
|
347
501
|
return nil, err
|
|
348
502
|
}
|
|
349
503
|
out := map[string]task.Mailbox{}
|
|
504
|
+
missing := make([]task.MailboxSpec, 0)
|
|
350
505
|
for _, spec := range specs {
|
|
351
506
|
if mb, ok := existing[spec.Title]; ok {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if err := s.cli.EnsureLabel(ctx, mb.Key, claimLabel(workflow)); err != nil {
|
|
355
|
-
return nil, fmt.Errorf("label mailbox %q: %w", mb.Key, err)
|
|
356
|
-
}
|
|
357
|
-
if err := s.cli.UpdateDescription(ctx, mb.Key, spec.Description); err != nil {
|
|
358
|
-
return nil, fmt.Errorf("describe mailbox %q: %w", mb.Key, err)
|
|
507
|
+
if err := s.cli.UpdateMailbox(ctx, mb.Key, spec.Description, claimLabel(workflow)); err != nil {
|
|
508
|
+
return nil, fmt.Errorf("reconcile mailbox %q: %w", mb.Key, err)
|
|
359
509
|
}
|
|
360
510
|
mb.Node = spec.Node
|
|
361
511
|
out[spec.Node] = mb
|
|
362
512
|
continue
|
|
363
513
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
514
|
+
missing = append(missing, spec)
|
|
515
|
+
}
|
|
516
|
+
if len(missing) == 0 {
|
|
517
|
+
return out, nil
|
|
518
|
+
}
|
|
519
|
+
createSpecs := make([]jirarest.SubtaskSpec, 0, len(missing))
|
|
520
|
+
for _, spec := range missing {
|
|
521
|
+
createSpecs = append(createSpecs, jirarest.SubtaskSpec{Title: spec.Title, Description: spec.Description})
|
|
522
|
+
}
|
|
523
|
+
created, err := s.cli.CreateSubtasks(ctx, parent.Key, s.effective.Project, claimLabel(workflow), createSpecs)
|
|
524
|
+
if err != nil {
|
|
525
|
+
return nil, fmt.Errorf("create mailboxes: %w", err)
|
|
526
|
+
}
|
|
527
|
+
if len(created) != len(missing) {
|
|
528
|
+
return nil, fmt.Errorf("create mailboxes: created %d of %d", len(created), len(missing))
|
|
529
|
+
}
|
|
530
|
+
for i, mailbox := range created {
|
|
531
|
+
spec := missing[i]
|
|
532
|
+
if mailbox.ID == "" || mailbox.Key == "" {
|
|
533
|
+
return nil, fmt.Errorf("create mailbox %q: Jira returned no id/key", spec.Title)
|
|
370
534
|
}
|
|
371
|
-
out[spec.Node] = task.Mailbox{ID:
|
|
535
|
+
out[spec.Node] = task.Mailbox{ID: mailbox.ID, Key: mailbox.Key, Node: spec.Node}
|
|
372
536
|
}
|
|
373
537
|
return out, nil
|
|
374
538
|
}
|
|
@@ -388,20 +552,15 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
|
|
|
388
552
|
}
|
|
389
553
|
tr := cfg.Transition
|
|
390
554
|
if target.Mailbox != nil {
|
|
391
|
-
if cfg.Assignee != "" {
|
|
392
|
-
if err := s.cli.Assign(ctx, target.Mailbox.Key, cfg.Assignee); err != nil {
|
|
393
|
-
return err
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
555
|
status := tr.TaskStatus
|
|
397
556
|
if status == "" {
|
|
398
557
|
status = defaultWorkTaskStatus
|
|
399
558
|
}
|
|
400
|
-
if err := s.transition(ctx, target.Mailbox.Key, status); err != nil {
|
|
559
|
+
if err := s.transition(ctx, target.Mailbox.Key, status, cfg.Assignee); err != nil {
|
|
401
560
|
return err
|
|
402
561
|
}
|
|
403
562
|
if tr.ParentStatus != "" {
|
|
404
|
-
return s.transition(ctx, target.Parent.Key, tr.ParentStatus)
|
|
563
|
+
return s.transition(ctx, target.Parent.Key, tr.ParentStatus, "")
|
|
405
564
|
}
|
|
406
565
|
return nil
|
|
407
566
|
}
|
|
@@ -410,13 +569,13 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
|
|
|
410
569
|
if status == "" {
|
|
411
570
|
status = defaultStartParentStatus
|
|
412
571
|
}
|
|
413
|
-
return s.transition(ctx, target.Parent.Key, status)
|
|
572
|
+
return s.transition(ctx, target.Parent.Key, status, "")
|
|
414
573
|
}
|
|
415
574
|
|
|
416
575
|
// transition applies a Jira status transition, mapping a human-incompatible
|
|
417
576
|
// current state to a conflict.
|
|
418
|
-
func (s *system) transition(ctx context.Context, key, status string) error {
|
|
419
|
-
err := s.cli.Transition(ctx, key, status)
|
|
577
|
+
func (s *system) transition(ctx context.Context, key, status, assignee string) error {
|
|
578
|
+
err := s.cli.Transition(ctx, key, status, assignee)
|
|
420
579
|
if err != nil && isConflict(err) {
|
|
421
580
|
return retry.ConflictError(err)
|
|
422
581
|
}
|
|
@@ -431,7 +590,7 @@ func isConflict(err error) bool {
|
|
|
431
590
|
|
|
432
591
|
// CompleteMailbox marks the mailbox Done using task-system semantics.
|
|
433
592
|
func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
|
|
434
|
-
return s.transition(ctx, mailbox.Key, "Done")
|
|
593
|
+
return s.transition(ctx, mailbox.Key, "Done", "")
|
|
435
594
|
}
|
|
436
595
|
|
|
437
596
|
// --- Comments ---
|
|
@@ -480,7 +639,7 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
|
|
|
480
639
|
// comments, labels, and history. No parent rollback runs.
|
|
481
640
|
func (s *system) ResetForRecovery(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
|
|
482
641
|
for _, mb := range mailboxes {
|
|
483
|
-
if err := s.transition(ctx, mb.Key, "To Do"); err != nil {
|
|
642
|
+
if err := s.transition(ctx, mb.Key, "To Do", ""); err != nil {
|
|
484
643
|
return fmt.Errorf("reset mailbox %s: %w", mb.Key, err)
|
|
485
644
|
}
|
|
486
645
|
}
|
|
@@ -496,6 +655,15 @@ func contains(xs []string, want string) bool {
|
|
|
496
655
|
return false
|
|
497
656
|
}
|
|
498
657
|
|
|
658
|
+
func containsFold(xs []string, want string) bool {
|
|
659
|
+
for _, x := range xs {
|
|
660
|
+
if strings.EqualFold(x, want) {
|
|
661
|
+
return true
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
|
|
499
667
|
func strField(fields map[string]any, key string) string {
|
|
500
668
|
s, _ := fields[key].(string)
|
|
501
669
|
return s
|
|
@@ -505,3 +673,8 @@ func strSliceField(fields map[string]any, key string) []string {
|
|
|
505
673
|
out, _ := fields[key].([]string)
|
|
506
674
|
return out
|
|
507
675
|
}
|
|
676
|
+
|
|
677
|
+
var (
|
|
678
|
+
_ task.System = (*system)(nil)
|
|
679
|
+
_ task.LifecycleDefaults = (*system)(nil)
|
|
680
|
+
)
|