relay-flow 0.2.0-alpha → 0.2.1-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 +18 -6
- package/cmd/relay-flow/commands_test.go +519 -15
- package/cmd/relay-flow/main.go +331 -119
- package/cmd/relay-flow/scenario_test.go +209 -34
- package/cmd/relay-flow/serve.go +1 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/internal/execution/goworkflows/activities.go +65 -65
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +73 -13
- package/internal/execution/goworkflows/fakes_test.go +13 -21
- package/internal/execution/goworkflows/interpreter.go +16 -8
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +45 -21
- package/internal/execution/goworkflows/recovery_test.go +5 -5
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +5 -0
- package/internal/paths/paths.go +18 -16
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service_test.go +4 -4
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/contract_test.go +2 -0
- package/internal/task/factory.go +16 -0
- package/internal/task/jira/auth.go +183 -0
- package/internal/task/jira/auth_test.go +107 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +36 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +92 -61
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +128 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/transition_defaults_test.go +18 -16
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- package/internal/task/jira/acli/testdata/search_success.json +0 -1
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -41,9 +41,14 @@ func TestSanitizeErr(t *testing.T) {
|
|
|
41
41
|
type fakeCLI struct {
|
|
42
42
|
repos []orcacli.Repo
|
|
43
43
|
worktrees []orcacli.Worktree
|
|
44
|
+
terminals map[string]orcacli.Terminal
|
|
44
45
|
|
|
45
46
|
createdBaseBranch string
|
|
46
47
|
createdParent string
|
|
48
|
+
status string
|
|
49
|
+
showHandles []string
|
|
50
|
+
createCommands []string
|
|
51
|
+
createN int
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
func (f *fakeCLI) ListRepos(context.Context) ([]orcacli.Repo, error) { return f.repos, nil }
|
|
@@ -62,16 +67,32 @@ func (f *fakeCLI) CreateWorktree(_ context.Context, ticketKey, repoID, parentWor
|
|
|
62
67
|
})
|
|
63
68
|
return nil
|
|
64
69
|
}
|
|
70
|
+
func (f *fakeCLI) SetWorktreeStatus(_ context.Context, _, status string) error {
|
|
71
|
+
f.status = status
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
65
74
|
func (f *fakeCLI) DeleteWorktree(context.Context, string) error { return nil }
|
|
66
|
-
func (f *fakeCLI) ShowTerminal(context.Context, string) (orcacli.Terminal, error) {
|
|
67
|
-
|
|
75
|
+
func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Terminal, error) {
|
|
76
|
+
f.showHandles = append(f.showHandles, handle)
|
|
77
|
+
t, ok := f.terminals[handle]
|
|
78
|
+
if !ok {
|
|
79
|
+
return orcacli.Terminal{}, orcacli.ErrTerminalUnavailable
|
|
80
|
+
}
|
|
81
|
+
return t, nil
|
|
68
82
|
}
|
|
69
83
|
func (f *fakeCLI) SendTerminal(context.Context, string, string) error { return nil }
|
|
70
84
|
func (f *fakeCLI) ListTerminals(context.Context, string) ([]orcacli.Terminal, error) {
|
|
71
85
|
return nil, nil
|
|
72
86
|
}
|
|
73
|
-
func (f *fakeCLI) CreateTerminal(context.Context, string,
|
|
74
|
-
|
|
87
|
+
func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command string) (string, error) {
|
|
88
|
+
f.createN++
|
|
89
|
+
f.createCommands = append(f.createCommands, command)
|
|
90
|
+
handle := "term-created-" + string(rune('0'+f.createN))
|
|
91
|
+
if f.terminals == nil {
|
|
92
|
+
f.terminals = map[string]orcacli.Terminal{}
|
|
93
|
+
}
|
|
94
|
+
f.terminals[handle] = orcacli.Terminal{Handle: handle, Title: title, Connected: true}
|
|
95
|
+
return handle, nil
|
|
75
96
|
}
|
|
76
97
|
func (f *fakeCLI) CloseTerminal(context.Context, string) error { return nil }
|
|
77
98
|
|
|
@@ -122,6 +143,124 @@ func TestEnsureEnvironment_BaseRefOverride(t *testing.T) {
|
|
|
122
143
|
}
|
|
123
144
|
}
|
|
124
145
|
|
|
146
|
+
func TestSetEnvironmentStatus(t *testing.T) {
|
|
147
|
+
fx := &fakeCLI{}
|
|
148
|
+
a, err := New(fx, config.RawValues{})
|
|
149
|
+
if err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
if err := a.SetEnvironmentStatus(context.Background(), runner.Environment{ID: "wt-PAY-1"}, runner.WorkspaceStatusInReview); err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
if fx.status != runner.WorkspaceStatusInReview {
|
|
156
|
+
t.Fatalf("status = %q, want %q", fx.status, runner.WorkspaceStatusInReview)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func TestFindTerminalUsesPersistedID(t *testing.T) {
|
|
161
|
+
fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
|
|
162
|
+
"term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
|
|
163
|
+
}}
|
|
164
|
+
a, err := New(fx, config.RawValues{})
|
|
165
|
+
if err != nil {
|
|
166
|
+
t.Fatal(err)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
got, ok, err := a.FindTerminal(context.Background(), runner.Terminal{ID: "term-stored", Title: "PAY-1:implement"})
|
|
170
|
+
if err != nil || !ok || got.ID != "term-stored" {
|
|
171
|
+
t.Fatalf("FindTerminal = %+v, %v, %v", got, ok, err)
|
|
172
|
+
}
|
|
173
|
+
if len(fx.showHandles) != 1 || fx.showHandles[0] != "term-stored" {
|
|
174
|
+
t.Fatalf("ShowTerminal handles = %v, want [term-stored]", fx.showHandles)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestFindTerminalReturnsOnlyLiveUsable(t *testing.T) {
|
|
179
|
+
fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
|
|
180
|
+
"term-dead": {Handle: "term-dead", Title: "PAY-1:implement", Connected: false},
|
|
181
|
+
}}
|
|
182
|
+
a, err := New(fx, config.RawValues{})
|
|
183
|
+
if err != nil {
|
|
184
|
+
t.Fatal(err)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if _, ok, err := a.FindTerminal(context.Background(), runner.Terminal{}); err != nil || ok {
|
|
188
|
+
t.Fatalf("FindTerminal(empty) ok=%v err=%v, want absent", ok, err)
|
|
189
|
+
}
|
|
190
|
+
if _, ok, err := a.FindTerminal(context.Background(), runner.Terminal{ID: "term-dead"}); err != nil || ok {
|
|
191
|
+
t.Fatalf("FindTerminal(dead) ok=%v err=%v, want absent", ok, err)
|
|
192
|
+
}
|
|
193
|
+
if len(fx.showHandles) != 1 || fx.showHandles[0] != "term-dead" {
|
|
194
|
+
t.Fatalf("ShowTerminal handles = %v, want [term-dead]", fx.showHandles)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func TestEnsureTerminalFindsBeforeCreate(t *testing.T) {
|
|
199
|
+
fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
|
|
200
|
+
"term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
|
|
201
|
+
}}
|
|
202
|
+
a, err := New(fx, config.RawValues{})
|
|
203
|
+
if err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
stored := runner.Terminal{ID: "term-stored", Title: "PAY-1:implement"}
|
|
207
|
+
|
|
208
|
+
got, err := a.EnsureTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, stored, "PAY-1:implement", runner.Command{Executable: "opencode"})
|
|
209
|
+
if err != nil {
|
|
210
|
+
t.Fatal(err)
|
|
211
|
+
}
|
|
212
|
+
if got.ID != stored.ID || fx.createN != 0 {
|
|
213
|
+
t.Fatalf("EnsureTerminal = %+v, creates=%d; want stored terminal and no create", got, fx.createN)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func TestEnsureTerminalCreatesWhenStoredTerminalUnavailable(t *testing.T) {
|
|
218
|
+
fx := &fakeCLI{}
|
|
219
|
+
a, err := New(fx, config.RawValues{})
|
|
220
|
+
if err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
command := runner.Command{Executable: "custom-harness", Args: []string{"--session", "opaque", "--prompt", "work", "--agent", "review"}}
|
|
224
|
+
|
|
225
|
+
got, err := a.EnsureTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, runner.Terminal{ID: "term-stale"}, "PAY-1:implement", command)
|
|
226
|
+
if err != nil {
|
|
227
|
+
t.Fatal(err)
|
|
228
|
+
}
|
|
229
|
+
if got.ID != "term-created-1" || fx.createN != 1 {
|
|
230
|
+
t.Fatalf("EnsureTerminal = %+v, creates=%d", got, fx.createN)
|
|
231
|
+
}
|
|
232
|
+
if len(fx.showHandles) != 1 || fx.showHandles[0] != "term-stale" {
|
|
233
|
+
t.Fatalf("ShowTerminal handles = %v, want [term-stale]", fx.showHandles)
|
|
234
|
+
}
|
|
235
|
+
if len(fx.createCommands) != 1 || fx.createCommands[0] != shellCommand(command) {
|
|
236
|
+
t.Fatalf("CreateTerminal commands = %v, want opaque command unchanged", fx.createCommands)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func TestCreateTerminalAlwaysCreatesAndTreatsCommandAsOpaque(t *testing.T) {
|
|
241
|
+
fx := &fakeCLI{}
|
|
242
|
+
a, err := New(fx, config.RawValues{})
|
|
243
|
+
if err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
command := runner.Command{Executable: "custom-harness", Args: []string{"--session", "opaque"}}
|
|
247
|
+
|
|
248
|
+
first, err := a.CreateTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, "PAY-1:implement", command)
|
|
249
|
+
if err != nil {
|
|
250
|
+
t.Fatal(err)
|
|
251
|
+
}
|
|
252
|
+
second, err := a.CreateTerminal(context.Background(), runner.Environment{ID: "wt-PAY-1"}, "PAY-1:implement", command)
|
|
253
|
+
if err != nil {
|
|
254
|
+
t.Fatal(err)
|
|
255
|
+
}
|
|
256
|
+
if first.ID == second.ID || fx.createN != 2 {
|
|
257
|
+
t.Fatalf("CreateTerminal IDs = %q, %q; creates=%d", first.ID, second.ID, fx.createN)
|
|
258
|
+
}
|
|
259
|
+
if len(fx.showHandles) != 0 {
|
|
260
|
+
t.Fatalf("CreateTerminal parsed resume syntax and inspected terminals: %v", fx.showHandles)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
125
264
|
// An existing ticket branch must win even over a configured baseRef. Passing
|
|
126
265
|
// any other base would make Orca hit its branch-name collision behavior.
|
|
127
266
|
func TestEnsureEnvironment_ExistingTicketBranchAvoidsCollision(t *testing.T) {
|
|
@@ -45,6 +45,7 @@ type Client interface {
|
|
|
45
45
|
ListRepos(ctx context.Context) ([]Repo, error)
|
|
46
46
|
ListWorktrees(ctx context.Context) ([]Worktree, error)
|
|
47
47
|
CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error
|
|
48
|
+
SetWorktreeStatus(ctx context.Context, worktreeID, status string) error
|
|
48
49
|
DeleteWorktree(ctx context.Context, worktreeID string) error
|
|
49
50
|
ShowTerminal(ctx context.Context, handle string) (Terminal, error)
|
|
50
51
|
SendTerminal(ctx context.Context, handle, text string) error
|
|
@@ -90,6 +91,10 @@ func (CLI) CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktree
|
|
|
90
91
|
"--parent-worktree", "worktree:"+parentWorktreeID, "--base-branch", baseBranch, "--json")
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
func (CLI) SetWorktreeStatus(ctx context.Context, worktreeID, status string) error {
|
|
95
|
+
return run(ctx, "worktree", "set", "--worktree", "id:"+worktreeID, "--workspace-status", status, "--json")
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
// FindExistingBranch returns the first local or remote-tracking branch whose
|
|
94
99
|
// short ref contains ticketKey. The returned ref is suitable for Orca's
|
|
95
100
|
// --base-branch argument.
|
|
@@ -33,6 +33,9 @@ func TestCLIContractsAgainstCapturedRealOutput(t *testing.T) {
|
|
|
33
33
|
if err := cli.CreateWorktree(ctx, "PAY-101", "repo-1", "wt-main", "origin/alice/PAY-101"); err != nil {
|
|
34
34
|
t.Fatal(err)
|
|
35
35
|
}
|
|
36
|
+
if err := cli.SetWorktreeStatus(ctx, "wt-PAY-101", "in-review"); err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
36
39
|
if err := cli.DeleteWorktree(ctx, "wt-PAY-101"); err != nil {
|
|
37
40
|
t.Fatal(err)
|
|
38
41
|
}
|
|
@@ -8,6 +8,8 @@ elif [ "$#" -eq 3 ] && [ "$1" = worktree ] && [ "$2" = list ] && [ "$3" = --json
|
|
|
8
8
|
fixture=worktree-list.json
|
|
9
9
|
elif [ "$#" -eq 11 ] && [ "$1" = worktree ] && [ "$2" = create ] && [ "$3" = --name ] && [ "$4" = PAY-101 ] && [ "$5" = --repo ] && [ "$6" = id:repo-1 ] && [ "$7" = --parent-worktree ] && [ "$8" = worktree:wt-main ] && [ "$9" = --base-branch ] && [ "${10}" = origin/alice/PAY-101 ] && [ "${11}" = --json ]; then
|
|
10
10
|
fixture=worktree-create.json
|
|
11
|
+
elif [ "$#" -eq 7 ] && [ "$1" = worktree ] && [ "$2" = set ] && [ "$3" = --worktree ] && [ "$4" = id:wt-PAY-101 ] && [ "$5" = --workspace-status ] && [ "$6" = in-review ] && [ "$7" = --json ]; then
|
|
12
|
+
fixture=worktree-create.json
|
|
11
13
|
elif [ "$#" -eq 5 ] && [ "$1" = worktree ] && [ "$2" = rm ] && [ "$3" = --worktree ] && [ "$4" = id:wt-PAY-101 ] && [ "$5" = --json ]; then
|
|
12
14
|
fixture=worktree-remove.json
|
|
13
15
|
elif [ "$#" -eq 6 ] && [ "$1" = terminal ] && [ "$2" = list ] && [ "$3" = --worktree ] && [ "$4" = id:wt-PAY-101 ] && [ "$5" = --include-visual-layouts ] && [ "$6" = --json ]; then
|
|
@@ -13,6 +13,12 @@ import (
|
|
|
13
13
|
|
|
14
14
|
var ErrSessionUnavailable = errors.New("stored session unavailable")
|
|
15
15
|
|
|
16
|
+
const (
|
|
17
|
+
WorkspaceStatusInProgress = "in-progress"
|
|
18
|
+
WorkspaceStatusInReview = "in-review"
|
|
19
|
+
WorkspaceStatusCompleted = "completed"
|
|
20
|
+
)
|
|
21
|
+
|
|
16
22
|
type RepoCandidate struct {
|
|
17
23
|
Name string `json:"name"`
|
|
18
24
|
Path string `json:"path"`
|
|
@@ -42,10 +48,11 @@ type RunSpec struct {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
// Runner owns ticket-scoped environments, terminals, and liveness.
|
|
45
|
-
// FindTerminal
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
51
|
+
// FindTerminal checks a persisted terminal handle against the external runner
|
|
52
|
+
// and returns only a live usable terminal. CloseTerminals closes agent terminals
|
|
53
|
+
// while preserving the environment/workspace; CleanupRun removes all
|
|
54
|
+
// runner-owned run resources at end. Both reconstruct identity from RunSpec
|
|
55
|
+
// without SQLite IDs.
|
|
49
56
|
//
|
|
50
57
|
// Terminal titles are stable and minimal: only "<ticket>:<node>" — never
|
|
51
58
|
// nodeVisitID, workflow name, agent name, or other changing metadata.
|
|
@@ -53,12 +60,12 @@ type Runner interface {
|
|
|
53
60
|
DiscoverRepos(ctx context.Context) ([]RepoCandidate, error)
|
|
54
61
|
ValidateRepo(ctx context.Context, name, path string) error
|
|
55
62
|
EnsureEnvironment(ctx context.Context, spec RunSpec) (Environment, error)
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
SetEnvironmentStatus(ctx context.Context, env Environment, status string) error
|
|
64
|
+
FindTerminal(ctx context.Context, terminal Terminal) (Terminal, bool, error)
|
|
58
65
|
CreateTerminal(ctx context.Context, env Environment, title string, command Command) (Terminal, error)
|
|
59
|
-
|
|
66
|
+
EnsureTerminal(ctx context.Context, env Environment, terminal Terminal, title string, command Command) (Terminal, error)
|
|
67
|
+
SendTerminal(ctx context.Context, terminal Terminal, text string) error
|
|
60
68
|
CloseTerminal(ctx context.Context, terminal Terminal) error
|
|
61
|
-
EnsureTerminal(ctx context.Context, env Environment, title string, command Command) (Terminal, error)
|
|
62
69
|
CloseTerminals(ctx context.Context, spec RunSpec) error
|
|
63
70
|
CleanupRun(ctx context.Context, spec RunSpec) error
|
|
64
71
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package task_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const alternateAuthPlugin = "alternate-auth-format-test"
|
|
17
|
+
|
|
18
|
+
var registerAlternateAuth sync.Once
|
|
19
|
+
|
|
20
|
+
func TestAlternativeTaskPluginOwnsCredentialsFormat(t *testing.T) {
|
|
21
|
+
registerAlternateAuth.Do(func() {
|
|
22
|
+
task.Register(alternateAuthPlugin, task.Factory{
|
|
23
|
+
RequiredRepoKeys: func() []string { return nil },
|
|
24
|
+
TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "alternate", nil },
|
|
25
|
+
Auth: func(_ context.Context, _ []string, stdin io.Reader) error {
|
|
26
|
+
key, err := io.ReadAll(stdin)
|
|
27
|
+
if err != nil {
|
|
28
|
+
return err
|
|
29
|
+
}
|
|
30
|
+
return os.WriteFile(filepath.Join(os.Getenv("RELAY_FLOW_HOME"), "credentials.yaml"),
|
|
31
|
+
[]byte("alternateApiKey: "+strings.TrimSpace(string(key))+"\n"), 0o600)
|
|
32
|
+
},
|
|
33
|
+
New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
root := t.TempDir()
|
|
37
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
38
|
+
if err := task.Auth(context.Background(), alternateAuthPlugin, nil, strings.NewReader("different-secret")); err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
raw, err := os.ReadFile(filepath.Join(root, "credentials.yaml"))
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
if string(raw) != "alternateApiKey: different-secret\n" {
|
|
46
|
+
t.Fatalf("alternate credentials = %q", raw)
|
|
47
|
+
}
|
|
48
|
+
}
|
package/internal/task/factory.go
CHANGED
|
@@ -3,6 +3,7 @@ package task
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
|
+
"io"
|
|
6
7
|
"sort"
|
|
7
8
|
"strings"
|
|
8
9
|
"sync"
|
|
@@ -26,6 +27,7 @@ type RepoSpec struct {
|
|
|
26
27
|
type Factory struct {
|
|
27
28
|
RequiredRepoKeys func() []string
|
|
28
29
|
TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
|
|
30
|
+
Auth func(context.Context, []string, io.Reader) error
|
|
29
31
|
New func(context.Context, RepoSpec) (System, error)
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -63,6 +65,20 @@ func New(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
|
63
65
|
return f.New(ctx, spec)
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
// Auth dispatches system-wide authentication to the selected task plugin.
|
|
69
|
+
// The plugin owns its flags, prompts, validation, credential format, and
|
|
70
|
+
// credential storage.
|
|
71
|
+
func Auth(ctx context.Context, name string, args []string, stdin io.Reader) error {
|
|
72
|
+
f, err := lookup(name)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return err
|
|
75
|
+
}
|
|
76
|
+
if f.Auth == nil {
|
|
77
|
+
return fmt.Errorf("task: plugin %q does not support authentication", name)
|
|
78
|
+
}
|
|
79
|
+
return f.Auth(ctx, args, stdin)
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
// RequiredRepoKeys returns the repo YAML keys the named plugin requires at
|
|
67
83
|
// registration.
|
|
68
84
|
func RequiredRepoKeys(name string) ([]string, error) {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"flag"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
"github.com/charmbracelet/huh"
|
|
15
|
+
"github.com/mattn/go-isatty"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
17
|
+
jirarest "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
|
|
18
|
+
"gopkg.in/yaml.v3"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type credentials struct {
|
|
22
|
+
Site string `yaml:"site"`
|
|
23
|
+
Email string `yaml:"email"`
|
|
24
|
+
Token string `yaml:"token"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func auth(ctx context.Context, args []string, stdin io.Reader) error {
|
|
28
|
+
fs := flag.NewFlagSet("task auth", flag.ContinueOnError)
|
|
29
|
+
fs.SetOutput(io.Discard)
|
|
30
|
+
site := fs.String("site", "", "Jira site URL")
|
|
31
|
+
email := fs.String("email", "", "Jira account email")
|
|
32
|
+
token := fs.String("token", "", "Jira API token")
|
|
33
|
+
if err := fs.Parse(args); err != nil {
|
|
34
|
+
return err
|
|
35
|
+
}
|
|
36
|
+
if fs.NArg() != 0 {
|
|
37
|
+
return errors.New("unexpected positional arguments")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
values := credentials{Site: strings.TrimSpace(*site), Email: strings.TrimSpace(*email), Token: *token}
|
|
41
|
+
flagged := values.Site != "" || values.Email != "" || values.Token != ""
|
|
42
|
+
if flagged && (values.Site == "" || values.Email == "" || values.Token == "") {
|
|
43
|
+
return errors.New("--site, --email, and --token must be given together")
|
|
44
|
+
}
|
|
45
|
+
if !flagged {
|
|
46
|
+
if isTTY(stdin) {
|
|
47
|
+
var err error
|
|
48
|
+
values, err = promptCredentials(values)
|
|
49
|
+
if err != nil {
|
|
50
|
+
return err
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
var ok bool
|
|
54
|
+
values, ok = readCredentialLines(stdin)
|
|
55
|
+
if !ok {
|
|
56
|
+
return errors.New("expected Jira site, email, and API token on stdin (or pass --site, --email, and --token)")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
client, err := jirarest.New(values.Site, values.Email, values.Token)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return err
|
|
64
|
+
}
|
|
65
|
+
if err := client.ValidateCredentials(ctx); err != nil {
|
|
66
|
+
return err
|
|
67
|
+
}
|
|
68
|
+
values.Site = strings.TrimRight(clientSite(values.Site), "/")
|
|
69
|
+
path, err := credentialsPath()
|
|
70
|
+
if err != nil {
|
|
71
|
+
return err
|
|
72
|
+
}
|
|
73
|
+
return saveCredentials(path, values)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func isTTY(stdin io.Reader) bool {
|
|
77
|
+
f, ok := stdin.(*os.File)
|
|
78
|
+
return ok && isatty.IsTerminal(f.Fd())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func promptCredentials(values credentials) (credentials, error) {
|
|
82
|
+
required := func(name string) func(string) error {
|
|
83
|
+
return func(value string) error {
|
|
84
|
+
if strings.TrimSpace(value) == "" {
|
|
85
|
+
return fmt.Errorf("%s is required", name)
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
form := huh.NewForm(huh.NewGroup(
|
|
91
|
+
huh.NewInput().Title("Jira site").Description("Your Atlassian site URL.").Placeholder("https://company.atlassian.net").Validate(required("Jira site")).Value(&values.Site),
|
|
92
|
+
huh.NewInput().Title("Jira email").Description("Email for the Jira API token.").Placeholder("you@company.com").Validate(required("Jira email")).Value(&values.Email),
|
|
93
|
+
huh.NewInput().Title("Jira API token").Description("Stored locally and never displayed.").EchoMode(huh.EchoModePassword).Validate(required("Jira API token")).Value(&values.Token),
|
|
94
|
+
).Title("Configure Jira"))
|
|
95
|
+
if err := form.Run(); err != nil {
|
|
96
|
+
return credentials{}, err
|
|
97
|
+
}
|
|
98
|
+
values.Site = strings.TrimSpace(values.Site)
|
|
99
|
+
values.Email = strings.TrimSpace(values.Email)
|
|
100
|
+
return values, nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func readCredentialLines(stdin io.Reader) (credentials, bool) {
|
|
104
|
+
values := make([]string, 0, 3)
|
|
105
|
+
scanner := bufio.NewScanner(stdin)
|
|
106
|
+
for len(values) < 3 && scanner.Scan() {
|
|
107
|
+
line := strings.TrimSpace(scanner.Text())
|
|
108
|
+
if line != "" {
|
|
109
|
+
values = append(values, line)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if len(values) != 3 {
|
|
113
|
+
return credentials{}, false
|
|
114
|
+
}
|
|
115
|
+
return credentials{Site: values[0], Email: values[1], Token: values[2]}, true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func clientSite(site string) string {
|
|
119
|
+
if !strings.Contains(site, "://") {
|
|
120
|
+
return "https://" + site
|
|
121
|
+
}
|
|
122
|
+
return site
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func credentialsPath() (string, error) {
|
|
126
|
+
if root := os.Getenv("RELAY_FLOW_HOME"); root != "" {
|
|
127
|
+
return filepath.Join(root, "credentials.yaml"), nil
|
|
128
|
+
}
|
|
129
|
+
home, err := os.UserHomeDir()
|
|
130
|
+
if err != nil {
|
|
131
|
+
return "", fmt.Errorf("resolve user home: %w", err)
|
|
132
|
+
}
|
|
133
|
+
return filepath.Join(home, ".relay-flow", "credentials.yaml"), nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func loadCredentialsDefault() (credentials, error) {
|
|
137
|
+
path, err := credentialsPath()
|
|
138
|
+
if err != nil {
|
|
139
|
+
return credentials{}, err
|
|
140
|
+
}
|
|
141
|
+
return loadCredentials(path)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func loadCredentials(path string) (credentials, error) {
|
|
145
|
+
info, err := os.Stat(path)
|
|
146
|
+
if err != nil {
|
|
147
|
+
return credentials{}, fmt.Errorf("stat credentials %s: %w", path, err)
|
|
148
|
+
}
|
|
149
|
+
if info.Mode().Perm() != 0o600 {
|
|
150
|
+
return credentials{}, fmt.Errorf("credentials %s must have mode 0600", path)
|
|
151
|
+
}
|
|
152
|
+
raw, err := os.ReadFile(path)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return credentials{}, fmt.Errorf("read credentials %s: %w", path, err)
|
|
155
|
+
}
|
|
156
|
+
var out credentials
|
|
157
|
+
decoder := yaml.NewDecoder(strings.NewReader(string(raw)))
|
|
158
|
+
decoder.KnownFields(true)
|
|
159
|
+
if err := decoder.Decode(&out); err != nil {
|
|
160
|
+
return credentials{}, fmt.Errorf("parse credentials %s: %w", path, err)
|
|
161
|
+
}
|
|
162
|
+
if out.Site == "" || out.Email == "" || out.Token == "" {
|
|
163
|
+
return credentials{}, errors.New("Jira site, email, and API token are required")
|
|
164
|
+
}
|
|
165
|
+
return out, nil
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func saveCredentials(path string, value credentials) error {
|
|
169
|
+
if value.Site == "" || value.Email == "" || value.Token == "" {
|
|
170
|
+
return errors.New("Jira site, email, and API token are required")
|
|
171
|
+
}
|
|
172
|
+
raw, err := yaml.Marshal(value)
|
|
173
|
+
if err != nil {
|
|
174
|
+
return fmt.Errorf("marshal credentials: %w", err)
|
|
175
|
+
}
|
|
176
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
177
|
+
return fmt.Errorf("create credentials directory: %w", err)
|
|
178
|
+
}
|
|
179
|
+
if err := config.WriteAtomic(path, raw, 0o600); err != nil {
|
|
180
|
+
return fmt.Errorf("write credentials: %w", err)
|
|
181
|
+
}
|
|
182
|
+
return nil
|
|
183
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func authServer(t *testing.T, valid bool) *httptest.Server {
|
|
18
|
+
t.Helper()
|
|
19
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
20
|
+
user, token, ok := r.BasicAuth()
|
|
21
|
+
if !ok || user != "bot@example.com" || token != "secret" || !valid {
|
|
22
|
+
http.Error(w, "invalid bot@example.com secret", http.StatusUnauthorized)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
w.Header().Set("Content-Type", "application/json")
|
|
26
|
+
switch r.URL.Path {
|
|
27
|
+
case "/rest/api/3/myself":
|
|
28
|
+
fmt.Fprint(w, `{"accountId":"bot"}`)
|
|
29
|
+
case "/rest/api/3/project/PAY/statuses":
|
|
30
|
+
fmt.Fprint(w, `[{"id":"1","subtask":true,"statuses":[{"name":"To Do"},{"name":"In Progress"},{"name":"Done"}]}]`)
|
|
31
|
+
default:
|
|
32
|
+
http.NotFound(w, r)
|
|
33
|
+
}
|
|
34
|
+
}))
|
|
35
|
+
t.Cleanup(server.Close)
|
|
36
|
+
return server
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestAuthWritesLoadsAndNewUsesJiraOwnedCredentials(t *testing.T) {
|
|
40
|
+
root := t.TempDir()
|
|
41
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
42
|
+
server := authServer(t, true)
|
|
43
|
+
if err := task.Auth(context.Background(), "jira", []string{
|
|
44
|
+
"--site", server.URL, "--email", "bot@example.com", "--token", "secret",
|
|
45
|
+
}, strings.NewReader("")); err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
path := filepath.Join(root, "credentials.yaml")
|
|
49
|
+
info, err := os.Stat(path)
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatal(err)
|
|
52
|
+
}
|
|
53
|
+
if info.Mode().Perm() != 0o600 {
|
|
54
|
+
t.Fatalf("credentials mode = %o, want 600", info.Mode().Perm())
|
|
55
|
+
}
|
|
56
|
+
got, err := loadCredentials(path)
|
|
57
|
+
if err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
if got.Site != server.URL || got.Email != "bot@example.com" || got.Token != "secret" {
|
|
61
|
+
t.Fatalf("credentials = %+v", got)
|
|
62
|
+
}
|
|
63
|
+
if _, err := task.New(context.Background(), "jira", task.RepoSpec{
|
|
64
|
+
Name: "payments", RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
65
|
+
}); err != nil {
|
|
66
|
+
t.Fatalf("task.New did not load Jira-owned credentials: %v", err)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestAuthRejectsInvalidCredentialsWithoutWriting(t *testing.T) {
|
|
71
|
+
root := t.TempDir()
|
|
72
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
73
|
+
server := authServer(t, false)
|
|
74
|
+
err := auth(context.Background(), []string{
|
|
75
|
+
"--site", server.URL, "--email", "bot@example.com", "--token", "secret",
|
|
76
|
+
}, strings.NewReader(""))
|
|
77
|
+
if err == nil {
|
|
78
|
+
t.Fatal("invalid credentials accepted")
|
|
79
|
+
}
|
|
80
|
+
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
|
|
81
|
+
t.Fatalf("credential error exposed a secret: %v", err)
|
|
82
|
+
}
|
|
83
|
+
if _, statErr := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(statErr) {
|
|
84
|
+
t.Fatalf("invalid auth wrote credentials: %v", statErr)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestLoadCredentialsRejectsPermissionsAndMalformedSecrets(t *testing.T) {
|
|
89
|
+
t.Run("permissions", func(t *testing.T) {
|
|
90
|
+
path := filepath.Join(t.TempDir(), "credentials.yaml")
|
|
91
|
+
if err := os.WriteFile(path, []byte("site: https://jira.example.com\nemail: bot@example.com\ntoken: secret\n"), 0o644); err != nil {
|
|
92
|
+
t.Fatal(err)
|
|
93
|
+
}
|
|
94
|
+
if _, err := loadCredentials(path); err == nil || !strings.Contains(err.Error(), "0600") {
|
|
95
|
+
t.Fatalf("permission error = %v", err)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
t.Run("redaction", func(t *testing.T) {
|
|
99
|
+
path := filepath.Join(t.TempDir(), "credentials.yaml")
|
|
100
|
+
if err := os.WriteFile(path, []byte("token: [super-secret"), 0o600); err != nil {
|
|
101
|
+
t.Fatal(err)
|
|
102
|
+
}
|
|
103
|
+
if _, err := loadCredentials(path); err == nil || strings.Contains(err.Error(), "super-secret") {
|
|
104
|
+
t.Fatalf("malformed credential error = %v", err)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|