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,232 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
func TestBeadsRepoRegistrationUsesIndependentSystemsAndPollers(t *testing.T) {
|
|
20
|
+
root := t.TempDir()
|
|
21
|
+
logPath := installRepoCompositionFakeBD(t)
|
|
22
|
+
configPath := filepath.Join(root, "config.yaml")
|
|
23
|
+
if err := config.SaveMachine(configPath, &config.Machine{
|
|
24
|
+
TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
|
|
25
|
+
Repos: map[string]config.Repo{},
|
|
26
|
+
}); err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
paymentsPath := filepath.Join(root, "code", "payments")
|
|
31
|
+
platformPath := filepath.Join(root, "code", "platform")
|
|
32
|
+
paymentsBeads := filepath.Join(root, "beads", "payments", ".beads")
|
|
33
|
+
platformBeads := filepath.Join(root, "beads", "platform", ".beads")
|
|
34
|
+
for _, path := range []string{paymentsPath, platformPath, paymentsBeads, platformBeads} {
|
|
35
|
+
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
36
|
+
t.Fatal(err)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
svc := repo.NewService(repo.ServiceConfig{
|
|
41
|
+
ConfigPath: configPath,
|
|
42
|
+
TaskPlugin: "beads",
|
|
43
|
+
Runner: &compositionRunner{},
|
|
44
|
+
Harness: &compositionHarness{},
|
|
45
|
+
Active: &compositionActive{},
|
|
46
|
+
Workflows: &compositionWorkflowRefs{},
|
|
47
|
+
})
|
|
48
|
+
if _, err := svc.Register(context.Background(), repo.RegisterInput{
|
|
49
|
+
Name: "payments", Path: paymentsPath,
|
|
50
|
+
TaskConfig: config.RawValues{"beadsDir": paymentsBeads},
|
|
51
|
+
}); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
if _, err := svc.Register(context.Background(), repo.RegisterInput{
|
|
55
|
+
Name: "platform", Path: platformPath,
|
|
56
|
+
TaskConfig: config.RawValues{"beadsDir": platformBeads},
|
|
57
|
+
}); err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
registered := svc.Registry().List()
|
|
62
|
+
if len(registered) != 2 {
|
|
63
|
+
t.Fatalf("registered repos = %d, want 2", len(registered))
|
|
64
|
+
}
|
|
65
|
+
if registered[0].TaskSystem == registered[1].TaskSystem {
|
|
66
|
+
t.Fatal("independent Beads repos shared one task system")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var handledMu sync.Mutex
|
|
70
|
+
handled := map[string]int{}
|
|
71
|
+
group := repo.NewPollerGroup(10, func(_ context.Context, r *repo.Repo, _ []task.Ticket) {
|
|
72
|
+
handledMu.Lock()
|
|
73
|
+
handled[r.Name]++
|
|
74
|
+
handledMu.Unlock()
|
|
75
|
+
})
|
|
76
|
+
group.Interval = 10 * time.Millisecond
|
|
77
|
+
group.ReplaceRepos(registered)
|
|
78
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
79
|
+
defer cancel()
|
|
80
|
+
go group.Run(ctx)
|
|
81
|
+
|
|
82
|
+
deadline := time.Now().Add(3 * time.Second)
|
|
83
|
+
for time.Now().Before(deadline) {
|
|
84
|
+
handledMu.Lock()
|
|
85
|
+
complete := handled["payments"] > 0 && handled["platform"] > 0
|
|
86
|
+
handledMu.Unlock()
|
|
87
|
+
if complete {
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
time.Sleep(5 * time.Millisecond)
|
|
91
|
+
}
|
|
92
|
+
handledMu.Lock()
|
|
93
|
+
if handled["payments"] == 0 || handled["platform"] == 0 {
|
|
94
|
+
t.Fatalf("poller handlers = %v, want both registered repos", handled)
|
|
95
|
+
}
|
|
96
|
+
handledMu.Unlock()
|
|
97
|
+
|
|
98
|
+
assertBDInvocationReachedRepoAndWorkspace(t, logPath, paymentsPath, paymentsBeads)
|
|
99
|
+
assertBDInvocationReachedRepoAndWorkspace(t, logPath, platformPath, platformBeads)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestBeadsRepoRegistrationRejectsSharedCanonicalWorkspace(t *testing.T) {
|
|
103
|
+
root := t.TempDir()
|
|
104
|
+
installRepoCompositionFakeBD(t)
|
|
105
|
+
configPath := filepath.Join(root, "config.yaml")
|
|
106
|
+
if err := config.SaveMachine(configPath, &config.Machine{
|
|
107
|
+
TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
|
|
108
|
+
Repos: map[string]config.Repo{},
|
|
109
|
+
}); err != nil {
|
|
110
|
+
t.Fatal(err)
|
|
111
|
+
}
|
|
112
|
+
firstPath := filepath.Join(root, "code", "payments")
|
|
113
|
+
secondPath := filepath.Join(root, "code", "platform")
|
|
114
|
+
workspace := filepath.Join(root, "beads", "shared", ".beads")
|
|
115
|
+
for _, path := range []string{firstPath, secondPath, workspace} {
|
|
116
|
+
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
117
|
+
t.Fatal(err)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
svc := repo.NewService(repo.ServiceConfig{
|
|
122
|
+
ConfigPath: configPath,
|
|
123
|
+
TaskPlugin: "beads",
|
|
124
|
+
Runner: &compositionRunner{},
|
|
125
|
+
Harness: &compositionHarness{},
|
|
126
|
+
Active: &compositionActive{},
|
|
127
|
+
Workflows: &compositionWorkflowRefs{},
|
|
128
|
+
})
|
|
129
|
+
if _, err := svc.Register(context.Background(), repo.RegisterInput{
|
|
130
|
+
Name: "payments", Path: firstPath,
|
|
131
|
+
TaskConfig: config.RawValues{"beadsDir": workspace},
|
|
132
|
+
}); err != nil {
|
|
133
|
+
t.Fatal(err)
|
|
134
|
+
}
|
|
135
|
+
if _, err := svc.Register(context.Background(), repo.RegisterInput{
|
|
136
|
+
Name: "platform", Path: secondPath,
|
|
137
|
+
TaskConfig: config.RawValues{"beadsDir": filepath.Join(workspace, ".")},
|
|
138
|
+
}); err == nil {
|
|
139
|
+
t.Fatal("second repo sharing canonical beadsDir was accepted")
|
|
140
|
+
}
|
|
141
|
+
if len(svc.Registry().List()) != 1 {
|
|
142
|
+
t.Fatalf("registry after duplicate scope rejection = %d, want 1", len(svc.Registry().List()))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func installRepoCompositionFakeBD(t *testing.T) string {
|
|
147
|
+
t.Helper()
|
|
148
|
+
binDir := t.TempDir()
|
|
149
|
+
fake := filepath.Join(binDir, "bd")
|
|
150
|
+
script, err := os.ReadFile(filepath.Join("testdata", "strict-bd-repo.sh"))
|
|
151
|
+
if err != nil {
|
|
152
|
+
t.Fatal(err)
|
|
153
|
+
}
|
|
154
|
+
if err := os.WriteFile(fake, script, 0o755); err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
logPath := filepath.Join(t.TempDir(), "bd-invocations.log")
|
|
158
|
+
if err := os.WriteFile(logPath, nil, 0o600); err != nil {
|
|
159
|
+
t.Fatal(err)
|
|
160
|
+
}
|
|
161
|
+
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
162
|
+
t.Setenv("BD_LOG", logPath)
|
|
163
|
+
// These must not leak into the child command environment. The adapter
|
|
164
|
+
// explicitly selects BEADS_DIR and removes unrelated Beads selectors.
|
|
165
|
+
t.Setenv("BEADS_DIR", "/ambient/workspace")
|
|
166
|
+
t.Setenv("BEADS_DB", "/ambient/beads.db")
|
|
167
|
+
t.Setenv("BD_DB", "/ambient/legacy.db")
|
|
168
|
+
return logPath
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func assertBDInvocationReachedRepoAndWorkspace(t *testing.T, logPath, repoPath, beadsDir string) {
|
|
172
|
+
t.Helper()
|
|
173
|
+
raw, err := os.ReadFile(logPath)
|
|
174
|
+
if err != nil {
|
|
175
|
+
t.Fatal(err)
|
|
176
|
+
}
|
|
177
|
+
wantPrefix := repoPath + "|" + beadsDir + "|"
|
|
178
|
+
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
|
179
|
+
if strings.HasPrefix(line, wantPrefix) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
t.Fatalf("no bd invocation used cmd.Dir=%q and BEADS_DIR=%q; log=%q", repoPath, beadsDir, raw)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type compositionRunner struct{}
|
|
187
|
+
|
|
188
|
+
func (*compositionRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
189
|
+
return nil, nil
|
|
190
|
+
}
|
|
191
|
+
func (*compositionRunner) ValidateRepo(context.Context, string, string) error { return nil }
|
|
192
|
+
func (*compositionRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
|
|
193
|
+
return runner.Environment{}, nil
|
|
194
|
+
}
|
|
195
|
+
func (*compositionRunner) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
func (*compositionRunner) FindTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
|
|
199
|
+
return runner.Terminal{}, false, nil
|
|
200
|
+
}
|
|
201
|
+
func (*compositionRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
|
|
202
|
+
return runner.Terminal{}, nil
|
|
203
|
+
}
|
|
204
|
+
func (*compositionRunner) EnsureTerminal(context.Context, runner.Environment, runner.Terminal, string, runner.Command) (runner.Terminal, error) {
|
|
205
|
+
return runner.Terminal{}, nil
|
|
206
|
+
}
|
|
207
|
+
func (*compositionRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
|
|
208
|
+
func (*compositionRunner) CloseTerminal(context.Context, runner.Terminal) error { return nil }
|
|
209
|
+
func (*compositionRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
|
|
210
|
+
func (*compositionRunner) CleanupRun(context.Context, runner.RunSpec) error { return nil }
|
|
211
|
+
|
|
212
|
+
type compositionHarness struct{}
|
|
213
|
+
|
|
214
|
+
func (*compositionHarness) SetupRepo(context.Context, string) error { return nil }
|
|
215
|
+
func (*compositionHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
216
|
+
func (*compositionHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
217
|
+
return harness.Session{}, false, nil
|
|
218
|
+
}
|
|
219
|
+
func (*compositionHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
|
|
220
|
+
return "", nil
|
|
221
|
+
}
|
|
222
|
+
func (*compositionHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
|
|
223
|
+
return runner.Command{}, nil
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type compositionActive struct{}
|
|
227
|
+
|
|
228
|
+
func (*compositionActive) HasActiveRepo(context.Context, string) (bool, error) { return false, nil }
|
|
229
|
+
|
|
230
|
+
type compositionWorkflowRefs struct{}
|
|
231
|
+
|
|
232
|
+
func (*compositionWorkflowRefs) ReferencesRepo(string) bool { return false }
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Beads-specific runtime configuration behavior. The shared inheritance and
|
|
12
|
+
// precedence invariants live in lifecycle_inheritance_test.go, which is
|
|
13
|
+
// mirrored by the Jira adapter.
|
|
14
|
+
|
|
15
|
+
func TestNodeAssigneeOverridesInheritedRepoAssignee(t *testing.T) {
|
|
16
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
17
|
+
sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
|
|
18
|
+
node := config.RawValues{"assignee": "dev@example.com"}
|
|
19
|
+
|
|
20
|
+
cfg := operationConfig(sys.WorkDefaults(), nil, node)
|
|
21
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
if len(client.updates) != 1 || client.updates[0].input.Assignee != "dev@example.com" {
|
|
25
|
+
t.Fatalf("updates = %+v, want the node assignee to win", client.updates)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// transitionTo is a lifecycle-point setting: a value configured above node
|
|
30
|
+
// scope applies to every lifecycle point that reads it, including end. This
|
|
31
|
+
// test pins that uniform precedence so the behavior is deliberate rather than
|
|
32
|
+
// discovered, and is why the documentation configures transitionTo on nodes.
|
|
33
|
+
func TestInheritedParentStatusAppliesToEveryLifecyclePoint(t *testing.T) {
|
|
34
|
+
client := newStatusClient(map[string]string{"demo-parent": "in_progress"})
|
|
35
|
+
sys := repoScopedSystem(client, config.RawValues{
|
|
36
|
+
"transitionTo": map[string]any{"parentStatus": "in_progress"},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
cfg := operationConfig(sys.EndDefaults(), nil, nil)
|
|
40
|
+
if err := sys.ApplyTaskConfig(context.Background(), endTarget(), cfg); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
if len(client.updates) != 0 {
|
|
44
|
+
t.Fatalf("updates = %+v, want the inherited parentStatus to win over the end default", client.updates)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The end node's own value restores closing behavior.
|
|
48
|
+
node := config.RawValues{"transitionTo": map[string]any{"parentStatus": "closed"}}
|
|
49
|
+
if err := sys.ApplyTaskConfig(context.Background(), endTarget(), operationConfig(sys.EndDefaults(), nil, node)); err != nil {
|
|
50
|
+
t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
if len(client.updates) != 1 || client.updates[0].input.Status != "closed" {
|
|
53
|
+
t.Fatalf("updates = %+v, want end node parentStatus closed", client.updates)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// RenderText has a fixed task.System signature and receives no workflow/node
|
|
58
|
+
// config. Beads therefore chooses the explicit limitation: lower-scope
|
|
59
|
+
// template overrides are rejected rather than accepted without runtime effect.
|
|
60
|
+
func TestBeadsLowerScopeTemplateOverridesAreRejected(t *testing.T) {
|
|
61
|
+
sys := repoScopedSystem(nil, nil)
|
|
62
|
+
workflow := config.RawValues{"templates": map[string]any{
|
|
63
|
+
"summaryComment": "workflow {{summaryReport}}",
|
|
64
|
+
}}
|
|
65
|
+
node := config.RawValues{"templates": map[string]any{
|
|
66
|
+
"feedbackComment": "node {{feedbackReport}}",
|
|
67
|
+
}}
|
|
68
|
+
|
|
69
|
+
err := sys.ValidateConfig(context.Background(), workflow, map[string]config.RawValues{"implement": node})
|
|
70
|
+
lower := strings.ToLower(errString(err))
|
|
71
|
+
if err == nil || !strings.Contains(lower, "template") || !strings.Contains(lower, "scope") {
|
|
72
|
+
t.Fatalf("lower-scope template override validation error = %v, want explicit unsupported-scope error", err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func errString(err error) string {
|
|
77
|
+
if err == nil {
|
|
78
|
+
return ""
|
|
79
|
+
}
|
|
80
|
+
return err.Error()
|
|
81
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func transitionConfig(parentStatus, taskStatus string) config.RawValues {
|
|
14
|
+
transition := map[string]any{}
|
|
15
|
+
if parentStatus != "" {
|
|
16
|
+
transition["parentStatus"] = parentStatus
|
|
17
|
+
}
|
|
18
|
+
if taskStatus != "" {
|
|
19
|
+
transition["taskStatus"] = taskStatus
|
|
20
|
+
}
|
|
21
|
+
return config.RawValues{"transitionTo": transition}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func statusTarget(mailboxID string) task.Target {
|
|
25
|
+
return task.Target{
|
|
26
|
+
Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"},
|
|
27
|
+
Mailbox: &task.Mailbox{ID: mailboxID, Key: mailboxID, Node: "implement"},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// endTarget is the parent-only target used for the start and end lifecycle
|
|
32
|
+
// points.
|
|
33
|
+
func endTarget() task.Target {
|
|
34
|
+
return task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestApplyTaskConfigOpensMailboxForFirstWorkEntry(t *testing.T) {
|
|
38
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
39
|
+
sys := &system{cli: client}
|
|
40
|
+
|
|
41
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress")); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
|
|
45
|
+
t.Fatalf("show calls = %v, want one mailbox read", client.shown)
|
|
46
|
+
}
|
|
47
|
+
if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "in_progress" {
|
|
48
|
+
t.Fatalf("updates = %+v, want open → in_progress", client.updates)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestCompleteMailboxClosesInProgressMailbox(t *testing.T) {
|
|
53
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
54
|
+
sys := &system{cli: client}
|
|
55
|
+
|
|
56
|
+
if err := sys.CompleteMailbox(context.Background(), task.Mailbox{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"}); err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
|
|
60
|
+
t.Fatalf("show calls = %v, want one mailbox read", client.shown)
|
|
61
|
+
}
|
|
62
|
+
if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "closed" {
|
|
63
|
+
t.Fatalf("updates = %+v, want in_progress → closed", client.updates)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestApplyTaskConfigTargetStateRetryDoesNotWrite(t *testing.T) {
|
|
68
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
69
|
+
sys := &system{cli: client}
|
|
70
|
+
|
|
71
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress")); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
|
|
75
|
+
t.Fatalf("show calls = %v, want one target-state read", client.shown)
|
|
76
|
+
}
|
|
77
|
+
if len(client.updates) != 0 {
|
|
78
|
+
t.Fatalf("target-state retry issued an update: %+v", client.updates)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestApplyTaskConfigReopensClosedMailboxOnWorkflowRevisit(t *testing.T) {
|
|
83
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "closed"})
|
|
84
|
+
sys := &system{cli: client}
|
|
85
|
+
|
|
86
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress")); err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
if len(client.shown) != 1 || client.shown[0] != "demo-parent.1" {
|
|
90
|
+
t.Fatalf("show calls = %v, want one mailbox read", client.shown)
|
|
91
|
+
}
|
|
92
|
+
if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "in_progress" {
|
|
93
|
+
t.Fatalf("updates = %+v, want closed → in_progress", client.updates)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestApplyTaskConfigRejectsIncompatibleManualMailboxState(t *testing.T) {
|
|
98
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "blocked"})
|
|
99
|
+
sys := &system{cli: client}
|
|
100
|
+
|
|
101
|
+
err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), transitionConfig("", "in_progress"))
|
|
102
|
+
if err == nil {
|
|
103
|
+
t.Fatal("incompatible manual mailbox state was accepted")
|
|
104
|
+
}
|
|
105
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
106
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
107
|
+
}
|
|
108
|
+
if len(client.updates) != 0 {
|
|
109
|
+
t.Fatalf("incompatible manual state issued an update: %+v", client.updates)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// relay-flow only ever drives a parent through open and in_progress, so those
|
|
114
|
+
// are the only states a close may follow. Any other state is human-owned and
|
|
115
|
+
// blocks the transition, exactly like the mailbox rule.
|
|
116
|
+
func TestApplyTaskConfigClosesParentOnlyFromRelayFlowStates(t *testing.T) {
|
|
117
|
+
for _, tc := range []struct {
|
|
118
|
+
name string
|
|
119
|
+
status string
|
|
120
|
+
wantUpdate bool
|
|
121
|
+
wantConfli bool
|
|
122
|
+
}{
|
|
123
|
+
{name: "open", status: "open", wantUpdate: true},
|
|
124
|
+
{name: "in progress", status: "in_progress", wantUpdate: true},
|
|
125
|
+
{name: "already closed", status: "closed"},
|
|
126
|
+
{name: "human blocked", status: "blocked", wantConfli: true},
|
|
127
|
+
{name: "human deferred", status: "deferred", wantConfli: true},
|
|
128
|
+
{name: "human hooked", status: "hooked", wantConfli: true},
|
|
129
|
+
} {
|
|
130
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
131
|
+
client := newStatusClient(map[string]string{"demo-parent": tc.status})
|
|
132
|
+
sys := &system{cli: client}
|
|
133
|
+
target := task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
|
|
134
|
+
|
|
135
|
+
err := sys.ApplyTaskConfig(context.Background(), target, transitionConfig("closed", ""))
|
|
136
|
+
if tc.wantConfli {
|
|
137
|
+
if err == nil {
|
|
138
|
+
t.Fatalf("human-owned parent status %q was overwritten", tc.status)
|
|
139
|
+
}
|
|
140
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
141
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
142
|
+
}
|
|
143
|
+
if len(client.updates) != 0 {
|
|
144
|
+
t.Fatalf("human-owned parent status issued an update: %+v", client.updates)
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatal(err)
|
|
150
|
+
}
|
|
151
|
+
if len(client.shown) != 1 || client.shown[0] != "demo-parent" {
|
|
152
|
+
t.Fatalf("show calls = %v, want one parent read", client.shown)
|
|
153
|
+
}
|
|
154
|
+
if tc.wantUpdate {
|
|
155
|
+
if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent" || client.updates[0].input.Status != "closed" {
|
|
156
|
+
t.Fatalf("updates = %+v, want parent → closed", client.updates)
|
|
157
|
+
}
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if len(client.updates) != 0 {
|
|
161
|
+
t.Fatalf("already-target parent issued an update: %+v", client.updates)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Jira parity: the run start moves the parent to in_progress, and the parent
|
|
168
|
+
// stays visible to the claimed-parent poll because that query includes
|
|
169
|
+
// in_progress.
|
|
170
|
+
func TestStartDefaultsMoveParentInProgress(t *testing.T) {
|
|
171
|
+
client := newStatusClient(map[string]string{"demo-parent": "open"})
|
|
172
|
+
sys := &system{cli: client}
|
|
173
|
+
target := task.Target{Parent: task.TicketRef{ID: "demo-parent", Key: "demo-parent"}}
|
|
174
|
+
|
|
175
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, sys.StartDefaults()); err != nil {
|
|
176
|
+
t.Fatal(err)
|
|
177
|
+
}
|
|
178
|
+
if len(client.updates) != 1 || client.updates[0].input.Status != "in_progress" {
|
|
179
|
+
t.Fatalf("updates = %+v, want parent open → in_progress at start", client.updates)
|
|
180
|
+
}
|
|
181
|
+
if got := client.issues["demo-parent"].Status; got != "in_progress" {
|
|
182
|
+
t.Fatalf("parent status = %q, want in_progress", got)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Jira parity: entering a node assigns that node's mailbox to the configured
|
|
187
|
+
// assignee in the same update that moves its status.
|
|
188
|
+
func TestApplyTaskConfigAssignsMailboxToConfiguredAssignee(t *testing.T) {
|
|
189
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
190
|
+
sys := &system{cli: client}
|
|
191
|
+
cfg := config.Merge(transitionConfig("", "in_progress"), config.RawValues{"assignee": "dev@example.com"})
|
|
192
|
+
|
|
193
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
if len(client.shown) != 1 || len(client.updates) != 1 {
|
|
197
|
+
t.Fatalf("calls = show:%v updates:%+v, want one read and one write", client.shown, client.updates)
|
|
198
|
+
}
|
|
199
|
+
update := client.updates[0].input
|
|
200
|
+
if update.Status != "in_progress" || update.Assignee != "dev@example.com" {
|
|
201
|
+
t.Fatalf("update = %+v, want status and assignee in one call", update)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func TestApplyTaskConfigAssigneeIsIdempotentAndStandalone(t *testing.T) {
|
|
206
|
+
t.Run("already assigned at target status", func(t *testing.T) {
|
|
207
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
208
|
+
client.issues["demo-parent.1"] = bdcli.Issue{ID: "demo-parent.1", Status: "in_progress", Assignee: "dev@example.com"}
|
|
209
|
+
sys := &system{cli: client}
|
|
210
|
+
cfg := config.Merge(transitionConfig("", "in_progress"), config.RawValues{"assignee": "dev@example.com"})
|
|
211
|
+
|
|
212
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
if len(client.updates) != 0 {
|
|
216
|
+
t.Fatalf("retried assignment issued an update: %+v", client.updates)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
t.Run("assignee without a transition", func(t *testing.T) {
|
|
221
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
222
|
+
sys := &system{cli: client}
|
|
223
|
+
|
|
224
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"),
|
|
225
|
+
config.RawValues{"assignee": "dev@example.com"}); err != nil {
|
|
226
|
+
t.Fatal(err)
|
|
227
|
+
}
|
|
228
|
+
if len(client.updates) != 1 || client.updates[0].input.Status != "" ||
|
|
229
|
+
client.updates[0].input.Assignee != "dev@example.com" {
|
|
230
|
+
t.Fatalf("updates = %+v, want an assignee-only update", client.updates)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|