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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/charmbracelet/huh"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// loadRepoRegistration asks the selected task plugin for the fields needed by
|
|
17
|
+
// registration. A second pass lets a plugin expose dependent choices after a
|
|
18
|
+
// value such as a project has been supplied.
|
|
19
|
+
func loadRepoRegistration(ctx context.Context, c *server.Client, supplied kvFlags) (task.Registration, error) {
|
|
20
|
+
initial, err := c.RepoRegistrationFields(ctx, nil)
|
|
21
|
+
if err != nil {
|
|
22
|
+
return task.Registration{}, err
|
|
23
|
+
}
|
|
24
|
+
if len(supplied) == 0 {
|
|
25
|
+
return initial, nil
|
|
26
|
+
}
|
|
27
|
+
dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(supplied))
|
|
28
|
+
if err != nil {
|
|
29
|
+
return task.Registration{}, err
|
|
30
|
+
}
|
|
31
|
+
return mergeRegistration(initial, dependent), nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func flatRegistrationValues(values kvFlags) config.RawValues {
|
|
35
|
+
out := make(config.RawValues, len(values))
|
|
36
|
+
for key, value := range values {
|
|
37
|
+
out[key] = value
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func mergeRegistration(first, second task.Registration) task.Registration {
|
|
43
|
+
fields := make([]task.RegistrationField, 0, len(first.Fields)+len(second.Fields))
|
|
44
|
+
positions := map[string]int{}
|
|
45
|
+
for _, field := range append(append([]task.RegistrationField(nil), first.Fields...), second.Fields...) {
|
|
46
|
+
if index, ok := positions[field.Key]; ok {
|
|
47
|
+
fields[index] = field
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
positions[field.Key] = len(fields)
|
|
51
|
+
fields = append(fields, field)
|
|
52
|
+
}
|
|
53
|
+
return task.Registration{Fields: fields}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// promptRepoRegistration renders only metadata returned by the task plugin.
|
|
57
|
+
// The core CLI does not know which values are Jira statuses or how they are
|
|
58
|
+
// validated.
|
|
59
|
+
func promptRepoRegistration(reg task.Registration, values kvFlags) error {
|
|
60
|
+
for _, field := range reg.Fields {
|
|
61
|
+
if field.Derived || values[field.Key] != "" {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
title := field.Title
|
|
65
|
+
if title == "" {
|
|
66
|
+
title = field.Key
|
|
67
|
+
}
|
|
68
|
+
if len(field.Options) > 0 {
|
|
69
|
+
selected := field.Default
|
|
70
|
+
if err := huh.NewForm(huh.NewGroup(
|
|
71
|
+
huh.NewSelect[string]().Title(title).Options(registrationSelectOptions(field)...).Value(&selected),
|
|
72
|
+
)).Run(); err != nil {
|
|
73
|
+
return err
|
|
74
|
+
}
|
|
75
|
+
if strings.TrimSpace(selected) == "" {
|
|
76
|
+
return fmt.Errorf("task field %q requires a value", field.Key)
|
|
77
|
+
}
|
|
78
|
+
values[field.Key] = selected
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
value := field.Default
|
|
82
|
+
input := huh.NewInput().Title(title).Value(&value).Validate(func(value string) error {
|
|
83
|
+
if strings.TrimSpace(value) == "" {
|
|
84
|
+
return fmt.Errorf("%s is required", title)
|
|
85
|
+
}
|
|
86
|
+
return nil
|
|
87
|
+
})
|
|
88
|
+
if err := huh.NewForm(huh.NewGroup(input)).Run(); err != nil {
|
|
89
|
+
return err
|
|
90
|
+
}
|
|
91
|
+
values[field.Key] = value
|
|
92
|
+
}
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func registrationSelectOptions(field task.RegistrationField) []huh.Option[string] {
|
|
97
|
+
options := make([]huh.Option[string], 0, len(field.Options)+1)
|
|
98
|
+
if field.Default == "" {
|
|
99
|
+
// Huh selects the first option when Enter is pressed. Keep an explicit
|
|
100
|
+
// empty choice first so a missing conventional default cannot silently
|
|
101
|
+
// become the first real Jira status.
|
|
102
|
+
options = append(options, huh.NewOption("Select a value...", ""))
|
|
103
|
+
}
|
|
104
|
+
for _, option := range field.Options {
|
|
105
|
+
options = append(options, huh.NewOption(option, option))
|
|
106
|
+
}
|
|
107
|
+
return options
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func registrationTaskConfig(reg task.Registration, supplied kvFlags, repoName string) (config.RawValues, error) {
|
|
111
|
+
known := make(map[string]task.RegistrationField, len(reg.Fields))
|
|
112
|
+
for _, field := range reg.Fields {
|
|
113
|
+
if strings.TrimSpace(field.Key) == "" {
|
|
114
|
+
return nil, errorsForRegistration("task plugin returned an empty registration key")
|
|
115
|
+
}
|
|
116
|
+
if _, exists := known[field.Key]; exists {
|
|
117
|
+
return nil, errorsForRegistration(fmt.Sprintf("duplicate task registration key %q", field.Key))
|
|
118
|
+
}
|
|
119
|
+
known[field.Key] = field
|
|
120
|
+
}
|
|
121
|
+
out := config.RawValues{}
|
|
122
|
+
for key, value := range supplied {
|
|
123
|
+
field, ok := known[key]
|
|
124
|
+
if !ok {
|
|
125
|
+
return nil, errorsForRegistration(fmt.Sprintf("unknown task key %q", key))
|
|
126
|
+
}
|
|
127
|
+
if field.Derived {
|
|
128
|
+
return nil, errorsForRegistration(fmt.Sprintf("task key %q is derived and cannot be overridden", key))
|
|
129
|
+
}
|
|
130
|
+
if strings.TrimSpace(value) == "" {
|
|
131
|
+
return nil, errorsForRegistration(fmt.Sprintf("task key %q requires a non-empty value", key))
|
|
132
|
+
}
|
|
133
|
+
if len(field.Options) > 0 && !containsRegistrationOption(field.Options, value) {
|
|
134
|
+
return nil, errorsForRegistration(fmt.Sprintf("task key %q value %q is not one of the available choices", key, value))
|
|
135
|
+
}
|
|
136
|
+
if err := setRegistrationValue(out, key, value); err != nil {
|
|
137
|
+
return nil, err
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for _, field := range reg.Fields {
|
|
141
|
+
if field.Derived {
|
|
142
|
+
if err := setRegistrationValue(out, field.Key, repoName); err != nil {
|
|
143
|
+
return nil, err
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if registrationValue(out, field.Key) == "" {
|
|
148
|
+
return nil, errorsForRegistration(fmt.Sprintf("missing required task key %q", field.Key))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out, nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func errorsForRegistration(message string) error { return fmt.Errorf("%s", message) }
|
|
155
|
+
|
|
156
|
+
func containsRegistrationOption(options []string, value string) bool {
|
|
157
|
+
for _, option := range options {
|
|
158
|
+
if option == value {
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func registrationValue(values config.RawValues, key string) string {
|
|
166
|
+
parts := strings.Split(key, ".")
|
|
167
|
+
var current any = map[string]any(values)
|
|
168
|
+
for _, part := range parts {
|
|
169
|
+
var next map[string]any
|
|
170
|
+
switch value := current.(type) {
|
|
171
|
+
case map[string]any:
|
|
172
|
+
next = value
|
|
173
|
+
case config.RawValues:
|
|
174
|
+
next = map[string]any(value)
|
|
175
|
+
default:
|
|
176
|
+
return ""
|
|
177
|
+
}
|
|
178
|
+
value, ok := next[part]
|
|
179
|
+
if !ok {
|
|
180
|
+
return ""
|
|
181
|
+
}
|
|
182
|
+
current = value
|
|
183
|
+
}
|
|
184
|
+
value, _ := current.(string)
|
|
185
|
+
return strings.TrimSpace(value)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func setRegistrationValue(values config.RawValues, key, value string) error {
|
|
189
|
+
parts := strings.Split(key, ".")
|
|
190
|
+
if len(parts) == 0 || parts[0] == "" {
|
|
191
|
+
return errorsForRegistration("task plugin returned an invalid registration key")
|
|
192
|
+
}
|
|
193
|
+
current := values
|
|
194
|
+
for _, part := range parts[:len(parts)-1] {
|
|
195
|
+
if part == "" {
|
|
196
|
+
return errorsForRegistration(fmt.Sprintf("task plugin returned an invalid registration key %q", key))
|
|
197
|
+
}
|
|
198
|
+
existing, ok := current[part]
|
|
199
|
+
if !ok {
|
|
200
|
+
nested := map[string]any{}
|
|
201
|
+
current[part] = nested
|
|
202
|
+
current = nested
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
var nested map[string]any
|
|
206
|
+
switch typed := existing.(type) {
|
|
207
|
+
case map[string]any:
|
|
208
|
+
nested = typed
|
|
209
|
+
case config.RawValues:
|
|
210
|
+
nested = map[string]any(typed)
|
|
211
|
+
current[part] = nested
|
|
212
|
+
default:
|
|
213
|
+
return errorsForRegistration(fmt.Sprintf("task registration key %q conflicts with another value", key))
|
|
214
|
+
}
|
|
215
|
+
current = nested
|
|
216
|
+
}
|
|
217
|
+
current[parts[len(parts)-1]] = value
|
|
218
|
+
return nil
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func registerSelectedReposDynamic(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, reg task.Registration, values kvFlags) error {
|
|
222
|
+
for _, index := range selected {
|
|
223
|
+
candidate := candidates[index]
|
|
224
|
+
taskCfg, err := registrationTaskConfig(reg, values, candidate.Name)
|
|
225
|
+
if err != nil {
|
|
226
|
+
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
227
|
+
}
|
|
228
|
+
info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: candidate.Name, Path: candidate.Path, TaskConfig: taskCfg})
|
|
229
|
+
if err != nil {
|
|
230
|
+
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
231
|
+
}
|
|
232
|
+
fmt.Println(info.Name)
|
|
233
|
+
}
|
|
234
|
+
return nil
|
|
235
|
+
}
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -565,8 +565,8 @@ func (d *serveDeps) RegisterNodeSession(ctx context.Context, registration runsvc
|
|
|
565
565
|
func (d *serveDeps) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
|
|
566
566
|
return d.repos.Discover(ctx)
|
|
567
567
|
}
|
|
568
|
-
func (d *serveDeps)
|
|
569
|
-
return d.repos.
|
|
568
|
+
func (d *serveDeps) TaskRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
569
|
+
return d.repos.RegistrationFields(ctx, values)
|
|
570
570
|
}
|
|
571
571
|
func (d *serveDeps) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
|
|
572
572
|
info, err := d.repos.Register(ctx, input)
|
|
@@ -101,6 +101,13 @@ repos:
|
|
|
101
101
|
# relay-flow repo name during repo registration.
|
|
102
102
|
project: PAY
|
|
103
103
|
component: payments
|
|
104
|
+
# These must be valid statuses in the PAY project. Interactive
|
|
105
|
+
# registration discovers them; scripted registration passes the same
|
|
106
|
+
# three keys with --set.
|
|
107
|
+
statusDefaults:
|
|
108
|
+
start: To Do
|
|
109
|
+
work: In Progress
|
|
110
|
+
end: Done
|
|
104
111
|
|
|
105
112
|
# Optional repo-level overrides inherit into workflows and nodes.
|
|
106
113
|
# assignee: relay-bot@example.com
|
|
@@ -11,10 +11,11 @@ taskConfig:
|
|
|
11
11
|
- To Do
|
|
12
12
|
issueTypes:
|
|
13
13
|
- Story
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
labels:
|
|
15
|
+
- "workflow:true"
|
|
16
|
+
assignees:
|
|
17
|
+
- currentUser()
|
|
18
|
+
# Jira resolves currentUser() to the authenticated Jira email.
|
|
18
19
|
# assignee: default-node-owner@example.com
|
|
19
20
|
# project: PAY
|
|
20
21
|
# component: api
|
|
@@ -28,48 +29,62 @@ nodes:
|
|
|
28
29
|
transitionTo:
|
|
29
30
|
parentStatus: In Progress
|
|
30
31
|
onSuccess:
|
|
31
|
-
- target:
|
|
32
|
+
- target: coding
|
|
32
33
|
when: The parent story is ready for implementation
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
coding:
|
|
35
36
|
type: agent
|
|
36
37
|
agent: build
|
|
37
|
-
description: Implement the parent story
|
|
38
|
-
|
|
38
|
+
description: Implement the parent story in the current ticket worktree.
|
|
39
|
+
nudgePrompt: |
|
|
40
|
+
Continue working on {{ticket}}. Read the parent {{taskSystem}} ticket
|
|
41
|
+
{{ticket}} and your assigned ticket {{mailbox}} to understand the requirements. Read
|
|
42
|
+
the latest feedback, address the requested changes, and work on the next
|
|
43
|
+
bounded task slice. Return the complete report. Valid choices are:
|
|
44
|
+
{{nextSteps}}.
|
|
39
45
|
taskConfig:
|
|
40
46
|
# assignee: developer@example.com
|
|
41
47
|
transitionTo:
|
|
42
48
|
taskStatus: In Progress
|
|
43
49
|
# parentStatus: In Progress
|
|
44
50
|
onSuccess:
|
|
45
|
-
- target:
|
|
51
|
+
- target: reviewing
|
|
46
52
|
when: Implementation and verification are complete
|
|
47
53
|
onFailure:
|
|
48
|
-
- target:
|
|
54
|
+
- target: coding
|
|
49
55
|
when: Implementation needs another pass
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
reviewing:
|
|
52
58
|
type: agent
|
|
53
59
|
agent: plan
|
|
54
|
-
description: Review the implementation
|
|
55
|
-
|
|
60
|
+
description: Review the completed implementation and report required changes.
|
|
61
|
+
nudgePrompt: |
|
|
62
|
+
Review {{ticket}}. Read the parent {{taskSystem}} ticket {{ticket}} and
|
|
63
|
+
your assigned ticket {{mailbox}} to understand the requirements. Re-check the
|
|
64
|
+
implementation and latest coding feedback, then return the complete
|
|
65
|
+
report. Valid choices are:
|
|
66
|
+
{{nextSteps}}.
|
|
56
67
|
taskConfig:
|
|
57
68
|
# assignee: reviewer@example.com
|
|
58
69
|
transitionTo:
|
|
59
70
|
taskStatus: In Progress
|
|
60
71
|
# parentStatus: In Review
|
|
61
72
|
onSuccess:
|
|
62
|
-
- target:
|
|
73
|
+
- target: humanReview
|
|
63
74
|
when: The changes are ready for human approval
|
|
64
75
|
onFailure:
|
|
65
|
-
- target:
|
|
76
|
+
- target: coding
|
|
66
77
|
when: Code changes are required
|
|
67
78
|
|
|
68
|
-
|
|
79
|
+
humanReview:
|
|
69
80
|
type: hitl
|
|
70
81
|
agent: plan
|
|
71
|
-
description:
|
|
72
|
-
|
|
82
|
+
description: Approve the reviewed implementation or request changes.
|
|
83
|
+
nudgePrompt: |
|
|
84
|
+
Review the completed work for {{ticket}} with the human. Read the parent
|
|
85
|
+
{{taskSystem}} ticket {{ticket}} and your assigned ticket {{mailbox}} to
|
|
86
|
+
understand the requirements. Return the complete report. Valid choices are:
|
|
87
|
+
{{nextSteps}}.
|
|
73
88
|
taskConfig:
|
|
74
89
|
# assignee: human-reviewer@example.com
|
|
75
90
|
transitionTo:
|
|
@@ -79,7 +94,7 @@ nodes:
|
|
|
79
94
|
- target: end
|
|
80
95
|
when: The human approves the review
|
|
81
96
|
onFailure:
|
|
82
|
-
- target:
|
|
97
|
+
- target: coding
|
|
83
98
|
when: The human requests code changes
|
|
84
99
|
|
|
85
100
|
end:
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.2.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.8-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
package/internal/repo/service.go
CHANGED
|
@@ -79,6 +79,12 @@ func (s *Service) RequiredRepoKeys() []string {
|
|
|
79
79
|
return keys
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// RegistrationFields returns task-plugin-owned registration prompts and
|
|
83
|
+
// choices. Core repo management does not interpret the returned keys.
|
|
84
|
+
func (s *Service) RegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
85
|
+
return task.RegistrationFields(ctx, s.taskPlugin, values)
|
|
86
|
+
}
|
|
87
|
+
|
|
82
88
|
type RegisterInput struct {
|
|
83
89
|
Name string
|
|
84
90
|
Path string
|
|
@@ -116,6 +122,13 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
|
|
|
116
122
|
return Info{}, fmt.Errorf("repo %q: required task config key %q is missing", input.Name, k)
|
|
117
123
|
}
|
|
118
124
|
}
|
|
125
|
+
if err := task.ValidateRegistration(ctx, s.taskPlugin, task.RepoRegistrationSpec{
|
|
126
|
+
Name: input.Name,
|
|
127
|
+
RootConfig: cfg.TaskConfig,
|
|
128
|
+
RepoConfig: input.TaskConfig,
|
|
129
|
+
}); err != nil {
|
|
130
|
+
return Info{}, fmt.Errorf("repo %q: %w", input.Name, err)
|
|
131
|
+
}
|
|
119
132
|
// Duplicate registration identity: compute this repo's identity and compare
|
|
120
133
|
// against every registered repo. Most plugins use their physical task scope;
|
|
121
134
|
// Beads includes its derived repository label so distinct repositories may
|
|
@@ -264,7 +264,10 @@ func (errInvalidRepo) Error() string { return "invalid repo" }
|
|
|
264
264
|
|
|
265
265
|
func TestRegisterAtomicallyPersists(t *testing.T) {
|
|
266
266
|
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
267
|
-
|
|
267
|
+
statusDefaults := map[string]any{"start": "Open", "work": "Working", "end": "Closed"}
|
|
268
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{
|
|
269
|
+
"project": "PAY", "component": "api", "statusDefaults": statusDefaults,
|
|
270
|
+
}}); err != nil {
|
|
268
271
|
t.Fatal(err)
|
|
269
272
|
}
|
|
270
273
|
cfg, err := config.LoadMachine(fx.cfgPath)
|
|
@@ -278,6 +281,16 @@ func TestRegisterAtomicallyPersists(t *testing.T) {
|
|
|
278
281
|
if r.TaskConfig["project"] != "PAY" {
|
|
279
282
|
t.Fatalf("repo taskConfig not persisted: %+v", r.TaskConfig)
|
|
280
283
|
}
|
|
284
|
+
var gotDefaults map[string]any
|
|
285
|
+
switch values := r.TaskConfig["statusDefaults"].(type) {
|
|
286
|
+
case map[string]any:
|
|
287
|
+
gotDefaults = values
|
|
288
|
+
case config.RawValues:
|
|
289
|
+
gotDefaults = map[string]any(values)
|
|
290
|
+
}
|
|
291
|
+
if gotDefaults == nil || gotDefaults["start"] != "Open" || gotDefaults["work"] != "Working" || gotDefaults["end"] != "Closed" {
|
|
292
|
+
t.Fatalf("repo statusDefaults not persisted: %#v", r.TaskConfig["statusDefaults"])
|
|
293
|
+
}
|
|
281
294
|
}
|
|
282
295
|
|
|
283
296
|
func TestRegisterSetsUpHarnessRepo(t *testing.T) {
|
|
@@ -2,15 +2,19 @@ package server_test
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
+
"context"
|
|
5
6
|
"encoding/json"
|
|
6
7
|
"fmt"
|
|
7
8
|
"io"
|
|
8
9
|
"net/http"
|
|
10
|
+
"path/filepath"
|
|
9
11
|
"strings"
|
|
10
12
|
"testing"
|
|
11
13
|
"time"
|
|
12
14
|
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
16
|
"github.com/rajpopat27/relay-flow/internal/run"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
14
18
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
19
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
16
20
|
)
|
|
@@ -287,6 +291,48 @@ func TestRunEndpointsExposeRetryDetails(t *testing.T) {
|
|
|
287
291
|
}
|
|
288
292
|
}
|
|
289
293
|
|
|
294
|
+
func TestRepoTaskFieldsSupportsInitialGetAndDependentPost(t *testing.T) {
|
|
295
|
+
fake := &fakeServices{}
|
|
296
|
+
c, cleanup := startHandler(t, fake)
|
|
297
|
+
defer cleanup()
|
|
298
|
+
|
|
299
|
+
code, env := do(t, c, http.MethodGet, "http://relay/repos/task-fields", nil)
|
|
300
|
+
if code != http.StatusOK || !env.OK || !bytes.Contains(env.Data, []byte(`"fields"`)) {
|
|
301
|
+
t.Fatalf("GET /repos/task-fields: code=%d env=%+v", code, env)
|
|
302
|
+
}
|
|
303
|
+
code, env = do(t, c, http.MethodPost, "http://relay/repos/task-fields", []byte(`{"values":{"project":"PAY"}}`))
|
|
304
|
+
if code != http.StatusOK || !env.OK || len(fake.registrationValues) != 2 {
|
|
305
|
+
t.Fatalf("POST /repos/task-fields: code=%d env=%+v values=%v", code, env, fake.registrationValues)
|
|
306
|
+
}
|
|
307
|
+
if fake.registrationValues[1]["project"] != "PAY" {
|
|
308
|
+
t.Fatalf("dependent registration values = %#v", fake.registrationValues[1])
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func TestClientRepoRegistrationFieldsUsesGetThenPost(t *testing.T) {
|
|
313
|
+
fake := &fakeServices{}
|
|
314
|
+
dir := t.TempDir()
|
|
315
|
+
_, cleanup := startHandlerOnSocket(t, dir, fake)
|
|
316
|
+
defer cleanup()
|
|
317
|
+
client := server.NewClient(filepath.Join(dir, "server.sock"))
|
|
318
|
+
keys, err := client.RepoTaskFields(context.Background())
|
|
319
|
+
if err != nil || len(keys) != 2 || keys[0] != "project" || keys[1] != "component" {
|
|
320
|
+
t.Fatalf("documented task fields = %v, err=%v", keys, err)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
initial, err := client.RepoRegistrationFields(context.Background(), nil)
|
|
324
|
+
if err != nil || len(initial.Fields) != 2 {
|
|
325
|
+
t.Fatalf("initial registration fields = %#v, err=%v", initial, err)
|
|
326
|
+
}
|
|
327
|
+
dependent, err := client.RepoRegistrationFields(context.Background(), config.RawValues{"project": "PAY"})
|
|
328
|
+
if err != nil || len(dependent.Fields) != 2 {
|
|
329
|
+
t.Fatalf("dependent registration fields = %#v, err=%v", dependent, err)
|
|
330
|
+
}
|
|
331
|
+
if len(fake.registrationValues) != 3 || fake.registrationValues[0] != nil || fake.registrationValues[1] != nil || fake.registrationValues[2]["project"] != "PAY" {
|
|
332
|
+
t.Fatalf("registration request values = %#v", fake.registrationValues)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
290
336
|
func TestRepoOperations(t *testing.T) {
|
|
291
337
|
c, cleanup := startHandler(t, &fakeServices{})
|
|
292
338
|
defer cleanup()
|
|
@@ -10,9 +10,11 @@ import (
|
|
|
10
10
|
"net/http"
|
|
11
11
|
"net/url"
|
|
12
12
|
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
14
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
14
15
|
"github.com/rajpopat27/relay-flow/internal/run"
|
|
15
16
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
16
18
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
17
19
|
)
|
|
18
20
|
|
|
@@ -134,8 +136,7 @@ func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, err
|
|
|
134
136
|
return out, nil
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
// RepoTaskFields returns the
|
|
138
|
-
// GET /repos/task-fields (server wraps the list as {"fields": [...]}).
|
|
139
|
+
// RepoTaskFields returns the documented initial required-key metadata.
|
|
139
140
|
func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
|
|
140
141
|
var out struct {
|
|
141
142
|
Fields []string `json:"fields"`
|
|
@@ -146,6 +147,33 @@ func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
|
|
|
146
147
|
return out.Fields, nil
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// RepoRegistrationFields returns task-plugin-owned repository prompts. Values
|
|
151
|
+
// are flat registration inputs so plugins can discover dependent choices (for
|
|
152
|
+
// example project statuses after project is selected). Initial discovery uses
|
|
153
|
+
// the documented GET endpoint; dependent discovery uses POST.
|
|
154
|
+
func (c *Client) RepoRegistrationFields(ctx context.Context, values config.RawValues) (task.Registration, error) {
|
|
155
|
+
if len(values) == 0 {
|
|
156
|
+
var out struct {
|
|
157
|
+
Registration task.Registration `json:"registration"`
|
|
158
|
+
}
|
|
159
|
+
if err := c.call(ctx, http.MethodGet, "/repos/task-fields", nil, &out); err != nil {
|
|
160
|
+
return task.Registration{}, err
|
|
161
|
+
}
|
|
162
|
+
return out.Registration, nil
|
|
163
|
+
}
|
|
164
|
+
payload, err := json.Marshal(struct {
|
|
165
|
+
Values config.RawValues `json:"values,omitempty"`
|
|
166
|
+
}{Values: values})
|
|
167
|
+
if err != nil {
|
|
168
|
+
return task.Registration{}, err
|
|
169
|
+
}
|
|
170
|
+
var out task.Registration
|
|
171
|
+
if err := c.call(ctx, http.MethodPost, "/repos/task-fields", payload, &out); err != nil {
|
|
172
|
+
return task.Registration{}, err
|
|
173
|
+
}
|
|
174
|
+
return out, nil
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
// RegisterRepo registers a repo by name/path with optional task config.
|
|
150
178
|
func (c *Client) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
|
|
151
179
|
payload, _ := json.Marshal(input)
|
|
@@ -12,10 +12,12 @@ import (
|
|
|
12
12
|
"path/filepath"
|
|
13
13
|
"testing"
|
|
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"
|
|
18
19
|
"github.com/rajpopat27/relay-flow/internal/server"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
19
21
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
20
22
|
)
|
|
21
23
|
|
|
@@ -23,20 +25,21 @@ import (
|
|
|
23
25
|
// seams. activeWorkflows seeds workflows with an active run (drives 409);
|
|
24
26
|
// failRepos forces a 500; slowReport simulates a long-running report call.
|
|
25
27
|
type fakeServices struct {
|
|
26
|
-
activeWorkflows
|
|
27
|
-
failRepos
|
|
28
|
-
slowReport
|
|
29
|
-
workflows
|
|
30
|
-
repos
|
|
31
|
-
runs
|
|
32
|
-
shutdownCh
|
|
33
|
-
registrations
|
|
34
|
-
runtimeAck
|
|
35
|
-
processedReports
|
|
36
|
-
submittedReports
|
|
37
|
-
restartRun
|
|
38
|
-
restartErr
|
|
39
|
-
restarts
|
|
28
|
+
activeWorkflows map[string]bool
|
|
29
|
+
failRepos bool
|
|
30
|
+
slowReport chan struct{}
|
|
31
|
+
workflows map[string]*workflow.Workflow
|
|
32
|
+
repos map[string]repo.Info
|
|
33
|
+
runs []run.Run
|
|
34
|
+
shutdownCh chan struct{}
|
|
35
|
+
registrations []run.NodeRuntimeRegistration
|
|
36
|
+
runtimeAck run.NodeRuntimeRegistrationAck
|
|
37
|
+
processedReports map[string]bool
|
|
38
|
+
submittedReports int
|
|
39
|
+
restartRun run.Run
|
|
40
|
+
restartErr error
|
|
41
|
+
restarts []string
|
|
42
|
+
registrationValues []config.RawValues
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
|
|
@@ -152,8 +155,9 @@ func (f *fakeServices) DiscoverRepos(context.Context) ([]runner.RepoCandidate, e
|
|
|
152
155
|
return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
func (f *fakeServices)
|
|
156
|
-
|
|
158
|
+
func (f *fakeServices) TaskRegistrationFields(_ context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
159
|
+
f.registrationValues = append(f.registrationValues, values)
|
|
160
|
+
return []task.RegistrationField{{Key: "project", Title: "Project"}, {Key: "component", Title: "Component", Derived: true}}, nil
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
func (f *fakeServices) RegisterRepo(_ context.Context, input repo.RegisterInput) (repo.Info, error) {
|