relay-flow 0.2.7-alpha → 0.2.9-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -37
- package/cmd/relay-flow/commands_test.go +199 -19
- package/cmd/relay-flow/main.go +183 -144
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/repo_registration.go +454 -0
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/config-reference.yaml +7 -0
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +47 -3
- package/internal/repo/service_test.go +36 -1
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +65 -0
- package/internal/server/client.go +40 -2
- package/internal/server/fixture_test.go +27 -16
- package/internal/server/server.go +80 -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/testdata/jira_search_issues.json +4 -4
- 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/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
|
@@ -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"}},
|
|
@@ -35,6 +35,14 @@ type CreatedSubtask struct {
|
|
|
35
35
|
Key string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// StatusProvider discovers the actual statuses configured for a Jira project.
|
|
39
|
+
// It is intentionally separate from Client so existing adapter test doubles
|
|
40
|
+
// and replaceable REST clients do not need provider-specific discovery unless
|
|
41
|
+
// registration needs it.
|
|
42
|
+
type StatusProvider interface {
|
|
43
|
+
Statuses(context.Context, string) ([]string, error)
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
type Client interface {
|
|
39
47
|
ValidateCredentials(context.Context) error
|
|
40
48
|
Search(context.Context, string) ([]byte, error)
|
|
@@ -70,6 +78,7 @@ type HTTPClient struct {
|
|
|
70
78
|
transitions map[string]transitionInfo
|
|
71
79
|
accounts map[string]string
|
|
72
80
|
statuses map[string]map[string]bool
|
|
81
|
+
statusNames map[string][]string
|
|
73
82
|
subtaskTypes map[string]string
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -97,6 +106,7 @@ func New(site, email, token string) (*HTTPClient, error) {
|
|
|
97
106
|
transitions: map[string]transitionInfo{},
|
|
98
107
|
accounts: map[string]string{},
|
|
99
108
|
statuses: map[string]map[string]bool{},
|
|
109
|
+
statusNames: map[string][]string{},
|
|
100
110
|
subtaskTypes: map[string]string{},
|
|
101
111
|
}, nil
|
|
102
112
|
}
|
|
@@ -186,43 +196,72 @@ func (c *HTTPClient) accountID(ctx context.Context, project, assignee string) (s
|
|
|
186
196
|
return "", fmt.Errorf("assignee %q is not assignable in project %s", assignee, project)
|
|
187
197
|
}
|
|
188
198
|
|
|
199
|
+
// Statuses returns the status names configured for all issue types in a Jira
|
|
200
|
+
// project, preserving Jira's response order and removing case-insensitive
|
|
201
|
+
// duplicates. The same discovery populates the subtask issue-type cache used
|
|
202
|
+
// when mailboxes are created.
|
|
203
|
+
func (c *HTTPClient) Statuses(ctx context.Context, project string) ([]string, error) {
|
|
204
|
+
_, names, err := c.loadStatuses(ctx, project)
|
|
205
|
+
if err != nil {
|
|
206
|
+
return nil, err
|
|
207
|
+
}
|
|
208
|
+
return append([]string(nil), names...), nil
|
|
209
|
+
}
|
|
210
|
+
|
|
189
211
|
func (c *HTTPClient) ValidateStatus(ctx context.Context, project, status string) error {
|
|
212
|
+
statuses, _, err := c.loadStatuses(ctx, project)
|
|
213
|
+
if err != nil {
|
|
214
|
+
return err
|
|
215
|
+
}
|
|
216
|
+
if !statuses[strings.ToLower(status)] {
|
|
217
|
+
return fmt.Errorf("status %q is not valid in project %s", status, project)
|
|
218
|
+
}
|
|
219
|
+
return nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func (c *HTTPClient) loadStatuses(ctx context.Context, project string) (map[string]bool, []string, error) {
|
|
190
223
|
c.mu.Lock()
|
|
191
224
|
statuses, ok := c.statuses[project]
|
|
225
|
+
names := append([]string(nil), c.statusNames[project]...)
|
|
192
226
|
c.mu.Unlock()
|
|
193
|
-
if
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
227
|
+
if ok {
|
|
228
|
+
return statuses, names, nil
|
|
229
|
+
}
|
|
230
|
+
var issueTypes []struct {
|
|
231
|
+
ID string `json:"id"`
|
|
232
|
+
Subtask bool `json:"subtask"`
|
|
233
|
+
Statuses []struct {
|
|
234
|
+
Name string `json:"name"`
|
|
235
|
+
} `json:"statuses"`
|
|
236
|
+
}
|
|
237
|
+
path := "/rest/api/3/project/" + url.PathEscape(project) + "/statuses"
|
|
238
|
+
if err := c.request(ctx, http.MethodGet, path, nil, nil, &issueTypes, true); err != nil {
|
|
239
|
+
return nil, nil, err
|
|
240
|
+
}
|
|
241
|
+
statuses = map[string]bool{}
|
|
242
|
+
names = make([]string, 0)
|
|
243
|
+
subtaskType := ""
|
|
244
|
+
for _, issueType := range issueTypes {
|
|
245
|
+
if issueType.Subtask && subtaskType == "" {
|
|
246
|
+
subtaskType = issueType.ID
|
|
247
|
+
}
|
|
248
|
+
for _, candidate := range issueType.Statuses {
|
|
249
|
+
name := strings.TrimSpace(candidate.Name)
|
|
250
|
+
if name == "" || statuses[strings.ToLower(name)] {
|
|
251
|
+
continue
|
|
213
252
|
}
|
|
253
|
+
statuses[strings.ToLower(name)] = true
|
|
254
|
+
names = append(names, name)
|
|
214
255
|
}
|
|
215
|
-
c.mu.Lock()
|
|
216
|
-
c.statuses[project] = statuses
|
|
217
|
-
if subtaskType != "" {
|
|
218
|
-
c.subtaskTypes[project] = subtaskType
|
|
219
|
-
}
|
|
220
|
-
c.mu.Unlock()
|
|
221
256
|
}
|
|
222
|
-
|
|
223
|
-
|
|
257
|
+
c.mu.Lock()
|
|
258
|
+
c.statuses[project] = statuses
|
|
259
|
+
c.statusNames[project] = append([]string(nil), names...)
|
|
260
|
+
if subtaskType != "" {
|
|
261
|
+
c.subtaskTypes[project] = subtaskType
|
|
224
262
|
}
|
|
225
|
-
|
|
263
|
+
c.mu.Unlock()
|
|
264
|
+
return statuses, names, nil
|
|
226
265
|
}
|
|
227
266
|
|
|
228
267
|
func (c *HTTPClient) View(ctx context.Context, key string) ([]byte, error) {
|
|
@@ -251,7 +290,7 @@ func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label
|
|
|
251
290
|
subtaskType := c.subtaskTypes[project]
|
|
252
291
|
c.mu.Unlock()
|
|
253
292
|
if subtaskType == "" {
|
|
254
|
-
if err := c.
|
|
293
|
+
if _, _, err := c.loadStatuses(ctx, project); err != nil {
|
|
255
294
|
return nil, err
|
|
256
295
|
}
|
|
257
296
|
c.mu.Lock()
|
|
@@ -290,7 +329,10 @@ func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label
|
|
|
290
329
|
}
|
|
291
330
|
created = append(created, result.Issues...)
|
|
292
331
|
for _, issue := range result.Issues {
|
|
293
|
-
|
|
332
|
+
// Jira chooses the subtask's initial status from the project
|
|
333
|
+
// workflow. Leave the status unknown so the first transition reads
|
|
334
|
+
// the actual state instead of assuming a conventional name.
|
|
335
|
+
c.setIssueState(issue.Key, "Sub-task", "")
|
|
294
336
|
}
|
|
295
337
|
}
|
|
296
338
|
return created, nil
|
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"io"
|
|
8
8
|
"net/http"
|
|
9
9
|
"net/http/httptest"
|
|
10
|
+
"reflect"
|
|
10
11
|
"strings"
|
|
11
12
|
"sync"
|
|
12
13
|
"testing"
|
|
@@ -93,6 +94,30 @@ func TestNewRejectsNonHTTPSRemoteSite(t *testing.T) {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
func TestStatusesDiscoversProjectWorkflowStatuses(t *testing.T) {
|
|
98
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
99
|
+
if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/project/PAY/statuses" {
|
|
100
|
+
http.NotFound(w, r)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
writeJSON(w, []any{
|
|
104
|
+
map[string]any{"id": "100", "subtask": true, "statuses": []any{
|
|
105
|
+
map[string]any{"name": "Open"},
|
|
106
|
+
map[string]any{"name": "Working"},
|
|
107
|
+
map[string]any{"name": "Closed"},
|
|
108
|
+
}},
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
statuses, err := s.client(t).Statuses(context.Background(), "PAY")
|
|
112
|
+
if err != nil {
|
|
113
|
+
t.Fatal(err)
|
|
114
|
+
}
|
|
115
|
+
want := []string{"Open", "Working", "Closed"}
|
|
116
|
+
if !reflect.DeepEqual(statuses, want) {
|
|
117
|
+
t.Fatalf("statuses = %v, want %v", statuses, want)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
func TestSearchPaginatesAndRequestsIssueLinks(t *testing.T) {
|
|
97
122
|
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
98
123
|
if r.URL.Path != "/rest/api/3/search/jql" || r.Method != http.MethodPost {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"reflect"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestStatusRegistrationDefaultsOnlySelectAvailableConventions(t *testing.T) {
|
|
14
|
+
withConventions := statusRegistrationField("statusDefaults.start", "Start", []string{"Open", "In Progress", "Closed"}, defaultStartParentStatus)
|
|
15
|
+
if withConventions.Default != defaultStartParentStatus {
|
|
16
|
+
t.Fatalf("default = %q, want %q", withConventions.Default, defaultStartParentStatus)
|
|
17
|
+
}
|
|
18
|
+
withoutConventions := statusRegistrationField("statusDefaults.end", "End", []string{"Open", "Working", "Closed"}, defaultEndParentStatus)
|
|
19
|
+
if withoutConventions.Default != "" {
|
|
20
|
+
t.Fatalf("missing conventional default = %q, want empty", withoutConventions.Default)
|
|
21
|
+
}
|
|
22
|
+
if !reflect.DeepEqual(withoutConventions.Options, []string{"Open", "Working", "Closed"}) {
|
|
23
|
+
t.Fatalf("options = %v", withoutConventions.Options)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestRepositoryStatusDefaultsDriveLifecycleAndMailboxCompletion(t *testing.T) {
|
|
28
|
+
fake := &fakeJira{}
|
|
29
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
|
|
30
|
+
Name: "payments",
|
|
31
|
+
RepoConfig: config.RawValues{
|
|
32
|
+
"project": "PAY",
|
|
33
|
+
"component": "api",
|
|
34
|
+
"statusDefaults": map[string]any{
|
|
35
|
+
"start": "Open",
|
|
36
|
+
"work": "Working",
|
|
37
|
+
"end": "Closed",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
if err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
defaults := task.LifecycleDefaults(sys)
|
|
45
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
46
|
+
mailbox := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
47
|
+
|
|
48
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, defaults.StartDefaults()); err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mailbox}, defaults.WorkDefaults()); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
if err := sys.CompleteMailbox(context.Background(), mailbox); err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Open" {
|
|
58
|
+
t.Fatalf("parent transitions = %v, want [Open]", fake.parentTransitions)
|
|
59
|
+
}
|
|
60
|
+
if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "Working" || fake.taskTransitions[1] != "Closed" {
|
|
61
|
+
t.Fatalf("mailbox transitions = %v, want [Working Closed]", fake.taskTransitions)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestJiraRegistrationRejectsComponentOverride(t *testing.T) {
|
|
66
|
+
err := validateJiraRegistration(context.Background(), task.RepoRegistrationSpec{
|
|
67
|
+
Name: "payments",
|
|
68
|
+
RepoConfig: config.RawValues{
|
|
69
|
+
"project": "PAY",
|
|
70
|
+
"component": "other-component",
|
|
71
|
+
"statusDefaults": map[string]any{
|
|
72
|
+
"start": "Open", "work": "Working", "end": "Closed",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
if err == nil || !strings.Contains(err.Error(), "must match repository name") {
|
|
77
|
+
t.Fatalf("component override error = %v", err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func TestJiraConstructionRejectsRootStatusDefaults(t *testing.T) {
|
|
82
|
+
_, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
|
|
83
|
+
Name: "payments",
|
|
84
|
+
RootConfig: config.RawValues{"statusDefaults": map[string]any{}},
|
|
85
|
+
RepoConfig: testRepoConfig(nil),
|
|
86
|
+
})
|
|
87
|
+
if err == nil || !strings.Contains(err.Error(), "configured per repository") {
|
|
88
|
+
t.Fatalf("root statusDefaults error = %v", err)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestJiraValidateConfigRejectsNonRepositoryStatusDefaults(t *testing.T) {
|
|
93
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
94
|
+
for _, tc := range []struct {
|
|
95
|
+
name string
|
|
96
|
+
node bool
|
|
97
|
+
}{
|
|
98
|
+
{name: "workflow"},
|
|
99
|
+
{name: "node", node: true},
|
|
100
|
+
} {
|
|
101
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
102
|
+
workflowConfig := config.RawValues{}
|
|
103
|
+
nodeConfig := map[string]config.RawValues{}
|
|
104
|
+
if tc.node {
|
|
105
|
+
nodeConfig["coding"] = config.RawValues{"statusDefaults": map[string]any{}}
|
|
106
|
+
} else {
|
|
107
|
+
workflowConfig["statusDefaults"] = map[string]any{}
|
|
108
|
+
}
|
|
109
|
+
err := sys.ValidateConfig(context.Background(), workflowConfig, nodeConfig)
|
|
110
|
+
if err == nil || !strings.Contains(err.Error(), "configured per repository") {
|
|
111
|
+
t.Fatalf("validation error = %v", err)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func TestJiraConstructionRequiresCompleteRepositoryStatusDefaults(t *testing.T) {
|
|
118
|
+
for _, repoConfig := range []config.RawValues{
|
|
119
|
+
{"project": "PAY", "component": "api"},
|
|
120
|
+
{"project": "PAY", "component": "api", "statusDefaults": map[string]any{}},
|
|
121
|
+
{"project": "PAY", "component": "api", "statusDefaults": map[string]any{
|
|
122
|
+
"start": "Open", "work": "Working",
|
|
123
|
+
}},
|
|
124
|
+
} {
|
|
125
|
+
client := &validationClient{}
|
|
126
|
+
_, err := newSystem(context.Background(), client, task.RepoSpec{Name: "payments", RepoConfig: repoConfig})
|
|
127
|
+
if err == nil {
|
|
128
|
+
t.Fatalf("repo config %#v was accepted without complete status defaults", repoConfig)
|
|
129
|
+
}
|
|
130
|
+
if len(client.statuses) != 0 {
|
|
131
|
+
t.Fatalf("status probes = %v for invalid repository config, want none", client.statuses)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -21,7 +21,7 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
|
|
|
21
21
|
"summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
|
|
22
22
|
"feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
|
|
23
23
|
}},
|
|
24
|
-
RepoConfig:
|
|
24
|
+
RepoConfig: testRepoConfig(nil),
|
|
25
25
|
})
|
|
26
26
|
if err != nil {
|
|
27
27
|
t.Fatal(err)
|
|
@@ -91,7 +91,7 @@ func TestTaskTemplatesRenderSharedReportContractValues(t *testing.T) {
|
|
|
91
91
|
}
|
|
92
92
|
fixture := fixtures["work"]
|
|
93
93
|
sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
|
|
94
|
-
Name: "payments", RepoConfig:
|
|
94
|
+
Name: "payments", RepoConfig: testRepoConfig(nil),
|
|
95
95
|
})
|
|
96
96
|
if err != nil {
|
|
97
97
|
t.Fatal(err)
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
|
|
16
16
|
},
|
|
17
17
|
"displayName": "Raj Popat",
|
|
18
|
-
"emailAddress": "
|
|
18
|
+
"emailAddress": "test.user@example.com",
|
|
19
19
|
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
20
20
|
"timeZone": "America/New_York"
|
|
21
21
|
},
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"fieldsToInclude": null,
|
|
50
50
|
"id": "157320",
|
|
51
|
-
"key": "
|
|
51
|
+
"key": "DEMO-41242",
|
|
52
52
|
"names": null,
|
|
53
53
|
"operations": null,
|
|
54
54
|
"properties": null,
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
|
|
75
75
|
},
|
|
76
76
|
"displayName": "Raj Popat",
|
|
77
|
-
"emailAddress": "
|
|
77
|
+
"emailAddress": "test.user@example.com",
|
|
78
78
|
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
79
79
|
"timeZone": "America/New_York"
|
|
80
80
|
},
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
"fieldsToInclude": null,
|
|
109
109
|
"id": "155110",
|
|
110
|
-
"key": "
|
|
110
|
+
"key": "DEMO-41175",
|
|
111
111
|
"names": null,
|
|
112
112
|
"operations": null,
|
|
113
113
|
"properties": null,
|
|
@@ -10,11 +10,9 @@ import (
|
|
|
10
10
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
11
|
)
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// nodes, and parent Done at end; an omitted work-node parent transition
|
|
17
|
-
// leaves the parent unchanged.
|
|
13
|
+
// Jira repository status defaults are deterministic: omitted transitions use
|
|
14
|
+
// the registered start/work/end values, and an omitted work-node parent
|
|
15
|
+
// transition leaves the parent unchanged.
|
|
18
16
|
//
|
|
19
17
|
// This test is package-local (package jira) so it can drive the adapter's
|
|
20
18
|
// ApplyTaskConfig against a test-local fake client without inventing an
|
|
@@ -128,10 +126,8 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
128
126
|
sys := newSystemWithFake(t, fake)
|
|
129
127
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
130
128
|
|
|
131
|
-
// end: omitted transitionTo.
|
|
132
|
-
//
|
|
133
|
-
// orchestration merges EndDefaults under the end node config before
|
|
134
|
-
// applying it to the parent target.
|
|
129
|
+
// end: omitted transitionTo. Run orchestration merges the registered
|
|
130
|
+
// EndDefaults under the end node config before applying it to the parent.
|
|
135
131
|
endCfg := config.Merge(sys.(task.LifecycleDefaults).EndDefaults(), config.RawValues{})
|
|
136
132
|
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endCfg); err != nil {
|
|
137
133
|
t.Fatalf("ApplyTaskConfig end failed: %v", err)
|
|
@@ -156,8 +152,8 @@ func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
|
|
|
156
152
|
if err := preparer.PrepareRestart(context.Background(), parent, mailboxes); err != nil {
|
|
157
153
|
t.Fatalf("PrepareRestart failed: %v", err)
|
|
158
154
|
}
|
|
159
|
-
if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] !=
|
|
160
|
-
t.Fatalf("mailbox transitions = %v, want two
|
|
155
|
+
if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != defaultStartParentStatus || fake.taskTransitions[1] != defaultStartParentStatus {
|
|
156
|
+
t.Fatalf("mailbox transitions = %v, want two %s transitions", fake.taskTransitions, defaultStartParentStatus)
|
|
161
157
|
}
|
|
162
158
|
if len(fake.parentTransitions) != 0 {
|
|
163
159
|
t.Fatalf("parent transitions = %v, want none (start owns parent status)", fake.parentTransitions)
|
|
@@ -165,7 +161,7 @@ func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
|
|
|
165
161
|
}
|
|
166
162
|
|
|
167
163
|
func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
|
|
168
|
-
fake := &fakeJira{transitionErr: errors.New(`transition to "
|
|
164
|
+
fake := &fakeJira{transitionErr: errors.New(`transition to "In Progress" is not available for PAY-102`)}
|
|
169
165
|
sys := newSystemWithFake(t, fake)
|
|
170
166
|
preparer := sys.(task.RestartPreparer)
|
|
171
167
|
err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "PAY-101"}, []task.Mailbox{
|
|
@@ -88,7 +88,9 @@ func TestValidateConfigProbesWorkflowAssignee(t *testing.T) {
|
|
|
88
88
|
|
|
89
89
|
func taskSpec(root, repo config.RawValues) task.RepoSpec {
|
|
90
90
|
if repo == nil {
|
|
91
|
-
repo =
|
|
91
|
+
repo = testRepoConfig(nil)
|
|
92
|
+
} else {
|
|
93
|
+
repo = config.Merge(testRepoConfig(nil), repo)
|
|
92
94
|
}
|
|
93
95
|
return task.RepoSpec{Name: "payments", RootConfig: root, RepoConfig: repo}
|
|
94
96
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package task
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RegistrationField describes one task-plugin-owned repository-registration
|
|
11
|
+
// value. The CLI only renders this metadata; it does not interpret provider
|
|
12
|
+
// names or status vocabulary. A field with Options is rendered as a select,
|
|
13
|
+
// otherwise it is rendered as free text. Derived fields are filled from the
|
|
14
|
+
// runner candidate name and are never prompted.
|
|
15
|
+
type RegistrationField struct {
|
|
16
|
+
Key string `json:"key"`
|
|
17
|
+
Title string `json:"title"`
|
|
18
|
+
Options []string `json:"options,omitempty"`
|
|
19
|
+
Default string `json:"default,omitempty"`
|
|
20
|
+
Derived bool `json:"derived,omitempty"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Registration describes the fields needed by a task plugin for one repo
|
|
24
|
+
// registration.
|
|
25
|
+
type Registration struct {
|
|
26
|
+
Fields []RegistrationField `json:"fields"`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// RegistrationFields returns task-plugin-owned registration metadata for the
|
|
30
|
+
// supplied values. Values use the plugin's registration keys (including
|
|
31
|
+
// dotted keys such as statusDefaults.start) and are intentionally opaque to
|
|
32
|
+
// core.
|
|
33
|
+
func RegistrationFields(ctx context.Context, name string, values config.RawValues) ([]RegistrationField, error) {
|
|
34
|
+
f, err := lookup(name)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return nil, err
|
|
37
|
+
}
|
|
38
|
+
if f.RegistrationFields == nil {
|
|
39
|
+
return nil, fmt.Errorf("task plugin %q does not provide repository registration fields", name)
|
|
40
|
+
}
|
|
41
|
+
return f.RegistrationFields(ctx, values)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ValidateRegistration lets a task plugin validate registration values,
|
|
45
|
+
// including remote project/workflow metadata, before the repository is
|
|
46
|
+
// persisted. Plugins that do not need this phase remain unchanged.
|
|
47
|
+
func ValidateRegistration(ctx context.Context, name string, spec RepoRegistrationSpec) error {
|
|
48
|
+
f, err := lookup(name)
|
|
49
|
+
if err != nil {
|
|
50
|
+
return err
|
|
51
|
+
}
|
|
52
|
+
if f.ValidateRegistration == nil {
|
|
53
|
+
return nil
|
|
54
|
+
}
|
|
55
|
+
if err := f.ValidateRegistration(ctx, spec); err != nil {
|
|
56
|
+
return fmt.Errorf("task registration: %w", err)
|
|
57
|
+
}
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
@@ -62,6 +62,7 @@ type NudgeTemplateData struct {
|
|
|
62
62
|
Workflow string
|
|
63
63
|
Repo string
|
|
64
64
|
Node string
|
|
65
|
+
Mailbox string
|
|
65
66
|
NextSteps string
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -70,7 +71,7 @@ var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
|
|
|
70
71
|
var nudgeVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
71
72
|
|
|
72
73
|
var knownNudgeVars = map[string]bool{
|
|
73
|
-
"taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
|
|
74
|
+
"taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "mailbox": true, "nextSteps": true,
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Parse strictly decodes a workflow YAML document. Unknown fields and
|
|
@@ -326,6 +327,8 @@ func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, err
|
|
|
326
327
|
return data.Repo
|
|
327
328
|
case "node":
|
|
328
329
|
return data.Node
|
|
330
|
+
case "mailbox":
|
|
331
|
+
return data.Mailbox
|
|
329
332
|
case "nextSteps":
|
|
330
333
|
return data.NextSteps
|
|
331
334
|
}
|
|
@@ -350,7 +350,7 @@ func TestValidateGraphReachability(t *testing.T) {
|
|
|
350
350
|
|
|
351
351
|
func TestValidateNudgeTemplate(t *testing.T) {
|
|
352
352
|
t.Run("supported variables", func(t *testing.T) {
|
|
353
|
-
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
|
|
353
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{mailbox}} {{nextSteps}}\"", 1)
|
|
354
354
|
wf := parse(t, "basicFlow", yaml)
|
|
355
355
|
if err := wf.Validate(); err != nil {
|
|
356
356
|
t.Fatalf("supported nudge variables rejected: %v", err)
|
|
@@ -389,16 +389,16 @@ func TestCleanupRunnerOnEndDefaultsFalse(t *testing.T) {
|
|
|
389
389
|
}
|
|
390
390
|
|
|
391
391
|
func TestRenderNudge(t *testing.T) {
|
|
392
|
-
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
|
|
392
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} mailbox={{mailbox}} steps={{nextSteps}}\"", 1)
|
|
393
393
|
wf := parse(t, "basicFlow", yaml)
|
|
394
394
|
for _, taskSystem := range []string{"jira", "linear"} {
|
|
395
395
|
out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
|
|
396
|
-
TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
|
|
396
|
+
TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", Mailbox: "PAY-101.1", NextSteps: "end",
|
|
397
397
|
})
|
|
398
398
|
if err != nil {
|
|
399
399
|
t.Fatalf("RenderNudge(%s) failed: %v", taskSystem, err)
|
|
400
400
|
}
|
|
401
|
-
want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
|
|
401
|
+
want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding mailbox=PAY-101.1 steps=end"
|
|
402
402
|
if out != want {
|
|
403
403
|
t.Fatalf("RenderNudge(%s) = %q, want %q", taskSystem, out, want)
|
|
404
404
|
}
|