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.
Files changed (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /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 ACLI without inventing an
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
- // fakeACLI is a test-local fakeable ACLI boundary recording transitions and
23
+ // fakeJira is a test-local fakeable Jira boundary recording transitions and
24
24
  // serving a fixed search batch for Poll.
25
- type fakeACLI struct {
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 []byte
32
+ searchJSON []byte
33
+ comments []string
34
+ addedComments []string
35
+ labelCalls []string
33
36
  }
34
37
 
35
- func (f *fakeACLI) transition(key, status string) {
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 fake ACLI. The
44
- // adapter owns its ACLI wiring; the test injects only the CLI seam.
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 := &fakeACLI{}
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 := &fakeACLI{}
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 := &fakeACLI{}
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) != 2 || fake.events[0] != "assign" || fake.events[1] != "transition" {
104
- t.Fatalf("mailbox events = %v, want [assign transition]", fake.events)
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 := &fakeACLI{assignErr: errors.New("assignment failed")}
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 := &fakeACLI{}
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 applies the end node config to the parent target.
130
- if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endConfig(config.RawValues{})); err != nil {
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 := &fakeACLI{}
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")
@@ -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