relay-flow 0.2.0-alpha → 0.2.2-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 +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- 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.go +6 -4
- 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/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- 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 → beads/bdcli/testdata/empty.json} +0 -0
- /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\n**Node:** 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,118 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestTaskTextTemplatesExposeValues(t *testing.T) {
|
|
16
|
+
fake := &fakeJira{}
|
|
17
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
|
|
18
|
+
Name: "payments",
|
|
19
|
+
RootConfig: config.RawValues{"templates": map[string]any{
|
|
20
|
+
"mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}",
|
|
21
|
+
"summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
|
|
22
|
+
"feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
|
|
23
|
+
}},
|
|
24
|
+
RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
25
|
+
})
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
data := task.TextData{
|
|
30
|
+
RunID: "run-1", Ticket: "PAY-1", Workflow: "flow", Repo: "payments",
|
|
31
|
+
Node: "review", NodeType: "hitl", Agent: "reviewer", NodeDescription: "Review changes.",
|
|
32
|
+
NextSteps: "implement; end", SuccessRoutes: "end", FailureRoutes: "implement",
|
|
33
|
+
Mailbox: "PAY-3", SourceNode: "review", TargetNode: "implement",
|
|
34
|
+
SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it",
|
|
35
|
+
}
|
|
36
|
+
description, err := sys.RenderText(task.TextMailboxDescription, data)
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
for _, want := range []string{"run-1", "PAY-1", "flow", "payments", "review", "hitl", "reviewer", "Review changes.", "implement; end", "PAY-3"} {
|
|
41
|
+
if !strings.Contains(description, want) {
|
|
42
|
+
t.Fatalf("description missing %q: %q", want, description)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
summary, err := sys.RenderText(task.TextSummaryComment, data)
|
|
46
|
+
if err != nil || !strings.Contains(summary, data.SummaryReport) {
|
|
47
|
+
t.Fatalf("summary = %q, %v", summary, err)
|
|
48
|
+
}
|
|
49
|
+
feedback, err := sys.RenderText(task.TextFeedbackComment, data)
|
|
50
|
+
if err != nil || !strings.Contains(feedback, "feedback review|implement|PAY-3") || !strings.Contains(feedback, data.FeedbackReport) {
|
|
51
|
+
t.Fatalf("feedback = %q, %v", feedback, err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestCommentTemplatesRequireReportValues(t *testing.T) {
|
|
56
|
+
for _, tc := range []struct {
|
|
57
|
+
name string
|
|
58
|
+
override map[string]any
|
|
59
|
+
wantError string
|
|
60
|
+
}{
|
|
61
|
+
{name: "summary", override: map[string]any{"summaryComment": "missing"}, wantError: "summaryComment must contain {{summaryReport}}"},
|
|
62
|
+
{name: "feedback", override: map[string]any{"feedbackComment": "missing"}, wantError: "feedbackComment must contain {{feedbackReport}}"},
|
|
63
|
+
{name: "unknown", override: map[string]any{"mailboxDescription": "{{mailboxInstructions}}"}, wantError: "unknown template variable {{mailboxInstructions}}"},
|
|
64
|
+
} {
|
|
65
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
66
|
+
raw := config.Merge(DefaultConfig(), config.RawValues{"templates": tc.override})
|
|
67
|
+
var cfg Config
|
|
68
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
err := validateTemplates(cfg.Templates)
|
|
72
|
+
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
|
73
|
+
t.Fatalf("validateTemplates error = %v, want %q", err, tc.wantError)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestTaskTemplatesRenderSharedReportContractValues(t *testing.T) {
|
|
80
|
+
b, err := os.ReadFile("../../../testdata/report-contract.json")
|
|
81
|
+
if err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
var fixtures map[string]struct {
|
|
85
|
+
Envelope run.ReportRequest `json:"envelope"`
|
|
86
|
+
SummaryReport string `json:"summaryReport"`
|
|
87
|
+
FeedbackReport string `json:"feedbackReport"`
|
|
88
|
+
}
|
|
89
|
+
if err := json.Unmarshal(b, &fixtures); err != nil {
|
|
90
|
+
t.Fatal(err)
|
|
91
|
+
}
|
|
92
|
+
fixture := fixtures["work"]
|
|
93
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
|
|
94
|
+
Name: "payments", RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
95
|
+
})
|
|
96
|
+
if err != nil {
|
|
97
|
+
t.Fatal(err)
|
|
98
|
+
}
|
|
99
|
+
data := task.TextData{
|
|
100
|
+
Node: "coding", SourceNode: "coding", TargetNode: "coding", Mailbox: "PAY-101:coding",
|
|
101
|
+
SummaryReport: fixture.SummaryReport, FeedbackReport: fixture.FeedbackReport,
|
|
102
|
+
}
|
|
103
|
+
summary, err := sys.RenderText(task.TextSummaryComment, data)
|
|
104
|
+
if err != nil {
|
|
105
|
+
t.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
if summary != "Summary for coding\n\n"+fixture.SummaryReport {
|
|
108
|
+
t.Fatalf("summary template did not render shared summaryReport:\n%s", summary)
|
|
109
|
+
}
|
|
110
|
+
feedback, err := sys.RenderText(task.TextFeedbackComment, data)
|
|
111
|
+
if err != nil {
|
|
112
|
+
t.Fatal(err)
|
|
113
|
+
}
|
|
114
|
+
wantFeedback := "Feedback from coding to coding mailbox PAY-101:coding\n\n" + fixture.FeedbackReport
|
|
115
|
+
if feedback != wantFeedback {
|
|
116
|
+
t.Fatalf("feedback template did not render shared feedbackReport:\n%s", feedback)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -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,14 +122,16 @@ 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
|
|
|
127
129
|
// end: omitted transitionTo. The end effective config carries no
|
|
128
130
|
// transition; the adapter must still default the parent to Done. Run
|
|
129
|
-
// orchestration
|
|
130
|
-
|
|
131
|
+
// orchestration merges EndDefaults under the end node config before
|
|
132
|
+
// applying it to the parent target.
|
|
133
|
+
endCfg := config.Merge(sys.(task.LifecycleDefaults).EndDefaults(), config.RawValues{})
|
|
134
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endCfg); err != nil {
|
|
131
135
|
t.Fatalf("ApplyTaskConfig end failed: %v", err)
|
|
132
136
|
}
|
|
133
137
|
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
|
|
@@ -136,7 +140,7 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
func TestExplicitTransitionsWin(t *testing.T) {
|
|
139
|
-
fake := &
|
|
143
|
+
fake := &fakeJira{}
|
|
140
144
|
sys := newSystemWithFake(t, fake)
|
|
141
145
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
142
146
|
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")
|
package/internal/task/task.go
CHANGED
|
@@ -37,6 +37,37 @@ type MailboxSpec struct {
|
|
|
37
37
|
Title string `json:"title"`
|
|
38
38
|
Description string `json:"description"`
|
|
39
39
|
TaskConfig config.RawValues `json:"taskConfig,omitempty"`
|
|
40
|
+
TextData TextData `json:"textData"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type TextKind string
|
|
44
|
+
|
|
45
|
+
const (
|
|
46
|
+
TextMailboxDescription TextKind = "mailboxDescription"
|
|
47
|
+
TextSummaryComment TextKind = "summaryComment"
|
|
48
|
+
TextFeedbackComment TextKind = "feedbackComment"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// TextData contains existing run, mailbox, and node values supplied to a
|
|
52
|
+
// task-system text template. The task system owns rendering; harness prompt
|
|
53
|
+
// templates are a separate contract.
|
|
54
|
+
type TextData struct {
|
|
55
|
+
RunID string
|
|
56
|
+
Ticket string
|
|
57
|
+
Workflow string
|
|
58
|
+
Repo string
|
|
59
|
+
Node string
|
|
60
|
+
NodeType string
|
|
61
|
+
Agent string
|
|
62
|
+
NodeDescription string
|
|
63
|
+
NextSteps string
|
|
64
|
+
SuccessRoutes string
|
|
65
|
+
FailureRoutes string
|
|
66
|
+
Mailbox string
|
|
67
|
+
SourceNode string
|
|
68
|
+
TargetNode string
|
|
69
|
+
SummaryReport string
|
|
70
|
+
FeedbackReport string
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
type Target struct {
|
|
@@ -59,6 +90,7 @@ type System interface {
|
|
|
59
90
|
Claim(ctx context.Context, ticket TicketRef, workflow string) error
|
|
60
91
|
|
|
61
92
|
ValidateConfig(ctx context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error
|
|
93
|
+
RenderText(kind TextKind, data TextData) (string, error)
|
|
62
94
|
EnsureMailboxes(ctx context.Context, parent TicketRef, workflow string, specs []MailboxSpec) (map[string]Mailbox, error)
|
|
63
95
|
ApplyTaskConfig(ctx context.Context, target Target, taskConfig config.RawValues) error
|
|
64
96
|
CompleteMailbox(ctx context.Context, mailbox Mailbox) error
|