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.
- package/README.md +155 -22
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +74 -0
- package/cmd/relay-flow/main.go +25 -0
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +4 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +105 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +50 -4
- package/internal/execution/goworkflows/interpreter.go +36 -29
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/recovery_test.go +2 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/run.go +6 -4
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/empty.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/jira.go +171 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +4 -2
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- 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)
|