relay-flow 0.0.1 → 0.2.0-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 (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -1,201 +1,175 @@
1
1
  package orca
2
2
 
3
3
  import (
4
+ "context"
5
+ "errors"
6
+ "os/exec"
7
+ "strings"
4
8
  "testing"
5
- "time"
6
9
 
7
- "github.com/rajpopat27/relay-flow/internal/orcacli"
10
+ "github.com/rajpopat27/relay-flow/internal/config"
8
11
  "github.com/rajpopat27/relay-flow/internal/runner"
9
- "github.com/rajpopat27/relay-flow/internal/tasks"
12
+ "github.com/rajpopat27/relay-flow/internal/runner/orca/orcacli"
10
13
  )
11
14
 
12
- type fakeOrca struct {
13
- listErr error
14
- worktrees []orcacli.Worktree
15
- terminals map[string][]orcacli.Terminal // worktree name → terminals
16
- created []string
17
- sent []string
18
- closed []string
19
- waitErr error
20
- createErr error
21
- findBranchFn func(repoPath, key string) (string, bool, error)
22
- agentExists bool
23
- }
24
-
25
- func (f *fakeOrca) WorktreeList() ([]orcacli.Worktree, error) { return f.worktrees, nil }
26
- func (f *fakeOrca) WorktreeCreate(ticketKey, repoID, parentWorktreeID, baseBranch string) error {
27
- f.created = append(f.created, ticketKey)
28
- f.worktrees = append(f.worktrees, orcacli.Worktree{ID: "wt-" + ticketKey, DisplayName: ticketKey, RepoID: repoID, Branch: baseBranch})
29
- return nil
30
- }
31
- func (f *fakeOrca) FindWorktree(repoID, displayName string) (orcacli.Worktree, bool, error) {
32
- for _, w := range f.worktrees {
33
- if w.RepoID == repoID && w.DisplayName == displayName {
34
- return w, true, nil
35
- }
15
+ // 9.5: info-level outcome logs must never embed argv payloads. sanitizeErr
16
+ // strips the "[args...]" middle from orcacli errors so the agent prompt and
17
+ // RELAY_FLOW_* env carried by --command are never written to server.log.
18
+ func TestSanitizeErr(t *testing.T) {
19
+ // Shape from runJSON/run: "orca [terminal create ... --command 'PAYLOAD']: exit status 1: boom"
20
+ // wrapped by CreateTerminal as "orca terminal create: %w".
21
+ wrapped := errors.New("orca terminal create: orca [terminal create --worktree name:PAY-1 --title PAY-1:coding --command 'PAYLOAD']: exit status 1: boom")
22
+ got := sanitizeErr(wrapped)
23
+ if strings.Contains(got, "PAYLOAD") || strings.Contains(got, "--command") {
24
+ t.Fatalf("sanitizeErr leaked argv payload: %q", got)
25
+ }
26
+ if !strings.Contains(got, "boom") {
27
+ t.Fatalf("sanitizeErr dropped failure reason: %q", got)
36
28
  }
37
- return orcacli.Worktree{}, false, nil
38
- }
39
- func (f *fakeOrca) MainWorktree(repoID string) (orcacli.Worktree, bool, error) {
40
- for _, w := range f.worktrees {
41
- if w.RepoID == repoID && w.IsMainWorktree {
42
- return w, true, nil
43
- }
29
+
30
+ if got := sanitizeErr(nil); got != "" {
31
+ t.Fatalf("sanitizeErr(nil) = %q, want empty", got)
44
32
  }
45
- return orcacli.Worktree{}, false, nil
46
- }
47
- func (f *fakeOrca) TerminalList(worktree string) ([]orcacli.Terminal, error) {
48
- if f.listErr != nil {
49
- return nil, f.listErr
33
+
34
+ plain := errors.New("unwrapped failure")
35
+ if got := sanitizeErr(plain); got != "unwrapped failure" {
36
+ t.Fatalf("sanitizeErr(plain) = %q", got)
50
37
  }
51
- return f.terminals[worktree], nil
52
38
  }
53
- func (f *fakeOrca) TerminalCreate(ticketKey, title, command string) (string, error) {
54
- if f.createErr != nil {
55
- return "", f.createErr
56
- }
57
- h := "h-" + title
58
- f.terminals["name:"+ticketKey] = append(f.terminals["name:"+ticketKey], orcacli.Terminal{Handle: h, Title: title, Connected: true})
59
- return h, nil
39
+
40
+ // fakeCLI is the orcacli.Client seam used by the Orca runner tests.
41
+ type fakeCLI struct {
42
+ repos []orcacli.Repo
43
+ worktrees []orcacli.Worktree
44
+
45
+ createdBaseBranch string
46
+ createdParent string
60
47
  }
61
- func (f *fakeOrca) TerminalWait(handle, forState string, timeoutMs int) error { return f.waitErr }
62
- func (f *fakeOrca) TerminalClose(handle string) error {
63
- f.closed = append(f.closed, handle)
48
+
49
+ func (f *fakeCLI) ListRepos(context.Context) ([]orcacli.Repo, error) { return f.repos, nil }
50
+ func (f *fakeCLI) ListWorktrees(context.Context) ([]orcacli.Worktree, error) {
51
+ return f.worktrees, nil
52
+ }
53
+ func (f *fakeCLI) CreateWorktree(_ context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error {
54
+ f.createdBaseBranch = baseBranch
55
+ f.createdParent = parentWorktreeID
56
+ f.worktrees = append(f.worktrees, orcacli.Worktree{
57
+ ID: "wt-new",
58
+ RepoID: repoID,
59
+ DisplayName: ticketKey,
60
+ Branch: "refs/heads/" + ticketKey,
61
+ Path: "/wt/" + ticketKey,
62
+ })
64
63
  return nil
65
64
  }
66
- func (f *fakeOrca) TerminalSend(handle, text string) error {
67
- f.sent = append(f.sent, handle+":"+text)
68
- return nil
65
+ func (f *fakeCLI) DeleteWorktree(context.Context, string) error { return nil }
66
+ func (f *fakeCLI) ShowTerminal(context.Context, string) (orcacli.Terminal, error) {
67
+ return orcacli.Terminal{}, orcacli.ErrTerminalUnavailable
68
+ }
69
+ func (f *fakeCLI) SendTerminal(context.Context, string, string) error { return nil }
70
+ func (f *fakeCLI) ListTerminals(context.Context, string) ([]orcacli.Terminal, error) {
71
+ return nil, nil
69
72
  }
73
+ func (f *fakeCLI) CreateTerminal(context.Context, string, string, string) (string, error) {
74
+ return "", nil
75
+ }
76
+ func (f *fakeCLI) CloseTerminal(context.Context, string) error { return nil }
70
77
 
71
- func newTestRunner(f *fakeOrca) *orcaRunner {
72
- return &orcaRunner{
73
- repoID: "repo-1",
74
- orca: f,
75
- exists: func(string) (bool, error) { return f.agentExists, nil },
76
- sleep: func(time.Duration) {},
77
- findBranch: func(repoPath, key string) (string, bool, error) {
78
- if f.findBranchFn != nil {
79
- return f.findBranchFn(repoPath, key)
80
- }
81
- return "", false, nil
78
+ // 9.16: when the repo's primary worktree is on master (refs/heads/master),
79
+ // EnsureEnvironment must pass --base-branch master (via CreateWorktree), not
80
+ // the hardcoded "main".
81
+ func TestEnsureEnvironment_PrimaryBranchMaster(t *testing.T) {
82
+ fx := &fakeCLI{
83
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
84
+ worktrees: []orcacli.Worktree{
85
+ {ID: "wt-main", RepoID: "r1", DisplayName: "app-main", Branch: "refs/heads/master", Path: "/srv/app", IsMainWorktree: true},
82
86
  },
83
87
  }
84
- }
85
-
86
- func TestUnmarshalConfig(t *testing.T) {
87
- if _, err := unmarshalConfig(map[string]any{}); err != nil {
88
- t.Fatalf("empty config must be valid: %v", err)
88
+ a, err := New(fx, config.RawValues{})
89
+ if err != nil {
90
+ t.Fatal(err)
89
91
  }
90
- if _, err := unmarshalConfig(nil); err != nil {
91
- t.Fatalf("nil config must be valid: %v", err)
92
+ _, err = a.EnsureEnvironment(context.Background(), runner.RunSpec{TicketKey: "PAY-1", RepoName: "app", RepoPath: "/srv/app"})
93
+ if err != nil {
94
+ t.Fatal(err)
92
95
  }
93
- if _, err := unmarshalConfig(map[string]any{"bogus": 1}); err == nil {
94
- t.Fatal("unknown field must be rejected")
96
+ if fx.createdBaseBranch != "master" {
97
+ t.Fatalf("CreateWorktree baseBranch = %q, want %q", fx.createdBaseBranch, "master")
98
+ }
99
+ if fx.createdParent != "wt-main" {
100
+ t.Fatalf("CreateWorktree parent = %q, want %q", fx.createdParent, "wt-main")
95
101
  }
96
102
  }
97
103
 
98
- func TestSpawnCreatesWorktreeAndTerminal(t *testing.T) {
99
- f := &fakeOrca{
100
- worktrees: []orcacli.Worktree{{ID: "main", DisplayName: "main", RepoID: "repo-1", IsMainWorktree: true, Branch: "main", Path: "/tmp"}},
101
- terminals: map[string][]orcacli.Terminal{},
102
- agentExists: true,
103
- }
104
- r := newTestRunner(f)
105
- tk := tasks.Ticket{Key: "XYZ-1"}
106
- err := r.Spawn(tk, "coding", "build", "do the work", map[string]string{
107
- "RELAY_FLOW_WORKFLOW": "wf", "RELAY_FLOW_TICKET": "XYZ-1", "RELAY_FLOW_NODE": "coding", "RELAY_FLOW_AGENT": "build",
108
- })
104
+ // Explicit baseRef config overrides the primary worktree's branch.
105
+ func TestEnsureEnvironment_BaseRefOverride(t *testing.T) {
106
+ fx := &fakeCLI{
107
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
108
+ worktrees: []orcacli.Worktree{
109
+ {ID: "wt-main", RepoID: "r1", DisplayName: "app-main", Branch: "refs/heads/master", Path: "/srv/app", IsMainWorktree: true},
110
+ },
111
+ }
112
+ a, err := New(fx, config.RawValues{"baseRef": "release/1.x"})
109
113
  if err != nil {
110
- t.Fatalf("%v", err)
114
+ t.Fatal(err)
111
115
  }
112
- if len(f.created) != 1 || f.created[0] != "XYZ-1" {
113
- t.Errorf("worktree created = %v", f.created)
116
+ _, err = a.EnsureEnvironment(context.Background(), runner.RunSpec{TicketKey: "PAY-1", RepoName: "app", RepoPath: "/srv/app"})
117
+ if err != nil {
118
+ t.Fatal(err)
114
119
  }
115
- terms := f.terminals["name:XYZ-1"]
116
- if len(terms) != 1 || terms[0].Title != "XYZ-1:build:coding" {
117
- t.Errorf("terminals = %+v", terms)
120
+ if fx.createdBaseBranch != "release/1.x" {
121
+ t.Fatalf("CreateWorktree baseBranch = %q, want %q", fx.createdBaseBranch, "release/1.x")
118
122
  }
119
123
  }
120
124
 
121
- func TestSpawnUnknownAgent(t *testing.T) {
122
- f := &fakeOrca{terminals: map[string][]orcacli.Terminal{}, agentExists: false}
123
- r := newTestRunner(f)
124
- if err := r.Spawn(tasks.Ticket{Key: "XYZ-1"}, "coding", "ghost", "p", nil); err == nil {
125
- t.Fatal("unknown agent must error")
126
- }
127
- if len(f.created) != 0 {
128
- t.Errorf("no worktree should be created for unknown agent: %v", f.created)
125
+ // An existing ticket branch must win even over a configured baseRef. Passing
126
+ // any other base would make Orca hit its branch-name collision behavior.
127
+ func TestEnsureEnvironment_ExistingTicketBranchAvoidsCollision(t *testing.T) {
128
+ repo := t.TempDir()
129
+ runGit := func(args ...string) {
130
+ t.Helper()
131
+ cmdArgs := append([]string{"-C", repo}, args...)
132
+ if out, err := exec.Command("git", cmdArgs...).CombinedOutput(); err != nil {
133
+ t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out)
134
+ }
129
135
  }
130
- }
136
+ runGit("init", "--quiet")
137
+ runGit("-c", "user.name=Relay Flow", "-c", "user.email=relay-flow@example.invalid", "commit", "--allow-empty", "--quiet", "-m", "initial")
138
+ runGit("branch", "alice/PAY-1")
131
139
 
132
- func TestFindExactTitle(t *testing.T) {
133
- f := &fakeOrca{terminals: map[string][]orcacli.Terminal{
134
- "name:XYZ-1": {
135
- {Handle: "h1", Title: "XYZ-1:build:coding"},
136
- {Handle: "h2", Title: "XYZ-1:build:reviewing"},
137
- {Handle: "h3", Title: "Terminal 1"},
140
+ fx := &fakeCLI{
141
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: repo}},
142
+ worktrees: []orcacli.Worktree{
143
+ {ID: "wt-main", RepoID: "r1", DisplayName: "app-main", Branch: "refs/heads/master", Path: repo, IsMainWorktree: true},
138
144
  },
139
- }}
140
- r := newTestRunner(f)
141
- s, ok, err := r.Find(tasks.Ticket{Key: "XYZ-1"}, "coding")
142
- if err != nil || !ok || s.ID != "h1" {
143
- t.Errorf("Find coding = %+v ok=%v err=%v", s, ok, err)
144
- }
145
- if _, ok, _ := r.Find(tasks.Ticket{Key: "XYZ-1"}, "nowhere"); ok {
146
- t.Error("Find nowhere must miss")
147
145
  }
148
- if _, ok, _ := r.Find(tasks.Ticket{Key: "XYZ-9"}, "coding"); ok {
149
- t.Error("Find unknown ticket must miss")
150
- }
151
- }
152
-
153
- func TestFindMissingWorktreeIsNotFound(t *testing.T) {
154
- // selector_not_found (worktree gone after crash) must be "no session",
155
- // not an error — otherwise bounce never reaches the respawn branch.
156
- f := &fakeOrca{listErr: errSelectorNotFound}
157
- r := newTestRunner(f)
158
- if _, ok, err := r.Find(tasks.Ticket{Key: "XYZ-9"}, "coding"); err != nil || ok {
159
- t.Errorf("Find = ok=%v err=%v, want no-session", ok, err)
160
- }
161
- // Real errors still propagate.
162
- f.listErr = errBoom
163
- if _, _, err := r.Find(tasks.Ticket{Key: "XYZ-9"}, "coding"); err == nil {
164
- t.Error("real list error must propagate")
146
+ a, err := New(fx, config.RawValues{"baseRef": "release/1.x"})
147
+ if err != nil {
148
+ t.Fatal(err)
165
149
  }
166
- }
167
-
168
- var errSelectorNotFound = errorString("exit status 1: selector_not_found")
169
- var errBoom = errorString("boom")
170
-
171
- type errorString string
172
-
173
- func (e errorString) Error() string { return string(e) }
174
-
175
- func TestNudgeSendsPrompt(t *testing.T) {
176
- f := &fakeOrca{}
177
- r := newTestRunner(f)
178
- if err := r.Nudge(runner.Session{ID: "h1", Title: "XYZ-1:build:coding"}, "continue please"); err != nil {
179
- t.Fatalf("%v", err)
150
+ if _, err := a.EnsureEnvironment(context.Background(), runner.RunSpec{TicketKey: "PAY-1", RepoName: "app", RepoPath: repo}); err != nil {
151
+ t.Fatal(err)
180
152
  }
181
- if len(f.sent) != 1 || f.sent[0] != "h1:continue please" {
182
- t.Errorf("sent = %v", f.sent)
153
+ if fx.createdBaseBranch != "alice/PAY-1" {
154
+ t.Fatalf("CreateWorktree baseBranch = %q, want existing ticket branch", fx.createdBaseBranch)
183
155
  }
184
156
  }
185
157
 
186
- func TestCloseClosesTicketTerminals(t *testing.T) {
187
- f := &fakeOrca{terminals: map[string][]orcacli.Terminal{
188
- "name:XYZ-1": {
189
- {Handle: "h1", Title: "XYZ-1:build:coding"},
190
- {Handle: "h2", Title: "XYZ-1:build:reviewing"},
191
- {Handle: "h3", Title: "Setup"}, // not ours — survives
192
- },
193
- }}
194
- r := newTestRunner(f)
195
- if err := r.Close(tasks.Ticket{Key: "XYZ-1"}); err != nil {
196
- t.Fatalf("%v", err)
197
- }
198
- if len(f.closed) != 2 {
199
- t.Errorf("closed = %v, want h1+h2 only", f.closed)
158
+ func TestPrimaryBranch(t *testing.T) {
159
+ cases := []struct {
160
+ in, want string
161
+ }{
162
+ {"refs/heads/master", "master"},
163
+ {"refs/heads/main", "main"},
164
+ {"refs/heads/release/1.x", "release/1.x"},
165
+ {"master", "master"}, // already bare
166
+ {"", "main"}, // empty → main fallback
167
+ {"refs/heads/", "main"}, // empty name → main fallback
168
+ }
169
+ for _, c := range cases {
170
+ got := primaryBranch(&orcacli.Worktree{Branch: c.in})
171
+ if got != c.want {
172
+ t.Errorf("primaryBranch(%q) = %q, want %q", c.in, got, c.want)
173
+ }
200
174
  }
201
175
  }
@@ -0,0 +1,215 @@
1
+ // Package orcacli wraps the Orca CLI behind a small fakeable interface.
2
+ // Every call is real; there is no dry-run mode.
3
+ package orcacli
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "os/exec"
11
+ "regexp"
12
+ "strings"
13
+ )
14
+
15
+ var ErrTerminalUnavailable = errors.New("terminal unavailable")
16
+
17
+ // Repo is an Orca-registered repository.
18
+ type Repo struct {
19
+ ID string `json:"id"`
20
+ DisplayName string `json:"displayName"`
21
+ Path string `json:"path"`
22
+ }
23
+
24
+ // Worktree is an Orca worktree (a runner environment).
25
+ type Worktree struct {
26
+ ID string `json:"id"`
27
+ RepoID string `json:"repoId"`
28
+ DisplayName string `json:"displayName"`
29
+ Branch string `json:"branch"`
30
+ Path string `json:"path"`
31
+ IsMainWorktree bool `json:"isMainWorktree"`
32
+ }
33
+
34
+ // Terminal is a tab's persistent identity: Title is the tab-level title set
35
+ // via --title, which persists — unlike the pane-level title, which the
36
+ // running program resets.
37
+ type Terminal struct {
38
+ Handle string
39
+ Title string
40
+ Connected bool
41
+ }
42
+
43
+ // Client is the fakeable Orca CLI seam used by the Orca runner adapter.
44
+ type Client interface {
45
+ ListRepos(ctx context.Context) ([]Repo, error)
46
+ ListWorktrees(ctx context.Context) ([]Worktree, error)
47
+ CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error
48
+ DeleteWorktree(ctx context.Context, worktreeID string) error
49
+ ShowTerminal(ctx context.Context, handle string) (Terminal, error)
50
+ SendTerminal(ctx context.Context, handle, text string) error
51
+ ListTerminals(ctx context.Context, worktree string) ([]Terminal, error)
52
+ CreateTerminal(ctx context.Context, ticketKey, title, command string) (handle string, err error)
53
+ CloseTerminal(ctx context.Context, handle string) error
54
+ }
55
+
56
+ // CLI is the production Client backed by the orca binary.
57
+ type CLI struct{}
58
+
59
+ func New() *CLI { return &CLI{} }
60
+
61
+ func (CLI) ListRepos(ctx context.Context) ([]Repo, error) {
62
+ var res struct {
63
+ Result struct {
64
+ Repos []Repo `json:"repos"`
65
+ } `json:"result"`
66
+ }
67
+ if err := runJSON(ctx, &res, "repo", "list", "--json"); err != nil {
68
+ return nil, fmt.Errorf("orca repo list: %w", err)
69
+ }
70
+ return res.Result.Repos, nil
71
+ }
72
+
73
+ func (CLI) ListWorktrees(ctx context.Context) ([]Worktree, error) {
74
+ var res struct {
75
+ Result struct {
76
+ Worktrees []Worktree `json:"worktrees"`
77
+ } `json:"result"`
78
+ }
79
+ if err := runJSON(ctx, &res, "worktree", "list", "--json"); err != nil {
80
+ return nil, fmt.Errorf("orca worktree list: %w", err)
81
+ }
82
+ return res.Result.Worktrees, nil
83
+ }
84
+
85
+ // CreateWorktree always sets --parent-worktree and --base-branch explicitly
86
+ // (never Orca's inferred defaults) so every ticket worktree has a
87
+ // deliberate, known ancestry.
88
+ func (CLI) CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error {
89
+ return run(ctx, "worktree", "create", "--name", ticketKey, "--repo", "id:"+repoID,
90
+ "--parent-worktree", "worktree:"+parentWorktreeID, "--base-branch", baseBranch, "--json")
91
+ }
92
+
93
+ // FindExistingBranch returns the first local or remote-tracking branch whose
94
+ // short ref contains ticketKey. The returned ref is suitable for Orca's
95
+ // --base-branch argument.
96
+ func FindExistingBranch(repoPath, ticketKey string) (string, bool, error) {
97
+ out, err := exec.Command("git", "-C", repoPath, "branch", "-a", "--list", "--format=%(refname:short)").CombinedOutput()
98
+ if err != nil {
99
+ return "", false, fmt.Errorf("git branch --list: %w: %s", err, strings.TrimSpace(string(out)))
100
+ }
101
+ match := regexp.MustCompile(regexp.QuoteMeta(ticketKey))
102
+ for _, line := range strings.Split(string(out), "\n") {
103
+ branch := strings.TrimSpace(line)
104
+ if branch != "" && match.MatchString(branch) {
105
+ return branch, true, nil
106
+ }
107
+ }
108
+ return "", false, nil
109
+ }
110
+
111
+ func (CLI) DeleteWorktree(ctx context.Context, worktreeID string) error {
112
+ return run(ctx, "worktree", "rm", "--worktree", "id:"+worktreeID, "--json")
113
+ }
114
+
115
+ func (CLI) ShowTerminal(ctx context.Context, handle string) (Terminal, error) {
116
+ var res struct {
117
+ Result struct {
118
+ Terminal struct {
119
+ Handle string `json:"handle"`
120
+ Title string `json:"title"`
121
+ Connected bool `json:"connected"`
122
+ Writable bool `json:"writable"`
123
+ } `json:"terminal"`
124
+ } `json:"result"`
125
+ }
126
+ if err := runJSON(ctx, &res, "terminal", "show", "--terminal", handle, "--json"); err != nil {
127
+ if strings.Contains(err.Error(), "terminal_handle_stale") {
128
+ return Terminal{}, ErrTerminalUnavailable
129
+ }
130
+ return Terminal{}, fmt.Errorf("orca terminal show: %w", err)
131
+ }
132
+ t := res.Result.Terminal
133
+ return Terminal{Handle: t.Handle, Title: t.Title, Connected: t.Connected && t.Writable}, nil
134
+ }
135
+
136
+ func (CLI) SendTerminal(ctx context.Context, handle, text string) error {
137
+ return run(ctx, "terminal", "send", "--terminal", handle, "--text", text, "--enter", "--json")
138
+ }
139
+
140
+ // ListTerminals returns tabs (with their persistent tab-level title) for a
141
+ // worktree, e.g. "name:PAY-101". --include-visual-layouts is mandatory:
142
+ // orca omits visualLayouts from JSON without it.
143
+ func (CLI) ListTerminals(ctx context.Context, worktree string) ([]Terminal, error) {
144
+ var res struct {
145
+ Result struct {
146
+ VisualLayouts []struct {
147
+ Root struct {
148
+ Tabs []struct {
149
+ Title string `json:"title"`
150
+ Panes struct {
151
+ Handle string `json:"handle"`
152
+ Connected bool `json:"connected"`
153
+ } `json:"panes"`
154
+ } `json:"tabs"`
155
+ } `json:"root"`
156
+ } `json:"visualLayouts"`
157
+ } `json:"result"`
158
+ }
159
+ if err := runJSON(ctx, &res, "terminal", "list", "--worktree", worktree, "--include-visual-layouts", "--json"); err != nil {
160
+ return nil, fmt.Errorf("orca terminal list: %w", err)
161
+ }
162
+ var terms []Terminal
163
+ for _, vl := range res.Result.VisualLayouts {
164
+ for _, tab := range vl.Root.Tabs {
165
+ terms = append(terms, Terminal{Handle: tab.Panes.Handle, Title: tab.Title, Connected: tab.Panes.Connected})
166
+ }
167
+ }
168
+ return terms, nil
169
+ }
170
+
171
+ // CreateTerminal launches the given shell command in a fresh terminal on
172
+ // the ticket's worktree.
173
+ func (CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string) (string, error) {
174
+ var res struct {
175
+ Result struct {
176
+ Terminal struct {
177
+ Handle string `json:"handle"`
178
+ } `json:"terminal"`
179
+ } `json:"result"`
180
+ }
181
+ if err := runJSON(ctx, &res, "terminal", "create",
182
+ "--worktree", "name:"+ticketKey,
183
+ "--title", title,
184
+ "--command", command,
185
+ "--json"); err != nil {
186
+ return "", fmt.Errorf("orca terminal create: %w", err)
187
+ }
188
+ return res.Result.Terminal.Handle, nil
189
+ }
190
+
191
+ func (CLI) CloseTerminal(ctx context.Context, handle string) error {
192
+ return run(ctx, "terminal", "close", "--terminal", handle, "--json")
193
+ }
194
+
195
+ func run(ctx context.Context, args ...string) error {
196
+ cmd := exec.CommandContext(ctx, "orca", args...)
197
+ out, err := cmd.CombinedOutput()
198
+ if err != nil {
199
+ return fmt.Errorf("orca %v: %w: %s", args, err, strings.TrimSpace(string(out)))
200
+ }
201
+ return nil
202
+ }
203
+
204
+ func runJSON(ctx context.Context, dest any, args ...string) error {
205
+ cmd := exec.CommandContext(ctx, "orca", args...)
206
+ out, err := cmd.Output()
207
+ if err != nil {
208
+ // orca prints its JSON error envelope to stdout even on failure.
209
+ return fmt.Errorf("orca %v: %w: %s", args, err, strings.TrimSpace(string(out)))
210
+ }
211
+ if err := json.Unmarshal(out, dest); err != nil {
212
+ return fmt.Errorf("orca %v: parse json: %w", args, err)
213
+ }
214
+ return nil
215
+ }