relay-flow 0.2.6-alpha → 0.2.8-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 +191 -33
- package/cmd/relay-flow/commands_test.go +82 -11
- package/cmd/relay-flow/main.go +18 -86
- package/cmd/relay-flow/repo_registration.go +235 -0
- package/cmd/relay-flow/serve.go +2 -2
- package/examples/config-reference.yaml +7 -0
- package/examples/default-story-workflow.yaml +34 -19
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/repo/service.go +13 -0
- package/internal/repo/service_test.go +14 -1
- package/internal/server/api_test.go +46 -0
- package/internal/server/client.go +30 -2
- package/internal/server/fixture_test.go +20 -16
- package/internal/server/server.go +39 -4
- package/internal/task/beads/beads.go +7 -2
- package/internal/task/beads/beads_test.go +13 -0
- package/internal/task/factory.go +9 -7
- package/internal/task/jira/auth_test.go +9 -1
- package/internal/task/jira/filters_test.go +2 -2
- package/internal/task/jira/helpers_test.go +13 -0
- package/internal/task/jira/jira.go +233 -44
- package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
- package/internal/task/jira/rest/client.go +73 -31
- package/internal/task/jira/rest/client_test.go +25 -0
- package/internal/task/jira/status_defaults_test.go +134 -0
- package/internal/task/jira/templates_test.go +2 -2
- package/internal/task/jira/transition_defaults_test.go +8 -12
- package/internal/task/jira/validation_test.go +3 -1
- package/internal/task/registration.go +59 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,36 +1,160 @@
|
|
|
1
1
|
# relay-flow
|
|
2
2
|
|
|
3
|
-
Durable, graph-based agent workflow runner. A ticket is the unit of work; a workflow YAML declares nodes and routes; a durable
|
|
3
|
+
Durable, graph-based agent workflow runner. A ticket is the unit of work; a workflow YAML declares nodes and routes; a durable executor drives progression, waits, retries, and recovery. Task systems (Jira and Beads), runners (Orca and Herdr), harnesses (OpenCode and Pi), and durable executors (go-workflows and Temporal) are selectable independently.
|
|
4
4
|
|
|
5
5
|
This is a ground-up rewrite. The previous per-workflow, in-memory daemon is gone. There is no migration path and no compatibility layer.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Quick start
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### 1. Install relay-flow
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|---|---|
|
|
15
|
-
| [opencode](https://opencode.ai) | Agents run in opencode sessions (harness) |
|
|
16
|
-
| [Orca](https://github.com/Necmttn/orca) CLI + app | Worktrees + terminals (runner) |
|
|
17
|
-
| `bd` CLI | Beads task-system access (required when `taskPlugin: beads`) |
|
|
18
|
-
| Dolt | External/server-backed Beads only |
|
|
19
|
-
| Jira API token | Jira REST API v3 access (required when `taskPlugin: jira`) |
|
|
20
|
-
| Go 1.24+ | Build the CLI |
|
|
13
|
+
Install the latest released CLI with Homebrew:
|
|
21
14
|
|
|
22
|
-
|
|
15
|
+
```sh
|
|
16
|
+
brew install rajpopat27/tap/relay-flow
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 2. Install the harness extension
|
|
20
|
+
|
|
21
|
+
Choose the harness that will run your agent sessions. Both commands install the
|
|
22
|
+
same `relay-flow-plugin` package with host-specific entrypoints.
|
|
23
|
+
|
|
24
|
+
**OpenCode**
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
opencode plugin relay-flow-plugin@0.2.8-alpha
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Pi**
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pi install npm:relay-flow-plugin@0.2.8-alpha
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
37
|
+
`-e`/`--extension` to relay-flow's Pi launch command.
|
|
38
|
+
|
|
39
|
+
### 3. Initialize relay-flow
|
|
40
|
+
|
|
41
|
+
Choose one task system, runner, harness, and durable executor. This example
|
|
42
|
+
uses Jira, Orca, OpenCode, and the default embedded executor:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
relay-flow init \
|
|
46
|
+
--task-plugin jira \
|
|
47
|
+
--runner-plugin orca \
|
|
48
|
+
--harness-plugin opencode
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The default executor is `goworkflows` with SQLite. To use Temporal instead,
|
|
52
|
+
add `--executor-plugin temporal`, `--temporal-address <host:port>`, and
|
|
53
|
+
`--temporal-namespace <name>` to the command.
|
|
54
|
+
|
|
55
|
+
### 4. Authenticate the task system
|
|
56
|
+
|
|
57
|
+
For Jira, authenticate the selected task plugin:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
relay-flow task auth
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For Beads, skip this command and initialize/authenticate the Beads workspace
|
|
64
|
+
with `bd` and, when needed, Dolt. See [Beads task system](#beads-task-system)
|
|
65
|
+
below.
|
|
66
|
+
|
|
67
|
+
### 5. Start the server
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
relay-flow serve --background
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 6. Register a repository
|
|
74
|
+
|
|
75
|
+
The repository must already exist in the selected runner. For Orca:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
orca repo add --path /work/payments
|
|
79
|
+
relay-flow repo register
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For Herdr, register the repository path directly; relay-flow creates ticket
|
|
83
|
+
worktrees lazily. The interactive registration asks for the task-system values
|
|
84
|
+
required by the selected task plugin.
|
|
85
|
+
|
|
86
|
+
### 7. Submit a workflow
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
relay-flow workflow submit --file examples/minimal-jira-task-workflow.yaml
|
|
90
|
+
relay-flow workflow list
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Replace the example workflow with
|
|
94
|
+
`examples/minimal-beads-task-workflow.yaml` when using Beads. The workflow's
|
|
95
|
+
`repos` value must match the name used during repository registration.
|
|
96
|
+
|
|
97
|
+
## Supported plugins
|
|
98
|
+
|
|
99
|
+
Each category is a replaceable boundary. Select one plugin from each category
|
|
100
|
+
at initialization; the workflow YAML and core orchestration do not change when
|
|
101
|
+
you switch an implementation.
|
|
102
|
+
|
|
103
|
+
| Category | Supported plugins | Owns |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| Task system | `jira`, `beads` | Parent tickets, mailbox subtasks, task state, labels, comments, and task configuration |
|
|
106
|
+
| Runner | `orca`, `herdr` | Ticket worktrees, environments, terminals, process liveness, and cleanup |
|
|
107
|
+
| Harness | `opencode`, `pi` | Agent launch commands, sessions, prompts, report parsing, nudges, and resume behavior |
|
|
108
|
+
| Durable executor | `goworkflows`, `temporal` | Graph progression, waits, retries, recovery, and durable execution state |
|
|
109
|
+
|
|
110
|
+
The default durable executor is `goworkflows`, which stores execution state in
|
|
111
|
+
SQLite. `temporal` uses an external Temporal server and is selected with the
|
|
112
|
+
Temporal address and namespace during `init`.
|
|
113
|
+
|
|
114
|
+
The plugin composition is explicit:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
Task system (Jira or Beads)
|
|
118
|
+
│
|
|
119
|
+
▼
|
|
120
|
+
Durable executor (go-workflows or Temporal)
|
|
121
|
+
│
|
|
122
|
+
▼
|
|
123
|
+
Runner (Orca or Herdr)
|
|
124
|
+
│
|
|
125
|
+
▼
|
|
126
|
+
Harness (OpenCode or Pi)
|
|
127
|
+
│
|
|
128
|
+
▼
|
|
129
|
+
relay-flow report transport
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The equivalent non-interactive selection is:
|
|
23
133
|
|
|
24
134
|
```sh
|
|
25
|
-
|
|
135
|
+
relay-flow init \
|
|
136
|
+
--task-plugin <jira|beads> \
|
|
137
|
+
--runner-plugin <orca|herdr> \
|
|
138
|
+
--harness-plugin <opencode|pi> \
|
|
139
|
+
--executor-plugin <goworkflows|temporal>
|
|
26
140
|
```
|
|
27
141
|
|
|
142
|
+
`--executor-plugin` defaults to `goworkflows`. Jira requires Jira credentials;
|
|
143
|
+
Beads requires the `bd` CLI and a configured workspace. Orca requires its CLI
|
|
144
|
+
and app; Herdr requires its CLI/server. OpenCode and Pi each require their
|
|
145
|
+
corresponding agent runtime. These integrations remain behind their small
|
|
146
|
+
contracts, so task-system fields do not leak into runners or harnesses.
|
|
147
|
+
|
|
148
|
+
## Detailed setup
|
|
149
|
+
|
|
150
|
+
### Harness configuration
|
|
151
|
+
|
|
28
152
|
OpenCode plugin configuration uses both entrypoints. The server entrypoint is listed in `opencode.json`:
|
|
29
153
|
|
|
30
154
|
```json
|
|
31
155
|
{
|
|
32
156
|
"$schema": "https://opencode.ai/config.json",
|
|
33
|
-
"plugin": ["relay-flow-plugin"]
|
|
157
|
+
"plugin": ["relay-flow-plugin@0.2.8-alpha"]
|
|
34
158
|
}
|
|
35
159
|
```
|
|
36
160
|
|
|
@@ -39,7 +163,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
39
163
|
```json
|
|
40
164
|
{
|
|
41
165
|
"$schema": "https://opencode.ai/tui.json",
|
|
42
|
-
"plugin": ["relay-flow-plugin"]
|
|
166
|
+
"plugin": ["relay-flow-plugin@0.2.8-alpha"]
|
|
43
167
|
}
|
|
44
168
|
```
|
|
45
169
|
|
|
@@ -55,7 +179,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
55
179
|
settings before starting a Pi harness session:
|
|
56
180
|
|
|
57
181
|
```sh
|
|
58
|
-
pi install npm:relay-flow-plugin
|
|
182
|
+
pi install npm:relay-flow-plugin@0.2.8-alpha
|
|
59
183
|
```
|
|
60
184
|
|
|
61
185
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -71,14 +195,14 @@ structured report, applies the agent/HITL nudge policy, and delivers
|
|
|
71
195
|
`reportId` comes from the harness session/message identity; `nodeVisitID` is
|
|
72
196
|
internal and is never part of either plugin payload.
|
|
73
197
|
|
|
74
|
-
###
|
|
198
|
+
### Machine setup details
|
|
75
199
|
|
|
76
200
|
```sh
|
|
77
201
|
relay-flow init
|
|
78
202
|
relay-flow task auth
|
|
79
203
|
```
|
|
80
204
|
|
|
81
|
-
`init`
|
|
205
|
+
`init` selects the task system, runner, harness, and durable executor (singleton options are automatic), writes machine config, and initializes the selected execution backend. `task auth` delegates authentication to that selected task plug-in. Jira prompts for its site, email, and masked API token, validates `/myself`, and owns the system-wide `credentials.yaml`; for scripts, pass `task auth --site`, `--email`, and `--token`. A normal init rerun refuses existing state. `relay-flow init --force` updates safe stopped instances while preserving durable and repo state.
|
|
82
206
|
|
|
83
207
|
For Beads, select the plugin explicitly when scripting setup:
|
|
84
208
|
|
|
@@ -131,11 +255,23 @@ orca repo add --path /work/payments # displayName becomes "payments"
|
|
|
131
255
|
relay-flow repo register
|
|
132
256
|
```
|
|
133
257
|
|
|
134
|
-
Shows a multi-select titled `Select repositories`; use Space to select Orca repos and Enter to confirm. Enter the Jira project once. Each repo is registered sequentially with its Orca name/path and a Jira component derived from that repo name. Earlier registrations remain if a later one fails.
|
|
258
|
+
Shows a multi-select titled `Select repositories`; use Space to select Orca repos and Enter to confirm. Enter the Jira project once, then choose the repository's start, work, and end statuses from the statuses discovered for that project. Each repo is registered sequentially with its Orca name/path and a Jira component derived from that repo name. Earlier registrations remain if a later one fails.
|
|
135
259
|
|
|
136
260
|
Each Jira poll uses REST v3 enhanced search and requests linked-issue status with the candidate fields. Tickets with any unfinished inward `Blocks` issue are filtered before routing; no per-ticket blocker lookup is made.
|
|
137
261
|
|
|
138
|
-
For
|
|
262
|
+
For scripted Jira registration, pass all three repository status defaults explicitly:
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
relay-flow repo register \
|
|
266
|
+
--name payments \
|
|
267
|
+
--path /work/payments \
|
|
268
|
+
--set project=PAY \
|
|
269
|
+
--set statusDefaults.start="Open" \
|
|
270
|
+
--set statusDefaults.work="Working" \
|
|
271
|
+
--set statusDefaults.end="Closed"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The values must be statuses available to the Jira project. Component is always derived from `--name` and cannot be overridden. Registration is rejected while another repo already holds the same canonical task scope.
|
|
139
275
|
|
|
140
276
|
### Beads task system
|
|
141
277
|
|
|
@@ -229,10 +365,7 @@ Workflows live at `~/.relay-flow/workflows/<name>.yaml` after submit. Replacemen
|
|
|
229
365
|
|
|
230
366
|
Use [`examples/config-reference.yaml`](examples/config-reference.yaml) for the complete machine configuration, [`examples/workflow-reference.yaml`](examples/workflow-reference.yaml) for the complete workflow schema, or the provider-specific minimal workflows [`examples/minimal-jira-task-workflow.yaml`](examples/minimal-jira-task-workflow.yaml) and [`examples/minimal-beads-task-workflow.yaml`](examples/minimal-beads-task-workflow.yaml). Runtime node agents should follow [`docs/agent-instructions.md`](docs/agent-instructions.md). The existing [`examples/default-story-workflow.yaml`](examples/default-story-workflow.yaml) remains a more detailed Jira Story example, while [`examples/beads-workflow.yaml`](examples/beads-workflow.yaml) shows the Beads lifecycle shape. Replace the repo name and uncomment only the optional fields you need.
|
|
231
367
|
|
|
232
|
-
Task, runner, and
|
|
233
|
-
configuration cannot run Jira and Beads simultaneously; use separate
|
|
234
|
-
`RELAY_FLOW_HOME` directories or machine configurations when both providers are
|
|
235
|
-
needed.
|
|
368
|
+
Task, runner, harness, and durable executor plugins are selected machine-wide. A single relay-flow configuration cannot run Jira and Beads simultaneously; use separate `RELAY_FLOW_HOME` directories or machine configurations when both providers are needed.
|
|
236
369
|
|
|
237
370
|
### Run
|
|
238
371
|
|
|
@@ -262,6 +395,8 @@ cleanupRunnerOnEnd: false # optional; when true the runner tears down at
|
|
|
262
395
|
taskConfig: # optional; adapter-owned; merged root → repo → workflow → node
|
|
263
396
|
filters:
|
|
264
397
|
parentStatuses: [To Do]
|
|
398
|
+
labels: ["workflow:true"]
|
|
399
|
+
assignees: ["currentUser()"] # Jira resolves this to the authenticated email.
|
|
265
400
|
|
|
266
401
|
nodes:
|
|
267
402
|
start:
|
|
@@ -271,20 +406,43 @@ nodes:
|
|
|
271
406
|
onSuccess: [{ target: coding }]
|
|
272
407
|
|
|
273
408
|
coding:
|
|
274
|
-
type: agent
|
|
275
|
-
agent: build
|
|
409
|
+
type: agent
|
|
410
|
+
agent: build
|
|
276
411
|
description: | # becomes the mailbox description and launch prompt
|
|
277
|
-
Implement the ticket.
|
|
412
|
+
Implement the ticket in the current worktree.
|
|
413
|
+
nudgePrompt: |
|
|
414
|
+
Continue working on {{ticket}}. Read the parent {{taskSystem}} ticket
|
|
415
|
+
{{ticket}} and your assigned ticket {{mailbox}} to understand the requirements. Read
|
|
416
|
+
the latest feedback, address the requested changes, and work on the next
|
|
417
|
+
bounded task slice. Return the complete report. Valid choices are:
|
|
418
|
+
{{nextSteps}}.
|
|
278
419
|
onSuccess: [{ target: reviewing, when: "work complete" }]
|
|
279
420
|
onFailure: [{ target: coding, when: "retry" }]
|
|
280
|
-
nudgePrompt: "Check edge cases for {{ticket}} before reporting." # optional custom instructions
|
|
281
421
|
|
|
282
422
|
reviewing:
|
|
423
|
+
type: agent
|
|
424
|
+
agent: plan
|
|
425
|
+
description: Review the completed implementation and report required changes.
|
|
426
|
+
nudgePrompt: |
|
|
427
|
+
Review {{ticket}}. Read the parent {{taskSystem}} ticket {{ticket}} and
|
|
428
|
+
your assigned ticket {{mailbox}} to understand the requirements. Re-check the
|
|
429
|
+
implementation and latest coding feedback, then return the complete
|
|
430
|
+
report. Valid choices are:
|
|
431
|
+
{{nextSteps}}.
|
|
432
|
+
onSuccess: [{ target: humanReview, when: "ready for human review" }]
|
|
433
|
+
onFailure: [{ target: coding, when: "changes required" }]
|
|
434
|
+
|
|
435
|
+
humanReview:
|
|
283
436
|
type: hitl
|
|
284
|
-
agent:
|
|
285
|
-
description:
|
|
286
|
-
|
|
287
|
-
|
|
437
|
+
agent: plan
|
|
438
|
+
description: Approve the reviewed implementation or request changes.
|
|
439
|
+
nudgePrompt: |
|
|
440
|
+
Review the completed work for {{ticket}} with the human. Read the parent
|
|
441
|
+
{{taskSystem}} ticket {{ticket}} and your assigned ticket {{mailbox}} to
|
|
442
|
+
understand the requirements. Return the complete report. Valid choices are:
|
|
443
|
+
{{nextSteps}}.
|
|
444
|
+
onSuccess: [{ target: end, when: "approved" }]
|
|
445
|
+
onFailure: [{ target: coding, when: "changes requested" }]
|
|
288
446
|
|
|
289
447
|
end: {}
|
|
290
448
|
```
|
|
@@ -468,7 +626,7 @@ tickets are not reopened automatically.
|
|
|
468
626
|
## Architecture
|
|
469
627
|
|
|
470
628
|
- **Task system** owns parent tickets, mailbox subtasks, task state, labels, comments, and adapter config. The parent ticket is the unit of work.
|
|
471
|
-
- **Durable workflow engine** (
|
|
629
|
+
- **Durable workflow engine** (`goworkflows` + SQLite or `temporal`) owns graph progression, waits, reports, retries, and recovery. No custom state machine.
|
|
472
630
|
- **Mailbox subtask** is one agent/HITL node's scratch space; its description defines the node's work and its comments hold the node's summary plus selected incoming feedback.
|
|
473
631
|
- **Harness** owns agent launch, session/report behavior, parsing, nudging, and resume semantics.
|
|
474
632
|
- **Runner** owns ticket worktrees/environments, terminals, liveness, and execution of harness commands.
|
|
@@ -640,9 +640,13 @@ type registerServer struct {
|
|
|
640
640
|
calls int
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
-
func (s *registerServer)
|
|
643
|
+
func (s *registerServer) TaskRegistrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
|
|
644
644
|
s.calls++
|
|
645
|
-
|
|
645
|
+
out := make([]task.RegistrationField, 0, len(s.fields))
|
|
646
|
+
for _, field := range s.fields {
|
|
647
|
+
out = append(out, task.RegistrationField{Key: field, Title: field, Derived: field == "component"})
|
|
648
|
+
}
|
|
649
|
+
return out, nil
|
|
646
650
|
}
|
|
647
651
|
func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput) (repo.Info, error) {
|
|
648
652
|
s.calls++
|
|
@@ -767,7 +771,67 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
|
|
|
767
771
|
}
|
|
768
772
|
}
|
|
769
773
|
|
|
770
|
-
func
|
|
774
|
+
func TestRegistrationSelectRequiresExplicitChoiceWithoutDefault(t *testing.T) {
|
|
775
|
+
field := task.RegistrationField{Key: "statusDefaults.start", Options: []string{"Open", "Working"}}
|
|
776
|
+
options := registrationSelectOptions(field)
|
|
777
|
+
if len(options) != 3 {
|
|
778
|
+
t.Fatalf("options = %d, want empty choice plus two statuses", len(options))
|
|
779
|
+
}
|
|
780
|
+
var selected string
|
|
781
|
+
selectField := huh.NewSelect[string]().Options(options...).Value(&selected)
|
|
782
|
+
var out bytes.Buffer
|
|
783
|
+
if err := selectField.RunAccessible(&out, strings.NewReader("1\n")); err != nil {
|
|
784
|
+
t.Fatal(err)
|
|
785
|
+
}
|
|
786
|
+
if selected != "" {
|
|
787
|
+
t.Fatalf("empty choice selected %q, want empty value", selected)
|
|
788
|
+
}
|
|
789
|
+
if err := selectField.RunAccessible(&out, strings.NewReader("2\n")); err != nil {
|
|
790
|
+
t.Fatal(err)
|
|
791
|
+
}
|
|
792
|
+
if selected != "Open" {
|
|
793
|
+
t.Fatalf("explicit status selection = %q, want Open", selected)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
func TestRegistrationTaskConfigBuildsNestedStatusDefaults(t *testing.T) {
|
|
798
|
+
registration := task.Registration{Fields: []task.RegistrationField{
|
|
799
|
+
{Key: "project", Title: "Project"},
|
|
800
|
+
{Key: "component", Title: "Component", Derived: true},
|
|
801
|
+
{Key: "statusDefaults.start", Title: "Start", Options: []string{"Open", "Working", "Closed"}},
|
|
802
|
+
{Key: "statusDefaults.work", Title: "Work", Options: []string{"Open", "Working", "Closed"}},
|
|
803
|
+
{Key: "statusDefaults.end", Title: "End", Options: []string{"Open", "Working", "Closed"}},
|
|
804
|
+
}}
|
|
805
|
+
got, err := registrationTaskConfig(registration, kvFlags{
|
|
806
|
+
"project": "PAY",
|
|
807
|
+
"statusDefaults.start": "Open",
|
|
808
|
+
"statusDefaults.work": "Working",
|
|
809
|
+
"statusDefaults.end": "Closed",
|
|
810
|
+
}, "payments")
|
|
811
|
+
if err != nil {
|
|
812
|
+
t.Fatal(err)
|
|
813
|
+
}
|
|
814
|
+
defaults, ok := got["statusDefaults"].(map[string]any)
|
|
815
|
+
if !ok || defaults["start"] != "Open" || defaults["work"] != "Working" || defaults["end"] != "Closed" {
|
|
816
|
+
t.Fatalf("nested statusDefaults = %#v", got["statusDefaults"])
|
|
817
|
+
}
|
|
818
|
+
for _, tc := range []struct {
|
|
819
|
+
name string
|
|
820
|
+
sets kvFlags
|
|
821
|
+
want string
|
|
822
|
+
}{
|
|
823
|
+
{name: "invalid status", sets: kvFlags{"project": "PAY", "statusDefaults.start": "Missing"}, want: "not one of the available choices"},
|
|
824
|
+
{name: "derived component", sets: kvFlags{"project": "PAY", "component": "other"}, want: "derived"},
|
|
825
|
+
} {
|
|
826
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
827
|
+
if _, err := registrationTaskConfig(registration, tc.sets, "payments"); err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
828
|
+
t.Fatalf("registration error = %v, want %q", err, tc.want)
|
|
829
|
+
}
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
func TestRepoMultiSelectAndPluginMapping(t *testing.T) {
|
|
771
835
|
selected := []int{0, 1}
|
|
772
836
|
field := repoMultiSelect([]huh.Option[int]{
|
|
773
837
|
huh.NewOption("payments", 0),
|
|
@@ -783,9 +847,10 @@ func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
|
|
|
783
847
|
if fmt.Sprint(selected) != "[0 1]" {
|
|
784
848
|
t.Fatalf("selected = %v, want [0 1]", selected)
|
|
785
849
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
850
|
+
registration := task.Registration{Fields: []task.RegistrationField{
|
|
851
|
+
{Key: "project", Title: "Project"},
|
|
852
|
+
{Key: "component", Title: "Component", Derived: true},
|
|
853
|
+
}}
|
|
789
854
|
|
|
790
855
|
home := t.TempDir()
|
|
791
856
|
deps := serveRegister(t, home, []string{"project", "component"})
|
|
@@ -794,8 +859,8 @@ func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
|
|
|
794
859
|
{Name: "payments", Path: "/srv/payments"},
|
|
795
860
|
{Name: "checkout", Path: "/srv/checkout"},
|
|
796
861
|
}
|
|
797
|
-
if err :=
|
|
798
|
-
|
|
862
|
+
if err := registerSelectedReposDynamic(context.Background(), client, candidates, selected,
|
|
863
|
+
registration, kvFlags{"project": "PAY"}); err != nil {
|
|
799
864
|
t.Fatal(err)
|
|
800
865
|
}
|
|
801
866
|
if len(deps.successful) != 2 {
|
|
@@ -818,8 +883,12 @@ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
|
|
|
818
883
|
{Name: "checkout", Path: "/srv/checkout"},
|
|
819
884
|
{Name: "later", Path: "/srv/later"},
|
|
820
885
|
}
|
|
821
|
-
|
|
822
|
-
|
|
886
|
+
registration := task.Registration{Fields: []task.RegistrationField{
|
|
887
|
+
{Key: "project", Title: "Project"},
|
|
888
|
+
{Key: "component", Title: "Component", Derived: true},
|
|
889
|
+
}}
|
|
890
|
+
err := registerSelectedReposDynamic(context.Background(), client, candidates, []int{0, 1, 2},
|
|
891
|
+
registration, kvFlags{"project": "PAY"})
|
|
823
892
|
if err == nil || !strings.Contains(err.Error(), "checkout") {
|
|
824
893
|
t.Fatalf("partial failure = %v, want failed repo name", err)
|
|
825
894
|
}
|
|
@@ -1012,7 +1081,9 @@ func (s *ackServer) CancelRun(context.Context, string, string) error { panic("un
|
|
|
1012
1081
|
func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
1013
1082
|
panic("unreachable")
|
|
1014
1083
|
}
|
|
1015
|
-
func (s *ackServer)
|
|
1084
|
+
func (s *ackServer) TaskRegistrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
|
|
1085
|
+
panic("unreachable")
|
|
1086
|
+
}
|
|
1016
1087
|
func (s *ackServer) RegisterRepo(context.Context, repo.RegisterInput) (repo.Info, error) {
|
|
1017
1088
|
panic("unreachable")
|
|
1018
1089
|
}
|
package/cmd/relay-flow/main.go
CHANGED
|
@@ -798,7 +798,7 @@ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
|
|
|
798
798
|
}
|
|
799
799
|
|
|
800
800
|
// cmdRepoRegister registers one flagged repo or multiple interactively selected
|
|
801
|
-
// runner repos
|
|
801
|
+
// runner repos using registration metadata supplied by the selected task plugin.
|
|
802
802
|
func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
|
|
803
803
|
ctx := context.Background()
|
|
804
804
|
flagged := flagName != "" || flagPath != "" || len(sets) > 0
|
|
@@ -809,16 +809,12 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
|
|
|
809
809
|
fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
|
|
810
810
|
return exitUsage
|
|
811
811
|
}
|
|
812
|
-
|
|
813
|
-
fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
|
|
814
|
-
return exitUsage
|
|
815
|
-
}
|
|
816
|
-
fields, err := c.RepoTaskFields(ctx)
|
|
812
|
+
registration, err := loadRepoRegistration(ctx, c, sets)
|
|
817
813
|
if err != nil {
|
|
818
814
|
fmt.Fprintln(os.Stderr, err)
|
|
819
815
|
return exitFail
|
|
820
816
|
}
|
|
821
|
-
taskCfg, err :=
|
|
817
|
+
taskCfg, err := registrationTaskConfig(registration, sets, flagName)
|
|
822
818
|
if err != nil {
|
|
823
819
|
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
824
820
|
return exitUsage
|
|
@@ -832,7 +828,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
|
|
|
832
828
|
return exitOK
|
|
833
829
|
}
|
|
834
830
|
|
|
835
|
-
|
|
831
|
+
registration, err := c.RepoRegistrationFields(ctx, nil)
|
|
836
832
|
if err != nil {
|
|
837
833
|
fmt.Fprintln(os.Stderr, err)
|
|
838
834
|
return exitFail
|
|
@@ -864,27 +860,24 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
|
|
|
864
860
|
return exitFail
|
|
865
861
|
}
|
|
866
862
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
863
|
+
shared := kvFlags{}
|
|
864
|
+
if err := promptRepoRegistration(registration, shared); err != nil {
|
|
865
|
+
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
866
|
+
return exitFail
|
|
867
|
+
}
|
|
868
|
+
if len(shared) > 0 {
|
|
869
|
+
dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(shared))
|
|
870
|
+
if err != nil {
|
|
871
|
+
fmt.Fprintln(os.Stderr, err)
|
|
872
|
+
return exitFail
|
|
873
|
+
}
|
|
874
|
+
registration = mergeRegistration(registration, dependent)
|
|
875
|
+
if err := promptRepoRegistration(registration, shared); err != nil {
|
|
879
876
|
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
880
877
|
return exitFail
|
|
881
878
|
}
|
|
882
879
|
}
|
|
883
|
-
|
|
884
|
-
for i, field := range sharedFields {
|
|
885
|
-
shared[field] = values[i]
|
|
886
|
-
}
|
|
887
|
-
if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
|
|
880
|
+
if err := registerSelectedReposDynamic(ctx, c, candidates, selected, registration, shared); err != nil {
|
|
888
881
|
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
889
882
|
return exitFail
|
|
890
883
|
}
|
|
@@ -904,67 +897,6 @@ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelec
|
|
|
904
897
|
})
|
|
905
898
|
}
|
|
906
899
|
|
|
907
|
-
func registrationSharedFields(fields []string) []string {
|
|
908
|
-
shared := make([]string, 0, len(fields))
|
|
909
|
-
for _, field := range fields {
|
|
910
|
-
if field != "component" {
|
|
911
|
-
shared = append(shared, field)
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
return shared
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
|
|
918
|
-
required := map[string]bool{}
|
|
919
|
-
for _, field := range fields {
|
|
920
|
-
required[field] = true
|
|
921
|
-
}
|
|
922
|
-
values := config.RawValues{}
|
|
923
|
-
for key, value := range supplied {
|
|
924
|
-
if !required[key] {
|
|
925
|
-
return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
|
|
926
|
-
}
|
|
927
|
-
if value == "" {
|
|
928
|
-
return nil, fmt.Errorf("task key %q requires a non-empty value", key)
|
|
929
|
-
}
|
|
930
|
-
values[key] = value
|
|
931
|
-
}
|
|
932
|
-
if required["component"] {
|
|
933
|
-
values["component"] = repoName
|
|
934
|
-
}
|
|
935
|
-
var missing []string
|
|
936
|
-
for _, field := range fields {
|
|
937
|
-
if _, ok := values[field]; !ok {
|
|
938
|
-
missing = append(missing, field)
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
if len(missing) > 0 {
|
|
942
|
-
return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
|
|
943
|
-
strings.Join(missing, ", "), missing[0])
|
|
944
|
-
}
|
|
945
|
-
return values, nil
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
|
|
949
|
-
for _, index := range selected {
|
|
950
|
-
candidate := candidates[index]
|
|
951
|
-
taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
|
|
952
|
-
if err != nil {
|
|
953
|
-
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
954
|
-
}
|
|
955
|
-
info, err := c.RegisterRepo(ctx, repo.RegisterInput{
|
|
956
|
-
Name: candidate.Name,
|
|
957
|
-
Path: candidate.Path,
|
|
958
|
-
TaskConfig: taskCfg,
|
|
959
|
-
})
|
|
960
|
-
if err != nil {
|
|
961
|
-
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
962
|
-
}
|
|
963
|
-
fmt.Println(info.Name)
|
|
964
|
-
}
|
|
965
|
-
return nil
|
|
966
|
-
}
|
|
967
|
-
|
|
968
900
|
// kvFlags collects repeated key=value flags (registration task config).
|
|
969
901
|
// Parse rejects malformed pairs, empty keys/values, and duplicates.
|
|
970
902
|
type kvFlags map[string]string
|