relay-flow 0.2.0-alpha → 0.2.1-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 +18 -6
- package/cmd/relay-flow/commands_test.go +519 -15
- package/cmd/relay-flow/main.go +331 -119
- package/cmd/relay-flow/scenario_test.go +209 -34
- package/cmd/relay-flow/serve.go +1 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/internal/execution/goworkflows/activities.go +65 -65
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +73 -13
- package/internal/execution/goworkflows/fakes_test.go +13 -21
- package/internal/execution/goworkflows/interpreter.go +16 -8
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +45 -21
- package/internal/execution/goworkflows/recovery_test.go +5 -5
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +5 -0
- package/internal/paths/paths.go +18 -16
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service_test.go +4 -4
- 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_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/contract_test.go +2 -0
- package/internal/task/factory.go +16 -0
- package/internal/task/jira/auth.go +183 -0
- package/internal/task/jira/auth_test.go +107 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +36 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +92 -61
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +128 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/transition_defaults_test.go +18 -16
- package/internal/task/jira/validation_test.go +1 -1
- 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 +0 -1
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestClaimUsesOneLabelAdd(t *testing.T) {
|
|
12
|
+
fake := &fakeJira{}
|
|
13
|
+
sys := newSystemWithFake(t, fake)
|
|
14
|
+
if err := sys.Claim(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow"); err != nil {
|
|
15
|
+
t.Fatal(err)
|
|
16
|
+
}
|
|
17
|
+
if len(fake.labelCalls) != 1 || fake.labelCalls[0] != "PAY-1:wf:flow" {
|
|
18
|
+
t.Fatalf("label calls = %v, want one claim update", fake.labelCalls)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestCommentKeepsMarkerIdempotency(t *testing.T) {
|
|
23
|
+
fake := &fakeJira{comments: []string{"existing\n<!-- visit:summary -->"}}
|
|
24
|
+
sys := newSystemWithFake(t, fake)
|
|
25
|
+
target := task.Target{Parent: task.TicketRef{Key: "PAY-1"}}
|
|
26
|
+
if err := sys.Comment(context.Background(), target, "summary", "visit:summary"); err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
if len(fake.addedComments) != 0 {
|
|
30
|
+
t.Fatal("duplicate marked comment was posted")
|
|
31
|
+
}
|
|
32
|
+
fake.comments = nil
|
|
33
|
+
if err := sys.Comment(context.Background(), target, "summary", "visit:summary"); err != nil {
|
|
34
|
+
t.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
if len(fake.addedComments) != 1 || !strings.Contains(fake.addedComments[0], "visit:summary") {
|
|
37
|
+
t.Fatalf("posted comments = %v", fake.addedComments)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -30,7 +30,7 @@ func (p *pollCountingSystem) Poll(ctx context.Context) ([]task.Ticket, error) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
func TestCompileFilterMatchesNormalizedFields(t *testing.T) {
|
|
33
|
-
sys := newSystemWithFake(t, &
|
|
33
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
34
34
|
|
|
35
35
|
match, err := sys.CompileFilter(config.RawValues{
|
|
36
36
|
"filters": map[string]any{
|
|
@@ -57,7 +57,7 @@ func TestCompileFilterMatchesNormalizedFields(t *testing.T) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
func TestCompileFilterRejectsNonMatching(t *testing.T) {
|
|
60
|
-
sys := newSystemWithFake(t, &
|
|
60
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
61
61
|
match, err := sys.CompileFilter(config.RawValues{
|
|
62
62
|
"filters": map[string]any{"parentStatuses": []any{"To Do"}},
|
|
63
63
|
})
|
|
@@ -80,7 +80,7 @@ func TestCompileFilterRejectsNonMatching(t *testing.T) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
func TestCompileFilterRejectsUnknownField(t *testing.T) {
|
|
83
|
-
sys := newSystemWithFake(t, &
|
|
83
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
84
84
|
if _, err := sys.CompileFilter(config.RawValues{
|
|
85
85
|
"filters": map[string]any{"jql": "project = PAY"},
|
|
86
86
|
}); err == nil {
|
|
@@ -91,7 +91,7 @@ func TestCompileFilterRejectsUnknownField(t *testing.T) {
|
|
|
91
91
|
func TestMatchingIsInMemoryNoRequery(t *testing.T) {
|
|
92
92
|
// One repo poll fetches the batch; the compiled matcher then evaluates
|
|
93
93
|
// every ticket in memory with no per-workflow re-query.
|
|
94
|
-
fake := &
|
|
94
|
+
fake := &fakeJira{searchJSON: []byte(`[{"id":"1","key":"PAY-1","fields":{"summary":"a","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"2","key":"PAY-2","fields":{"summary":"b","status":{"name":"Done"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"3","key":"PAY-3","fields":{"summary":"c","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}}]`)}
|
|
95
95
|
base := newSystemWithFake(t, fake)
|
|
96
96
|
counting := &pollCountingSystem{System: base}
|
|
97
97
|
|
|
@@ -130,7 +130,7 @@ func TestJiraJSONNormalization(t *testing.T) {
|
|
|
130
130
|
// The adapter normalizes Jira search JSON (status, issue type, labels,
|
|
131
131
|
// assignee) into task.Ticket.Fields. Package-local so it can call the
|
|
132
132
|
// adapter's unexported normalization directly.
|
|
133
|
-
//
|
|
133
|
+
// The REST boundary supplies a normalized array of issue objects, and the
|
|
134
134
|
// normalized assignee is the user's email address — the stable identity
|
|
135
135
|
// workflow filters match on, not the human-readable display name.
|
|
136
136
|
raw := []byte(`[{"id":"1","key":"PAY-101","fields":{"summary":"parent","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":["coding"],"assignee":{"displayName":"Relay Bot","emailAddress":"relay@bot"}}}]`)
|
|
@@ -159,21 +159,41 @@ func TestJiraJSONNormalization(t *testing.T) {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
func
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
func TestJiraJSONNormalizationFiltersOnlyOpenInwardBlockers(t *testing.T) {
|
|
163
|
+
raw := []byte(`[
|
|
164
|
+
{"id":"1","key":"PAY-1","fields":{"summary":"open blocker","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[],"issuelinks":[
|
|
165
|
+
{"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-1","fields":{"status":{"statusCategory":{"key":"done"}}}}},
|
|
166
|
+
{"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-2","fields":{"status":{"statusCategory":{"key":"new"}}}}}
|
|
167
|
+
]}},
|
|
168
|
+
{"id":"2","key":"PAY-2","fields":{"summary":"closed blockers","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[],"issuelinks":[
|
|
169
|
+
{"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-3","fields":{"status":{"statusCategory":{"key":"done"}}}}}
|
|
170
|
+
]}},
|
|
171
|
+
{"id":"3","key":"PAY-3","fields":{"summary":"blocks another","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[],"issuelinks":[
|
|
172
|
+
{"type":{"name":"Blocks"},"outwardIssue":{"key":"OTHER-4","fields":{"status":{"statusCategory":{"key":"new"}}}}}
|
|
173
|
+
]}}
|
|
174
|
+
]`)
|
|
175
|
+
tickets, err := normalizeSearchResponse(raw)
|
|
176
|
+
if err != nil {
|
|
177
|
+
t.Fatal(err)
|
|
178
|
+
}
|
|
179
|
+
if len(tickets) != 2 || tickets[0].Key != "PAY-2" || tickets[1].Key != "PAY-3" {
|
|
180
|
+
t.Fatalf("eligible tickets = %+v, want PAY-2 and PAY-3", tickets)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func TestJiraJSONNormalizationParsesCapturedIssueShape(t *testing.T) {
|
|
185
|
+
// Preserve the captured issue-field fixture while the REST client contract
|
|
186
|
+
// itself is exercised through strict HTTP tests.
|
|
187
|
+
raw, err := os.ReadFile("testdata/jira_search_issues.json")
|
|
168
188
|
if err != nil {
|
|
169
|
-
t.Fatalf("read
|
|
189
|
+
t.Fatalf("read Jira fixture: %v", err)
|
|
170
190
|
}
|
|
171
191
|
tickets, err := normalizeSearchResponse(raw)
|
|
172
192
|
if err != nil {
|
|
173
|
-
t.Fatalf("normalization of
|
|
193
|
+
t.Fatalf("normalization of captured Jira output failed: %v", err)
|
|
174
194
|
}
|
|
175
195
|
if len(tickets) == 0 {
|
|
176
|
-
t.Fatal("
|
|
196
|
+
t.Fatal("captured Jira fixture yielded no tickets")
|
|
177
197
|
}
|
|
178
198
|
for _, tk := range tickets {
|
|
179
199
|
if tk.Key == "" || tk.ID == "" {
|
|
@@ -197,7 +217,7 @@ func TestJiraJSONNormalizationParsesRealAcliSearchShape(t *testing.T) {
|
|
|
197
217
|
func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
|
|
198
218
|
// 9.9: workflow assignee filters match the normalized EMAIL identity,
|
|
199
219
|
// not the display name. e2e workflow.yaml filters on the user's email.
|
|
200
|
-
sys := newSystemWithFake(t, &
|
|
220
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
201
221
|
match, err := sys.CompileFilter(config.RawValues{
|
|
202
222
|
"filters": map[string]any{"assignees": []any{"raj.popat@example.com"}},
|
|
203
223
|
})
|
|
@@ -218,7 +238,7 @@ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
|
|
|
218
238
|
// assignee"); it matches the normalized ticket's assignee field.
|
|
219
239
|
// NOTE: the filter key `assignees` follows the same naming as
|
|
220
240
|
// parentStatuses/issueTypes/labels (plural); the docs do not pin the key.
|
|
221
|
-
sys := newSystemWithFake(t, &
|
|
241
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
222
242
|
match, err := sys.CompileFilter(config.RawValues{
|
|
223
243
|
"filters": map[string]any{"assignees": []any{"relay-bot@example.com"}},
|
|
224
244
|
})
|
|
@@ -4,19 +4,21 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
|
|
6
6
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/task/jira/rest"
|
|
7
8
|
)
|
|
8
9
|
|
|
9
|
-
// fakeClient adapts the test-local
|
|
10
|
-
// to the production acli.Client seam.
|
|
10
|
+
// fakeClient adapts the test-local Jira fake to the production REST seam.
|
|
11
11
|
type fakeClient struct {
|
|
12
|
-
fake *
|
|
12
|
+
fake *fakeJira
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
func (f *fakeClient) Search(context.Context, string) ([]byte, error) {
|
|
16
16
|
return f.fake.searchJSON, nil
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
func (f *fakeClient)
|
|
19
|
+
func (f *fakeClient) ValidateCredentials(context.Context) error { return nil }
|
|
20
|
+
|
|
21
|
+
func (f *fakeClient) ValidateAssignee(context.Context, string, string) error { return nil }
|
|
20
22
|
|
|
21
23
|
func (f *fakeClient) ValidateStatus(context.Context, string, string) error { return nil }
|
|
22
24
|
|
|
@@ -24,29 +26,37 @@ func (f *fakeClient) View(context.Context, string) ([]byte, error) {
|
|
|
24
26
|
return []byte(`{"fields":{"labels":[],"subtasks":[]}}`), nil
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
func (f *fakeClient)
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
func (f *fakeClient) Assign(_ context.Context, key, assignee string) error {
|
|
32
|
-
f.fake.assignments = append(f.fake.assignments, key+":"+assignee)
|
|
33
|
-
f.fake.events = append(f.fake.events, "assign")
|
|
34
|
-
return f.fake.assignErr
|
|
29
|
+
func (f *fakeClient) CreateSubtasks(context.Context, string, string, string, []rest.SubtaskSpec) ([]rest.CreatedSubtask, error) {
|
|
30
|
+
return nil, errNotFaked
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
func (f *fakeClient) Transition(_ context.Context, key, status string) error {
|
|
33
|
+
func (f *fakeClient) Transition(_ context.Context, key, status, assignee string) error {
|
|
34
|
+
if assignee != "" {
|
|
35
|
+
f.fake.assignments = append(f.fake.assignments, key+":"+assignee)
|
|
36
|
+
if f.fake.assignErr != nil {
|
|
37
|
+
return f.fake.assignErr
|
|
38
|
+
}
|
|
39
|
+
}
|
|
38
40
|
f.fake.events = append(f.fake.events, "transition")
|
|
39
41
|
f.fake.transition(key, status)
|
|
40
42
|
return nil
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
func (f *fakeClient) EnsureLabel(context.Context,
|
|
45
|
+
func (f *fakeClient) EnsureLabel(_ context.Context, key, label string) error {
|
|
46
|
+
f.fake.labelCalls = append(f.fake.labelCalls, key+":"+label)
|
|
47
|
+
return nil
|
|
48
|
+
}
|
|
44
49
|
|
|
45
|
-
func (f *fakeClient)
|
|
50
|
+
func (f *fakeClient) UpdateMailbox(context.Context, string, string, string) error { return nil }
|
|
46
51
|
|
|
47
|
-
func (f *fakeClient) ListComments(context.Context, string) ([]string, error) {
|
|
52
|
+
func (f *fakeClient) ListComments(context.Context, string) ([]string, error) {
|
|
53
|
+
return append([]string(nil), f.fake.comments...), nil
|
|
54
|
+
}
|
|
48
55
|
|
|
49
|
-
func (f *fakeClient) AddComment(context.Context, string, string) error {
|
|
56
|
+
func (f *fakeClient) AddComment(_ context.Context, _ string, body string) error {
|
|
57
|
+
f.fake.addedComments = append(f.fake.addedComments, body)
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
50
60
|
|
|
51
61
|
type notFakedError struct{}
|
|
52
62
|
|
|
@@ -54,7 +64,7 @@ func (notFakedError) Error() string { return "not faked" }
|
|
|
54
64
|
|
|
55
65
|
var errNotFaked = notFakedError{}
|
|
56
66
|
|
|
57
|
-
// newSystemForTest builds the adapter around the test-local
|
|
58
|
-
func newSystemForTest(fake *
|
|
67
|
+
// newSystemForTest builds the adapter around the test-local REST seam.
|
|
68
|
+
func newSystemForTest(fake *fakeJira) (task.System, error) {
|
|
59
69
|
return newSystemForCLI(&fakeClient{fake: fake})
|
|
60
70
|
}
|
|
@@ -8,11 +8,12 @@ import (
|
|
|
8
8
|
"log/slog"
|
|
9
9
|
"sort"
|
|
10
10
|
"strings"
|
|
11
|
+
"sync"
|
|
11
12
|
|
|
12
13
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
14
|
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
14
15
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
|
-
"github.com/rajpopat27/relay-flow/internal/task/jira/
|
|
16
|
+
jirarest "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
// Config is the adapter-owned typed config for every scope.
|
|
@@ -20,7 +21,6 @@ type Config struct {
|
|
|
20
21
|
Assignee string `yaml:"assignee,omitempty"`
|
|
21
22
|
Project string `yaml:"project,omitempty"`
|
|
22
23
|
Component string `yaml:"component,omitempty"`
|
|
23
|
-
Site string `yaml:"site,omitempty"`
|
|
24
24
|
Filters Filters `yaml:"filters,omitempty"`
|
|
25
25
|
Transition TransitionTo `yaml:"transitionTo,omitempty"`
|
|
26
26
|
}
|
|
@@ -46,6 +46,14 @@ const (
|
|
|
46
46
|
defaultEndParentStatus = "Done"
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
+
var (
|
|
50
|
+
clientsMu sync.Mutex
|
|
51
|
+
clients = map[string]struct {
|
|
52
|
+
token string
|
|
53
|
+
client *jirarest.HTTPClient
|
|
54
|
+
}{}
|
|
55
|
+
)
|
|
56
|
+
|
|
49
57
|
// claimLabel is the permanent workflow claim label.
|
|
50
58
|
func claimLabel(workflow string) string { return "wf:" + workflow }
|
|
51
59
|
|
|
@@ -65,24 +73,60 @@ func init() {
|
|
|
65
73
|
if proj == "" || comp == "" {
|
|
66
74
|
return "", fmt.Errorf("jira task scope requires repo project and component")
|
|
67
75
|
}
|
|
68
|
-
|
|
76
|
+
creds, err := loadCredentialsDefault()
|
|
77
|
+
if err != nil {
|
|
78
|
+
return "", fmt.Errorf("jira credentials: %w", err)
|
|
79
|
+
}
|
|
80
|
+
return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
|
|
69
81
|
},
|
|
82
|
+
Auth: auth,
|
|
70
83
|
New: func(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
71
|
-
|
|
84
|
+
merged := config.Merge(spec.RootConfig, spec.RepoConfig)
|
|
85
|
+
var cfg Config
|
|
86
|
+
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
87
|
+
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
88
|
+
}
|
|
89
|
+
creds, err := loadCredentialsDefault()
|
|
90
|
+
if err != nil {
|
|
91
|
+
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
92
|
+
}
|
|
93
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return nil, err
|
|
96
|
+
}
|
|
97
|
+
return newSystem(ctx, client, spec)
|
|
72
98
|
},
|
|
73
99
|
})
|
|
74
100
|
}
|
|
75
101
|
|
|
102
|
+
func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
|
|
103
|
+
key := strings.TrimRight(site, "/") + "\x00" + email
|
|
104
|
+
clientsMu.Lock()
|
|
105
|
+
defer clientsMu.Unlock()
|
|
106
|
+
if cached := clients[key]; cached.client != nil && cached.token == token {
|
|
107
|
+
return cached.client, nil
|
|
108
|
+
}
|
|
109
|
+
client, err := jirarest.New(site, email, token)
|
|
110
|
+
if err != nil {
|
|
111
|
+
return nil, err
|
|
112
|
+
}
|
|
113
|
+
clients[key] = struct {
|
|
114
|
+
token string
|
|
115
|
+
client *jirarest.HTTPClient
|
|
116
|
+
}{token: token, client: client}
|
|
117
|
+
return client, nil
|
|
118
|
+
}
|
|
119
|
+
|
|
76
120
|
// system is the repo-bound Jira task.System. It is safe for concurrent use;
|
|
77
|
-
// the
|
|
121
|
+
// the REST client owns connection reuse, caches, and request limiting.
|
|
78
122
|
type system struct {
|
|
79
|
-
cli
|
|
123
|
+
cli jirarest.Client
|
|
80
124
|
repoName string
|
|
81
125
|
base config.RawValues
|
|
82
126
|
effective Config // root+repo merged
|
|
83
127
|
}
|
|
84
128
|
|
|
85
|
-
func newSystem(ctx context.Context, cli
|
|
129
|
+
func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
|
|
86
130
|
if spec.Name == "" {
|
|
87
131
|
return nil, fmt.Errorf("jira: repo name is required")
|
|
88
132
|
}
|
|
@@ -92,7 +136,7 @@ func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*syste
|
|
|
92
136
|
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
93
137
|
}
|
|
94
138
|
if cfg.Assignee != "" {
|
|
95
|
-
if err := cli.ValidateAssignee(ctx, cfg.Assignee); err != nil {
|
|
139
|
+
if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
|
|
96
140
|
return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
|
|
97
141
|
}
|
|
98
142
|
}
|
|
@@ -110,11 +154,10 @@ func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*syste
|
|
|
110
154
|
return s, nil
|
|
111
155
|
}
|
|
112
156
|
|
|
113
|
-
// newSystemForCLI constructs a system around an explicit
|
|
114
|
-
func newSystemForCLI(cli
|
|
157
|
+
// newSystemForCLI constructs a system around an explicit Jira client seam.
|
|
158
|
+
func newSystemForCLI(cli jirarest.Client) (task.System, error) {
|
|
115
159
|
return newSystem(context.Background(), cli, task.RepoSpec{
|
|
116
160
|
Name: "payments",
|
|
117
|
-
RootConfig: config.RawValues{},
|
|
118
161
|
RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
119
162
|
})
|
|
120
163
|
}
|
|
@@ -198,27 +241,10 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
198
241
|
|
|
199
242
|
// --- Claim ---
|
|
200
243
|
|
|
201
|
-
// Claim adds wf:<workflow>
|
|
202
|
-
//
|
|
244
|
+
// Claim adds wf:<workflow> using the claims already inspected by routing.
|
|
245
|
+
// Jira's label-add operation is idempotent.
|
|
203
246
|
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)
|
|
247
|
+
return s.cli.EnsureLabel(ctx, ticket.Key, claimLabel(workflow))
|
|
222
248
|
}
|
|
223
249
|
|
|
224
250
|
// --- Config validation ---
|
|
@@ -231,7 +257,7 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
231
257
|
if err != nil {
|
|
232
258
|
return fmt.Errorf("workflow taskConfig: %w", err)
|
|
233
259
|
}
|
|
234
|
-
if err := s.validateAssignee(ctx, "workflow", workflowCfg.Assignee); err != nil {
|
|
260
|
+
if err := s.validateAssignee(ctx, "workflow", workflowCfg.Project, workflowCfg.Assignee); err != nil {
|
|
235
261
|
return err
|
|
236
262
|
}
|
|
237
263
|
if err := s.validateTransition(ctx, "workflow", workflowCfg.Project, workflowCfg.Transition); err != nil {
|
|
@@ -247,7 +273,7 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
247
273
|
if err != nil {
|
|
248
274
|
return fmt.Errorf("node %q taskConfig: %w", n, err)
|
|
249
275
|
}
|
|
250
|
-
if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Assignee); err != nil {
|
|
276
|
+
if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Assignee); err != nil {
|
|
251
277
|
return err
|
|
252
278
|
}
|
|
253
279
|
if err := s.validateTransition(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Transition); err != nil {
|
|
@@ -257,11 +283,11 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
|
|
|
257
283
|
return nil
|
|
258
284
|
}
|
|
259
285
|
|
|
260
|
-
func (s *system) validateAssignee(ctx context.Context, scope, assignee string) error {
|
|
286
|
+
func (s *system) validateAssignee(ctx context.Context, scope, project, assignee string) error {
|
|
261
287
|
if assignee == "" {
|
|
262
288
|
return nil
|
|
263
289
|
}
|
|
264
|
-
if err := s.cli.ValidateAssignee(ctx, assignee); err != nil {
|
|
290
|
+
if err := s.cli.ValidateAssignee(ctx, project, assignee); err != nil {
|
|
265
291
|
return fmt.Errorf("%s assignee %q: %w", scope, assignee, err)
|
|
266
292
|
}
|
|
267
293
|
return nil
|
|
@@ -347,28 +373,38 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
|
|
|
347
373
|
return nil, err
|
|
348
374
|
}
|
|
349
375
|
out := map[string]task.Mailbox{}
|
|
376
|
+
missing := make([]task.MailboxSpec, 0)
|
|
350
377
|
for _, spec := range specs {
|
|
351
378
|
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)
|
|
379
|
+
if err := s.cli.UpdateMailbox(ctx, mb.Key, spec.Description, claimLabel(workflow)); err != nil {
|
|
380
|
+
return nil, fmt.Errorf("reconcile mailbox %q: %w", mb.Key, err)
|
|
359
381
|
}
|
|
360
382
|
mb.Node = spec.Node
|
|
361
383
|
out[spec.Node] = mb
|
|
362
384
|
continue
|
|
363
385
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
386
|
+
missing = append(missing, spec)
|
|
387
|
+
}
|
|
388
|
+
if len(missing) == 0 {
|
|
389
|
+
return out, nil
|
|
390
|
+
}
|
|
391
|
+
createSpecs := make([]jirarest.SubtaskSpec, 0, len(missing))
|
|
392
|
+
for _, spec := range missing {
|
|
393
|
+
createSpecs = append(createSpecs, jirarest.SubtaskSpec{Title: spec.Title, Description: spec.Description})
|
|
394
|
+
}
|
|
395
|
+
created, err := s.cli.CreateSubtasks(ctx, parent.Key, s.effective.Project, claimLabel(workflow), createSpecs)
|
|
396
|
+
if err != nil {
|
|
397
|
+
return nil, fmt.Errorf("create mailboxes: %w", err)
|
|
398
|
+
}
|
|
399
|
+
if len(created) != len(missing) {
|
|
400
|
+
return nil, fmt.Errorf("create mailboxes: created %d of %d", len(created), len(missing))
|
|
401
|
+
}
|
|
402
|
+
for i, mailbox := range created {
|
|
403
|
+
spec := missing[i]
|
|
404
|
+
if mailbox.ID == "" || mailbox.Key == "" {
|
|
405
|
+
return nil, fmt.Errorf("create mailbox %q: Jira returned no id/key", spec.Title)
|
|
370
406
|
}
|
|
371
|
-
out[spec.Node] = task.Mailbox{ID:
|
|
407
|
+
out[spec.Node] = task.Mailbox{ID: mailbox.ID, Key: mailbox.Key, Node: spec.Node}
|
|
372
408
|
}
|
|
373
409
|
return out, nil
|
|
374
410
|
}
|
|
@@ -388,20 +424,15 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
|
|
|
388
424
|
}
|
|
389
425
|
tr := cfg.Transition
|
|
390
426
|
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
427
|
status := tr.TaskStatus
|
|
397
428
|
if status == "" {
|
|
398
429
|
status = defaultWorkTaskStatus
|
|
399
430
|
}
|
|
400
|
-
if err := s.transition(ctx, target.Mailbox.Key, status); err != nil {
|
|
431
|
+
if err := s.transition(ctx, target.Mailbox.Key, status, cfg.Assignee); err != nil {
|
|
401
432
|
return err
|
|
402
433
|
}
|
|
403
434
|
if tr.ParentStatus != "" {
|
|
404
|
-
return s.transition(ctx, target.Parent.Key, tr.ParentStatus)
|
|
435
|
+
return s.transition(ctx, target.Parent.Key, tr.ParentStatus, "")
|
|
405
436
|
}
|
|
406
437
|
return nil
|
|
407
438
|
}
|
|
@@ -410,13 +441,13 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
|
|
|
410
441
|
if status == "" {
|
|
411
442
|
status = defaultStartParentStatus
|
|
412
443
|
}
|
|
413
|
-
return s.transition(ctx, target.Parent.Key, status)
|
|
444
|
+
return s.transition(ctx, target.Parent.Key, status, "")
|
|
414
445
|
}
|
|
415
446
|
|
|
416
447
|
// transition applies a Jira status transition, mapping a human-incompatible
|
|
417
448
|
// 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)
|
|
449
|
+
func (s *system) transition(ctx context.Context, key, status, assignee string) error {
|
|
450
|
+
err := s.cli.Transition(ctx, key, status, assignee)
|
|
420
451
|
if err != nil && isConflict(err) {
|
|
421
452
|
return retry.ConflictError(err)
|
|
422
453
|
}
|
|
@@ -431,7 +462,7 @@ func isConflict(err error) bool {
|
|
|
431
462
|
|
|
432
463
|
// CompleteMailbox marks the mailbox Done using task-system semantics.
|
|
433
464
|
func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
|
|
434
|
-
return s.transition(ctx, mailbox.Key, "Done")
|
|
465
|
+
return s.transition(ctx, mailbox.Key, "Done", "")
|
|
435
466
|
}
|
|
436
467
|
|
|
437
468
|
// --- Comments ---
|
|
@@ -480,7 +511,7 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
|
|
|
480
511
|
// comments, labels, and history. No parent rollback runs.
|
|
481
512
|
func (s *system) ResetForRecovery(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
|
|
482
513
|
for _, mb := range mailboxes {
|
|
483
|
-
if err := s.transition(ctx, mb.Key, "To Do"); err != nil {
|
|
514
|
+
if err := s.transition(ctx, mb.Key, "To Do", ""); err != nil {
|
|
484
515
|
return fmt.Errorf("reset mailbox %s: %w", mb.Key, err)
|
|
485
516
|
}
|
|
486
517
|
}
|
|
@@ -3,13 +3,12 @@ package jira
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"fmt"
|
|
6
|
+
"strings"
|
|
6
7
|
|
|
7
8
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
8
9
|
)
|
|
9
10
|
|
|
10
|
-
// rawIssue
|
|
11
|
-
// ARRAY of these (no {"issues":[...]} REST envelope) — the adapter owns
|
|
12
|
-
// the acli wire contract, not the REST API's.
|
|
11
|
+
// rawIssue is the subset of Jira REST issue fields normalized by the adapter.
|
|
13
12
|
type rawIssue struct {
|
|
14
13
|
ID string `json:"id"`
|
|
15
14
|
Key string `json:"key"`
|
|
@@ -33,11 +32,26 @@ type rawIssue struct {
|
|
|
33
32
|
Summary string `json:"summary"`
|
|
34
33
|
} `json:"fields"`
|
|
35
34
|
} `json:"subtasks"`
|
|
35
|
+
IssueLinks []struct {
|
|
36
|
+
Type struct {
|
|
37
|
+
Name string `json:"name"`
|
|
38
|
+
} `json:"type"`
|
|
39
|
+
InwardIssue *struct {
|
|
40
|
+
Key string `json:"key"`
|
|
41
|
+
Fields struct {
|
|
42
|
+
Status struct {
|
|
43
|
+
StatusCategory struct {
|
|
44
|
+
Key string `json:"key"`
|
|
45
|
+
} `json:"statusCategory"`
|
|
46
|
+
} `json:"status"`
|
|
47
|
+
} `json:"fields"`
|
|
48
|
+
} `json:"inwardIssue"`
|
|
49
|
+
} `json:"issuelinks"`
|
|
36
50
|
} `json:"fields"`
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
// normalizeSearchResponse converts
|
|
40
|
-
//
|
|
53
|
+
// normalizeSearchResponse converts REST issue objects into normalized parent
|
|
54
|
+
// tickets: status, issueType, labels,
|
|
41
55
|
// and assignee become plain Fields entries. Assignee is normalized to the
|
|
42
56
|
// user's email address — the stable, machine-comparable identity workflow
|
|
43
57
|
// filters match against (displayName is human-readable, not an identifier).
|
|
@@ -49,6 +63,9 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
|
|
|
49
63
|
}
|
|
50
64
|
out := make([]task.Ticket, 0, len(issues))
|
|
51
65
|
for _, issue := range issues {
|
|
66
|
+
if hasOpenBlocker(issue) {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
52
69
|
fields := map[string]any{
|
|
53
70
|
"status": issue.Fields.Status.Name,
|
|
54
71
|
"issueType": issue.Fields.IssueType.Name,
|
|
@@ -68,6 +85,16 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
|
|
|
68
85
|
return out, nil
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
func hasOpenBlocker(issue rawIssue) bool {
|
|
89
|
+
for _, link := range issue.Fields.IssueLinks {
|
|
90
|
+
if link.Type.Name == "Blocks" && link.InwardIssue != nil &&
|
|
91
|
+
!strings.EqualFold(link.InwardIssue.Fields.Status.StatusCategory.Key, "done") {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
func claimLabels(labels []string) []string {
|
|
72
99
|
var out []string
|
|
73
100
|
for _, l := range labels {
|
|
@@ -78,15 +105,6 @@ func claimLabels(labels []string) []string {
|
|
|
78
105
|
return out
|
|
79
106
|
}
|
|
80
107
|
|
|
81
|
-
// labelsOf extracts label strings from a raw Jira issue view.
|
|
82
|
-
func labelsOf(raw []byte) ([]string, error) {
|
|
83
|
-
var issue rawIssue
|
|
84
|
-
if err := json.Unmarshal(raw, &issue); err != nil {
|
|
85
|
-
return nil, fmt.Errorf("jira view: parse json: %w", err)
|
|
86
|
-
}
|
|
87
|
-
return issue.Fields.Labels, nil
|
|
88
|
-
}
|
|
89
|
-
|
|
90
108
|
// subtasksOf maps existing subtask titles (<ticket>:<node>) to mailboxes.
|
|
91
109
|
func subtasksOf(raw []byte) (map[string]task.Mailbox, error) {
|
|
92
110
|
var issue rawIssue
|