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
|
@@ -9,8 +9,12 @@ import (
|
|
|
9
9
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
// Factory constructs a Harness from root harness config
|
|
13
|
-
|
|
12
|
+
// Factory constructs a Harness from root harness config and supplies the
|
|
13
|
+
// selected harness's init defaults without coupling core to an adapter.
|
|
14
|
+
type Factory struct {
|
|
15
|
+
DefaultConfig func() config.RawValues
|
|
16
|
+
New func(config.RawValues) (Harness, error)
|
|
17
|
+
}
|
|
14
18
|
|
|
15
19
|
var (
|
|
16
20
|
registryMu sync.RWMutex
|
|
@@ -35,7 +39,25 @@ func New(name string, cfg config.RawValues) (Harness, error) {
|
|
|
35
39
|
if !ok {
|
|
36
40
|
return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
37
41
|
}
|
|
38
|
-
return f(cfg)
|
|
42
|
+
return f.New(config.Merge(defaultConfig(f), cfg))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Defaults returns a fresh copy of the named harness's root config defaults.
|
|
46
|
+
func Defaults(name string) (config.RawValues, error) {
|
|
47
|
+
registryMu.RLock()
|
|
48
|
+
f, ok := registry[name]
|
|
49
|
+
registryMu.RUnlock()
|
|
50
|
+
if !ok {
|
|
51
|
+
return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
52
|
+
}
|
|
53
|
+
return config.Merge(defaultConfig(f)), nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func defaultConfig(f Factory) config.RawValues {
|
|
57
|
+
if f.DefaultConfig == nil {
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
60
|
+
return f.DefaultConfig()
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
// ValidateName returns an error listing registered names when name is not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Package harness defines the harness contract:
|
|
2
|
-
// discovery, resume syntax, and launch
|
|
3
|
-
// executes the returned command; the harness
|
|
4
|
-
// state.
|
|
1
|
+
// Package harness defines the harness contract: repository setup, prompt
|
|
2
|
+
// rendering, agent validation, session discovery, resume syntax, and launch
|
|
3
|
+
// command construction. The runner executes the returned command; the harness
|
|
4
|
+
// never manipulates runner state.
|
|
5
5
|
package harness
|
|
6
6
|
|
|
7
7
|
import (
|
|
@@ -17,6 +17,29 @@ type Session struct {
|
|
|
17
17
|
Title string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
type PromptKind string
|
|
21
|
+
|
|
22
|
+
const (
|
|
23
|
+
PromptInitial PromptKind = "initial"
|
|
24
|
+
PromptFeedback PromptKind = "feedback"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// PromptData is the task-system-neutral data core supplies to the selected
|
|
28
|
+
// harness. Harness templates, including HITL instructions, are rendered only
|
|
29
|
+
// by the harness.
|
|
30
|
+
type PromptData struct {
|
|
31
|
+
TaskSystem string
|
|
32
|
+
Ticket string
|
|
33
|
+
Workflow string
|
|
34
|
+
Repo string
|
|
35
|
+
Node string
|
|
36
|
+
NodeType workflow.NodeType
|
|
37
|
+
Agent string
|
|
38
|
+
NodeDescription string
|
|
39
|
+
NextSteps string
|
|
40
|
+
Mailbox string
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
type LaunchSpec struct {
|
|
21
44
|
RunID identity.RunID
|
|
22
45
|
NodeVisitID identity.NodeVisitID
|
|
@@ -30,12 +53,15 @@ type LaunchSpec struct {
|
|
|
30
53
|
Title string
|
|
31
54
|
Prompt string
|
|
32
55
|
NudgePrompt string
|
|
56
|
+
PromptData PromptData
|
|
33
57
|
NextSteps []workflow.Route
|
|
34
58
|
ResumeID string
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
type Harness interface {
|
|
62
|
+
SetupRepo(ctx context.Context, repoPath string) error
|
|
38
63
|
ValidateAgent(ctx context.Context, repoPath, agent string) error
|
|
39
64
|
FindSession(ctx context.Context, repoPath, title string) (Session, bool, error)
|
|
65
|
+
RenderPrompt(kind PromptKind, data PromptData, nudgeTemplate string) (string, error)
|
|
40
66
|
BuildCommand(spec LaunchSpec) (runner.Command, error)
|
|
41
67
|
}
|
|
@@ -15,36 +15,94 @@ import (
|
|
|
15
15
|
"os"
|
|
16
16
|
"os/exec"
|
|
17
17
|
"path/filepath"
|
|
18
|
+
"regexp"
|
|
18
19
|
"strings"
|
|
19
20
|
|
|
20
21
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
21
22
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
22
23
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
24
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
23
25
|
)
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const (
|
|
28
|
+
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
29
|
+
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
30
|
+
|
|
31
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
|
|
32
|
+
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
33
|
+
defaultHITLPrompt = `Before submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer.`
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
37
|
+
|
|
38
|
+
var knownPromptVars = map[string]bool{
|
|
39
|
+
"taskSystem": true, "ticket": true, "workflow": true, "repo": true,
|
|
40
|
+
"node": true, "nodeType": true, "agent": true, "nodeDescription": true,
|
|
41
|
+
"nextSteps": true, "mailbox": true,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Config is the adapter-owned root harnessConfig.
|
|
45
|
+
type Config struct {
|
|
46
|
+
Initial string `yaml:"initial"`
|
|
47
|
+
Feedback string `yaml:"feedback"`
|
|
48
|
+
HITL string `yaml:"hitl"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// DefaultConfig is written by relay-flow init and also fills omitted values
|
|
52
|
+
// when older or hand-written config is loaded.
|
|
53
|
+
func DefaultConfig() config.RawValues {
|
|
54
|
+
return config.RawValues{
|
|
55
|
+
"initial": defaultInitialPrompt,
|
|
56
|
+
"feedback": defaultFeedbackPrompt,
|
|
57
|
+
"hitl": defaultHITLPrompt,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
28
60
|
|
|
29
61
|
func init() {
|
|
30
|
-
harness.Register("opencode",
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
harness.Register("opencode", harness.Factory{
|
|
63
|
+
DefaultConfig: DefaultConfig,
|
|
64
|
+
New: func(raw config.RawValues) (harness.Harness, error) {
|
|
65
|
+
var cfg Config
|
|
66
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
67
|
+
return nil, fmt.Errorf("opencode harnessConfig: %w", err)
|
|
68
|
+
}
|
|
69
|
+
for name, tmpl := range map[string]string{
|
|
70
|
+
"initial": cfg.Initial,
|
|
71
|
+
"feedback": cfg.Feedback,
|
|
72
|
+
"hitl": cfg.HITL,
|
|
73
|
+
} {
|
|
74
|
+
if err := validateTemplate(tmpl); err != nil {
|
|
75
|
+
return nil, fmt.Errorf("opencode harnessConfig templates.%s: %w", name, err)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return New(cfg), nil
|
|
79
|
+
},
|
|
36
80
|
})
|
|
37
81
|
}
|
|
38
82
|
|
|
39
83
|
// Harness implements harness.Harness for OpenCode. It is safe for
|
|
40
84
|
// concurrent use; the seams below are only swapped by tests before use.
|
|
41
85
|
type Harness struct {
|
|
86
|
+
templates Config
|
|
42
87
|
// listAgents is the test seam; nil → real `opencode agent list`.
|
|
43
88
|
listAgents func(ctx context.Context) ([]string, error)
|
|
44
89
|
}
|
|
45
90
|
|
|
46
91
|
// New returns the production Harness.
|
|
47
|
-
func New() *Harness {
|
|
92
|
+
func New(cfg ...Config) *Harness {
|
|
93
|
+
if len(cfg) > 0 {
|
|
94
|
+
return &Harness{templates: cfg[0]}
|
|
95
|
+
}
|
|
96
|
+
return &Harness{templates: Config{
|
|
97
|
+
Initial: defaultInitialPrompt, Feedback: defaultFeedbackPrompt, HITL: defaultHITLPrompt,
|
|
98
|
+
}}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// SetupRepo ensures the relay-flow runtime plugin is configured for OpenCode
|
|
102
|
+
// in the registered repository.
|
|
103
|
+
func (h *Harness) SetupRepo(_ context.Context, repoPath string) error {
|
|
104
|
+
return setupRepo(repoPath)
|
|
105
|
+
}
|
|
48
106
|
|
|
49
107
|
// ValidateAgent reports whether name is a known OpenCode agent for the
|
|
50
108
|
// repo, per `opencode agent list` (agent names are the unindented first
|
|
@@ -75,6 +133,26 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
|
|
|
75
133
|
return harness.Session{}, false, nil
|
|
76
134
|
}
|
|
77
135
|
|
|
136
|
+
// RenderPrompt renders the selected session prompt, appends HITL approval
|
|
137
|
+
// instructions for HITL nodes, then renders and appends the node's nudge
|
|
138
|
+
// template.
|
|
139
|
+
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
140
|
+
var tmpl string
|
|
141
|
+
switch kind {
|
|
142
|
+
case harness.PromptInitial:
|
|
143
|
+
tmpl = h.templates.Initial
|
|
144
|
+
case harness.PromptFeedback:
|
|
145
|
+
tmpl = h.templates.Feedback
|
|
146
|
+
default:
|
|
147
|
+
return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
|
|
148
|
+
}
|
|
149
|
+
prompt := renderTemplate(tmpl, data)
|
|
150
|
+
if data.NodeType == workflow.NodeHITL {
|
|
151
|
+
prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
|
|
152
|
+
}
|
|
153
|
+
return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
|
|
154
|
+
}
|
|
155
|
+
|
|
78
156
|
// BuildCommand returns the structured opencode invocation with the
|
|
79
157
|
// required RELAY_FLOW_* env. spec.ResumeID non-empty resumes the prior
|
|
80
158
|
// session (`opencode --session <id>`); empty is a fresh launch. The
|
|
@@ -164,3 +242,40 @@ func listAgents(ctx context.Context) ([]string, error) {
|
|
|
164
242
|
}
|
|
165
243
|
return names, nil
|
|
166
244
|
}
|
|
245
|
+
|
|
246
|
+
func validateTemplate(tmpl string) error {
|
|
247
|
+
for _, match := range promptVarPattern.FindAllStringSubmatch(tmpl, -1) {
|
|
248
|
+
if !knownPromptVars[match[1]] {
|
|
249
|
+
return fmt.Errorf("unknown template variable {{%s}}", match[1])
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return nil
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func renderTemplate(tmpl string, data harness.PromptData) string {
|
|
256
|
+
values := map[string]string{
|
|
257
|
+
"taskSystem": data.TaskSystem,
|
|
258
|
+
"ticket": data.Ticket,
|
|
259
|
+
"workflow": data.Workflow,
|
|
260
|
+
"repo": data.Repo,
|
|
261
|
+
"node": data.Node,
|
|
262
|
+
"nodeType": string(data.NodeType),
|
|
263
|
+
"agent": data.Agent,
|
|
264
|
+
"nodeDescription": data.NodeDescription,
|
|
265
|
+
"nextSteps": data.NextSteps,
|
|
266
|
+
"mailbox": data.Mailbox,
|
|
267
|
+
}
|
|
268
|
+
return promptVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
|
|
269
|
+
return values[promptVarPattern.FindStringSubmatch(match)[1]]
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func appendPrompt(prompt, extra string) string {
|
|
274
|
+
if extra == "" {
|
|
275
|
+
return prompt
|
|
276
|
+
}
|
|
277
|
+
if prompt == "" {
|
|
278
|
+
return extra
|
|
279
|
+
}
|
|
280
|
+
return prompt + "\n\n" + extra
|
|
281
|
+
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
package opencode_test
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
4
7
|
"reflect"
|
|
8
|
+
"strings"
|
|
5
9
|
"testing"
|
|
6
10
|
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
7
12
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
8
13
|
"github.com/rajpopat27/relay-flow/internal/harness/opencode"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
9
15
|
)
|
|
10
16
|
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.2-alpha"
|
|
18
|
+
|
|
11
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
12
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
13
21
|
tests := []struct {
|
|
@@ -48,3 +56,178 @@ func TestBuildCommandArgv(t *testing.T) {
|
|
|
48
56
|
})
|
|
49
57
|
}
|
|
50
58
|
}
|
|
59
|
+
|
|
60
|
+
func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
61
|
+
raw := config.RawValues{
|
|
62
|
+
"initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
|
|
63
|
+
"feedback": "feedback {{mailbox}}",
|
|
64
|
+
"hitl": "hitl {{node}}",
|
|
65
|
+
}
|
|
66
|
+
h, err := harness.New("opencode", raw)
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
data := harness.PromptData{
|
|
71
|
+
TaskSystem: "linear", Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments",
|
|
72
|
+
Node: "review", NodeType: workflow.NodeHITL, Agent: "build", NodeDescription: "Review it.",
|
|
73
|
+
NextSteps: "end (when: approved)", Mailbox: "PAY-234",
|
|
74
|
+
}
|
|
75
|
+
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
|
|
76
|
+
initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
|
|
77
|
+
if err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nhitl review\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
|
|
81
|
+
if initial != wantInitial {
|
|
82
|
+
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
83
|
+
}
|
|
84
|
+
feedback, err := h.RenderPrompt(harness.PromptFeedback, data, nudge)
|
|
85
|
+
if err != nil {
|
|
86
|
+
t.Fatal(err)
|
|
87
|
+
}
|
|
88
|
+
if want := "feedback PAY-234\n\nhitl review\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
|
|
89
|
+
t.Fatalf("feedback prompt = %q, want %q", feedback, want)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
|
|
94
|
+
_, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
|
|
95
|
+
if err == nil || !strings.Contains(err.Error(), "unknown template variable {{unknown}}") {
|
|
96
|
+
t.Fatalf("harness.New error = %v", err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestSetupRepoCreatesOpenCodeJSON(t *testing.T) {
|
|
101
|
+
dir := t.TempDir()
|
|
102
|
+
setupTwice(t, dir)
|
|
103
|
+
path := filepath.Join(dir, "opencode.json")
|
|
104
|
+
data, err := os.ReadFile(path)
|
|
105
|
+
if err != nil {
|
|
106
|
+
t.Fatal(err)
|
|
107
|
+
}
|
|
108
|
+
var cfg struct {
|
|
109
|
+
Plugin []string `json:"plugin"`
|
|
110
|
+
}
|
|
111
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
112
|
+
t.Fatalf("created config is invalid JSON: %v\n%s", err, data)
|
|
113
|
+
}
|
|
114
|
+
if !reflect.DeepEqual(cfg.Plugin, []string{configuredPlugin}) {
|
|
115
|
+
t.Fatalf("plugin = %v", cfg.Plugin)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func TestSetupRepoUpdatesExistingJSONAndPreservesContent(t *testing.T) {
|
|
120
|
+
dir := t.TempDir()
|
|
121
|
+
path := filepath.Join(dir, "opencode.json")
|
|
122
|
+
original := "{\n \"theme\": \"catppuccin\",\n \"plugin\": [\"keep-me\", \"relay-flow-plugin@0.1.0\"],\n \"nested\": {\"enabled\": true}\n}\n"
|
|
123
|
+
if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
setupTwice(t, dir)
|
|
127
|
+
data, err := os.ReadFile(path)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
var cfg struct {
|
|
132
|
+
Theme string `json:"theme"`
|
|
133
|
+
Plugin []string `json:"plugin"`
|
|
134
|
+
Nested map[string]bool `json:"nested"`
|
|
135
|
+
}
|
|
136
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
137
|
+
t.Fatalf("updated config is invalid JSON: %v\n%s", err, data)
|
|
138
|
+
}
|
|
139
|
+
if cfg.Theme != "catppuccin" || !cfg.Nested["enabled"] {
|
|
140
|
+
t.Fatalf("existing config changed: %+v", cfg)
|
|
141
|
+
}
|
|
142
|
+
if !reflect.DeepEqual(cfg.Plugin, []string{"keep-me", configuredPlugin}) {
|
|
143
|
+
t.Fatalf("plugin = %v", cfg.Plugin)
|
|
144
|
+
}
|
|
145
|
+
info, err := os.Stat(path)
|
|
146
|
+
if err != nil {
|
|
147
|
+
t.Fatal(err)
|
|
148
|
+
}
|
|
149
|
+
if info.Mode().Perm() != 0o640 {
|
|
150
|
+
t.Fatalf("mode = %o, want 640", info.Mode().Perm())
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestSetupRepoUpdatesJSONCOnceAndPreservesComments(t *testing.T) {
|
|
155
|
+
dir := t.TempDir()
|
|
156
|
+
path := filepath.Join(dir, "opencode.jsonc")
|
|
157
|
+
original := `{
|
|
158
|
+
// theme stays
|
|
159
|
+
"theme": "catppuccin",
|
|
160
|
+
"plugin": [
|
|
161
|
+
"keep-me", // existing plugin stays
|
|
162
|
+
"relay-flow-plugin",
|
|
163
|
+
/* duplicate old relay-flow entry */
|
|
164
|
+
"relay-flow-plugin@0.1.0",
|
|
165
|
+
],
|
|
166
|
+
"nested": {"enabled": true}, // trailing comment stays
|
|
167
|
+
}
|
|
168
|
+
`
|
|
169
|
+
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
|
170
|
+
t.Fatal(err)
|
|
171
|
+
}
|
|
172
|
+
setupTwice(t, dir)
|
|
173
|
+
data, err := os.ReadFile(path)
|
|
174
|
+
if err != nil {
|
|
175
|
+
t.Fatal(err)
|
|
176
|
+
}
|
|
177
|
+
text := string(data)
|
|
178
|
+
for _, preserved := range []string{"// theme stays", "// existing plugin stays", "/* duplicate old relay-flow entry */", "// trailing comment stays", "\"keep-me\"", "\"nested\": {\"enabled\": true}"} {
|
|
179
|
+
if !strings.Contains(text, preserved) {
|
|
180
|
+
t.Fatalf("config lost %q:\n%s", preserved, text)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if strings.Count(text, configuredPlugin) != 1 || strings.Contains(text, "relay-flow-plugin@0.1.0") {
|
|
184
|
+
t.Fatalf("relay-flow plugin not updated exactly once:\n%s", text)
|
|
185
|
+
}
|
|
186
|
+
if _, err := os.Stat(filepath.Join(dir, "opencode.json")); !os.IsNotExist(err) {
|
|
187
|
+
t.Fatalf("unexpected opencode.json created: %v", err)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func TestSetupRepoAddsPluginPropertyToJSONCWithComments(t *testing.T) {
|
|
192
|
+
dir := t.TempDir()
|
|
193
|
+
path := filepath.Join(dir, "opencode.jsonc")
|
|
194
|
+
original := "{\n \"theme\": \"catppuccin\" // keep at end\n}\n"
|
|
195
|
+
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
setupTwice(t, dir)
|
|
199
|
+
data, err := os.ReadFile(path)
|
|
200
|
+
if err != nil {
|
|
201
|
+
t.Fatal(err)
|
|
202
|
+
}
|
|
203
|
+
text := string(data)
|
|
204
|
+
if !strings.Contains(text, "// keep at end") || strings.Count(text, configuredPlugin) != 1 {
|
|
205
|
+
t.Fatalf("config not preserved and updated:\n%s", text)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func setupTwice(t *testing.T, dir string) {
|
|
210
|
+
t.Helper()
|
|
211
|
+
h := opencode.New()
|
|
212
|
+
if err := h.SetupRepo(t.Context(), dir); err != nil {
|
|
213
|
+
t.Fatalf("first SetupRepo: %v", err)
|
|
214
|
+
}
|
|
215
|
+
path := filepath.Join(dir, "opencode.json")
|
|
216
|
+
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
217
|
+
path = filepath.Join(dir, "opencode.jsonc")
|
|
218
|
+
}
|
|
219
|
+
first, err := os.ReadFile(path)
|
|
220
|
+
if err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
if err := h.SetupRepo(t.Context(), dir); err != nil {
|
|
224
|
+
t.Fatalf("second SetupRepo: %v", err)
|
|
225
|
+
}
|
|
226
|
+
second, err := os.ReadFile(path)
|
|
227
|
+
if err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if !reflect.DeepEqual(first, second) {
|
|
231
|
+
t.Fatalf("SetupRepo is not idempotent\nfirst:\n%s\nsecond:\n%s", first, second)
|
|
232
|
+
}
|
|
233
|
+
}
|