relay-flow 0.2.1-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 (72) hide show
  1. package/README.md +155 -22
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +74 -0
  4. package/cmd/relay-flow/main.go +25 -0
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +4 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  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 +105 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine_test.go +33 -15
  16. package/internal/execution/goworkflows/fakes_test.go +50 -4
  17. package/internal/execution/goworkflows/interpreter.go +36 -29
  18. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  19. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  20. package/internal/execution/goworkflows/recovery_test.go +2 -2
  21. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  22. package/internal/harness/contract_test.go +10 -0
  23. package/internal/harness/factory.go +25 -3
  24. package/internal/harness/harness.go +30 -4
  25. package/internal/harness/opencode/opencode.go +125 -10
  26. package/internal/harness/opencode/opencode_test.go +183 -0
  27. package/internal/harness/opencode/repo_setup.go +361 -0
  28. package/internal/harness/plugin_selection_test.go +5 -5
  29. package/internal/recover/recover.go +11 -6
  30. package/internal/repo/service.go +10 -0
  31. package/internal/repo/service_test.go +51 -2
  32. package/internal/run/run.go +6 -4
  33. package/internal/task/beads/bdcli/bdcli.go +323 -0
  34. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  35. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  36. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  37. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  38. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  39. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  40. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  41. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  42. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  43. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  44. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  45. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  46. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  47. package/internal/task/beads/beads.go +840 -0
  48. package/internal/task/beads/beads_test.go +609 -0
  49. package/internal/task/beads/comments_test.go +242 -0
  50. package/internal/task/beads/config_compatibility_test.go +163 -0
  51. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  52. package/internal/task/beads/repo_composition_test.go +232 -0
  53. package/internal/task/beads/runtime_config_test.go +81 -0
  54. package/internal/task/beads/status_compatibility_test.go +233 -0
  55. package/internal/task/beads/status_test.go +257 -0
  56. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  57. package/internal/task/beads/validation_test.go +110 -0
  58. package/internal/task/contract_test.go +10 -0
  59. package/internal/task/factory.go +37 -4
  60. package/internal/task/jira/auth.go +27 -1
  61. package/internal/task/jira/auth_test.go +54 -1
  62. package/internal/task/jira/filters_test.go +60 -0
  63. package/internal/task/jira/jira.go +171 -29
  64. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  65. package/internal/task/jira/rest/adf.go +119 -82
  66. package/internal/task/jira/rest/adf_test.go +60 -0
  67. package/internal/task/jira/rest/client_test.go +1 -1
  68. package/internal/task/jira/templates_test.go +118 -0
  69. package/internal/task/jira/transition_defaults_test.go +4 -2
  70. package/internal/task/task.go +32 -0
  71. package/internal/workflow/report_test.go +45 -0
  72. package/package.json +1 -1
@@ -0,0 +1,609 @@
1
+ package beads
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "reflect"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/config"
15
+ "github.com/rajpopat27/relay-flow/internal/task"
16
+ "github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
17
+ )
18
+
19
+ func TestBeadsFactoryIsRegisteredWithBeadsDirRequirement(t *testing.T) {
20
+ if !hasString(task.Names(), "beads") {
21
+ t.Fatalf("task plugins = %v, want beads", task.Names())
22
+ }
23
+ keys, err := task.RequiredRepoKeys("beads")
24
+ if err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ if !reflect.DeepEqual(keys, []string{"beadsDir"}) {
28
+ t.Fatalf("required repo keys = %v, want [beadsDir]", keys)
29
+ }
30
+ }
31
+
32
+ func TestBeadsConfigRejectsUnknownFields(t *testing.T) {
33
+ for _, field := range []string{"unknownField", "project", "component"} {
34
+ t.Run(field, func(t *testing.T) {
35
+ err := task.ValidateTextConfig("beads", config.RawValues{field: "must be rejected"})
36
+ if err == nil {
37
+ t.Fatalf("unknown Beads config field %q was accepted", field)
38
+ }
39
+ })
40
+ }
41
+ }
42
+
43
+ func TestBeadsConfigRejectsExplicitNulls(t *testing.T) {
44
+ err := task.ValidateTextConfig("beads", config.RawValues{
45
+ "templates": map[string]any{
46
+ "summaryComment": nil,
47
+ },
48
+ })
49
+ if err == nil || !strings.Contains(err.Error(), "explicit null") {
50
+ t.Fatalf("explicit null error = %v", err)
51
+ }
52
+ }
53
+
54
+ func TestBeadsScopeRequiresBeadsDir(t *testing.T) {
55
+ for _, repoConfig := range []config.RawValues{
56
+ {},
57
+ {"beadsDir": ""},
58
+ {"beadsDir": " "},
59
+ } {
60
+ if _, err := task.TaskScopeKey("beads", nil, repoConfig); err == nil {
61
+ t.Fatalf("scope accepted repo config %#v without a usable beadsDir", repoConfig)
62
+ }
63
+ }
64
+ if _, err := task.TaskScopeKey("beads", config.RawValues{"beadsDir": t.TempDir()}, nil); err == nil {
65
+ t.Fatal("root beadsDir satisfied the required repo-scoped key")
66
+ }
67
+
68
+ file := filepath.Join(t.TempDir(), "not-a-directory")
69
+ if err := os.WriteFile(file, []byte("file"), 0o600); err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ if _, err := task.TaskScopeKey("beads", nil, config.RawValues{"beadsDir": file}); err == nil {
73
+ t.Fatal("file path was accepted as a Beads workspace directory")
74
+ }
75
+ }
76
+
77
+ func TestBeadsScopeIsCanonicalAndIndependent(t *testing.T) {
78
+ root := t.TempDir()
79
+ first := filepath.Join(root, "first")
80
+ second := filepath.Join(root, "second")
81
+ if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if err := os.MkdirAll(first, 0o755); err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ if err := os.MkdirAll(second, 0o755); err != nil {
88
+ t.Fatal(err)
89
+ }
90
+
91
+ firstInput := filepath.Join(root, "nested", "..", "first", ".")
92
+ firstScope, err := task.TaskScopeKey("beads", nil, config.RawValues{"beadsDir": firstInput})
93
+ if err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ wantFirst, err := filepath.Abs(first)
97
+ if err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if firstScope != filepath.Clean(wantFirst) {
101
+ t.Fatalf("first scope = %q, want canonical %q", firstScope, filepath.Clean(wantFirst))
102
+ }
103
+
104
+ secondScope, err := task.TaskScopeKey("beads", nil, config.RawValues{"beadsDir": second + string(os.PathSeparator)})
105
+ if err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ if secondScope == firstScope {
109
+ t.Fatalf("independent Beads directories share scope %q", firstScope)
110
+ }
111
+ }
112
+
113
+ func TestBeadsDuplicateScopeInputsResolveIdentically(t *testing.T) {
114
+ root := t.TempDir()
115
+ workspace := filepath.Join(root, "workspace")
116
+ if err := os.Mkdir(workspace, 0o755); err != nil {
117
+ t.Fatal(err)
118
+ }
119
+
120
+ first, err := task.TaskScopeKey("beads", nil, config.RawValues{"beadsDir": workspace})
121
+ if err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ second, err := task.TaskScopeKey("beads", nil, config.RawValues{"beadsDir": filepath.Join(workspace, ".")})
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ if first != second {
129
+ t.Fatalf("equivalent Beads directories produced scopes %q and %q", first, second)
130
+ }
131
+ }
132
+
133
+ func TestBeadsAuthIsNoOpWithoutCreatingCredentials(t *testing.T) {
134
+ home := t.TempDir()
135
+ t.Setenv("RELAY_FLOW_HOME", home)
136
+ if err := task.Auth(context.Background(), "beads", nil, strings.NewReader("")); err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ if _, err := os.Stat(filepath.Join(home, "credentials.yaml")); !os.IsNotExist(err) {
140
+ t.Fatalf("Beads auth created credentials: %v", err)
141
+ }
142
+ }
143
+
144
+ func TestBeadsAuthRejectsUnsupportedArgumentsWithoutCreatingCredentials(t *testing.T) {
145
+ home := t.TempDir()
146
+ t.Setenv("RELAY_FLOW_HOME", home)
147
+ if err := task.Auth(context.Background(), "beads", []string{"--token", "secret"}, strings.NewReader("")); err == nil {
148
+ t.Fatal("unsupported Beads auth arguments were accepted")
149
+ }
150
+ if _, err := os.Stat(filepath.Join(home, "credentials.yaml")); !os.IsNotExist(err) {
151
+ t.Fatalf("unsupported Beads auth created credentials: %v", err)
152
+ }
153
+ }
154
+
155
+ func TestIssueNormalizationMapsBeadsFieldsAndClaims(t *testing.T) {
156
+ issue := bdcli.Issue{
157
+ ID: "demo-a1b2",
158
+ Title: "Implement adapter",
159
+ Description: "Work on the adapter.",
160
+ Status: "in_progress",
161
+ IssueType: "epic",
162
+ Priority: 2,
163
+ Assignee: "relay-bot",
164
+ Labels: []string{"wf:implementation", "backend"},
165
+ }
166
+ ticket := issueToTicket(issue)
167
+ if ticket.ID != issue.ID || ticket.Key != issue.ID || ticket.Title != issue.Title {
168
+ t.Fatalf("ticket identity = %+v", ticket)
169
+ }
170
+ if !reflect.DeepEqual(ticket.WorkflowClaims, []string{"wf:implementation"}) {
171
+ t.Fatalf("workflow claims = %v", ticket.WorkflowClaims)
172
+ }
173
+ wantFields := map[string]any{
174
+ "status": "in_progress",
175
+ "issueType": "epic",
176
+ "assignee": "relay-bot",
177
+ "priority": 2,
178
+ "description": "Work on the adapter.",
179
+ "labels": []string{"wf:implementation", "backend"},
180
+ }
181
+ if !reflect.DeepEqual(ticket.Fields, wantFields) {
182
+ t.Fatalf("normalized fields = %#v, want %#v", ticket.Fields, wantFields)
183
+ }
184
+ if got := normalizeFields(issue); !reflect.DeepEqual(got, wantFields) {
185
+ t.Fatalf("normalizeFields = %#v, want %#v", got, wantFields)
186
+ }
187
+ }
188
+
189
+ func TestExtractWorkflowClaimsKeepsOnlyWorkflowLabels(t *testing.T) {
190
+ labels := []string{"backend", "wf:implementation", "wf:review", "wf:"}
191
+ want := []string{"wf:implementation", "wf:review"}
192
+ if got := extractWorkflowClaims(labels); !reflect.DeepEqual(got, want) {
193
+ t.Fatalf("workflow claims = %v, want %v", got, want)
194
+ }
195
+ }
196
+
197
+ func TestPollDeduplicatesReadyAndClaimedParentsByIssueID(t *testing.T) {
198
+ ready := bdcli.Issue{ID: "demo-a1b2", Title: "ready copy", Status: "open", IssueType: "epic"}
199
+ claimedDuplicate := bdcli.Issue{ID: "demo-a1b2", Title: "claimed copy", Status: "in_progress", IssueType: "epic", Labels: []string{"wf:implementation"}}
200
+ claimedOnly := bdcli.Issue{ID: "demo-c3d4", Title: "claimed only", Status: "blocked", IssueType: "task", Labels: []string{"wf:review"}}
201
+ fake := &pollClient{
202
+ ready: []bdcli.Issue{ready},
203
+ claimed: []bdcli.Issue{claimedDuplicate, claimedOnly},
204
+ }
205
+ sys := &system{cli: fake}
206
+
207
+ got, err := sys.Poll(context.Background())
208
+ if err != nil {
209
+ t.Fatal(err)
210
+ }
211
+ if len(got) != 2 {
212
+ t.Fatalf("Poll returned %d tickets, want 2: %+v", len(got), got)
213
+ }
214
+ seen := map[string]task.Ticket{}
215
+ for _, ticket := range got {
216
+ seen[ticket.ID] = ticket
217
+ }
218
+ if len(seen) != 2 {
219
+ t.Fatalf("Poll returned duplicate IDs: %+v", got)
220
+ }
221
+ if seen["demo-a1b2"].ID != "demo-a1b2" {
222
+ t.Fatalf("duplicate parent has wrong identity: %+v", seen["demo-a1b2"])
223
+ }
224
+ if seen["demo-c3d4"].Title != "claimed only" {
225
+ t.Fatalf("claimed-only parent missing: %+v", seen["demo-c3d4"])
226
+ }
227
+ if fake.readyCalls != 1 || fake.claimedCalls != 1 {
228
+ t.Fatalf("poll queries = ready:%d claimed:%d, want one each", fake.readyCalls, fake.claimedCalls)
229
+ }
230
+ }
231
+
232
+ func TestNormalizedChildIsExcludedFromPolling(t *testing.T) {
233
+ parent := bdcli.Issue{ID: "demo-parent", Title: "parent", Status: "open", IssueType: "epic"}
234
+ child := bdcli.Issue{ID: "demo-parent.1", Title: "demo-parent:implement", Status: "open", IssueType: "task", Parent: "demo-parent"}
235
+ fake := &pollClient{ready: []bdcli.Issue{child, parent}, claimed: []bdcli.Issue{child}}
236
+ got, err := (&system{cli: fake}).Poll(context.Background())
237
+ if err != nil {
238
+ t.Fatal(err)
239
+ }
240
+ if len(got) != 1 || got[0].ID != parent.ID {
241
+ t.Fatalf("Poll = %+v, want only top-level parent", got)
242
+ }
243
+ }
244
+
245
+ func TestMailboxHelpersUseStableTitleAndIDs(t *testing.T) {
246
+ if got := mailboxTitle("demo-parent", "implement"); got != "demo-parent:implement" {
247
+ t.Fatalf("mailboxTitle = %q", got)
248
+ }
249
+ issue := bdcli.Issue{ID: "demo-parent.1", Title: "demo-parent:implement"}
250
+ mailbox := issueToMailbox(issue, "implement")
251
+ if mailbox.ID != issue.ID || mailbox.Key != issue.ID || mailbox.Node != "implement" {
252
+ t.Fatalf("issueToMailbox = %+v", mailbox)
253
+ }
254
+ }
255
+
256
+ func TestFindMailboxRejectsDuplicateStableTitles(t *testing.T) {
257
+ children := []bdcli.Issue{
258
+ {ID: "demo-parent.1", Title: "demo-parent:implement"},
259
+ {ID: "demo-parent.2", Title: "demo-parent:implement"},
260
+ }
261
+ if _, err := findMailbox(children, "demo-parent:implement"); err == nil {
262
+ t.Fatal("duplicate stable mailbox title was accepted")
263
+ }
264
+ }
265
+
266
+ func TestFindMailboxReportsMissingAndReturnsExisting(t *testing.T) {
267
+ children := []bdcli.Issue{{ID: "demo-parent.1", Title: "demo-parent:implement"}}
268
+ got, err := findMailbox(children, "demo-parent:implement")
269
+ if err != nil || got.ID != "demo-parent.1" {
270
+ t.Fatalf("findMailbox existing = %+v, %v", got, err)
271
+ }
272
+ if _, err := findMailbox(children, "demo-parent:review"); err == nil {
273
+ t.Fatal("missing mailbox was reported as existing")
274
+ }
275
+ }
276
+
277
+ func TestEnsureMailboxesCreatesMissingAndReconcilesExisting(t *testing.T) {
278
+ existing := bdcli.Issue{
279
+ ID: "demo-parent.1",
280
+ Title: "demo-parent:implement",
281
+ Description: "stale description",
282
+ Labels: []string{"old-label"},
283
+ Parent: "demo-parent",
284
+ }
285
+ fake := &mailboxClient{children: []bdcli.Issue{existing}}
286
+ sys := &system{cli: fake}
287
+ specs := []task.MailboxSpec{
288
+ {Node: "implement", Title: "demo-parent:implement", Description: "updated work"},
289
+ {Node: "review", Title: "demo-parent:review", Description: "review work"},
290
+ }
291
+
292
+ got, err := sys.EnsureMailboxes(context.Background(), task.TicketRef{ID: "demo-parent", Key: "demo-parent"}, "implementation", specs)
293
+ if err != nil {
294
+ t.Fatal(err)
295
+ }
296
+ if len(got) != len(specs) {
297
+ t.Fatalf("mailboxes = %+v, want one per spec", got)
298
+ }
299
+ if got["implement"].ID != "demo-parent.1" || got["implement"].Key != "demo-parent.1" {
300
+ t.Fatalf("existing mailbox = %+v", got["implement"])
301
+ }
302
+ if got["review"].ID != "demo-parent.2" || got["review"].Key != "demo-parent.2" {
303
+ t.Fatalf("created mailbox = %+v", got["review"])
304
+ }
305
+ if fake.listChildrenCalls != 1 {
306
+ t.Fatalf("ListChildren calls = %d, want 1", fake.listChildrenCalls)
307
+ }
308
+ if len(fake.created) != 1 || fake.created[0].parentID != "demo-parent" || fake.created[0].title != "demo-parent:review" || fake.created[0].description != "review work" || fake.created[0].label != "wf:implementation" {
309
+ t.Fatalf("created children = %+v", fake.created)
310
+ }
311
+ if len(fake.updates) != 1 {
312
+ t.Fatalf("reconciliation updates = %+v, want one existing-child update", fake.updates)
313
+ }
314
+ update := fake.updates[0]
315
+ if update.issueID != "demo-parent.1" || update.input.Description == nil || *update.input.Description != "updated work" || !reflect.DeepEqual(update.input.AddLabels, []string{"wf:implementation"}) {
316
+ t.Fatalf("existing-child update = %+v", update)
317
+ }
318
+ }
319
+
320
+ func TestEnsureMailboxesReusesStableMailboxOnRevisit(t *testing.T) {
321
+ fake := &mailboxClient{children: []bdcli.Issue{{
322
+ ID: "demo-parent.1", Title: "demo-parent:implement", Parent: "demo-parent",
323
+ }}}
324
+ sys := &system{cli: fake}
325
+ specs := []task.MailboxSpec{{Node: "implement", Title: "demo-parent:implement", Description: "same work"}}
326
+ parent := task.TicketRef{ID: "demo-parent", Key: "demo-parent"}
327
+
328
+ first, err := sys.EnsureMailboxes(context.Background(), parent, "implementation", specs)
329
+ if err != nil {
330
+ t.Fatal(err)
331
+ }
332
+ second, err := sys.EnsureMailboxes(context.Background(), parent, "implementation", specs)
333
+ if err != nil {
334
+ t.Fatal(err)
335
+ }
336
+ if first["implement"] != second["implement"] {
337
+ t.Fatalf("mailbox changed on revisit: first=%+v second=%+v", first["implement"], second["implement"])
338
+ }
339
+ if len(fake.created) != 0 {
340
+ t.Fatalf("revisit created new mailbox: %+v", fake.created)
341
+ }
342
+ if len(fake.updates) != 2 {
343
+ t.Fatalf("revisit reconciliation updates = %d, want one per call", len(fake.updates))
344
+ }
345
+ }
346
+
347
+ func TestEnsureMailboxesRejectsDuplicateExistingStableTitles(t *testing.T) {
348
+ fake := &mailboxClient{children: []bdcli.Issue{
349
+ {ID: "demo-parent.1", Title: "demo-parent:implement", Parent: "demo-parent"},
350
+ {ID: "demo-parent.2", Title: "demo-parent:implement", Parent: "demo-parent"},
351
+ }}
352
+ sys := &system{cli: fake}
353
+ _, err := sys.EnsureMailboxes(context.Background(), task.TicketRef{ID: "demo-parent", Key: "demo-parent"}, "implementation", []task.MailboxSpec{{Node: "implement", Title: "demo-parent:implement", Description: "work"}})
354
+ if err == nil {
355
+ t.Fatal("duplicate stable mailbox title was accepted")
356
+ }
357
+ if len(fake.updates) != 0 || len(fake.created) != 0 {
358
+ t.Fatalf("duplicate mailbox caused side effects: updates=%+v creates=%+v", fake.updates, fake.created)
359
+ }
360
+ }
361
+
362
+ func TestEnsureMailboxesConcurrentCallersDoNotCreateDuplicateStableTitles(t *testing.T) {
363
+ fake := newConcurrentMailboxClient()
364
+ sys := &system{cli: fake}
365
+ parent := task.TicketRef{ID: "demo-parent", Key: "demo-parent"}
366
+ specs := []task.MailboxSpec{
367
+ {Node: "implement", Title: "demo-parent:implement", Description: "implement work"},
368
+ {Node: "review", Title: "demo-parent:review", Description: "review work"},
369
+ }
370
+
371
+ errs := make(chan error, 2)
372
+ go func() {
373
+ _, err := sys.EnsureMailboxes(context.Background(), parent, "implementation", specs)
374
+ errs <- err
375
+ }()
376
+ select {
377
+ case <-fake.firstListStarted:
378
+ case <-time.After(time.Second):
379
+ t.Fatal("first concurrent caller did not start listing children")
380
+ }
381
+ go func() {
382
+ _, err := sys.EnsureMailboxes(context.Background(), parent, "implementation", specs)
383
+ errs <- err
384
+ }()
385
+ for i := 0; i < 2; i++ {
386
+ if err := <-errs; err != nil {
387
+ t.Fatal(err)
388
+ }
389
+ }
390
+
391
+ if fake.createCount() != len(specs) {
392
+ t.Fatalf("created %d mailbox children, want %d", fake.createCount(), len(specs))
393
+ }
394
+ if got := fake.titles(); len(got) != len(specs) || got["demo-parent:implement"] != 1 || got["demo-parent:review"] != 1 {
395
+ t.Fatalf("stable mailbox title counts = %v, want one each", got)
396
+ }
397
+ }
398
+
399
+ func hasString(values []string, want string) bool {
400
+ for _, value := range values {
401
+ if value == want {
402
+ return true
403
+ }
404
+ }
405
+ return false
406
+ }
407
+
408
+ type pollClient struct {
409
+ ready, claimed []bdcli.Issue
410
+ readyCalls int
411
+ claimedCalls int
412
+ }
413
+
414
+ func (f *pollClient) Probe(context.Context) error { return nil }
415
+
416
+ func (f *pollClient) ListReady(context.Context) ([]bdcli.Issue, error) {
417
+ f.readyCalls++
418
+ return append([]bdcli.Issue(nil), f.ready...), nil
419
+ }
420
+
421
+ func (f *pollClient) ListClaimed(context.Context) ([]bdcli.Issue, error) {
422
+ f.claimedCalls++
423
+ return append([]bdcli.Issue(nil), f.claimed...), nil
424
+ }
425
+
426
+ func (*pollClient) ListChildren(context.Context, string) ([]bdcli.Issue, error) {
427
+ return nil, errors.New("unexpected ListChildren call")
428
+ }
429
+
430
+ func (*pollClient) Show(context.Context, string) (bdcli.Issue, error) {
431
+ return bdcli.Issue{}, errors.New("unexpected Show call")
432
+ }
433
+
434
+ func (*pollClient) ListComments(context.Context, string) ([]bdcli.Comment, error) {
435
+ return nil, errors.New("unexpected ListComments call")
436
+ }
437
+
438
+ func (*pollClient) CreateChild(context.Context, string, string, string, string) (bdcli.Issue, error) {
439
+ return bdcli.Issue{}, errors.New("unexpected CreateChild call")
440
+ }
441
+
442
+ func (*pollClient) Update(context.Context, string, bdcli.UpdateInput) error {
443
+ return errors.New("unexpected Update call")
444
+ }
445
+
446
+ func (*pollClient) AddComment(context.Context, string, string) error {
447
+ return errors.New("unexpected AddComment call")
448
+ }
449
+
450
+ var _ bdcli.Client = (*pollClient)(nil)
451
+
452
+ type mailboxClient struct {
453
+ children []bdcli.Issue
454
+ listChildrenCalls int
455
+ created []createChildCall
456
+ updates []updateCall
457
+ }
458
+
459
+ type createChildCall struct {
460
+ parentID string
461
+ title string
462
+ description string
463
+ label string
464
+ }
465
+
466
+ type updateCall struct {
467
+ issueID string
468
+ input bdcli.UpdateInput
469
+ }
470
+
471
+ func (f *mailboxClient) Probe(context.Context) error { return nil }
472
+
473
+ func (*mailboxClient) ListReady(context.Context) ([]bdcli.Issue, error) {
474
+ return nil, errors.New("unexpected ListReady call")
475
+ }
476
+
477
+ func (*mailboxClient) ListClaimed(context.Context) ([]bdcli.Issue, error) {
478
+ return nil, errors.New("unexpected ListClaimed call")
479
+ }
480
+
481
+ func (f *mailboxClient) ListChildren(context.Context, string) ([]bdcli.Issue, error) {
482
+ f.listChildrenCalls++
483
+ return append([]bdcli.Issue(nil), f.children...), nil
484
+ }
485
+
486
+ func (*mailboxClient) Show(context.Context, string) (bdcli.Issue, error) {
487
+ return bdcli.Issue{}, errors.New("unexpected Show call")
488
+ }
489
+
490
+ func (*mailboxClient) ListComments(context.Context, string) ([]bdcli.Comment, error) {
491
+ return nil, errors.New("unexpected ListComments call")
492
+ }
493
+
494
+ func (f *mailboxClient) CreateChild(_ context.Context, parentID, title, description, label string) (bdcli.Issue, error) {
495
+ issue := bdcli.Issue{
496
+ ID: "demo-parent.2",
497
+ Title: title,
498
+ Description: description,
499
+ Labels: []string{label},
500
+ Parent: parentID,
501
+ }
502
+ f.created = append(f.created, createChildCall{parentID: parentID, title: title, description: description, label: label})
503
+ f.children = append(f.children, issue)
504
+ return issue, nil
505
+ }
506
+
507
+ func (f *mailboxClient) Update(_ context.Context, issueID string, input bdcli.UpdateInput) error {
508
+ f.updates = append(f.updates, updateCall{issueID: issueID, input: input})
509
+ return nil
510
+ }
511
+
512
+ func (*mailboxClient) AddComment(context.Context, string, string) error {
513
+ return errors.New("unexpected AddComment call")
514
+ }
515
+
516
+ var _ bdcli.Client = (*mailboxClient)(nil)
517
+
518
+ type concurrentMailboxClient struct {
519
+ mu sync.Mutex
520
+ children []bdcli.Issue
521
+ created []createChildCall
522
+ listCount int
523
+ firstListStarted chan struct{}
524
+ secondList chan struct{}
525
+ firstListOnce sync.Once
526
+ secondListOnce sync.Once
527
+ }
528
+
529
+ func newConcurrentMailboxClient() *concurrentMailboxClient {
530
+ return &concurrentMailboxClient{
531
+ firstListStarted: make(chan struct{}),
532
+ secondList: make(chan struct{}),
533
+ }
534
+ }
535
+
536
+ func (*concurrentMailboxClient) Probe(context.Context) error { return nil }
537
+
538
+ func (*concurrentMailboxClient) ListReady(context.Context) ([]bdcli.Issue, error) {
539
+ return nil, errors.New("unexpected ListReady call")
540
+ }
541
+
542
+ func (*concurrentMailboxClient) ListClaimed(context.Context) ([]bdcli.Issue, error) {
543
+ return nil, errors.New("unexpected ListClaimed call")
544
+ }
545
+
546
+ func (f *concurrentMailboxClient) ListChildren(context.Context, string) ([]bdcli.Issue, error) {
547
+ f.mu.Lock()
548
+ f.listCount++
549
+ listCount := f.listCount
550
+ children := append([]bdcli.Issue(nil), f.children...)
551
+ f.mu.Unlock()
552
+ if listCount == 1 {
553
+ f.firstListOnce.Do(func() { close(f.firstListStarted) })
554
+ }
555
+ if listCount >= 2 {
556
+ f.secondListOnce.Do(func() { close(f.secondList) })
557
+ }
558
+ return children, nil
559
+ }
560
+
561
+ func (*concurrentMailboxClient) Show(context.Context, string) (bdcli.Issue, error) {
562
+ return bdcli.Issue{}, errors.New("unexpected Show call")
563
+ }
564
+
565
+ func (*concurrentMailboxClient) ListComments(context.Context, string) ([]bdcli.Comment, error) {
566
+ return nil, errors.New("unexpected ListComments call")
567
+ }
568
+
569
+ func (f *concurrentMailboxClient) CreateChild(_ context.Context, parentID, title, description, label string) (bdcli.Issue, error) {
570
+ // Model the CLI's individual-command serialization without making the
571
+ // list-then-create sequence atomic. Two unprotected callers can therefore
572
+ // both observe the same empty snapshot and create duplicate stable titles.
573
+ select {
574
+ case <-f.secondList:
575
+ case <-time.After(100 * time.Millisecond):
576
+ }
577
+ f.mu.Lock()
578
+ defer f.mu.Unlock()
579
+ issue := bdcli.Issue{ID: "demo-parent." + string(rune('0'+len(f.children)+1)), Title: title, Parent: parentID}
580
+ f.children = append(f.children, issue)
581
+ f.created = append(f.created, createChildCall{parentID: parentID, title: title, description: description, label: label})
582
+ return issue, nil
583
+ }
584
+
585
+ func (*concurrentMailboxClient) Update(context.Context, string, bdcli.UpdateInput) error {
586
+ return nil
587
+ }
588
+
589
+ func (*concurrentMailboxClient) AddComment(context.Context, string, string) error {
590
+ return errors.New("unexpected AddComment call")
591
+ }
592
+
593
+ func (f *concurrentMailboxClient) createCount() int {
594
+ f.mu.Lock()
595
+ defer f.mu.Unlock()
596
+ return len(f.created)
597
+ }
598
+
599
+ func (f *concurrentMailboxClient) titles() map[string]int {
600
+ f.mu.Lock()
601
+ defer f.mu.Unlock()
602
+ counts := make(map[string]int, len(f.children))
603
+ for _, child := range f.children {
604
+ counts[child.Title]++
605
+ }
606
+ return counts
607
+ }
608
+
609
+ var _ bdcli.Client = (*concurrentMailboxClient)(nil)