relay-flow 0.1.0-alpha.0 → 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 +160 -143
- package/cmd/relay-flow/commands_test.go +968 -0
- package/cmd/relay-flow/main.go +877 -175
- package/cmd/relay-flow/scenario_test.go +1310 -0
- package/cmd/relay-flow/serve.go +610 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +520 -0
- package/internal/execution/goworkflows/engine_test.go +660 -0
- package/internal/execution/goworkflows/fakes_test.go +509 -0
- package/internal/execution/goworkflows/interpreter.go +613 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +133 -0
- package/internal/execution/goworkflows/node_runtime_test.go +510 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +174 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +69 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +132 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +84 -0
- package/internal/router/router_test.go +231 -0
- package/internal/run/manager.go +121 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +286 -0
- package/internal/runner/contract_test.go +259 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +341 -172
- package/internal/runner/orca/orca_test.go +256 -143
- package/internal/runner/orca/orcacli/orcacli.go +220 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +157 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +32 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +54 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/auth_test.go +48 -0
- package/internal/task/contract_test.go +225 -0
- package/internal/task/factory.go +119 -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 +254 -0
- package/internal/task/jira/helpers_test.go +70 -0
- package/internal/task/jira/jira.go +538 -0
- package/internal/task/jira/normalize.go +119 -0
- 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/testdata/jira_search_issues.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +158 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +345 -0
- package/internal/workflow/workflow_test.go +412 -0
- package/package.json +7 -2
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"changelog": null,
|
|
4
|
+
"editmeta": null,
|
|
5
|
+
"expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
|
|
6
|
+
"fields": {
|
|
7
|
+
"assignee": {
|
|
8
|
+
"accountId": "712020:dad5df85-a7bd-4337-b858-0faf9012a009",
|
|
9
|
+
"accountType": "atlassian",
|
|
10
|
+
"active": true,
|
|
11
|
+
"avatarUrls": {
|
|
12
|
+
"16x16": "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",
|
|
13
|
+
"24x24": "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",
|
|
14
|
+
"32x32": "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",
|
|
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
|
+
},
|
|
17
|
+
"displayName": "Raj Popat",
|
|
18
|
+
"emailAddress": "raj.popat@wolterskluwer.com",
|
|
19
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
20
|
+
"timeZone": "America/New_York"
|
|
21
|
+
},
|
|
22
|
+
"issuetype": {
|
|
23
|
+
"avatarId": 10318,
|
|
24
|
+
"description": "A small, distinct piece of work.",
|
|
25
|
+
"hierarchyLevel": 0,
|
|
26
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium",
|
|
27
|
+
"id": "10017",
|
|
28
|
+
"name": "Task",
|
|
29
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issuetype/10017",
|
|
30
|
+
"subtask": false
|
|
31
|
+
},
|
|
32
|
+
"labels": [],
|
|
33
|
+
"status": {
|
|
34
|
+
"description": "",
|
|
35
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/",
|
|
36
|
+
"id": "10007",
|
|
37
|
+
"name": "To Do",
|
|
38
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/status/10007",
|
|
39
|
+
"statusCategory": {
|
|
40
|
+
"colorName": "blue-gray",
|
|
41
|
+
"id": 2,
|
|
42
|
+
"key": "new",
|
|
43
|
+
"name": "To Do",
|
|
44
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/statuscategory/2"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"summary": "e2e: hello world program in python"
|
|
48
|
+
},
|
|
49
|
+
"fieldsToInclude": null,
|
|
50
|
+
"id": "157320",
|
|
51
|
+
"key": "GHCOS-41242",
|
|
52
|
+
"names": null,
|
|
53
|
+
"operations": null,
|
|
54
|
+
"properties": null,
|
|
55
|
+
"renderedFields": null,
|
|
56
|
+
"schema": null,
|
|
57
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issue/157320",
|
|
58
|
+
"transitions": null,
|
|
59
|
+
"versionedRepresentations": null
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"changelog": null,
|
|
63
|
+
"editmeta": null,
|
|
64
|
+
"expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
|
|
65
|
+
"fields": {
|
|
66
|
+
"assignee": {
|
|
67
|
+
"accountId": "712020:dad5df85-a7bd-4337-b858-0faf9012a009",
|
|
68
|
+
"accountType": "atlassian",
|
|
69
|
+
"active": true,
|
|
70
|
+
"avatarUrls": {
|
|
71
|
+
"16x16": "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",
|
|
72
|
+
"24x24": "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",
|
|
73
|
+
"32x32": "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",
|
|
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
|
+
},
|
|
76
|
+
"displayName": "Raj Popat",
|
|
77
|
+
"emailAddress": "raj.popat@wolterskluwer.com",
|
|
78
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
79
|
+
"timeZone": "America/New_York"
|
|
80
|
+
},
|
|
81
|
+
"issuetype": {
|
|
82
|
+
"avatarId": 10303,
|
|
83
|
+
"description": "A problem or error.",
|
|
84
|
+
"hierarchyLevel": 0,
|
|
85
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium",
|
|
86
|
+
"id": "10019",
|
|
87
|
+
"name": "Bug",
|
|
88
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issuetype/10019",
|
|
89
|
+
"subtask": false
|
|
90
|
+
},
|
|
91
|
+
"labels": [],
|
|
92
|
+
"status": {
|
|
93
|
+
"description": "",
|
|
94
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/",
|
|
95
|
+
"id": "10007",
|
|
96
|
+
"name": "To Do",
|
|
97
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/status/10007",
|
|
98
|
+
"statusCategory": {
|
|
99
|
+
"colorName": "blue-gray",
|
|
100
|
+
"id": 2,
|
|
101
|
+
"key": "new",
|
|
102
|
+
"name": "To Do",
|
|
103
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/statuscategory/2"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"summary": "OpsDB Sync Validation Bug"
|
|
107
|
+
},
|
|
108
|
+
"fieldsToInclude": null,
|
|
109
|
+
"id": "155110",
|
|
110
|
+
"key": "GHCOS-41175",
|
|
111
|
+
"names": null,
|
|
112
|
+
"operations": null,
|
|
113
|
+
"properties": null,
|
|
114
|
+
"renderedFields": null,
|
|
115
|
+
"schema": null,
|
|
116
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issue/155110",
|
|
117
|
+
"transitions": null,
|
|
118
|
+
"versionedRepresentations": null
|
|
119
|
+
}
|
|
120
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// 3.5: Jira transition defaults per specs/workflow-definition "Jira
|
|
13
|
+
// transition defaults are deterministic": omitted transitions default to
|
|
14
|
+
// parent In Progress at start, mailbox In Progress at agent/HITL work
|
|
15
|
+
// nodes, and parent Done at end; an omitted work-node parent transition
|
|
16
|
+
// leaves the parent unchanged.
|
|
17
|
+
//
|
|
18
|
+
// This test is package-local (package jira) so it can drive the adapter's
|
|
19
|
+
// ApplyTaskConfig against a test-local fake client without inventing an
|
|
20
|
+
// exported production constructor seam. The adapter resolves defaults
|
|
21
|
+
// internally; the fake records the transitions the adapter issues.
|
|
22
|
+
|
|
23
|
+
// fakeJira is a test-local fakeable Jira boundary recording transitions and
|
|
24
|
+
// serving a fixed search batch for Poll.
|
|
25
|
+
type fakeJira struct {
|
|
26
|
+
parentTransitions []string
|
|
27
|
+
taskTransitions []string
|
|
28
|
+
assignments []string
|
|
29
|
+
assignErr error
|
|
30
|
+
events []string
|
|
31
|
+
// searchJSON is the raw Jira search response Poll serves.
|
|
32
|
+
searchJSON []byte
|
|
33
|
+
comments []string
|
|
34
|
+
addedComments []string
|
|
35
|
+
labelCalls []string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (f *fakeJira) transition(key, status string) {
|
|
39
|
+
if key == "PAY-101" {
|
|
40
|
+
f.parentTransitions = append(f.parentTransitions, status)
|
|
41
|
+
} else {
|
|
42
|
+
f.taskTransitions = append(f.taskTransitions, status)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// newSystemWithFake builds the adapter around the test-local Jira client.
|
|
47
|
+
func newSystemWithFake(t *testing.T, fake *fakeJira) task.System {
|
|
48
|
+
t.Helper()
|
|
49
|
+
sys, err := newSystemForTest(fake)
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatalf("adapter construction failed: %v", err)
|
|
52
|
+
}
|
|
53
|
+
return sys
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestStartDefaultParentInProgress(t *testing.T) {
|
|
57
|
+
fake := &fakeJira{}
|
|
58
|
+
sys := newSystemWithFake(t, fake)
|
|
59
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
60
|
+
|
|
61
|
+
// start: omitted transitionTo, parent-only target.
|
|
62
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, config.RawValues{}); err != nil {
|
|
63
|
+
t.Fatalf("ApplyTaskConfig start failed: %v", err)
|
|
64
|
+
}
|
|
65
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
|
|
66
|
+
t.Fatalf("start parent transitions = %v, want [In Progress]", fake.parentTransitions)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestWorkNodeDefaultMailboxInProgressParentUnchanged(t *testing.T) {
|
|
71
|
+
fake := &fakeJira{}
|
|
72
|
+
sys := newSystemWithFake(t, fake)
|
|
73
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
74
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
75
|
+
|
|
76
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{}); err != nil {
|
|
77
|
+
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
78
|
+
}
|
|
79
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Progress" {
|
|
80
|
+
t.Fatalf("mailbox transitions = %v, want [In Progress]", fake.taskTransitions)
|
|
81
|
+
}
|
|
82
|
+
if len(fake.parentTransitions) != 0 {
|
|
83
|
+
t.Fatalf("parent transitions = %v, want none (parent unchanged when omitted)", fake.parentTransitions)
|
|
84
|
+
}
|
|
85
|
+
if len(fake.assignments) != 0 {
|
|
86
|
+
t.Fatalf("mailbox assignments = %v, want none when assignee omitted", fake.assignments)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
|
|
91
|
+
fake := &fakeJira{}
|
|
92
|
+
sys := newSystemWithFake(t, fake)
|
|
93
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
94
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
95
|
+
|
|
96
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
|
|
97
|
+
"assignee": "reviewer@example.com",
|
|
98
|
+
})
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
101
|
+
}
|
|
102
|
+
if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:reviewer@example.com" {
|
|
103
|
+
t.Fatalf("mailbox assignments = %v, want [PAY-102:reviewer@example.com]", fake.assignments)
|
|
104
|
+
}
|
|
105
|
+
if len(fake.events) != 1 || fake.events[0] != "transition" {
|
|
106
|
+
t.Fatalf("mailbox events = %v, want one combined transition", fake.events)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestWorkNodeAssignmentFailurePreventsTransition(t *testing.T) {
|
|
111
|
+
fake := &fakeJira{assignErr: errors.New("assignment failed")}
|
|
112
|
+
sys := newSystemWithFake(t, fake)
|
|
113
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
114
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
115
|
+
|
|
116
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
|
|
117
|
+
"assignee": "reviewer@example.com",
|
|
118
|
+
})
|
|
119
|
+
if err == nil || len(fake.taskTransitions) != 0 {
|
|
120
|
+
t.Fatalf("ApplyTaskConfig error = %v, transitions = %v", err, fake.taskTransitions)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestEndDefaultParentDone(t *testing.T) {
|
|
125
|
+
fake := &fakeJira{}
|
|
126
|
+
sys := newSystemWithFake(t, fake)
|
|
127
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
128
|
+
|
|
129
|
+
// end: omitted transitionTo. The end effective config carries no
|
|
130
|
+
// transition; the adapter must still default the parent to Done. Run
|
|
131
|
+
// orchestration applies the end node config to the parent target.
|
|
132
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endConfig(config.RawValues{})); err != nil {
|
|
133
|
+
t.Fatalf("ApplyTaskConfig end failed: %v", err)
|
|
134
|
+
}
|
|
135
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
|
|
136
|
+
t.Fatalf("end parent transitions = %v, want [Done]", fake.parentTransitions)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func TestExplicitTransitionsWin(t *testing.T) {
|
|
141
|
+
fake := &fakeJira{}
|
|
142
|
+
sys := newSystemWithFake(t, fake)
|
|
143
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
144
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "review"}
|
|
145
|
+
|
|
146
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
|
|
147
|
+
"transitionTo": map[string]any{"parentStatus": "In Review", "taskStatus": "In Review"},
|
|
148
|
+
})
|
|
149
|
+
if err != nil {
|
|
150
|
+
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
151
|
+
}
|
|
152
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Review" {
|
|
153
|
+
t.Fatalf("parent transitions = %v, want [In Review]", fake.parentTransitions)
|
|
154
|
+
}
|
|
155
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Review" {
|
|
156
|
+
t.Fatalf("mailbox transitions = %v, want [In Review]", fake.taskTransitions)
|
|
157
|
+
}
|
|
158
|
+
}
|