relay-flow 0.2.0-alpha → 0.2.1-alpha

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