relay-flow 0.2.1-alpha → 0.2.3-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +193 -25
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +79 -1
  4. package/cmd/relay-flow/main.go +50 -1
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +16 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +124 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +20 -0
  16. package/internal/execution/goworkflows/engine_test.go +33 -15
  17. package/internal/execution/goworkflows/fakes_test.go +84 -10
  18. package/internal/execution/goworkflows/interpreter.go +84 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  21. package/internal/execution/goworkflows/projection.go +52 -11
  22. package/internal/execution/goworkflows/recovery_test.go +131 -2
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/harness/contract_test.go +10 -0
  25. package/internal/harness/factory.go +25 -3
  26. package/internal/harness/harness.go +30 -4
  27. package/internal/harness/opencode/opencode.go +125 -10
  28. package/internal/harness/opencode/opencode_test.go +238 -0
  29. package/internal/harness/opencode/repo_setup.go +401 -0
  30. package/internal/harness/plugin_selection_test.go +5 -5
  31. package/internal/identity/identity.go +28 -1
  32. package/internal/identity/identity_test.go +40 -0
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/service.go +10 -0
  35. package/internal/repo/service_test.go +51 -2
  36. package/internal/run/manager.go +193 -25
  37. package/internal/run/run.go +30 -10
  38. package/internal/run/run_manager_test.go +100 -0
  39. package/internal/server/api_test.go +54 -0
  40. package/internal/server/client.go +11 -0
  41. package/internal/server/fixture_test.go +18 -0
  42. package/internal/server/server.go +16 -0
  43. package/internal/task/beads/bdcli/bdcli.go +323 -0
  44. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  45. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  46. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  47. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  48. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  49. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  50. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  51. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  52. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  56. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  57. package/internal/task/beads/beads.go +856 -0
  58. package/internal/task/beads/beads_test.go +609 -0
  59. package/internal/task/beads/comments_test.go +242 -0
  60. package/internal/task/beads/config_compatibility_test.go +163 -0
  61. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  62. package/internal/task/beads/repo_composition_test.go +232 -0
  63. package/internal/task/beads/runtime_config_test.go +81 -0
  64. package/internal/task/beads/status_compatibility_test.go +276 -0
  65. package/internal/task/beads/status_test.go +257 -0
  66. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  67. package/internal/task/beads/validation_test.go +110 -0
  68. package/internal/task/contract_test.go +10 -0
  69. package/internal/task/factory.go +37 -4
  70. package/internal/task/jira/auth.go +27 -1
  71. package/internal/task/jira/auth_test.go +54 -1
  72. package/internal/task/jira/filters_test.go +60 -0
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +187 -29
  75. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  76. package/internal/task/jira/rest/adf.go +119 -82
  77. package/internal/task/jira/rest/adf_test.go +60 -0
  78. package/internal/task/jira/rest/client_test.go +1 -1
  79. package/internal/task/jira/templates_test.go +118 -0
  80. package/internal/task/jira/transition_defaults_test.go +47 -2
  81. package/internal/task/task.go +40 -0
  82. package/internal/workflow/report_test.go +45 -0
  83. package/package.json +1 -1
@@ -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
- // Config is the adapter-owned root harnessConfig. OpenCode takes no
26
- // configuration; any key is rejected.
27
- type Config struct{}
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 = `Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.`
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", func(raw config.RawValues) (harness.Harness, error) {
31
- var cfg Config
32
- if err := config.DecodeStrict(raw, &cfg); err != nil {
33
- return nil, fmt.Errorf("opencode harnessConfig: %w", err)
34
- }
35
- return New(), nil
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 { return &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 the harness-owned
137
+ // HITL/TUI instructions for HITL nodes, then renders and appends the node's
138
+ // nudge 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.3-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,233 @@ 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
+ }
65
+ h, err := harness.New("opencode", raw)
66
+ if err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ data := harness.PromptData{
70
+ TaskSystem: "linear", Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments",
71
+ Node: "review", NodeType: workflow.NodeHITL, Agent: "build", NodeDescription: "Review it.",
72
+ NextSteps: "end (when: approved)", Mailbox: "PAY-234",
73
+ }
74
+ nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
75
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
76
+ if err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
80
+ if initial != wantInitial {
81
+ t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
82
+ }
83
+ feedback, err := h.RenderPrompt(harness.PromptFeedback, data, nudge)
84
+ if err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
88
+ t.Fatalf("feedback prompt = %q, want %q", feedback, want)
89
+ }
90
+ }
91
+
92
+ func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
93
+ _, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
94
+ if err == nil || !strings.Contains(err.Error(), "unknown template variable {{unknown}}") {
95
+ t.Fatalf("harness.New error = %v", err)
96
+ }
97
+ }
98
+
99
+ func TestSetupRepoCreatesOpenCodeJSON(t *testing.T) {
100
+ dir := t.TempDir()
101
+ setupTwice(t, dir)
102
+ path := filepath.Join(dir, "opencode.json")
103
+ data, err := os.ReadFile(path)
104
+ if err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ var cfg struct {
108
+ Plugin []string `json:"plugin"`
109
+ }
110
+ if err := json.Unmarshal(data, &cfg); err != nil {
111
+ t.Fatalf("created config is invalid JSON: %v\n%s", err, data)
112
+ }
113
+ if !reflect.DeepEqual(cfg.Plugin, []string{configuredPlugin}) {
114
+ t.Fatalf("plugin = %v", cfg.Plugin)
115
+ }
116
+ }
117
+
118
+ func TestSetupRepoUpdatesExistingJSONAndPreservesContent(t *testing.T) {
119
+ dir := t.TempDir()
120
+ path := filepath.Join(dir, "opencode.json")
121
+ original := "{\n \"theme\": \"catppuccin\",\n \"plugin\": [\"keep-me\", \"relay-flow-plugin@0.1.0\"],\n \"nested\": {\"enabled\": true}\n}\n"
122
+ if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ setupTwice(t, dir)
126
+ data, err := os.ReadFile(path)
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ var cfg struct {
131
+ Theme string `json:"theme"`
132
+ Plugin []string `json:"plugin"`
133
+ Nested map[string]bool `json:"nested"`
134
+ }
135
+ if err := json.Unmarshal(data, &cfg); err != nil {
136
+ t.Fatalf("updated config is invalid JSON: %v\n%s", err, data)
137
+ }
138
+ if cfg.Theme != "catppuccin" || !cfg.Nested["enabled"] {
139
+ t.Fatalf("existing config changed: %+v", cfg)
140
+ }
141
+ if !reflect.DeepEqual(cfg.Plugin, []string{"keep-me", configuredPlugin}) {
142
+ t.Fatalf("plugin = %v", cfg.Plugin)
143
+ }
144
+ info, err := os.Stat(path)
145
+ if err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if info.Mode().Perm() != 0o640 {
149
+ t.Fatalf("mode = %o, want 640", info.Mode().Perm())
150
+ }
151
+ }
152
+
153
+ func TestSetupRepoUpdatesJSONCOnceAndPreservesComments(t *testing.T) {
154
+ dir := t.TempDir()
155
+ path := filepath.Join(dir, "opencode.jsonc")
156
+ original := `{
157
+ // theme stays
158
+ "theme": "catppuccin",
159
+ "plugin": [
160
+ "keep-me", // existing plugin stays
161
+ "relay-flow-plugin",
162
+ /* duplicate old relay-flow entry */
163
+ "relay-flow-plugin@0.1.0",
164
+ ],
165
+ "nested": {"enabled": true}, // trailing comment stays
166
+ }
167
+ `
168
+ if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ setupTwice(t, dir)
172
+ data, err := os.ReadFile(path)
173
+ if err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ text := string(data)
177
+ for _, preserved := range []string{"// theme stays", "// existing plugin stays", "/* duplicate old relay-flow entry */", "// trailing comment stays", "\"keep-me\"", "\"nested\": {\"enabled\": true}"} {
178
+ if !strings.Contains(text, preserved) {
179
+ t.Fatalf("config lost %q:\n%s", preserved, text)
180
+ }
181
+ }
182
+ if strings.Count(text, configuredPlugin) != 1 || strings.Contains(text, "relay-flow-plugin@0.1.0") {
183
+ t.Fatalf("relay-flow plugin not updated exactly once:\n%s", text)
184
+ }
185
+ if _, err := os.Stat(filepath.Join(dir, "opencode.json")); !os.IsNotExist(err) {
186
+ t.Fatalf("unexpected opencode.json created: %v", err)
187
+ }
188
+ }
189
+
190
+ func TestSetupRepoAddsPluginPropertyToJSONCWithComments(t *testing.T) {
191
+ dir := t.TempDir()
192
+ path := filepath.Join(dir, "opencode.jsonc")
193
+ original := "{\n \"theme\": \"catppuccin\" // keep at end\n}\n"
194
+ if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ setupTwice(t, dir)
198
+ data, err := os.ReadFile(path)
199
+ if err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ text := string(data)
203
+ if !strings.Contains(text, "// keep at end") || strings.Count(text, configuredPlugin) != 1 {
204
+ t.Fatalf("config not preserved and updated:\n%s", text)
205
+ }
206
+ }
207
+
208
+ func TestSetupRepoCreatesOpenCodeTUIConfig(t *testing.T) {
209
+ dir := t.TempDir()
210
+ if err := opencode.New().SetupRepo(t.Context(), dir); err != nil {
211
+ t.Fatalf("SetupRepo: %v", err)
212
+ }
213
+ path := filepath.Join(dir, ".opencode", "tui.json")
214
+ data, err := os.ReadFile(path)
215
+ if err != nil {
216
+ t.Fatal(err)
217
+ }
218
+ var cfg struct {
219
+ Schema string `json:"$schema"`
220
+ Plugin []string `json:"plugin"`
221
+ }
222
+ if err := json.Unmarshal(data, &cfg); err != nil {
223
+ t.Fatalf("created TUI config is invalid JSON: %v\n%s", err, data)
224
+ }
225
+ if cfg.Schema != "https://opencode.ai/tui.json" {
226
+ t.Fatalf("$schema = %q", cfg.Schema)
227
+ }
228
+ if !reflect.DeepEqual(cfg.Plugin, []string{configuredPlugin}) {
229
+ t.Fatalf("plugin = %v", cfg.Plugin)
230
+ }
231
+ }
232
+
233
+ func TestSetupRepoUpdatesExistingOpenCodeTUIJSONC(t *testing.T) {
234
+ dir := t.TempDir()
235
+ configDir := filepath.Join(dir, ".opencode")
236
+ if err := os.MkdirAll(configDir, 0o755); err != nil {
237
+ t.Fatal(err)
238
+ }
239
+ path := filepath.Join(configDir, "tui.jsonc")
240
+ original := `{
241
+ // keep the local theme
242
+ "theme": "catppuccin"
243
+ }
244
+ `
245
+ if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ if err := opencode.New().SetupRepo(t.Context(), dir); err != nil {
249
+ t.Fatalf("SetupRepo: %v", err)
250
+ }
251
+ data, err := os.ReadFile(path)
252
+ if err != nil {
253
+ t.Fatal(err)
254
+ }
255
+ text := string(data)
256
+ if !strings.Contains(text, "// keep the local theme") || !strings.Contains(text, configuredPlugin) {
257
+ t.Fatalf("TUI config was not preserved and updated:\n%s", text)
258
+ }
259
+ if _, err := os.Stat(filepath.Join(configDir, "tui.json")); !os.IsNotExist(err) {
260
+ t.Fatalf("unexpected tui.json created: %v", err)
261
+ }
262
+ }
263
+
264
+ func setupTwice(t *testing.T, dir string) {
265
+ t.Helper()
266
+ h := opencode.New()
267
+ if err := h.SetupRepo(t.Context(), dir); err != nil {
268
+ t.Fatalf("first SetupRepo: %v", err)
269
+ }
270
+ path := filepath.Join(dir, "opencode.json")
271
+ if _, err := os.Stat(path); os.IsNotExist(err) {
272
+ path = filepath.Join(dir, "opencode.jsonc")
273
+ }
274
+ first, err := os.ReadFile(path)
275
+ if err != nil {
276
+ t.Fatal(err)
277
+ }
278
+ if err := h.SetupRepo(t.Context(), dir); err != nil {
279
+ t.Fatalf("second SetupRepo: %v", err)
280
+ }
281
+ second, err := os.ReadFile(path)
282
+ if err != nil {
283
+ t.Fatal(err)
284
+ }
285
+ if !reflect.DeepEqual(first, second) {
286
+ t.Fatalf("SetupRepo is not idempotent\nfirst:\n%s\nsecond:\n%s", first, second)
287
+ }
288
+ }