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
package/cmd/relay-flow/main.go
CHANGED
|
@@ -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:
|
|
59
|
-
Config:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
171
|
-
if
|
|
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
|
|
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 =
|
|
240
|
+
names = readInitLines(stdin)
|
|
200
241
|
if names == nil {
|
|
201
|
-
fmt.Fprintln(os.Stderr, "init: expected
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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(
|
|
364
|
+
for len(values) < 3 && scanner.Scan() {
|
|
278
365
|
line := strings.TrimSpace(scanner.Text())
|
|
279
366
|
if line == "" {
|
|
280
367
|
continue
|
|
281
368
|
}
|
|
282
|
-
|
|
369
|
+
values = append(values, line)
|
|
283
370
|
}
|
|
284
|
-
if len(
|
|
371
|
+
if len(values) < 3 {
|
|
285
372
|
return nil
|
|
286
373
|
}
|
|
287
|
-
return
|
|
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
|
|
517
|
-
//
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
huh.NewInput().Title(
|
|
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
|
-
|
|
623
|
-
for i,
|
|
624
|
-
|
|
774
|
+
shared := kvFlags{}
|
|
775
|
+
for i, field := range sharedFields {
|
|
776
|
+
shared[field] = values[i]
|
|
625
777
|
}
|
|
626
|
-
if err :=
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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).
|