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.
Files changed (55) hide show
  1. package/README.md +18 -6
  2. package/cmd/relay-flow/commands_test.go +519 -15
  3. package/cmd/relay-flow/main.go +331 -119
  4. package/cmd/relay-flow/scenario_test.go +209 -34
  5. package/cmd/relay-flow/serve.go +1 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/internal/execution/goworkflows/activities.go +65 -65
  8. package/internal/execution/goworkflows/engine.go +41 -8
  9. package/internal/execution/goworkflows/engine_test.go +73 -13
  10. package/internal/execution/goworkflows/fakes_test.go +13 -21
  11. package/internal/execution/goworkflows/interpreter.go +16 -8
  12. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  13. package/internal/execution/goworkflows/node_runtime_test.go +45 -21
  14. package/internal/execution/goworkflows/recovery_test.go +5 -5
  15. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  16. package/internal/harness/contract_test.go +5 -0
  17. package/internal/paths/paths.go +18 -16
  18. package/internal/repo/repo.go +13 -0
  19. package/internal/repo/service_test.go +4 -4
  20. package/internal/router/router.go +3 -2
  21. package/internal/router/router_test.go +87 -0
  22. package/internal/run/manager.go +14 -1
  23. package/internal/run/run_manager_test.go +21 -1
  24. package/internal/runner/contract_test.go +64 -26
  25. package/internal/runner/orca/orca.go +30 -54
  26. package/internal/runner/orca/orca_test.go +143 -4
  27. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  28. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  29. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  30. package/internal/runner/runner.go +15 -8
  31. package/internal/task/auth_test.go +48 -0
  32. package/internal/task/contract_test.go +2 -0
  33. package/internal/task/factory.go +16 -0
  34. package/internal/task/jira/auth.go +183 -0
  35. package/internal/task/jira/auth_test.go +107 -0
  36. package/internal/task/jira/effects_test.go +39 -0
  37. package/internal/task/jira/filters_test.go +36 -16
  38. package/internal/task/jira/helpers_test.go +29 -19
  39. package/internal/task/jira/jira.go +92 -61
  40. package/internal/task/jira/normalize.go +32 -14
  41. package/internal/task/jira/rest/adf.go +128 -0
  42. package/internal/task/jira/rest/client.go +573 -0
  43. package/internal/task/jira/rest/client_test.go +381 -0
  44. package/internal/task/jira/transition_defaults_test.go +18 -16
  45. package/internal/task/jira/validation_test.go +1 -1
  46. package/internal/workflow/workflow.go +9 -6
  47. package/internal/workflow/workflow_test.go +14 -12
  48. package/package.json +2 -1
  49. package/internal/task/jira/acli/acli.go +0 -306
  50. package/internal/task/jira/acli/acli_test.go +0 -208
  51. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  52. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  53. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  54. package/internal/task/jira/acli/testdata/search_success.json +0 -1
  55. /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
- return orcacli.Terminal{}, orcacli.ErrTerminalUnavailable
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, string, string) (string, error) {
74
- return "", nil
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 returns only a live usable terminal. CloseTerminals closes
46
- // agent terminals while preserving the environment/workspace; CleanupRun
47
- // removes all runner-owned run resources at end. Both reconstruct identity
48
- // from RunSpec without SQLite IDs.
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
- InspectTerminal(ctx context.Context, terminal Terminal) (Terminal, bool, error)
57
- SendTerminal(ctx context.Context, terminal Terminal, text string) error
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
- FindTerminal(ctx context.Context, env Environment, title string) (Terminal, bool, error)
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
+ }
@@ -24,6 +24,8 @@ type fakeSystem struct {
24
24
  resets []string
25
25
  }
26
26
 
27
+ var _ task.System = (*fakeSystem)(nil)
28
+
27
29
  type storedTicket struct {
28
30
  ticket task.Ticket
29
31
  isMailbox bool
@@ -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
+ }