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.
Files changed (118) hide show
  1. package/README.md +160 -143
  2. package/cmd/relay-flow/commands_test.go +968 -0
  3. package/cmd/relay-flow/main.go +877 -175
  4. package/cmd/relay-flow/scenario_test.go +1310 -0
  5. package/cmd/relay-flow/serve.go +610 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/go.mod +69 -2
  8. package/go.sum +185 -0
  9. package/internal/config/config.go +88 -0
  10. package/internal/config/machine.go +99 -48
  11. package/internal/config/machine_test.go +248 -0
  12. package/internal/config/merge_test.go +118 -0
  13. package/internal/config/writeatomic.go +36 -0
  14. package/internal/config/writeatomic_test.go +98 -0
  15. package/internal/execution/goworkflows/activities.go +490 -0
  16. package/internal/execution/goworkflows/engine.go +520 -0
  17. package/internal/execution/goworkflows/engine_test.go +660 -0
  18. package/internal/execution/goworkflows/fakes_test.go +509 -0
  19. package/internal/execution/goworkflows/interpreter.go +613 -0
  20. package/internal/execution/goworkflows/logging_test.go +154 -0
  21. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  22. package/internal/execution/goworkflows/node_runtime_integration_test.go +133 -0
  23. package/internal/execution/goworkflows/node_runtime_test.go +510 -0
  24. package/internal/execution/goworkflows/projection.go +504 -0
  25. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  26. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  27. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  28. package/internal/harness/contract_test.go +174 -0
  29. package/internal/harness/factory.go +63 -0
  30. package/internal/harness/harness.go +41 -0
  31. package/internal/harness/opencode/opencode.go +166 -0
  32. package/internal/harness/opencode/opencode_test.go +50 -0
  33. package/internal/harness/plugin_selection_test.go +126 -0
  34. package/internal/identity/identity.go +37 -0
  35. package/internal/logging/logging.go +56 -0
  36. package/internal/logging/logging_test.go +116 -0
  37. package/internal/paths/paths.go +69 -0
  38. package/internal/recover/recover.go +115 -0
  39. package/internal/repo/poller.go +186 -0
  40. package/internal/repo/poller_test.go +327 -0
  41. package/internal/repo/repo.go +132 -0
  42. package/internal/repo/service.go +216 -0
  43. package/internal/repo/service_test.go +298 -0
  44. package/internal/retry/retry.go +118 -0
  45. package/internal/router/router.go +84 -0
  46. package/internal/router/router_test.go +231 -0
  47. package/internal/run/manager.go +121 -0
  48. package/internal/run/run.go +140 -0
  49. package/internal/run/run_identity_test.go +52 -0
  50. package/internal/run/run_manager_test.go +286 -0
  51. package/internal/runner/contract_test.go +259 -0
  52. package/internal/runner/factory.go +65 -0
  53. package/internal/runner/orca/orca.go +341 -172
  54. package/internal/runner/orca/orca_test.go +256 -143
  55. package/internal/runner/orca/orcacli/orcacli.go +220 -0
  56. package/internal/runner/orca/orcacli/orcacli_test.go +157 -0
  57. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  58. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +32 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  66. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  67. package/internal/runner/runner.go +54 -64
  68. package/internal/server/api_test.go +300 -0
  69. package/internal/server/client.go +192 -74
  70. package/internal/server/fixture_test.go +248 -0
  71. package/internal/server/server.go +425 -248
  72. package/internal/server/shutdown_test.go +116 -0
  73. package/internal/task/auth_test.go +48 -0
  74. package/internal/task/contract_test.go +225 -0
  75. package/internal/task/factory.go +119 -0
  76. package/internal/task/jira/auth.go +183 -0
  77. package/internal/task/jira/auth_test.go +107 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +254 -0
  80. package/internal/task/jira/helpers_test.go +70 -0
  81. package/internal/task/jira/jira.go +538 -0
  82. package/internal/task/jira/normalize.go +119 -0
  83. package/internal/task/jira/rest/adf.go +128 -0
  84. package/internal/task/jira/rest/client.go +573 -0
  85. package/internal/task/jira/rest/client_test.go +381 -0
  86. package/internal/task/jira/testdata/jira_search_issues.json +120 -0
  87. package/internal/task/jira/transition_defaults_test.go +158 -0
  88. package/internal/task/jira/validation_test.go +94 -0
  89. package/internal/task/task.go +84 -0
  90. package/internal/workflow/report.go +85 -0
  91. package/internal/workflow/report_test.go +259 -0
  92. package/internal/workflow/service.go +142 -0
  93. package/internal/workflow/store.go +136 -0
  94. package/internal/workflow/store_test.go +282 -0
  95. package/internal/workflow/workflow.go +345 -0
  96. package/internal/workflow/workflow_test.go +412 -0
  97. package/package.json +7 -2
  98. package/internal/acli/acli.go +0 -229
  99. package/internal/config/demo_test.go +0 -17
  100. package/internal/config/schema.go +0 -193
  101. package/internal/config/schema_test.go +0 -162
  102. package/internal/daemon/daemon.go +0 -218
  103. package/internal/daemon/daemon_test.go +0 -204
  104. package/internal/discovery/discovery.go +0 -122
  105. package/internal/discovery/discovery_test.go +0 -62
  106. package/internal/opencode/opencode.go +0 -26
  107. package/internal/orcacli/orcacli.go +0 -264
  108. package/internal/runner/orca/README.md +0 -64
  109. package/internal/runner/runner_test.go +0 -64
  110. package/internal/server/server_test.go +0 -195
  111. package/internal/tasks/jira/README.md +0 -69
  112. package/internal/tasks/jira/component_test.go +0 -16
  113. package/internal/tasks/jira/decode.go +0 -24
  114. package/internal/tasks/jira/jira.go +0 -231
  115. package/internal/tasks/jira/jira_test.go +0 -259
  116. package/internal/tasks/jira/jql_test.go +0 -16
  117. package/internal/tasks/tasks.go +0 -90
  118. 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
+ }