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,381 @@
|
|
|
1
|
+
package rest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"testing"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type requestRecord struct {
|
|
16
|
+
Method string
|
|
17
|
+
Path string
|
|
18
|
+
Body []byte
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type jiraServer struct {
|
|
22
|
+
t *testing.T
|
|
23
|
+
server *httptest.Server
|
|
24
|
+
mu sync.Mutex
|
|
25
|
+
records []requestRecord
|
|
26
|
+
handle func(http.ResponseWriter, *http.Request, []byte)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func newJiraServer(t *testing.T, handle func(http.ResponseWriter, *http.Request, []byte)) *jiraServer {
|
|
30
|
+
t.Helper()
|
|
31
|
+
s := &jiraServer{t: t, handle: handle}
|
|
32
|
+
s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
33
|
+
body, err := io.ReadAll(r.Body)
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
user, token, ok := r.BasicAuth()
|
|
38
|
+
if !ok || user != "bot@example.com" || token != "secret" {
|
|
39
|
+
http.Error(w, "bad auth", http.StatusUnauthorized)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
s.mu.Lock()
|
|
43
|
+
s.records = append(s.records, requestRecord{Method: r.Method, Path: r.URL.Path, Body: body})
|
|
44
|
+
s.mu.Unlock()
|
|
45
|
+
handle(w, r, body)
|
|
46
|
+
}))
|
|
47
|
+
t.Cleanup(s.server.Close)
|
|
48
|
+
return s
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (s *jiraServer) client(t *testing.T) *HTTPClient {
|
|
52
|
+
t.Helper()
|
|
53
|
+
c, err := New(s.server.URL, "bot@example.com", "secret")
|
|
54
|
+
if err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
return c
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (s *jiraServer) count(method, path string) int {
|
|
61
|
+
s.mu.Lock()
|
|
62
|
+
defer s.mu.Unlock()
|
|
63
|
+
n := 0
|
|
64
|
+
for _, record := range s.records {
|
|
65
|
+
if record.Method == method && record.Path == path {
|
|
66
|
+
n++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return n
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func writeJSON(w http.ResponseWriter, value any) {
|
|
73
|
+
w.Header().Set("Content-Type", "application/json")
|
|
74
|
+
_ = json.NewEncoder(w).Encode(value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestValidateCredentialsUsesMyselfAndBasicAuth(t *testing.T) {
|
|
78
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
79
|
+
if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/myself" {
|
|
80
|
+
http.NotFound(w, r)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
writeJSON(w, map[string]any{"accountId": "abc"})
|
|
84
|
+
})
|
|
85
|
+
if err := s.client(t).ValidateCredentials(context.Background()); err != nil {
|
|
86
|
+
t.Fatal(err)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func TestNewRejectsNonHTTPSRemoteSite(t *testing.T) {
|
|
91
|
+
if _, err := New("http://jira.example.com", "bot@example.com", "secret"); err == nil {
|
|
92
|
+
t.Fatal("insecure remote Jira site accepted")
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func TestSearchPaginatesAndRequestsIssueLinks(t *testing.T) {
|
|
97
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
98
|
+
if r.URL.Path != "/rest/api/3/search/jql" || r.Method != http.MethodPost {
|
|
99
|
+
http.NotFound(w, r)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
var request struct {
|
|
103
|
+
Fields []string `json:"fields"`
|
|
104
|
+
NextPageToken string `json:"nextPageToken"`
|
|
105
|
+
}
|
|
106
|
+
if err := json.Unmarshal(body, &request); err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
if !contains(request.Fields, "issuelinks") {
|
|
110
|
+
t.Fatalf("search fields = %v, missing issuelinks", request.Fields)
|
|
111
|
+
}
|
|
112
|
+
if request.NextPageToken == "" {
|
|
113
|
+
writeJSON(w, map[string]any{"issues": []any{issue("PAY-1")}, "nextPageToken": "next", "isLast": false})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
writeJSON(w, map[string]any{"issues": []any{issue("PAY-2")}, "isLast": true})
|
|
117
|
+
})
|
|
118
|
+
raw, err := s.client(t).Search(context.Background(), "project = PAY")
|
|
119
|
+
if err != nil {
|
|
120
|
+
t.Fatal(err)
|
|
121
|
+
}
|
|
122
|
+
var issues []json.RawMessage
|
|
123
|
+
if err := json.Unmarshal(raw, &issues); err != nil || len(issues) != 2 {
|
|
124
|
+
t.Fatalf("issues = %s, err=%v", raw, err)
|
|
125
|
+
}
|
|
126
|
+
if s.count(http.MethodPost, "/rest/api/3/search/jql") != 2 {
|
|
127
|
+
t.Fatal("search did not use exactly one call per page")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func issue(key string) map[string]any {
|
|
132
|
+
return map[string]any{"id": key, "key": key, "fields": map[string]any{
|
|
133
|
+
"summary": key, "status": map[string]any{"name": "To Do"}, "issuetype": map[string]any{"name": "Task"}, "labels": []string{},
|
|
134
|
+
}}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func TestCreateSubtasksBatchesAndIncludesADFParentAndLabel(t *testing.T) {
|
|
138
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
139
|
+
if r.URL.Path != "/rest/api/3/issue/bulk" {
|
|
140
|
+
http.NotFound(w, r)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
var request struct {
|
|
144
|
+
IssueUpdates []struct {
|
|
145
|
+
Fields map[string]json.RawMessage `json:"fields"`
|
|
146
|
+
} `json:"issueUpdates"`
|
|
147
|
+
}
|
|
148
|
+
if err := json.Unmarshal(body, &request); err != nil {
|
|
149
|
+
t.Fatal(err)
|
|
150
|
+
}
|
|
151
|
+
created := make([]any, len(request.IssueUpdates))
|
|
152
|
+
for i, update := range request.IssueUpdates {
|
|
153
|
+
for _, field := range []string{"parent", "description", "labels"} {
|
|
154
|
+
if len(update.Fields[field]) == 0 {
|
|
155
|
+
t.Fatalf("bulk create missing %s: %s", field, body)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if !strings.Contains(string(update.Fields["description"]), `"type":"doc"`) {
|
|
159
|
+
t.Fatalf("description is not ADF: %s", update.Fields["description"])
|
|
160
|
+
}
|
|
161
|
+
created[i] = map[string]any{"id": fmt.Sprint(i + 1), "key": fmt.Sprintf("PAY-%d", i+2)}
|
|
162
|
+
}
|
|
163
|
+
writeJSON(w, map[string]any{"issues": created, "errors": []any{}})
|
|
164
|
+
})
|
|
165
|
+
client := s.client(t)
|
|
166
|
+
client.subtaskTypes["PAY"] = "10001"
|
|
167
|
+
specs := make([]SubtaskSpec, 51)
|
|
168
|
+
for i := range specs {
|
|
169
|
+
specs[i] = SubtaskSpec{Title: fmt.Sprintf("PAY-1:n%d", i), Description: "Work:\nDo it"}
|
|
170
|
+
}
|
|
171
|
+
created, err := client.CreateSubtasks(context.Background(), "PAY-1", "PAY", "wf:flow", specs)
|
|
172
|
+
if err != nil {
|
|
173
|
+
t.Fatal(err)
|
|
174
|
+
}
|
|
175
|
+
if len(created) != 51 || s.count(http.MethodPost, "/rest/api/3/issue/bulk") != 2 {
|
|
176
|
+
t.Fatalf("created=%d calls=%d, want 51/2", len(created), s.count(http.MethodPost, "/rest/api/3/issue/bulk"))
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func TestTransitionCombinesAssigneeAndCachesLookups(t *testing.T) {
|
|
181
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
182
|
+
switch {
|
|
183
|
+
case r.URL.Path == "/rest/api/3/search/jql":
|
|
184
|
+
writeJSON(w, map[string]any{"issues": []any{issue("PAY-1")}, "isLast": true})
|
|
185
|
+
case r.URL.Path == "/rest/api/3/issue/PAY-1/transitions" && r.Method == http.MethodGet:
|
|
186
|
+
writeJSON(w, map[string]any{"transitions": []any{map[string]any{
|
|
187
|
+
"id": "21", "to": map[string]any{"name": "In Progress"}, "fields": map[string]any{"assignee": map[string]any{}},
|
|
188
|
+
}}})
|
|
189
|
+
case r.URL.Path == "/rest/api/3/user/assignable/search":
|
|
190
|
+
writeJSON(w, []any{map[string]any{"accountId": "acct", "emailAddress": "worker@example.com"}})
|
|
191
|
+
case r.URL.Path == "/rest/api/3/issue/PAY-1/transitions" && r.Method == http.MethodPost:
|
|
192
|
+
if !strings.Contains(string(body), `"assignee":{"accountId":"acct"}`) {
|
|
193
|
+
t.Fatalf("transition did not include assignee: %s", body)
|
|
194
|
+
}
|
|
195
|
+
w.WriteHeader(http.StatusNoContent)
|
|
196
|
+
default:
|
|
197
|
+
http.NotFound(w, r)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
c := s.client(t)
|
|
201
|
+
if _, err := c.Search(context.Background(), "key = PAY-1"); err != nil {
|
|
202
|
+
t.Fatal(err)
|
|
203
|
+
}
|
|
204
|
+
if err := c.Transition(context.Background(), "PAY-1", "In Progress", "worker@example.com"); err != nil {
|
|
205
|
+
t.Fatal(err)
|
|
206
|
+
}
|
|
207
|
+
if s.count(http.MethodPut, "/rest/api/3/issue/PAY-1/assignee") != 0 {
|
|
208
|
+
t.Fatal("assignment used a separate call despite transition-screen support")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestTransitionFallsBackToSeparateAssignment(t *testing.T) {
|
|
213
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
214
|
+
switch {
|
|
215
|
+
case r.URL.Path == "/rest/api/3/search/jql":
|
|
216
|
+
writeJSON(w, map[string]any{"issues": []any{issue("PAY-1")}, "isLast": true})
|
|
217
|
+
case r.URL.Path == "/rest/api/3/issue/PAY-1/transitions" && r.Method == http.MethodGet:
|
|
218
|
+
writeJSON(w, map[string]any{"transitions": []any{map[string]any{
|
|
219
|
+
"id": "21", "to": map[string]any{"name": "In Progress"}, "fields": map[string]any{},
|
|
220
|
+
}}})
|
|
221
|
+
case r.URL.Path == "/rest/api/3/user/assignable/search":
|
|
222
|
+
writeJSON(w, []any{map[string]any{"accountId": "acct", "emailAddress": "worker@example.com"}})
|
|
223
|
+
case r.URL.Path == "/rest/api/3/issue/PAY-1/assignee" && r.Method == http.MethodPut:
|
|
224
|
+
w.WriteHeader(http.StatusNoContent)
|
|
225
|
+
case r.URL.Path == "/rest/api/3/issue/PAY-1/transitions" && r.Method == http.MethodPost:
|
|
226
|
+
w.WriteHeader(http.StatusNoContent)
|
|
227
|
+
default:
|
|
228
|
+
http.NotFound(w, r)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
c := s.client(t)
|
|
232
|
+
if _, err := c.Search(context.Background(), "key = PAY-1"); err != nil {
|
|
233
|
+
t.Fatal(err)
|
|
234
|
+
}
|
|
235
|
+
if err := c.Transition(context.Background(), "PAY-1", "In Progress", "worker@example.com"); err != nil {
|
|
236
|
+
t.Fatal(err)
|
|
237
|
+
}
|
|
238
|
+
if s.count(http.MethodPut, "/rest/api/3/issue/PAY-1/assignee") != 1 {
|
|
239
|
+
t.Fatal("transition without assignee field did not use assignment fallback")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func TestUpdateMailboxIsOneCall(t *testing.T) {
|
|
244
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
245
|
+
if r.Method != http.MethodPut || r.URL.Path != "/rest/api/3/issue/PAY-2" {
|
|
246
|
+
http.NotFound(w, r)
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if !strings.Contains(string(body), `"description"`) || !strings.Contains(string(body), `"labels"`) {
|
|
250
|
+
t.Fatalf("combined update missing fields: %s", body)
|
|
251
|
+
}
|
|
252
|
+
w.WriteHeader(http.StatusNoContent)
|
|
253
|
+
})
|
|
254
|
+
if err := s.client(t).UpdateMailbox(context.Background(), "PAY-2", "Work:\nDo it", "wf:flow"); err != nil {
|
|
255
|
+
t.Fatal(err)
|
|
256
|
+
}
|
|
257
|
+
if s.count(http.MethodPut, "/rest/api/3/issue/PAY-2") != 1 {
|
|
258
|
+
t.Fatal("mailbox reconciliation was not one call")
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func TestEnsureLabelIsOneAddCall(t *testing.T) {
|
|
263
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
264
|
+
if r.Method != http.MethodPut || r.URL.Path != "/rest/api/3/issue/PAY-1" {
|
|
265
|
+
http.NotFound(w, r)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if !strings.Contains(string(body), `"add":"wf:flow"`) {
|
|
269
|
+
t.Fatalf("label update = %s", body)
|
|
270
|
+
}
|
|
271
|
+
w.WriteHeader(http.StatusNoContent)
|
|
272
|
+
})
|
|
273
|
+
if err := s.client(t).EnsureLabel(context.Background(), "PAY-1", "wf:flow"); err != nil {
|
|
274
|
+
t.Fatal(err)
|
|
275
|
+
}
|
|
276
|
+
if s.count(http.MethodPut, "/rest/api/3/issue/PAY-1") != 1 {
|
|
277
|
+
t.Fatal("claim label was not one call")
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func TestTransitionAlreadyAtTargetMakesNoTransitionCall(t *testing.T) {
|
|
282
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
283
|
+
if r.URL.Path == "/rest/api/3/search/jql" {
|
|
284
|
+
item := issue("PAY-1")
|
|
285
|
+
item["fields"].(map[string]any)["status"] = map[string]any{"name": "Done"}
|
|
286
|
+
writeJSON(w, map[string]any{"issues": []any{item}, "isLast": true})
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
http.NotFound(w, r)
|
|
290
|
+
})
|
|
291
|
+
c := s.client(t)
|
|
292
|
+
if _, err := c.Search(context.Background(), "key = PAY-1"); err != nil {
|
|
293
|
+
t.Fatal(err)
|
|
294
|
+
}
|
|
295
|
+
if err := c.Transition(context.Background(), "PAY-1", "Done", ""); err != nil {
|
|
296
|
+
t.Fatal(err)
|
|
297
|
+
}
|
|
298
|
+
if s.count(http.MethodGet, "/rest/api/3/issue/PAY-1/transitions") != 0 {
|
|
299
|
+
t.Fatal("already-completed issue queried transitions")
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func TestCommentsUseADFAndParseMarker(t *testing.T) {
|
|
304
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
305
|
+
if r.URL.Path != "/rest/api/3/issue/PAY-2/comment" {
|
|
306
|
+
http.NotFound(w, r)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if r.Method == http.MethodGet {
|
|
310
|
+
writeJSON(w, map[string]any{"comments": []any{map[string]any{"body": ADF("summary\n\n<!-- visit:summary -->")}}, "startAt": 0, "total": 1})
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
if !strings.Contains(string(body), `"type":"doc"`) {
|
|
314
|
+
t.Fatalf("comment is not ADF: %s", body)
|
|
315
|
+
}
|
|
316
|
+
w.WriteHeader(http.StatusCreated)
|
|
317
|
+
})
|
|
318
|
+
c := s.client(t)
|
|
319
|
+
comments, err := c.ListComments(context.Background(), "PAY-2")
|
|
320
|
+
if err != nil || len(comments) != 1 || !strings.Contains(comments[0], "visit:summary") {
|
|
321
|
+
t.Fatalf("comments=%v err=%v", comments, err)
|
|
322
|
+
}
|
|
323
|
+
if err := c.AddComment(context.Background(), "PAY-2", "SUMMARY:\nDone"); err != nil {
|
|
324
|
+
t.Fatal(err)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func TestRetries429WithoutExposingCredentials(t *testing.T) {
|
|
329
|
+
calls := 0
|
|
330
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
331
|
+
calls++
|
|
332
|
+
if calls == 1 {
|
|
333
|
+
w.Header().Set("Retry-After", "0")
|
|
334
|
+
http.Error(w, "limited", http.StatusTooManyRequests)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
writeJSON(w, map[string]any{"accountId": "abc"})
|
|
338
|
+
})
|
|
339
|
+
if err := s.client(t).ValidateCredentials(context.Background()); err != nil {
|
|
340
|
+
t.Fatal(err)
|
|
341
|
+
}
|
|
342
|
+
if calls != 2 {
|
|
343
|
+
t.Fatalf("calls=%d, want one retry", calls)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func TestErrorRedactsToken(t *testing.T) {
|
|
348
|
+
s := newJiraServer(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) {
|
|
349
|
+
http.Error(w, "rejected secret for bot@example.com", http.StatusUnauthorized)
|
|
350
|
+
})
|
|
351
|
+
err := s.client(t).ValidateCredentials(context.Background())
|
|
352
|
+
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
|
|
353
|
+
t.Fatalf("error did not redact Jira credentials: %v", err)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func TestADFFormatsHeadingsListsAndRoundTripsMarker(t *testing.T) {
|
|
358
|
+
doc := ADF("SUMMARY\n\nNode: implement\n\n- first\n- second\n\n```\ngo test ./...\n```\n\n<!-- visit:summary -->")
|
|
359
|
+
raw, err := json.Marshal(doc)
|
|
360
|
+
if err != nil {
|
|
361
|
+
t.Fatal(err)
|
|
362
|
+
}
|
|
363
|
+
text := string(raw)
|
|
364
|
+
for _, want := range []string{`"type":"heading"`, `"type":"bulletList"`, `"type":"codeBlock"`, `"type":"strong"`} {
|
|
365
|
+
if !strings.Contains(text, want) {
|
|
366
|
+
t.Fatalf("ADF %s missing %s", text, want)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if got := ADFText(raw); !strings.Contains(got, "visit:summary") {
|
|
370
|
+
t.Fatalf("ADF marker round trip = %q", got)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func contains(values []string, want string) bool {
|
|
375
|
+
for _, value := range values {
|
|
376
|
+
if value == want {
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
@@ -16,23 +16,26 @@ import (
|
|
|
16
16
|
// leaves the parent unchanged.
|
|
17
17
|
//
|
|
18
18
|
// This test is package-local (package jira) so it can drive the adapter's
|
|
19
|
-
// ApplyTaskConfig against a test-local fake
|
|
19
|
+
// ApplyTaskConfig against a test-local fake client without inventing an
|
|
20
20
|
// exported production constructor seam. The adapter resolves defaults
|
|
21
21
|
// internally; the fake records the transitions the adapter issues.
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// fakeJira is a test-local fakeable Jira boundary recording transitions and
|
|
24
24
|
// serving a fixed search batch for Poll.
|
|
25
|
-
type
|
|
25
|
+
type fakeJira struct {
|
|
26
26
|
parentTransitions []string
|
|
27
27
|
taskTransitions []string
|
|
28
28
|
assignments []string
|
|
29
29
|
assignErr error
|
|
30
30
|
events []string
|
|
31
31
|
// searchJSON is the raw Jira search response Poll serves.
|
|
32
|
-
searchJSON
|
|
32
|
+
searchJSON []byte
|
|
33
|
+
comments []string
|
|
34
|
+
addedComments []string
|
|
35
|
+
labelCalls []string
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
func (f *
|
|
38
|
+
func (f *fakeJira) transition(key, status string) {
|
|
36
39
|
if key == "PAY-101" {
|
|
37
40
|
f.parentTransitions = append(f.parentTransitions, status)
|
|
38
41
|
} else {
|
|
@@ -40,9 +43,8 @@ func (f *fakeACLI) transition(key, status string) {
|
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
// newSystemWithFake builds the adapter around the test-local
|
|
44
|
-
|
|
45
|
-
func newSystemWithFake(t *testing.T, fake *fakeACLI) task.System {
|
|
46
|
+
// newSystemWithFake builds the adapter around the test-local Jira client.
|
|
47
|
+
func newSystemWithFake(t *testing.T, fake *fakeJira) task.System {
|
|
46
48
|
t.Helper()
|
|
47
49
|
sys, err := newSystemForTest(fake)
|
|
48
50
|
if err != nil {
|
|
@@ -52,7 +54,7 @@ func newSystemWithFake(t *testing.T, fake *fakeACLI) task.System {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
func TestStartDefaultParentInProgress(t *testing.T) {
|
|
55
|
-
fake := &
|
|
57
|
+
fake := &fakeJira{}
|
|
56
58
|
sys := newSystemWithFake(t, fake)
|
|
57
59
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
58
60
|
|
|
@@ -66,7 +68,7 @@ func TestStartDefaultParentInProgress(t *testing.T) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
func TestWorkNodeDefaultMailboxInProgressParentUnchanged(t *testing.T) {
|
|
69
|
-
fake := &
|
|
71
|
+
fake := &fakeJira{}
|
|
70
72
|
sys := newSystemWithFake(t, fake)
|
|
71
73
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
72
74
|
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
@@ -86,7 +88,7 @@ func TestWorkNodeDefaultMailboxInProgressParentUnchanged(t *testing.T) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
|
|
89
|
-
fake := &
|
|
91
|
+
fake := &fakeJira{}
|
|
90
92
|
sys := newSystemWithFake(t, fake)
|
|
91
93
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
92
94
|
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
@@ -100,13 +102,13 @@ func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
|
|
|
100
102
|
if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:reviewer@example.com" {
|
|
101
103
|
t.Fatalf("mailbox assignments = %v, want [PAY-102:reviewer@example.com]", fake.assignments)
|
|
102
104
|
}
|
|
103
|
-
if len(fake.events) !=
|
|
104
|
-
t.Fatalf("mailbox events = %v, want
|
|
105
|
+
if len(fake.events) != 1 || fake.events[0] != "transition" {
|
|
106
|
+
t.Fatalf("mailbox events = %v, want one combined transition", fake.events)
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
func TestWorkNodeAssignmentFailurePreventsTransition(t *testing.T) {
|
|
109
|
-
fake := &
|
|
111
|
+
fake := &fakeJira{assignErr: errors.New("assignment failed")}
|
|
110
112
|
sys := newSystemWithFake(t, fake)
|
|
111
113
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
112
114
|
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
@@ -120,7 +122,7 @@ func TestWorkNodeAssignmentFailurePreventsTransition(t *testing.T) {
|
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
func TestEndDefaultParentDone(t *testing.T) {
|
|
123
|
-
fake := &
|
|
125
|
+
fake := &fakeJira{}
|
|
124
126
|
sys := newSystemWithFake(t, fake)
|
|
125
127
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
126
128
|
|
|
@@ -136,7 +138,7 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
func TestExplicitTransitionsWin(t *testing.T) {
|
|
139
|
-
fake := &
|
|
141
|
+
fake := &fakeJira{}
|
|
140
142
|
sys := newSystemWithFake(t, fake)
|
|
141
143
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
142
144
|
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "review"}
|
|
@@ -16,7 +16,7 @@ type validationClient struct {
|
|
|
16
16
|
statuses []string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
func (c *validationClient) ValidateAssignee(_ context.Context, assignee string) error {
|
|
19
|
+
func (c *validationClient) ValidateAssignee(_ context.Context, _ string, assignee string) error {
|
|
20
20
|
c.assignees = append(c.assignees, assignee)
|
|
21
21
|
if assignee == "missing" {
|
|
22
22
|
return errors.New("invalid assignee")
|
|
@@ -57,11 +57,12 @@ type Route struct {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
type NudgeTemplateData struct {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
TaskSystem string
|
|
61
|
+
Ticket string
|
|
62
|
+
Workflow string
|
|
63
|
+
Repo string
|
|
64
|
+
Node string
|
|
65
|
+
NextSteps string
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
|
|
@@ -69,7 +70,7 @@ var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
|
|
|
69
70
|
var nudgeVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
70
71
|
|
|
71
72
|
var knownNudgeVars = map[string]bool{
|
|
72
|
-
"ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
|
|
73
|
+
"taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// Parse strictly decodes a workflow YAML document. Unknown fields and
|
|
@@ -315,6 +316,8 @@ func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, err
|
|
|
315
316
|
out := nudgeVarPattern.ReplaceAllStringFunc(tmpl, func(m string) string {
|
|
316
317
|
varName := nudgeVarPattern.FindStringSubmatch(m)[1]
|
|
317
318
|
switch varName {
|
|
319
|
+
case "taskSystem":
|
|
320
|
+
return data.TaskSystem
|
|
318
321
|
case "ticket":
|
|
319
322
|
return data.Ticket
|
|
320
323
|
case "workflow":
|
|
@@ -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: \"{{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}} {{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,21 +389,23 @@ 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: \"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}} steps={{nextSteps}}\"", 1)
|
|
393
393
|
wf := parse(t, "basicFlow", yaml)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
394
|
+
for _, taskSystem := range []string{"jira", "linear"} {
|
|
395
|
+
out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
|
|
396
|
+
TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
|
|
397
|
+
})
|
|
398
|
+
if err != nil {
|
|
399
|
+
t.Fatalf("RenderNudge(%s) failed: %v", taskSystem, err)
|
|
400
|
+
}
|
|
401
|
+
want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
|
|
402
|
+
if out != want {
|
|
403
|
+
t.Fatalf("RenderNudge(%s) = %q, want %q", taskSystem, out, want)
|
|
404
|
+
}
|
|
403
405
|
}
|
|
404
406
|
|
|
405
407
|
wf.Nodes["coding"] = workflow.Node{Type: workflow.NodeAgent}
|
|
406
|
-
out, err
|
|
408
|
+
out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{})
|
|
407
409
|
if err != nil || out != "" {
|
|
408
410
|
t.Fatalf("empty RenderNudge = %q, %v; want empty", out, err)
|
|
409
411
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relay-flow",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-alpha",
|
|
4
4
|
"description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
|
|
5
5
|
"bin": {
|
|
6
6
|
"relay-flow": "./bin/relay-flow.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"cmd",
|
|
12
|
+
"examples",
|
|
12
13
|
"internal",
|
|
13
14
|
"go.mod",
|
|
14
15
|
"go.sum"
|