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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- 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 +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -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/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- 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/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 +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Package bdcli is the narrow subprocess client for the Beads bd CLI.
|
|
2
|
+
//
|
|
3
|
+
// Beads owns its storage and workspace selection. This package only executes
|
|
4
|
+
// bd commands with JSON output and translates the small portion of that JSON
|
|
5
|
+
// contract needed by the task-system adapter.
|
|
6
|
+
package bdcli
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"bytes"
|
|
10
|
+
"context"
|
|
11
|
+
"encoding/json"
|
|
12
|
+
"errors"
|
|
13
|
+
"fmt"
|
|
14
|
+
"io"
|
|
15
|
+
"os"
|
|
16
|
+
"os/exec"
|
|
17
|
+
"strings"
|
|
18
|
+
"sync"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// CLI executes bd in one repository and one configured Beads workspace.
|
|
22
|
+
// Commands for a workspace are serialized because a single bd workspace may
|
|
23
|
+
// be backed by an embedded database.
|
|
24
|
+
type CLI struct {
|
|
25
|
+
repoPath string
|
|
26
|
+
beadsDir string
|
|
27
|
+
mu sync.Mutex
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// New constructs a CLI client for the registered code repository and Beads
|
|
31
|
+
// workspace. The paths are passed to every child process; this function does
|
|
32
|
+
// not initialize or otherwise inspect the workspace.
|
|
33
|
+
func New(repoPath, beadsDir string) *CLI {
|
|
34
|
+
return &CLI{repoPath: repoPath, beadsDir: beadsDir}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Issue contains only the Beads issue fields relay-flow needs.
|
|
38
|
+
type Issue struct {
|
|
39
|
+
ID string `json:"id"`
|
|
40
|
+
Title string `json:"title"`
|
|
41
|
+
Description string `json:"description,omitempty"`
|
|
42
|
+
Status string `json:"status"`
|
|
43
|
+
IssueType string `json:"issue_type"`
|
|
44
|
+
Priority int `json:"priority"`
|
|
45
|
+
Assignee string `json:"assignee,omitempty"`
|
|
46
|
+
Labels []string `json:"labels,omitempty"`
|
|
47
|
+
Parent string `json:"parent,omitempty"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Comment is a Beads issue comment.
|
|
51
|
+
type Comment struct {
|
|
52
|
+
ID string `json:"id"`
|
|
53
|
+
Text string `json:"text"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Client is the narrow command seam consumed by the Beads task adapter.
|
|
57
|
+
type Client interface {
|
|
58
|
+
Probe(ctx context.Context) error
|
|
59
|
+
|
|
60
|
+
ListReady(ctx context.Context) ([]Issue, error)
|
|
61
|
+
ListClaimed(ctx context.Context) ([]Issue, error)
|
|
62
|
+
|
|
63
|
+
ListChildren(ctx context.Context, parentID string) ([]Issue, error)
|
|
64
|
+
Show(ctx context.Context, issueID string) (Issue, error)
|
|
65
|
+
|
|
66
|
+
ListComments(ctx context.Context, issueID string) ([]Comment, error)
|
|
67
|
+
|
|
68
|
+
CreateChild(
|
|
69
|
+
ctx context.Context,
|
|
70
|
+
parentID string,
|
|
71
|
+
title string,
|
|
72
|
+
description string,
|
|
73
|
+
label string,
|
|
74
|
+
) (Issue, error)
|
|
75
|
+
|
|
76
|
+
Update(ctx context.Context, issueID string, input UpdateInput) error
|
|
77
|
+
AddComment(ctx context.Context, issueID, body string) error
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// UpdateInput describes the supported bd update flags. Description is sent
|
|
81
|
+
// through stdin when non-nil so multiline mailbox descriptions are preserved.
|
|
82
|
+
type UpdateInput struct {
|
|
83
|
+
Description *string
|
|
84
|
+
Status string
|
|
85
|
+
Assignee string
|
|
86
|
+
AddLabels []string
|
|
87
|
+
ClearDefer bool
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// CommandError reports a bd process that exited unsuccessfully. Stdout and
|
|
91
|
+
// stderr are kept separate because bd may emit useful diagnostics on either
|
|
92
|
+
// stream.
|
|
93
|
+
type CommandError struct {
|
|
94
|
+
Args []string
|
|
95
|
+
ExitCode int
|
|
96
|
+
Stderr string
|
|
97
|
+
Stdout string
|
|
98
|
+
|
|
99
|
+
cause error
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func (e *CommandError) Error() string {
|
|
103
|
+
if e == nil {
|
|
104
|
+
return "bd command failed"
|
|
105
|
+
}
|
|
106
|
+
message := fmt.Sprintf("bd %v: exit status %d", e.Args, e.ExitCode)
|
|
107
|
+
if stderr := strings.TrimSpace(e.Stderr); stderr != "" {
|
|
108
|
+
message += ": " + stderr
|
|
109
|
+
}
|
|
110
|
+
return message
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func (e *CommandError) Unwrap() error {
|
|
114
|
+
if e == nil {
|
|
115
|
+
return nil
|
|
116
|
+
}
|
|
117
|
+
return e.cause
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Probe performs a harmless bounded read to ensure that the selected bd
|
|
121
|
+
// binary can use the configured workspace.
|
|
122
|
+
func (c *CLI) Probe(ctx context.Context) error {
|
|
123
|
+
var issues []Issue
|
|
124
|
+
return c.runJSON(ctx, []string{"list", "--ready", "--limit", "1", "--no-parent", "--json"}, nil, &issues)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ListReady lists ready top-level issues.
|
|
128
|
+
func (c *CLI) ListReady(ctx context.Context) ([]Issue, error) {
|
|
129
|
+
var issues []Issue
|
|
130
|
+
if err := c.runJSON(ctx, []string{"list", "--ready", "--no-parent", "--limit", "0", "--json"}, nil, &issues); err != nil {
|
|
131
|
+
return nil, err
|
|
132
|
+
}
|
|
133
|
+
return issues, nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ListClaimed lists active top-level issues carrying a relay-flow workflow
|
|
137
|
+
// claim. The deferred status is intentional: it is part of the selected bd
|
|
138
|
+
// command contract for this adapter.
|
|
139
|
+
func (c *CLI) ListClaimed(ctx context.Context) ([]Issue, error) {
|
|
140
|
+
var issues []Issue
|
|
141
|
+
if err := c.runJSON(ctx, []string{
|
|
142
|
+
"list", "--no-parent", "--status", "open,in_progress,blocked,deferred",
|
|
143
|
+
"--label-pattern", "wf:*", "--limit", "0", "--json",
|
|
144
|
+
}, nil, &issues); err != nil {
|
|
145
|
+
return nil, err
|
|
146
|
+
}
|
|
147
|
+
return issues, nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ListChildren lists every child issue of parentID, including closed issues.
|
|
151
|
+
func (c *CLI) ListChildren(ctx context.Context, parentID string) ([]Issue, error) {
|
|
152
|
+
var issues []Issue
|
|
153
|
+
if err := c.runJSON(ctx, []string{
|
|
154
|
+
"list", "--parent", parentID, "--all", "--limit", "0", "--json",
|
|
155
|
+
}, nil, &issues); err != nil {
|
|
156
|
+
return nil, err
|
|
157
|
+
}
|
|
158
|
+
return issues, nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Show reads one issue. The selected bd CLI returns a one-element JSON array
|
|
162
|
+
// for show, so an empty result is treated as an unusable response.
|
|
163
|
+
func (c *CLI) Show(ctx context.Context, issueID string) (Issue, error) {
|
|
164
|
+
var issues []Issue
|
|
165
|
+
if err := c.runJSON(ctx, []string{"show", issueID, "--json"}, nil, &issues); err != nil {
|
|
166
|
+
return Issue{}, err
|
|
167
|
+
}
|
|
168
|
+
if len(issues) == 0 {
|
|
169
|
+
return Issue{}, fmt.Errorf("bd show %q returned no issues", issueID)
|
|
170
|
+
}
|
|
171
|
+
return issues[0], nil
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ListComments lists comments for one issue.
|
|
175
|
+
func (c *CLI) ListComments(ctx context.Context, issueID string) ([]Comment, error) {
|
|
176
|
+
var comments []Comment
|
|
177
|
+
if err := c.runJSON(ctx, []string{"comments", issueID, "--json"}, nil, &comments); err != nil {
|
|
178
|
+
return nil, err
|
|
179
|
+
}
|
|
180
|
+
return comments, nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// CreateChild creates a task child under parentID. The description is sent on
|
|
184
|
+
// stdin to preserve multiline text exactly.
|
|
185
|
+
func (c *CLI) CreateChild(ctx context.Context, parentID, title, description, label string) (Issue, error) {
|
|
186
|
+
var issue Issue
|
|
187
|
+
if err := c.runJSON(ctx, []string{
|
|
188
|
+
"create", title,
|
|
189
|
+
"--type", "task",
|
|
190
|
+
"--parent", parentID,
|
|
191
|
+
"--no-inherit-labels",
|
|
192
|
+
"--labels", label,
|
|
193
|
+
"--stdin", "--json",
|
|
194
|
+
}, strings.NewReader(description), &issue); err != nil {
|
|
195
|
+
return Issue{}, err
|
|
196
|
+
}
|
|
197
|
+
return issue, nil
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Update applies the requested bd update flags. The method intentionally does
|
|
201
|
+
// not add conditional status flags; status reconciliation belongs to the
|
|
202
|
+
// Beads adapter, which reads the issue before calling Update.
|
|
203
|
+
func (c *CLI) Update(ctx context.Context, issueID string, input UpdateInput) error {
|
|
204
|
+
args := []string{"update", issueID}
|
|
205
|
+
var stdin io.Reader
|
|
206
|
+
if input.Description != nil {
|
|
207
|
+
args = append(args, "--description=-")
|
|
208
|
+
stdin = strings.NewReader(*input.Description)
|
|
209
|
+
}
|
|
210
|
+
if input.Status != "" {
|
|
211
|
+
args = append(args, "--status", input.Status)
|
|
212
|
+
}
|
|
213
|
+
if input.Assignee != "" {
|
|
214
|
+
args = append(args, "--assignee", input.Assignee)
|
|
215
|
+
}
|
|
216
|
+
for _, label := range input.AddLabels {
|
|
217
|
+
args = append(args, "--add-label", label)
|
|
218
|
+
}
|
|
219
|
+
if input.ClearDefer {
|
|
220
|
+
args = append(args, "--defer", "")
|
|
221
|
+
}
|
|
222
|
+
if len(args) == 2 {
|
|
223
|
+
return errors.New("bd update requires at least one update field")
|
|
224
|
+
}
|
|
225
|
+
args = append(args, "--json")
|
|
226
|
+
var response any
|
|
227
|
+
return c.runJSON(ctx, args, stdin, &response)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// AddComment writes a comment through stdin so multiline content is not
|
|
231
|
+
// interpreted as shell or command-line text.
|
|
232
|
+
func (c *CLI) AddComment(ctx context.Context, issueID, body string) error {
|
|
233
|
+
var response any
|
|
234
|
+
return c.runJSON(ctx, []string{"comment", issueID, "--stdin", "--json"}, strings.NewReader(body), &response)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// run executes one serialized bd command and captures stdout and stderr
|
|
238
|
+
// independently.
|
|
239
|
+
func (c *CLI) run(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) {
|
|
240
|
+
c.mu.Lock()
|
|
241
|
+
defer c.mu.Unlock()
|
|
242
|
+
|
|
243
|
+
cmd := exec.CommandContext(ctx, "bd", args...)
|
|
244
|
+
cmd.Dir = c.repoPath
|
|
245
|
+
cmd.Env = commandEnvironment(c.repoPath, c.beadsDir)
|
|
246
|
+
cmd.Stdin = stdin
|
|
247
|
+
|
|
248
|
+
var stdout, stderr bytes.Buffer
|
|
249
|
+
cmd.Stdout = &stdout
|
|
250
|
+
cmd.Stderr = &stderr
|
|
251
|
+
if err := cmd.Run(); err != nil {
|
|
252
|
+
exitCode := -1
|
|
253
|
+
var exitErr *exec.ExitError
|
|
254
|
+
if errors.As(err, &exitErr) && exitErr.ProcessState != nil {
|
|
255
|
+
exitCode = exitErr.ExitCode()
|
|
256
|
+
}
|
|
257
|
+
return stdout.Bytes(), &CommandError{
|
|
258
|
+
Args: append([]string(nil), args...),
|
|
259
|
+
ExitCode: exitCode,
|
|
260
|
+
Stderr: stderr.String(),
|
|
261
|
+
Stdout: stdout.String(),
|
|
262
|
+
cause: err,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return stdout.Bytes(), nil
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// runJSON runs bd and decodes its stdout. Informational text printed before
|
|
269
|
+
// the JSON value is tolerated; stderr remains available only on CommandError
|
|
270
|
+
// and is never treated as JSON input.
|
|
271
|
+
func (c *CLI) runJSON(ctx context.Context, args []string, stdin io.Reader, destination any) error {
|
|
272
|
+
data, err := c.run(ctx, args, stdin)
|
|
273
|
+
if err != nil {
|
|
274
|
+
return err
|
|
275
|
+
}
|
|
276
|
+
return parseJSON(data, destination)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// commandEnvironment preserves unrelated process environment while removing
|
|
280
|
+
// ambient Beads selectors that could redirect a child command. The configured
|
|
281
|
+
// workspace is always the sole BEADS_DIR entry.
|
|
282
|
+
func commandEnvironment(_ string, beadsDir string) []string {
|
|
283
|
+
ambient := os.Environ()
|
|
284
|
+
env := make([]string, 0, len(ambient)+1)
|
|
285
|
+
for _, entry := range ambient {
|
|
286
|
+
key, _, ok := strings.Cut(entry, "=")
|
|
287
|
+
if ok && (key == "BEADS_DIR" || key == "BEADS_DB" || key == "BD_DB") {
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
env = append(env, entry)
|
|
291
|
+
}
|
|
292
|
+
env = append(env, "BEADS_DIR="+beadsDir)
|
|
293
|
+
return env
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// parseJSON decodes the first valid JSON value in data, allowing informational
|
|
297
|
+
// lines before it. This keeps the parser tolerant of bd's human-facing notices
|
|
298
|
+
// without combining stderr with stdout.
|
|
299
|
+
func parseJSON(data []byte, destination any) error {
|
|
300
|
+
var lastErr error
|
|
301
|
+
for offset, value := range data {
|
|
302
|
+
if value != '{' && value != '[' {
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
decoder := json.NewDecoder(bytes.NewReader(data[offset:]))
|
|
306
|
+
var raw json.RawMessage
|
|
307
|
+
if err := decoder.Decode(&raw); err != nil {
|
|
308
|
+
lastErr = err
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
if err := json.Unmarshal(raw, destination); err != nil {
|
|
312
|
+
lastErr = err
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
return nil
|
|
316
|
+
}
|
|
317
|
+
if lastErr == nil {
|
|
318
|
+
lastErr = errors.New("no JSON value found")
|
|
319
|
+
}
|
|
320
|
+
return fmt.Errorf("parse bd JSON: %w", lastErr)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
var _ Client = (*CLI)(nil)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
package bdcli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestRunJSONUsesRepoDirectoryAndConfiguredWorkspace(t *testing.T) {
|
|
14
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
15
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
16
|
+
|
|
17
|
+
var got []map[string]any
|
|
18
|
+
cli := New(repoPath, beadsDir)
|
|
19
|
+
if err := cli.runJSON(context.Background(), []string{"array", "--json"}, nil, &got); err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
if len(got) != 1 || got[0]["id"] != "demo-1" {
|
|
23
|
+
t.Fatalf("decoded array = %#v", got)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestCLIUsesExactReadCommands(t *testing.T) {
|
|
28
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
29
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
30
|
+
cli := New(repoPath, beadsDir)
|
|
31
|
+
ctx := context.Background()
|
|
32
|
+
|
|
33
|
+
if err := cli.Probe(ctx); err != nil {
|
|
34
|
+
t.Fatalf("Probe: %v", err)
|
|
35
|
+
}
|
|
36
|
+
if issues, err := cli.ListReady(ctx); err != nil || len(issues) != 1 || issues[0].ID != "demo-1" {
|
|
37
|
+
t.Fatalf("ListReady = %#v, %v", issues, err)
|
|
38
|
+
}
|
|
39
|
+
if issues, err := cli.ListClaimed(ctx); err != nil || len(issues) != 1 || issues[0].ID != "demo-1" {
|
|
40
|
+
t.Fatalf("ListClaimed = %#v, %v", issues, err)
|
|
41
|
+
}
|
|
42
|
+
if issues, err := cli.ListChildren(ctx, "demo-parent"); err != nil || len(issues) != 1 || issues[0].Parent != "demo-parent" {
|
|
43
|
+
t.Fatalf("ListChildren = %#v, %v", issues, err)
|
|
44
|
+
}
|
|
45
|
+
if issue, err := cli.Show(ctx, "demo-parent"); err != nil || issue.ID != "demo-parent" {
|
|
46
|
+
t.Fatalf("Show = %#v, %v", issue, err)
|
|
47
|
+
}
|
|
48
|
+
if comments, err := cli.ListComments(ctx, "demo-parent"); err != nil || len(comments) != 1 || comments[0].Text != "existing comment" {
|
|
49
|
+
t.Fatalf("ListComments = %#v, %v", comments, err)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func TestCLIUsesExactWriteCommandsAndStdin(t *testing.T) {
|
|
54
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
55
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
56
|
+
cli := New(repoPath, beadsDir)
|
|
57
|
+
ctx := context.Background()
|
|
58
|
+
|
|
59
|
+
if _, err := cli.CreateChild(ctx, "demo-parent", "demo-parent:implement", "mailbox description\nsecond line\n", "wf:implementation"); err != nil {
|
|
60
|
+
t.Fatalf("CreateChild: %v", err)
|
|
61
|
+
}
|
|
62
|
+
description := "reconciled description\nwith two lines\n"
|
|
63
|
+
if err := cli.Update(ctx, "demo-parent.1", UpdateInput{
|
|
64
|
+
Description: &description,
|
|
65
|
+
AddLabels: []string{"wf:implementation"},
|
|
66
|
+
}); err != nil {
|
|
67
|
+
t.Fatalf("Update description/label: %v", err)
|
|
68
|
+
}
|
|
69
|
+
if err := cli.Update(ctx, "demo-parent", UpdateInput{AddLabels: []string{"wf:implementation"}}); err != nil {
|
|
70
|
+
t.Fatalf("Update claim label: %v", err)
|
|
71
|
+
}
|
|
72
|
+
if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "in_progress"}); err != nil {
|
|
73
|
+
t.Fatalf("Update status: %v", err)
|
|
74
|
+
}
|
|
75
|
+
if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "in_progress", Assignee: "dev@example.com"}); err != nil {
|
|
76
|
+
t.Fatalf("Update status and assignee: %v", err)
|
|
77
|
+
}
|
|
78
|
+
if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Assignee: "dev@example.com"}); err != nil {
|
|
79
|
+
t.Fatalf("Update assignee only: %v", err)
|
|
80
|
+
}
|
|
81
|
+
if err := cli.AddComment(ctx, "demo-parent.1", "summary line\n\nsecond line\n"); err != nil {
|
|
82
|
+
t.Fatalf("AddComment: %v", err)
|
|
83
|
+
}
|
|
84
|
+
if err := cli.Update(ctx, "demo-parent", UpdateInput{Status: "open", ClearDefer: true}); err != nil {
|
|
85
|
+
t.Fatalf("Update recovery reset: %v", err)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func TestCLIUsesCloseAndReopenUpdateCommands(t *testing.T) {
|
|
90
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
91
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
92
|
+
cli := New(repoPath, beadsDir)
|
|
93
|
+
ctx := context.Background()
|
|
94
|
+
|
|
95
|
+
if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "closed"}); err != nil {
|
|
96
|
+
t.Fatalf("Close update: %v", err)
|
|
97
|
+
}
|
|
98
|
+
if err := cli.Update(ctx, "demo-parent.1", UpdateInput{Status: "open", ClearDefer: true}); err != nil {
|
|
99
|
+
t.Fatalf("Reopen update: %v", err)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestStrictFakeBDRejectsUnexpectedArgv(t *testing.T) {
|
|
104
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
105
|
+
fake := installStrictFakeBD(t, repoPath, beadsDir)
|
|
106
|
+
|
|
107
|
+
cmd := exec.Command(fake, "array", "--limit", "1", "--json")
|
|
108
|
+
cmd.Dir = repoPath
|
|
109
|
+
cmd.Env = []string{
|
|
110
|
+
"PATH=" + os.Getenv("PATH"),
|
|
111
|
+
"BD_EXPECT_CWD=" + repoPath,
|
|
112
|
+
"BD_EXPECT_BEADS_DIR=" + beadsDir,
|
|
113
|
+
"BEADS_DIR=" + beadsDir,
|
|
114
|
+
"BD_FIXTURE_DIR=" + os.Getenv("BD_FIXTURE_DIR"),
|
|
115
|
+
}
|
|
116
|
+
if err := cmd.Run(); err == nil {
|
|
117
|
+
t.Fatal("strict fake accepted an unsupported argv")
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestRunJSONDecodesObjectAndAcceptsEmptyArray(t *testing.T) {
|
|
122
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
123
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
124
|
+
cli := New(repoPath, beadsDir)
|
|
125
|
+
|
|
126
|
+
var object map[string]any
|
|
127
|
+
if err := cli.runJSON(context.Background(), []string{"object", "demo-1", "--json"}, nil, &object); err != nil {
|
|
128
|
+
t.Fatal(err)
|
|
129
|
+
}
|
|
130
|
+
if object["id"] != "demo-1" || object["title"] != "Shown issue" {
|
|
131
|
+
t.Fatalf("decoded object = %#v", object)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
var empty []map[string]any
|
|
135
|
+
if err := cli.runJSON(context.Background(), []string{"empty", "--json"}, nil, &empty); err != nil {
|
|
136
|
+
t.Fatal(err)
|
|
137
|
+
}
|
|
138
|
+
if empty == nil || len(empty) != 0 {
|
|
139
|
+
t.Fatalf("decoded empty array = %#v", empty)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func TestRunJSONPreservesMultilineStdin(t *testing.T) {
|
|
144
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
145
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
146
|
+
|
|
147
|
+
var got map[string]any
|
|
148
|
+
cli := New(repoPath, beadsDir)
|
|
149
|
+
stdin := strings.NewReader("first line\nsecond line\n\nfinal line\n")
|
|
150
|
+
if err := cli.runJSON(context.Background(), []string{"stdin", "--json"}, stdin, &got); err != nil {
|
|
151
|
+
t.Fatal(err)
|
|
152
|
+
}
|
|
153
|
+
if got["stdin"] != "accepted" {
|
|
154
|
+
t.Fatalf("decoded stdin response = %#v", got)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func TestCommandEnvironmentIsolatesBeadsSelectors(t *testing.T) {
|
|
159
|
+
t.Setenv("BEADS_DIR", "/ambient/workspace")
|
|
160
|
+
t.Setenv("BEADS_DB", "/ambient/beads.db")
|
|
161
|
+
t.Setenv("BD_DB", "/ambient/legacy.db")
|
|
162
|
+
t.Setenv("UNRELATED_ENV", "preserve-me")
|
|
163
|
+
|
|
164
|
+
env := commandEnvironment("/repo", "/configured/workspace")
|
|
165
|
+
values := make(map[string][]string)
|
|
166
|
+
for _, entry := range env {
|
|
167
|
+
key, value, ok := strings.Cut(entry, "=")
|
|
168
|
+
if !ok {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
values[key] = append(values[key], value)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if got := values["BEADS_DIR"]; len(got) != 1 || got[0] != "/configured/workspace" {
|
|
175
|
+
t.Fatalf("BEADS_DIR entries = %#v", got)
|
|
176
|
+
}
|
|
177
|
+
for _, key := range []string{"BEADS_DB", "BD_DB"} {
|
|
178
|
+
if got := values[key]; len(got) != 0 {
|
|
179
|
+
t.Fatalf("ambient %s leaked into command environment: %#v", key, got)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if got := values["UNRELATED_ENV"]; len(got) != 1 || got[0] != "preserve-me" {
|
|
183
|
+
t.Fatalf("unrelated environment = %#v", got)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func TestParseJSONSkipsInformationalOutputBeforeValue(t *testing.T) {
|
|
188
|
+
var got map[string]string
|
|
189
|
+
if err := parseJSON([]byte("bd: using workspace\n{\"id\":\"demo-1\"}\n"), &got); err != nil {
|
|
190
|
+
t.Fatal(err)
|
|
191
|
+
}
|
|
192
|
+
if got["id"] != "demo-1" {
|
|
193
|
+
t.Fatalf("parsed value = %#v", got)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func TestRunJSONRejectsMalformedOutput(t *testing.T) {
|
|
198
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
199
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
200
|
+
|
|
201
|
+
var got map[string]any
|
|
202
|
+
err := New(repoPath, beadsDir).runJSON(context.Background(), []string{"malformed", "--json"}, nil, &got)
|
|
203
|
+
if err == nil {
|
|
204
|
+
t.Fatal("malformed JSON was accepted")
|
|
205
|
+
}
|
|
206
|
+
var commandErr *CommandError
|
|
207
|
+
if errors.As(err, &commandErr) {
|
|
208
|
+
t.Fatalf("malformed JSON reported as command failure: %#v", commandErr)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestRunReportsExitCodeStdoutAndStderrSeparately(t *testing.T) {
|
|
213
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
214
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
215
|
+
|
|
216
|
+
_, err := New(repoPath, beadsDir).run(context.Background(), []string{"fail"}, nil)
|
|
217
|
+
if err == nil {
|
|
218
|
+
t.Fatal("ordinary non-zero bd exit was accepted")
|
|
219
|
+
}
|
|
220
|
+
var commandErr *CommandError
|
|
221
|
+
if !errors.As(err, &commandErr) {
|
|
222
|
+
t.Fatalf("error = %T %v, want CommandError", err, err)
|
|
223
|
+
}
|
|
224
|
+
if commandErr.ExitCode != 23 {
|
|
225
|
+
t.Fatalf("exit code = %d, want 23", commandErr.ExitCode)
|
|
226
|
+
}
|
|
227
|
+
if !strings.Contains(commandErr.Stdout, "partial stdout") {
|
|
228
|
+
t.Fatalf("stdout = %q", commandErr.Stdout)
|
|
229
|
+
}
|
|
230
|
+
if !strings.Contains(commandErr.Stderr, "failure detail") {
|
|
231
|
+
t.Fatalf("stderr = %q", commandErr.Stderr)
|
|
232
|
+
}
|
|
233
|
+
if len(commandErr.Args) != 1 || commandErr.Args[0] != "fail" {
|
|
234
|
+
t.Fatalf("command args = %#v", commandErr.Args)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func TestRunJSONDoesNotTreatStderrAsJSONInput(t *testing.T) {
|
|
239
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
240
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
241
|
+
|
|
242
|
+
var got map[string]any
|
|
243
|
+
if err := New(repoPath, beadsDir).runJSON(context.Background(), []string{"stderr", "--json"}, nil, &got); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
if got["id"] != "demo-1" {
|
|
247
|
+
t.Fatalf("decoded stderr response = %#v", got)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func TestRunJSONToleratesInformationalStdout(t *testing.T) {
|
|
252
|
+
repoPath, beadsDir := newTestPaths(t)
|
|
253
|
+
installStrictFakeBD(t, repoPath, beadsDir)
|
|
254
|
+
|
|
255
|
+
var got map[string]any
|
|
256
|
+
if err := New(repoPath, beadsDir).runJSON(context.Background(), []string{"info", "--json"}, nil, &got); err != nil {
|
|
257
|
+
t.Fatal(err)
|
|
258
|
+
}
|
|
259
|
+
if got["id"] != "demo-1" {
|
|
260
|
+
t.Fatalf("decoded informational response = %#v", got)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func newTestPaths(t *testing.T) (repoPath, beadsDir string) {
|
|
265
|
+
t.Helper()
|
|
266
|
+
repoPath = t.TempDir()
|
|
267
|
+
beadsDir = filepath.Join(t.TempDir(), ".beads")
|
|
268
|
+
if err := os.Mkdir(beadsDir, 0o755); err != nil {
|
|
269
|
+
t.Fatal(err)
|
|
270
|
+
}
|
|
271
|
+
return repoPath, beadsDir
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func installStrictFakeBD(t *testing.T, repoPath, beadsDir string) string {
|
|
275
|
+
t.Helper()
|
|
276
|
+
binDir := t.TempDir()
|
|
277
|
+
fake := filepath.Join(binDir, "bd")
|
|
278
|
+
script, err := os.ReadFile(filepath.Join("testdata", "strict-bd.sh"))
|
|
279
|
+
if err != nil {
|
|
280
|
+
t.Fatal(err)
|
|
281
|
+
}
|
|
282
|
+
if err := os.WriteFile(fake, script, 0o755); err != nil {
|
|
283
|
+
t.Fatal(err)
|
|
284
|
+
}
|
|
285
|
+
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
286
|
+
t.Setenv("BD_EXPECT_CWD", repoPath)
|
|
287
|
+
t.Setenv("BD_EXPECT_BEADS_DIR", beadsDir)
|
|
288
|
+
fixtureDir, err := filepath.Abs("testdata")
|
|
289
|
+
if err != nil {
|
|
290
|
+
t.Fatal(err)
|
|
291
|
+
}
|
|
292
|
+
t.Setenv("BD_FIXTURE_DIR", fixtureDir)
|
|
293
|
+
t.Setenv("BEADS_DIR", "/ambient/workspace")
|
|
294
|
+
t.Setenv("BEADS_DB", "/ambient/beads.db")
|
|
295
|
+
t.Setenv("BD_DB", "/ambient/legacy.db")
|
|
296
|
+
return fake
|
|
297
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"id":"demo-1","title":"Ready issue"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"id":"demo-parent.1","title":"demo-parent:implement","description":"Existing mailbox","status":"open","issue_type":"task","priority":2,"labels":["wf:implementation"],"parent":"demo-parent"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"id":"demo-1","title":"Claimed issue","description":"Claimed description","status":"in_progress","issue_type":"epic","priority":1,"labels":["wf:implementation"]}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"id":"comment-2","text":"summary line"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"id":"comment-1","text":"existing comment"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"id":"demo-parent.1","title":"demo-parent:implement","description":"mailbox description\nsecond line\n","status":"open","issue_type":"task","priority":2,"labels":["wf:implementation"],"parent":"demo-parent"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"id":"demo-1","title":"Shown issue"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"id":"demo-1","title":"Ready issue","description":"Ready description","status":"open","issue_type":"epic","priority":1,"labels":[]}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"id":"demo-parent","title":"Parent issue","description":"Parent description","status":"open","issue_type":"epic","priority":1,"labels":[]}]
|