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
@@ -1,20 +1,30 @@
1
1
  package main
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "context"
6
+ "database/sql"
5
7
  "errors"
8
+ "fmt"
9
+ "io"
6
10
  "net"
7
11
  "net/http"
8
12
  "os"
13
+ "os/exec"
9
14
  "path/filepath"
10
15
  "strings"
16
+ "sync"
17
+ "syscall"
11
18
  "testing"
12
19
  "time"
13
20
 
21
+ "github.com/charmbracelet/huh"
22
+ "github.com/rajpopat27/relay-flow/internal/config"
14
23
  "github.com/rajpopat27/relay-flow/internal/repo"
15
24
  runsvc "github.com/rajpopat27/relay-flow/internal/run"
16
25
  "github.com/rajpopat27/relay-flow/internal/runner"
17
26
  "github.com/rajpopat27/relay-flow/internal/server"
27
+ "github.com/rajpopat27/relay-flow/internal/task"
18
28
  "github.com/rajpopat27/relay-flow/internal/workflow"
19
29
  )
20
30
 
@@ -33,7 +43,7 @@ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
33
43
  func TestCommandSurfaceExists(t *testing.T) {
34
44
  home := t.TempDir()
35
45
  commands := [][]string{
36
- {"init"}, {"serve", "--recover"}, {"stop"}, {"report"},
46
+ {"init"}, {"task", "auth"}, {"serve", "--recover"}, {"stop"}, {"report"},
37
47
  {"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
38
48
  {"workflow", "list"}, {"workflow", "get", "--name", "x"},
39
49
  {"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
@@ -92,11 +102,69 @@ func TestRunListRowShowsLifecycleAndActiveRetry(t *testing.T) {
92
102
  // without them per 5.5).
93
103
  func initHome(t *testing.T, home string) {
94
104
  t.Helper()
95
- if code := cli(t, home, "jira\norca\nopencode\n", "init"); code != 0 {
105
+ if code := cli(t, home, "", initArgs()...); code != 0 {
96
106
  t.Fatalf("init exit = %d, want 0", code)
97
107
  }
98
108
  }
99
109
 
110
+ func initArgs() []string {
111
+ return []string{"init", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"}
112
+ }
113
+
114
+ func initInput() string {
115
+ return "jira\norca\nopencode\n"
116
+ }
117
+
118
+ const authTestPlugin = "auth-dispatch-test"
119
+
120
+ var (
121
+ registerAuthTestPlugin sync.Once
122
+ authTestArgs []string
123
+ authTestInput string
124
+ )
125
+
126
+ func ensureAuthTestPlugin() {
127
+ registerAuthTestPlugin.Do(func() {
128
+ task.Register(authTestPlugin, task.Factory{
129
+ RequiredRepoKeys: func() []string { return nil },
130
+ TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "auth-test", nil },
131
+ Auth: func(_ context.Context, args []string, stdin io.Reader) error {
132
+ authTestArgs = append([]string(nil), args...)
133
+ body, err := io.ReadAll(stdin)
134
+ if err != nil {
135
+ return err
136
+ }
137
+ authTestInput = string(body)
138
+ return nil
139
+ },
140
+ New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
141
+ })
142
+ })
143
+ }
144
+
145
+ func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
146
+ ensureAuthTestPlugin()
147
+ home := t.TempDir()
148
+ root := filepath.Join(home, ".relay-flow")
149
+ if err := os.MkdirAll(root, 0o700); err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if err := config.SaveMachine(filepath.Join(root, "config.yaml"), &config.Machine{TaskPlugin: authTestPlugin}); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ authTestArgs = nil
156
+ authTestInput = ""
157
+ if code := cli(t, home, "plugin-owned-input", "task", "auth", "--custom", "value"); code != 0 {
158
+ t.Fatalf("task auth exit = %d, want 0", code)
159
+ }
160
+ if got := strings.Join(authTestArgs, " "); got != "--custom value" {
161
+ t.Fatalf("plugin args = %q", got)
162
+ }
163
+ if authTestInput != "plugin-owned-input" {
164
+ t.Fatalf("plugin stdin = %q", authTestInput)
165
+ }
166
+ }
167
+
100
168
  // 3.29 (lock): serve creates server.lock owner-only. Asserted through the
101
169
  // serve fixture.
102
170
  func TestServerLockIsOwnerOnly(t *testing.T) {
@@ -193,7 +261,7 @@ func TestReportUnreachableServerExits1(t *testing.T) {
193
261
  func TestInitRefusesToOverwrite(t *testing.T) {
194
262
  home := t.TempDir()
195
263
  // init reads the three plugin selections from stdin.
196
- if code := cli(t, home, "jira\norca\nopencode\n", "init"); code != 0 {
264
+ if code := cli(t, home, initInput(), "init"); code != 0 {
197
265
  t.Fatalf("first init exit = %d, want 0", code)
198
266
  }
199
267
  cfgPath := filepath.Join(home, ".relay-flow", "config.yaml")
@@ -214,7 +282,7 @@ func TestInitRefusesToOverwrite(t *testing.T) {
214
282
  t.Fatalf("state.db is not a SQLite database (missing magic header); got %q", dbBefore[:min(16, len(dbBefore))])
215
283
  }
216
284
 
217
- if code := cli(t, home, "jira\norca\nopencode\n", "init"); code == 0 {
285
+ if code := cli(t, home, initInput(), "init"); code == 0 {
218
286
  t.Fatal("init overwrote existing config/history")
219
287
  }
220
288
  if readFile(t, cfgPath) != cfg {
@@ -225,19 +293,192 @@ func TestInitRefusesToOverwrite(t *testing.T) {
225
293
  }
226
294
  }
227
295
 
296
+ func TestInitSelectionTitlesAndSingletons(t *testing.T) {
297
+ for _, tc := range []struct {
298
+ title string
299
+ value string
300
+ }{
301
+ {"Select task system", "jira"},
302
+ {"Select runner", "orca"},
303
+ {"Select harness", "opencode"},
304
+ } {
305
+ var selected string
306
+ field, err := pluginSelectField(tc.title, []string{tc.value}, &selected)
307
+ if err != nil {
308
+ t.Fatal(err)
309
+ }
310
+ if field == nil {
311
+ t.Fatalf("%s singleton field is nil", tc.title)
312
+ }
313
+ var singletonOut bytes.Buffer
314
+ if err := field.RunAccessible(&singletonOut, strings.NewReader("1\n")); err != nil {
315
+ t.Fatal(err)
316
+ }
317
+ if selected != tc.value || !strings.Contains(singletonOut.String(), tc.title) {
318
+ t.Fatalf("%s singleton selection = %q, output %q", tc.title, selected, singletonOut.String())
319
+ }
320
+
321
+ field, err = pluginSelectField(tc.title, []string{tc.value, "other"}, &selected)
322
+ if err != nil {
323
+ t.Fatal(err)
324
+ }
325
+ var out bytes.Buffer
326
+ if err := field.RunAccessible(&out, strings.NewReader("1\n")); err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ if !strings.Contains(out.String(), tc.title) {
330
+ t.Fatalf("selection output %q missing title %q", out.String(), tc.title)
331
+ }
332
+ }
333
+ }
334
+
335
+ func TestInitPrintsSelectedValuesAndCompletion(t *testing.T) {
336
+ code, out := captureStdout(t, func() int {
337
+ return cli(t, t.TempDir(), "", initArgs()...)
338
+ })
339
+ if code != 0 {
340
+ t.Fatalf("init exit = %d", code)
341
+ }
342
+ for _, want := range []string{"Task system: jira", "Runner: orca", "Harness: opencode", "Relay-flow initialized"} {
343
+ if !strings.Contains(out, want) {
344
+ t.Fatalf("init output %q missing %q", out, want)
345
+ }
346
+ }
347
+ }
348
+
349
+ func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
350
+ home := t.TempDir()
351
+ if code := cli(t, home, "", initArgs()...); code != 0 {
352
+ t.Fatalf("init exit = %d", code)
353
+ }
354
+ root := filepath.Join(home, ".relay-flow")
355
+ configText := readFile(t, filepath.Join(root, "config.yaml"))
356
+ if strings.Contains(configText, "jira-site") || strings.Contains(configText, "email") || strings.Contains(configText, "token") {
357
+ t.Fatalf("normal config contains task credentials: %s", configText)
358
+ }
359
+ if _, err := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(err) {
360
+ t.Fatalf("init wrote credentials.yaml: %v", err)
361
+ }
362
+ }
363
+
364
+ func TestInitForceRejectsRunningServerAndNonterminalRun(t *testing.T) {
365
+ t.Run("server running", func(t *testing.T) {
366
+ home := t.TempDir()
367
+ initHome(t, home)
368
+ lockPath := filepath.Join(home, ".relay-flow", "server.lock")
369
+ lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
370
+ if err != nil {
371
+ t.Fatal(err)
372
+ }
373
+ defer lock.Close()
374
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
375
+ t.Fatal(err)
376
+ }
377
+ defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
378
+ if code := cli(t, home, "", "init", "--force",
379
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 1 {
380
+ t.Fatalf("forced init with server lock exit = %d, want 1", code)
381
+ }
382
+ })
383
+
384
+ t.Run("nonterminal run", func(t *testing.T) {
385
+ home := t.TempDir()
386
+ initHome(t, home)
387
+ root := filepath.Join(home, ".relay-flow")
388
+ insertRun(t, filepath.Join(root, "state.db"), "active", "starting")
389
+ configBefore := readFile(t, filepath.Join(root, "config.yaml"))
390
+ if code := cli(t, home, "", "init", "--force",
391
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 1 {
392
+ t.Fatalf("forced init with active run exit = %d, want 1", code)
393
+ }
394
+ if got := readFile(t, filepath.Join(root, "config.yaml")); got != configBefore {
395
+ t.Fatal("rejected forced init changed config")
396
+ }
397
+ })
398
+ }
399
+
400
+ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
401
+ home := t.TempDir()
402
+ initHome(t, home)
403
+ root := filepath.Join(home, ".relay-flow")
404
+ dbPath := filepath.Join(root, "state.db")
405
+ insertRun(t, dbPath, "completed", "completed")
406
+ dbBefore, err := os.Stat(dbPath)
407
+ if err != nil {
408
+ t.Fatal(err)
409
+ }
410
+
411
+ cfg, err := config.LoadMachine(filepath.Join(root, "config.yaml"))
412
+ if err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ cfg.Repos = map[string]config.Repo{"existing": {Path: "/srv/existing"}}
416
+ if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
417
+ t.Fatal(err)
418
+ }
419
+ workflowDir := filepath.Join(root, "workflows")
420
+ if err := os.MkdirAll(workflowDir, 0700); err != nil {
421
+ t.Fatal(err)
422
+ }
423
+ workflowPath := filepath.Join(workflowDir, "saved.yaml")
424
+ logPath := filepath.Join(root, "server.log")
425
+ if err := os.WriteFile(workflowPath, []byte("saved workflow\n"), 0644); err != nil {
426
+ t.Fatal(err)
427
+ }
428
+ if err := os.WriteFile(logPath, []byte("saved log\n"), 0600); err != nil {
429
+ t.Fatal(err)
430
+ }
431
+
432
+ args := append([]string{"init", "--force"}, initArgs()[1:]...)
433
+ if code := cli(t, home, "", args...); code != 0 {
434
+ t.Fatalf("forced init exit = %d, want 0", code)
435
+ }
436
+ if got := readFile(t, workflowPath); got != "saved workflow\n" {
437
+ t.Fatalf("workflow changed: %q", got)
438
+ }
439
+ if got := readFile(t, logPath); got != "saved log\n" {
440
+ t.Fatalf("log changed: %q", got)
441
+ }
442
+ dbAfter, err := os.Stat(dbPath)
443
+ if err != nil {
444
+ t.Fatal(err)
445
+ }
446
+ if !os.SameFile(dbBefore, dbAfter) {
447
+ t.Fatal("forced init recreated state.db")
448
+ }
449
+ cfg, err = config.LoadMachine(filepath.Join(root, "config.yaml"))
450
+ if err != nil {
451
+ t.Fatal(err)
452
+ }
453
+ if _, ok := cfg.Repos["existing"]; !ok {
454
+ t.Fatal("forced init removed existing repo config")
455
+ }
456
+ db, err := sql.Open("sqlite", dbPath)
457
+ if err != nil {
458
+ t.Fatal(err)
459
+ }
460
+ defer db.Close()
461
+ var state string
462
+ if err := db.QueryRow(`SELECT state FROM relay_runs WHERE id = 'completed'`).Scan(&state); err != nil {
463
+ t.Fatalf("completed history missing: %v", err)
464
+ }
465
+ if state != "completed" {
466
+ t.Fatalf("completed history state = %q", state)
467
+ }
468
+ }
469
+
228
470
  // 8.2: --task-plugin/--runner-plugin/--harness-plugin run init without any
229
471
  // prompt/stdin and write the same machine config as the stdin path.
230
472
  func TestInitFlagsNonInteractive(t *testing.T) {
231
473
  home := t.TempDir()
232
474
  // Flags fully replace stdin: empty stdin must still succeed.
233
- if code := cli(t, home, "", "init",
234
- "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 0 {
475
+ if code := cli(t, home, "", initArgs()...); code != 0 {
235
476
  t.Fatalf("flagged init exit = %d, want 0", code)
236
477
  }
237
478
  cfgFlags := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml"))
238
479
 
239
480
  home2 := t.TempDir()
240
- if code := cli(t, home2, "jira\norca\nopencode\n", "init"); code != 0 {
481
+ if code := cli(t, home2, initInput(), "init"); code != 0 {
241
482
  t.Fatalf("stdin init exit = %d, want 0", code)
242
483
  }
243
484
  cfgStdin := readFile(t, filepath.Join(home2, ".relay-flow", "config.yaml"))
@@ -271,13 +512,57 @@ func min(a, b int) int {
271
512
  return b
272
513
  }
273
514
 
515
+ func captureStdout(t *testing.T, fn func() int) (int, string) {
516
+ t.Helper()
517
+ old := os.Stdout
518
+ r, w, err := os.Pipe()
519
+ if err != nil {
520
+ t.Fatal(err)
521
+ }
522
+ os.Stdout = w
523
+ done := make(chan []byte, 1)
524
+ go func() {
525
+ b, _ := io.ReadAll(r)
526
+ done <- b
527
+ }()
528
+ code := fn()
529
+ _ = w.Close()
530
+ os.Stdout = old
531
+ out := <-done
532
+ _ = r.Close()
533
+ return code, string(out)
534
+ }
535
+
536
+ func insertRun(t *testing.T, path, id, state string) {
537
+ t.Helper()
538
+ db, err := sql.Open("sqlite", path)
539
+ if err != nil {
540
+ t.Fatal(err)
541
+ }
542
+ defer db.Close()
543
+ now := time.Now().UTC()
544
+ var finished any
545
+ if state == "completed" || state == "canceled" {
546
+ finished = now
547
+ }
548
+ _, err = db.Exec(`INSERT INTO relay_runs
549
+ (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
550
+ VALUES (?, 'repo', 'workflow', ?, ?, ?, ?, ?, ?)`, id, id, id, state, now, now, finished)
551
+ if err != nil {
552
+ t.Fatal(err)
553
+ }
554
+ }
555
+
274
556
  // 8.4: fully-flagged repo register never prompts and produces the same
275
557
  // repo entry the interactive run would post.
276
558
  type registerServer struct {
277
- ackServer // embed for the unreachable stubs
278
- fields []string
279
- gotInput *repo.RegisterInput
280
- calls int
559
+ ackServer // embed for the unreachable stubs
560
+ fields []string
561
+ gotInput *repo.RegisterInput
562
+ gotInputs []repo.RegisterInput
563
+ successful []repo.RegisterInput
564
+ failName string
565
+ calls int
281
566
  }
282
567
 
283
568
  func (s *registerServer) TaskFields(context.Context) ([]string, error) {
@@ -288,6 +573,11 @@ func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput)
288
573
  s.calls++
289
574
  cp := in
290
575
  s.gotInput = &cp
576
+ s.gotInputs = append(s.gotInputs, cp)
577
+ if in.Name == s.failName {
578
+ return repo.Info{}, errors.New("registration failed")
579
+ }
580
+ s.successful = append(s.successful, cp)
291
581
  return repo.Info{Name: in.Name, Path: in.Path, TaskConfig: in.TaskConfig}, nil
292
582
  }
293
583
 
@@ -319,7 +609,7 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
319
609
  // Fully-flagged run: no stdin, no TTY — must not prompt.
320
610
  code := cli(t, home, "", "repo", "register",
321
611
  "--name", "payments", "--path", "/srv/payments",
322
- "--set", "project=PAY", "--set", "component=core")
612
+ "--set", "project=PAY")
323
613
  if code != 0 {
324
614
  t.Fatalf("fully-flagged register exit = %d, want 0", code)
325
615
  }
@@ -329,16 +619,16 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
329
619
  if deps.gotInput.Name != "payments" || deps.gotInput.Path != "/srv/payments" {
330
620
  t.Fatalf("name/path = %q/%q", deps.gotInput.Name, deps.gotInput.Path)
331
621
  }
332
- if deps.gotInput.TaskConfig["project"] != "PAY" || deps.gotInput.TaskConfig["component"] != "core" {
622
+ if deps.gotInput.TaskConfig["project"] != "PAY" || deps.gotInput.TaskConfig["component"] != "payments" {
333
623
  t.Fatalf("taskConfig = %v", deps.gotInput.TaskConfig)
334
624
  }
335
625
 
336
- // Missing a required key is a usage error; only the TaskFields lookup
626
+ // Missing project is a usage error; component is derived from --name.
337
627
  // is allowed to hit the server (RegisterRepo must not be called).
338
628
  home2 := t.TempDir()
339
629
  deps2 := serveRegister(t, home2, []string{"project", "component"})
340
630
  if code := cli(t, home2, "", "repo", "register",
341
- "--name", "x", "--path", "/x", "--set", "project=PAY"); code != 2 {
631
+ "--name", "x", "--path", "/x"); code != 2 {
342
632
  t.Fatalf("missing required key exit = %d, want 2", code)
343
633
  }
344
634
  if deps2.gotInput != nil {
@@ -388,6 +678,220 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
388
678
  "--set", "project=PAY", "--set", "bogus=v"); code != 2 {
389
679
  t.Fatalf("unknown key exit = %d, want 2", code)
390
680
  }
681
+
682
+ // Component can never be prompted or overridden.
683
+ home6 := t.TempDir()
684
+ deps6 := serveRegister(t, home6, []string{"project", "component"})
685
+ if code := cli(t, home6, "", "repo", "register",
686
+ "--name", "x", "--path", "/x",
687
+ "--set", "project=PAY", "--set", "component=override"); code != 2 {
688
+ t.Fatalf("component override exit = %d, want 2", code)
689
+ }
690
+ if deps6.gotInput != nil {
691
+ t.Fatal("component override reached RegisterRepo")
692
+ }
693
+ }
694
+
695
+ func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
696
+ selected := []int{0, 1}
697
+ field := repoMultiSelect([]huh.Option[int]{
698
+ huh.NewOption("payments", 0),
699
+ huh.NewOption("checkout", 1),
700
+ }, &selected)
701
+ var out bytes.Buffer
702
+ if err := field.RunAccessible(&out, strings.NewReader("0\n")); err != nil {
703
+ t.Fatal(err)
704
+ }
705
+ if !strings.Contains(out.String(), "Select repositories") {
706
+ t.Fatalf("multi-select output %q missing title", out.String())
707
+ }
708
+ if fmt.Sprint(selected) != "[0 1]" {
709
+ t.Fatalf("selected = %v, want [0 1]", selected)
710
+ }
711
+ if got := registrationSharedFields([]string{"project", "component"}); fmt.Sprint(got) != "[project]" {
712
+ t.Fatalf("prompted fields = %v, want project only", got)
713
+ }
714
+
715
+ home := t.TempDir()
716
+ deps := serveRegister(t, home, []string{"project", "component"})
717
+ client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
718
+ candidates := []runner.RepoCandidate{
719
+ {Name: "payments", Path: "/srv/payments"},
720
+ {Name: "checkout", Path: "/srv/checkout"},
721
+ }
722
+ if err := registerSelectedRepos(context.Background(), client, candidates, selected,
723
+ []string{"project", "component"}, kvFlags{"project": "PAY"}); err != nil {
724
+ t.Fatal(err)
725
+ }
726
+ if len(deps.successful) != 2 {
727
+ t.Fatalf("registrations = %d, want 2", len(deps.successful))
728
+ }
729
+ for _, input := range deps.successful {
730
+ if input.TaskConfig["project"] != "PAY" || input.TaskConfig["component"] != input.Name {
731
+ t.Fatalf("registration %+v has task config %v", input, input.TaskConfig)
732
+ }
733
+ }
734
+ }
735
+
736
+ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
737
+ home := t.TempDir()
738
+ deps := serveRegister(t, home, []string{"project", "component"})
739
+ deps.failName = "checkout"
740
+ client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
741
+ candidates := []runner.RepoCandidate{
742
+ {Name: "payments", Path: "/srv/payments"},
743
+ {Name: "checkout", Path: "/srv/checkout"},
744
+ {Name: "later", Path: "/srv/later"},
745
+ }
746
+ err := registerSelectedRepos(context.Background(), client, candidates, []int{0, 1, 2},
747
+ []string{"project", "component"}, kvFlags{"project": "PAY"})
748
+ if err == nil || !strings.Contains(err.Error(), "checkout") {
749
+ t.Fatalf("partial failure = %v, want failed repo name", err)
750
+ }
751
+ if len(deps.successful) != 1 || deps.successful[0].Name != "payments" {
752
+ t.Fatalf("successful registrations = %+v, want payments preserved", deps.successful)
753
+ }
754
+ if len(deps.gotInputs) != 2 {
755
+ t.Fatalf("registration attempts = %d, want stop after failed second repo", len(deps.gotInputs))
756
+ }
757
+ }
758
+
759
+ func TestBackgroundServeReadinessAndStop(t *testing.T) {
760
+ home := t.TempDir()
761
+ root := filepath.Join(home, ".relay-flow")
762
+ t.Setenv("RELAY_FLOW_HOME", root)
763
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
764
+ t.Fatalf("init exit = %d", code)
765
+ }
766
+ binary := buildCLIBinary(t)
767
+ start := exec.Command(binary, "serve", "--background")
768
+ start.Env = os.Environ()
769
+ out, err := start.CombinedOutput()
770
+ if err != nil {
771
+ t.Fatalf("background serve: %v\n%s", err, out)
772
+ }
773
+ if !strings.Contains(string(out), "Relay-flow server started") {
774
+ t.Fatalf("background output = %q", out)
775
+ }
776
+ client := server.NewClient(filepath.Join(root, "server.sock"))
777
+ if _, err := client.ListRepos(context.Background()); err != nil {
778
+ t.Fatalf("ready server did not respond: %v", err)
779
+ }
780
+ stop := exec.Command(binary, "stop")
781
+ stop.Env = os.Environ()
782
+ if out, err := stop.CombinedOutput(); err != nil {
783
+ t.Fatalf("stop: %v\n%s", err, out)
784
+ }
785
+ waitForServerStop(t, client)
786
+ }
787
+
788
+ func TestBackgroundServeFailurePointsToLog(t *testing.T) {
789
+ home := t.TempDir()
790
+ root := filepath.Join(home, ".relay-flow")
791
+ t.Setenv("RELAY_FLOW_HOME", root)
792
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
793
+ t.Fatalf("init exit = %d", code)
794
+ }
795
+ if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte("invalid: true\n"), 0600); err != nil {
796
+ t.Fatal(err)
797
+ }
798
+ binary := buildCLIBinary(t)
799
+ start := exec.Command(binary, "serve", "--background")
800
+ start.Env = os.Environ()
801
+ out, err := start.CombinedOutput()
802
+ if err == nil {
803
+ t.Fatalf("background serve unexpectedly succeeded: %s", out)
804
+ }
805
+ if !strings.Contains(string(out), "server.log") {
806
+ t.Fatalf("background failure %q does not point to server.log", out)
807
+ }
808
+ log := readFile(t, filepath.Join(root, "server.log"))
809
+ if !strings.Contains(log, "server startup failed") {
810
+ t.Fatalf("server.log missing startup error: %q", log)
811
+ }
812
+ }
813
+
814
+ func TestForegroundServeRemainsBlocking(t *testing.T) {
815
+ home := t.TempDir()
816
+ root := filepath.Join(home, ".relay-flow")
817
+ t.Setenv("RELAY_FLOW_HOME", root)
818
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
819
+ t.Fatalf("init exit = %d", code)
820
+ }
821
+ binary := buildCLIBinary(t)
822
+ serve := exec.Command(binary, "serve")
823
+ serve.Env = os.Environ()
824
+ if err := serve.Start(); err != nil {
825
+ t.Fatal(err)
826
+ }
827
+ wait := make(chan error, 1)
828
+ go func() { wait <- serve.Wait() }()
829
+ t.Cleanup(func() {
830
+ if serve.Process != nil {
831
+ _ = serve.Process.Kill()
832
+ }
833
+ })
834
+ client := server.NewClient(filepath.Join(root, "server.sock"))
835
+ waitForServer(t, client)
836
+ select {
837
+ case err := <-wait:
838
+ t.Fatalf("foreground serve returned before stop: %v", err)
839
+ case <-time.After(200 * time.Millisecond):
840
+ }
841
+ stop := exec.Command(binary, "stop")
842
+ stop.Env = os.Environ()
843
+ if out, err := stop.CombinedOutput(); err != nil {
844
+ t.Fatalf("stop: %v\n%s", err, out)
845
+ }
846
+ select {
847
+ case err := <-wait:
848
+ if err != nil {
849
+ t.Fatalf("foreground serve exit: %v", err)
850
+ }
851
+ case <-time.After(5 * time.Second):
852
+ t.Fatal("foreground serve did not exit after stop")
853
+ }
854
+ }
855
+
856
+ func buildCLIBinary(t *testing.T) string {
857
+ t.Helper()
858
+ binary := filepath.Join(t.TempDir(), "relay-flow")
859
+ cmd := exec.Command("go", "build", "-buildvcs=false", "-o", binary, ".")
860
+ out, err := cmd.CombinedOutput()
861
+ if err != nil {
862
+ t.Fatalf("build relay-flow: %v\n%s", err, out)
863
+ }
864
+ return binary
865
+ }
866
+
867
+ func waitForServer(t *testing.T, client *server.Client) {
868
+ t.Helper()
869
+ deadline := time.Now().Add(5 * time.Second)
870
+ for time.Now().Before(deadline) {
871
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
872
+ _, err := client.ListRepos(ctx)
873
+ cancel()
874
+ if err == nil {
875
+ return
876
+ }
877
+ time.Sleep(20 * time.Millisecond)
878
+ }
879
+ t.Fatal("server did not become ready")
880
+ }
881
+
882
+ func waitForServerStop(t *testing.T, client *server.Client) {
883
+ t.Helper()
884
+ deadline := time.Now().Add(5 * time.Second)
885
+ for time.Now().Before(deadline) {
886
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
887
+ _, err := client.ListRepos(ctx)
888
+ cancel()
889
+ if err != nil {
890
+ return
891
+ }
892
+ time.Sleep(20 * time.Millisecond)
893
+ }
894
+ t.Fatal("server remained reachable after stop")
391
895
  }
392
896
 
393
897
  var errReportInvalid = errors.New("invalid report")