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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- 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/
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
11
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
9
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/runner/orca/orcacli"
|
|
10
13
|
)
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
func (f *
|
|
63
|
-
|
|
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 *
|
|
67
|
-
|
|
68
|
-
return
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
94
|
-
t.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
err :=
|
|
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.
|
|
114
|
+
t.Fatal(err)
|
|
111
115
|
}
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
182
|
-
t.
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
},
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|