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
@@ -12,7 +12,9 @@ import (
12
12
  "flag"
13
13
  "fmt"
14
14
  "io"
15
+ "log/slog"
15
16
  "os"
17
+ "os/exec"
16
18
  "os/signal"
17
19
  "path/filepath"
18
20
  "strings"
@@ -55,14 +57,15 @@ func home() (paths.Paths, error) {
55
57
  if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
56
58
  root := env
57
59
  return paths.Paths{
58
- Root: root,
59
- Config: filepath.Join(root, "config.yaml"),
60
- Workflows: filepath.Join(root, "workflows"),
61
- Database: filepath.Join(root, "state.db"),
62
- Socket: filepath.Join(root, "server.sock"),
63
- Lock: filepath.Join(root, "server.lock"),
64
- ServerLog: filepath.Join(root, "server.log"),
65
- PluginLog: filepath.Join(root, "plugin.log"),
60
+ Root: root,
61
+ Config: filepath.Join(root, "config.yaml"),
62
+ Credentials: filepath.Join(root, "credentials.yaml"),
63
+ Workflows: filepath.Join(root, "workflows"),
64
+ Database: filepath.Join(root, "state.db"),
65
+ Socket: filepath.Join(root, "server.sock"),
66
+ Lock: filepath.Join(root, "server.lock"),
67
+ ServerLog: filepath.Join(root, "server.log"),
68
+ PluginLog: filepath.Join(root, "plugin.log"),
66
69
  }, nil
67
70
  }
68
71
  return paths.ForUserHome()
@@ -109,6 +112,8 @@ func run(args []string, stdin io.Reader) int {
109
112
  return exitUsage
110
113
  }
111
114
  return cmdRuntimeRegister(client, stdin)
115
+ case "task":
116
+ return cmdTask(p, args[1:], stdin)
112
117
  case "workflow":
113
118
  return cmdWorkflow(client, args[1:])
114
119
  case "repo":
@@ -125,8 +130,9 @@ func usage(w io.Writer) {
125
130
  fmt.Fprintln(w, `relay-flow — durable ticket runner
126
131
 
127
132
  Usage:
128
- relay-flow init [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
129
- relay-flow serve [--recover] [--debug]
133
+ relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
134
+ relay-flow task auth [task-plugin options]
135
+ relay-flow serve [--recover] [--debug] [--background]
130
136
  relay-flow stop
131
137
  relay-flow report
132
138
 
@@ -149,7 +155,8 @@ Usage:
149
155
 
150
156
  // cmdInit is the init composition root (docs lines 950/1028): select the
151
157
  // three plugin names, atomically write the machine config, and initialize
152
- // the SQLite database. Refuses to overwrite existing config or history.
158
+ // the SQLite database. Refuses to overwrite existing config or history unless
159
+ // --force safely updates plugin selections while preserving durable state.
153
160
  // Plugin selection precedence: --task-plugin/--runner-plugin/--harness-plugin
154
161
  // flags (all three required for a fully non-interactive run) → huh form on
155
162
  // a TTY → three stdin lines (task, runner, harness), the documented test
@@ -159,6 +166,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
159
166
  taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
160
167
  runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
161
168
  harnessName := fs.String("harness-plugin", "", "harness plugin name (non-interactive)")
169
+ force := fs.Bool("force", false, "update plugin selections while preserving existing state")
162
170
  if err := fs.Parse(args); err != nil {
163
171
  return exitUsage
164
172
  }
@@ -167,20 +175,53 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
167
175
  fmt.Fprintln(os.Stderr, "init: --task-plugin, --runner-plugin, and --harness-plugin must be given together")
168
176
  return exitUsage
169
177
  }
170
- // Refuse to overwrite existing state.
171
- if _, err := os.Stat(p.Config); err == nil {
178
+ configExists, err := pathExists(p.Config)
179
+ if err != nil {
180
+ fmt.Fprintln(os.Stderr, err)
181
+ return exitFail
182
+ }
183
+ databaseExists, err := pathExists(p.Database)
184
+ if err != nil {
185
+ fmt.Fprintln(os.Stderr, err)
186
+ return exitFail
187
+ }
188
+ if !*force && configExists {
172
189
  fmt.Fprintln(os.Stderr, "init: already initialized (config exists): "+p.Config)
173
190
  return exitFail
174
191
  }
175
- if _, err := os.Stat(p.Database); err == nil {
192
+ if !*force && databaseExists {
176
193
  fmt.Fprintln(os.Stderr, "init: already initialized (database exists): "+p.Database)
177
194
  return exitFail
178
195
  }
196
+ if *force && configExists && !databaseExists {
197
+ fmt.Fprintln(os.Stderr, "init: database is missing; refusing to recreate existing durable state")
198
+ return exitFail
199
+ }
179
200
  if err := os.MkdirAll(p.Root, 0o700); err != nil {
180
201
  fmt.Fprintln(os.Stderr, err)
181
202
  return exitFail
182
203
  }
183
204
  _ = os.Chmod(p.Root, 0o700)
205
+ var unlock func()
206
+ if *force {
207
+ unlock, err = lockForForcedInit(p.Lock)
208
+ if err != nil {
209
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
210
+ return exitFail
211
+ }
212
+ defer unlock()
213
+ if databaseExists {
214
+ active, err := goworkflows.HasNonterminalRuns(p.Database)
215
+ if err != nil {
216
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
217
+ return exitFail
218
+ }
219
+ if active {
220
+ fmt.Fprintln(os.Stderr, "init: cannot use --force while a run is nonterminal")
221
+ return exitFail
222
+ }
223
+ }
224
+ }
184
225
 
185
226
  // Selection precedence: flags → TTY form → stdin lines. The stdin path
186
227
  // is the documented test seam and script path.
@@ -196,9 +237,9 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
196
237
  return exitFail
197
238
  }
198
239
  default:
199
- names = readPluginLines(stdin)
240
+ names = readInitLines(stdin)
200
241
  if names == nil {
201
- fmt.Fprintln(os.Stderr, "init: expected three plugin selections (task, runner, harness) on stdin")
242
+ fmt.Fprintln(os.Stderr, "init: expected task, runner, and harness plugin selections on stdin")
202
243
  return exitFail
203
244
  }
204
245
  }
@@ -217,83 +258,160 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
217
258
  return exitFail
218
259
  }
219
260
 
220
- cfg := &config.Machine{
221
- TaskPlugin: names[0],
222
- RunnerPlugin: names[1],
223
- HarnessPlugin: names[2],
224
- KeepTerminalsAlive: true,
225
- KeepSessionsAlive: true,
261
+ cfg := &config.Machine{KeepTerminalsAlive: true, KeepSessionsAlive: true}
262
+ if *force && configExists {
263
+ cfg, err = config.LoadMachine(p.Config)
264
+ if err != nil {
265
+ fmt.Fprintln(os.Stderr, err)
266
+ return exitFail
267
+ }
226
268
  }
269
+ cfg.TaskPlugin = names[0]
270
+ cfg.RunnerPlugin = names[1]
271
+ cfg.HarnessPlugin = names[2]
227
272
  if err := config.SaveMachine(p.Config, cfg); err != nil {
228
273
  fmt.Fprintln(os.Stderr, err)
229
274
  return exitFail
230
275
  }
231
- if err := goworkflows.InitDatabase(p.Database); err != nil {
232
- fmt.Fprintln(os.Stderr, err)
233
- return exitFail
276
+ if !databaseExists {
277
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
278
+ fmt.Fprintln(os.Stderr, err)
279
+ return exitFail
280
+ }
234
281
  }
282
+ fmt.Printf("Task system: %s\nRunner: %s\nHarness: %s\nRelay-flow initialized\n", names[0], names[1], names[2])
235
283
  return exitOK
236
284
  }
237
285
 
286
+ func pathExists(path string) (bool, error) {
287
+ _, err := os.Stat(path)
288
+ if err == nil {
289
+ return true, nil
290
+ }
291
+ if os.IsNotExist(err) {
292
+ return false, nil
293
+ }
294
+ return false, fmt.Errorf("stat %s: %w", path, err)
295
+ }
296
+
297
+ func lockForForcedInit(path string) (func(), error) {
298
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
299
+ if err != nil {
300
+ return nil, fmt.Errorf("open server lock: %w", err)
301
+ }
302
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
303
+ f.Close()
304
+ return nil, fmt.Errorf("cannot use --force while the server is running")
305
+ }
306
+ return func() {
307
+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
308
+ _ = f.Close()
309
+ }, nil
310
+ }
311
+
238
312
  // isTTY reports whether stdin is an interactive terminal.
239
313
  func isTTY(stdin io.Reader) bool {
240
314
  f, ok := stdin.(*os.File)
241
315
  return ok && isatty.IsTerminal(f.Fd())
242
316
  }
243
317
 
244
- // pickPluginsInteractive runs the huh form: one searchable select per
245
- // plugin (task, runner, harness); enter advances/submits.
318
+ // pickPluginsInteractive runs one searchable select per plugin type.
246
319
  func pickPluginsInteractive() ([]string, error) {
247
320
  names := make([]string, 3)
248
- form := huh.NewForm(
249
- huh.NewGroup(huh.NewSelect[string]().
250
- Title("Task plugin").
251
- Options(huh.NewOptions(task.Names()...)...).
252
- Filtering(true).
253
- Value(&names[0])),
254
- huh.NewGroup(huh.NewSelect[string]().
255
- Title("Runner plugin").
256
- Options(huh.NewOptions(runner.Names()...)...).
257
- Filtering(true).
258
- Value(&names[1])),
259
- huh.NewGroup(huh.NewSelect[string]().
260
- Title("Harness plugin").
261
- Options(huh.NewOptions(harness.Names()...)...).
262
- Filtering(true).
263
- Value(&names[2])),
264
- )
321
+ groups := make([]*huh.Group, 0, 3)
322
+ for _, selection := range []struct {
323
+ title string
324
+ options []string
325
+ value *string
326
+ }{
327
+ {"Select task system", task.Names(), &names[0]},
328
+ {"Select runner", runner.Names(), &names[1]},
329
+ {"Select harness", harness.Names(), &names[2]},
330
+ } {
331
+ field, err := pluginSelectField(selection.title, selection.options, selection.value)
332
+ if err != nil {
333
+ return nil, err
334
+ }
335
+ if field != nil {
336
+ groups = append(groups, huh.NewGroup(field))
337
+ }
338
+ }
339
+ if len(groups) == 0 {
340
+ return names, nil
341
+ }
342
+ form := huh.NewForm(groups...)
265
343
  if err := form.Run(); err != nil {
266
344
  return nil, err
267
345
  }
268
346
  return names, nil
269
347
  }
270
348
 
271
- // readPluginLines reads the three plugin names from stdin, one per line
272
- // (task, runner, harness); trailing whitespace tolerated. Returns nil when
273
- // fewer than three non-empty lines are present.
274
- func readPluginLines(stdin io.Reader) []string {
275
- names := make([]string, 0, 3)
349
+ func pluginSelectField(title string, options []string, value *string) (huh.Field, error) {
350
+ if len(options) == 0 {
351
+ return nil, fmt.Errorf("%s: no plugins registered", title)
352
+ }
353
+ return huh.NewSelect[string]().
354
+ Title(title).
355
+ Description("Press / to filter available plugins.").
356
+ Options(huh.NewOptions(options...)...).
357
+ Value(value), nil
358
+ }
359
+
360
+ // readInitLines reads the three plugin selections.
361
+ func readInitLines(stdin io.Reader) []string {
362
+ values := make([]string, 0, 3)
276
363
  scanner := bufio.NewScanner(stdin)
277
- for len(names) < 3 && scanner.Scan() {
364
+ for len(values) < 3 && scanner.Scan() {
278
365
  line := strings.TrimSpace(scanner.Text())
279
366
  if line == "" {
280
367
  continue
281
368
  }
282
- names = append(names, line)
369
+ values = append(values, line)
283
370
  }
284
- if len(names) != 3 {
371
+ if len(values) < 3 {
285
372
  return nil
286
373
  }
287
- return names
374
+ return values
375
+ }
376
+
377
+ // cmdTask dispatches task-system commands to the selected task plugin.
378
+ func cmdTask(p paths.Paths, args []string, stdin io.Reader) int {
379
+ if len(args) == 0 || args[0] != "auth" {
380
+ usage(os.Stderr)
381
+ return exitUsage
382
+ }
383
+ return cmdTaskAuth(p, args[1:], stdin)
384
+ }
385
+
386
+ func cmdTaskAuth(p paths.Paths, args []string, stdin io.Reader) int {
387
+ cfg, err := config.LoadMachine(p.Config)
388
+ if err != nil {
389
+ fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
390
+ return exitFail
391
+ }
392
+ if err := task.Auth(context.Background(), cfg.TaskPlugin, args, stdin); err != nil {
393
+ fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
394
+ return exitFail
395
+ }
396
+ return exitOK
288
397
  }
289
398
 
290
399
  func cmdServe(p paths.Paths, args []string) int {
291
400
  fs := flag.NewFlagSet("serve", flag.ContinueOnError)
292
401
  recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
293
402
  debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
403
+ background := fs.Bool("background", false, "start the server detached and return after readiness")
294
404
  if err := fs.Parse(args); err != nil {
295
405
  return exitUsage
296
406
  }
407
+ if *background {
408
+ if err := startBackgroundServe(p, *recover, *debug); err != nil {
409
+ fmt.Fprintln(os.Stderr, err)
410
+ return exitFail
411
+ }
412
+ fmt.Println("Relay-flow server started")
413
+ return exitOK
414
+ }
297
415
  logCloser, err := logging.Setup(p.ServerLog, logging.Options{
298
416
  Debug: *debug,
299
417
  Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
@@ -306,12 +424,69 @@ func cmdServe(p paths.Paths, args []string) int {
306
424
  ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
307
425
  defer stop()
308
426
  if err := serveRoot(ctx, p, *recover); err != nil {
427
+ slog.Error("server startup failed", "error", err)
309
428
  fmt.Fprintln(os.Stderr, err)
310
429
  return exitFail
311
430
  }
312
431
  return exitOK
313
432
  }
314
433
 
434
+ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
435
+ client := server.NewClient(p.Socket)
436
+ if serverResponding(client, 200*time.Millisecond) {
437
+ return fmt.Errorf("serve --background: server is already running")
438
+ }
439
+ executable, err := os.Executable()
440
+ if err != nil {
441
+ return fmt.Errorf("serve --background: resolve executable: %w", err)
442
+ }
443
+ childArgs := []string{"serve"}
444
+ if recover {
445
+ childArgs = append(childArgs, "--recover")
446
+ }
447
+ if debug {
448
+ childArgs = append(childArgs, "--debug")
449
+ }
450
+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
451
+ if err != nil {
452
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
453
+ }
454
+ defer devNull.Close()
455
+ cmd := exec.Command(executable, childArgs...)
456
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
457
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
458
+ if err := cmd.Start(); err != nil {
459
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
460
+ }
461
+ wait := make(chan error, 1)
462
+ go func() { wait <- cmd.Wait() }()
463
+ deadline := time.NewTimer(10 * time.Second)
464
+ defer deadline.Stop()
465
+ ticker := time.NewTicker(50 * time.Millisecond)
466
+ defer ticker.Stop()
467
+ for {
468
+ select {
469
+ case err := <-wait:
470
+ return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", err, p.ServerLog)
471
+ case <-deadline.C:
472
+ _ = cmd.Process.Kill()
473
+ <-wait
474
+ return fmt.Errorf("serve --background: startup timed out; see %s", p.ServerLog)
475
+ case <-ticker.C:
476
+ if serverResponding(client, 200*time.Millisecond) {
477
+ return nil
478
+ }
479
+ }
480
+ }
481
+ }
482
+
483
+ func serverResponding(client *server.Client, timeout time.Duration) bool {
484
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
485
+ defer cancel()
486
+ _, err := client.ListRepos(ctx)
487
+ return err == nil
488
+ }
489
+
315
490
  // --- report ---
316
491
 
317
492
  // cmdReport reads one JSON object from stdin and posts it to /reports.
@@ -513,12 +688,8 @@ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
513
688
  return exitUsage
514
689
  }
515
690
 
516
- // cmdRepoRegister registers one repo. Fully-flagged invocation
517
- // (--name/--path plus --set for every required task repo key) never
518
- // prompts and produces the same repo entry as the interactive run.
519
- // With no flags, a TTY drives the interactive flow: discover runner
520
- // repos, huh searchable select, then prompts for name, path, and each
521
- // required key with the selected candidate's name/path as defaults.
691
+ // cmdRepoRegister registers one flagged repo or multiple interactively selected
692
+ // runner repos. Jira project is shared and component is always the repo name.
522
693
  func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
523
694
  ctx := context.Background()
524
695
  flagged := flagName != "" || flagPath != "" || len(sets) > 0
@@ -529,39 +700,18 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
529
700
  fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
530
701
  return exitUsage
531
702
  }
532
- taskCfg := config.RawValues{}
533
- for k, v := range sets {
534
- if v == "" {
535
- fmt.Fprintf(os.Stderr, "repo register: --set %s requires a non-empty value\n", k)
536
- return exitUsage
537
- }
538
- taskCfg[k] = v
703
+ if _, ok := sets["component"]; ok {
704
+ fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
705
+ return exitUsage
539
706
  }
540
707
  fields, err := c.RepoTaskFields(ctx)
541
708
  if err != nil {
542
709
  fmt.Fprintln(os.Stderr, err)
543
710
  return exitFail
544
711
  }
545
- required := map[string]bool{}
546
- for _, f := range fields {
547
- required[f] = true
548
- }
549
- for k := range taskCfg {
550
- if !required[k] {
551
- fmt.Fprintf(os.Stderr, "repo register: unknown task key %q (required keys: %s)\n",
552
- k, strings.Join(fields, ", "))
553
- return exitUsage
554
- }
555
- }
556
- var missing []string
557
- for _, f := range fields {
558
- if _, ok := taskCfg[f]; !ok {
559
- missing = append(missing, f)
560
- }
561
- }
562
- if len(missing) > 0 {
563
- fmt.Fprintf(os.Stderr, "repo register: missing required task keys: %s (pass --set %s=<value>)\n",
564
- strings.Join(missing, ", "), missing[0])
712
+ taskCfg, err := repoTaskConfig(fields, sets, flagName)
713
+ if err != nil {
714
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
565
715
  return exitUsage
566
716
  }
567
717
  info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
@@ -593,55 +743,117 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
593
743
  return exitFail
594
744
  }
595
745
 
596
- // Searchable select over discovered candidates.
597
- selected := 0
746
+ selected := []int{}
598
747
  options := make([]huh.Option[int], len(candidates))
599
748
  for i, cand := range candidates {
600
749
  options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
601
750
  }
602
- pick := huh.NewForm(huh.NewGroup(huh.NewSelect[int]().
603
- Title("Repository").
604
- Options(options...).
605
- Filtering(true).
606
- Value(&selected)))
751
+ pickField := repoMultiSelect(options, &selected)
752
+ pick := huh.NewForm(huh.NewGroup(pickField))
607
753
  if err := pick.Run(); err != nil {
608
754
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
609
755
  return exitFail
610
756
  }
611
757
 
612
- // Name/path inputs default to the SELECTED candidate, then one input
613
- // per required task repo key. Values pass through as entered; the
614
- // service validates required keys.
615
- cand := candidates[selected]
616
- name := cand.Name
617
- path := cand.Path
618
- inputs := []huh.Field{
619
- huh.NewInput().Title("Name").Value(&name),
620
- huh.NewInput().Title("Path").Value(&path),
758
+ sharedFields := registrationSharedFields(fields)
759
+ values := make([]string, len(sharedFields))
760
+ inputs := make([]huh.Field, len(sharedFields))
761
+ for i, field := range sharedFields {
762
+ title := field
763
+ if field == "project" {
764
+ title = "Jira project"
765
+ }
766
+ inputs[i] = huh.NewInput().Title(title).Value(&values[i])
767
+ }
768
+ if len(inputs) > 0 {
769
+ if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
770
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
771
+ return exitFail
772
+ }
621
773
  }
622
- values := make([]string, len(fields))
623
- for i, f := range fields {
624
- inputs = append(inputs, huh.NewInput().Title(f).Value(&values[i]))
774
+ shared := kvFlags{}
775
+ for i, field := range sharedFields {
776
+ shared[field] = values[i]
625
777
  }
626
- if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
778
+ if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
627
779
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
628
780
  return exitFail
629
781
  }
630
- taskCfg := config.RawValues{}
631
- for i, f := range fields {
632
- taskCfg[f] = values[i]
782
+ return exitOK
783
+ }
784
+
785
+ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
786
+ return huh.NewMultiSelect[int]().
787
+ Title("Select repositories").
788
+ Options(options...).
789
+ Value(selected).
790
+ Validate(func(values []int) error {
791
+ if len(values) == 0 {
792
+ return fmt.Errorf("select at least one repository")
793
+ }
794
+ return nil
795
+ })
796
+ }
797
+
798
+ func registrationSharedFields(fields []string) []string {
799
+ shared := make([]string, 0, len(fields))
800
+ for _, field := range fields {
801
+ if field != "component" {
802
+ shared = append(shared, field)
803
+ }
633
804
  }
634
- info, err := c.RegisterRepo(ctx, repo.RegisterInput{
635
- Name: name,
636
- Path: path,
637
- TaskConfig: taskCfg,
638
- })
639
- if err != nil {
640
- fmt.Fprintln(os.Stderr, err)
641
- return exitFail
805
+ return shared
806
+ }
807
+
808
+ func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
809
+ required := map[string]bool{}
810
+ for _, field := range fields {
811
+ required[field] = true
642
812
  }
643
- fmt.Println(info.Name)
644
- return exitOK
813
+ values := config.RawValues{}
814
+ for key, value := range supplied {
815
+ if !required[key] {
816
+ return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
817
+ }
818
+ if value == "" {
819
+ return nil, fmt.Errorf("task key %q requires a non-empty value", key)
820
+ }
821
+ values[key] = value
822
+ }
823
+ if required["component"] {
824
+ values["component"] = repoName
825
+ }
826
+ var missing []string
827
+ for _, field := range fields {
828
+ if _, ok := values[field]; !ok {
829
+ missing = append(missing, field)
830
+ }
831
+ }
832
+ if len(missing) > 0 {
833
+ return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
834
+ strings.Join(missing, ", "), missing[0])
835
+ }
836
+ return values, nil
837
+ }
838
+
839
+ func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
840
+ for _, index := range selected {
841
+ candidate := candidates[index]
842
+ taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
843
+ if err != nil {
844
+ return fmt.Errorf("%s: %w", candidate.Name, err)
845
+ }
846
+ info, err := c.RegisterRepo(ctx, repo.RegisterInput{
847
+ Name: candidate.Name,
848
+ Path: candidate.Path,
849
+ TaskConfig: taskCfg,
850
+ })
851
+ if err != nil {
852
+ return fmt.Errorf("%s: %w", candidate.Name, err)
853
+ }
854
+ fmt.Println(info.Name)
855
+ }
856
+ return nil
645
857
  }
646
858
 
647
859
  // kvFlags collects repeated key=value flags (registration task config).