relay-flow 0.2.7-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 +19 -7
- 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/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
|
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
|
|
|
24
24
|
**OpenCode**
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
|
-
opencode plugin relay-flow-plugin@0.2.
|
|
27
|
+
opencode plugin relay-flow-plugin@0.2.8-alpha
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
**Pi**
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
pi install npm:relay-flow-plugin@0.2.
|
|
33
|
+
pi install npm:relay-flow-plugin@0.2.8-alpha
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
@@ -154,7 +154,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
|
|
|
154
154
|
```json
|
|
155
155
|
{
|
|
156
156
|
"$schema": "https://opencode.ai/config.json",
|
|
157
|
-
"plugin": ["relay-flow-plugin@0.2.
|
|
157
|
+
"plugin": ["relay-flow-plugin@0.2.8-alpha"]
|
|
158
158
|
}
|
|
159
159
|
```
|
|
160
160
|
|
|
@@ -163,7 +163,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
163
163
|
```json
|
|
164
164
|
{
|
|
165
165
|
"$schema": "https://opencode.ai/tui.json",
|
|
166
|
-
"plugin": ["relay-flow-plugin@0.2.
|
|
166
|
+
"plugin": ["relay-flow-plugin@0.2.8-alpha"]
|
|
167
167
|
}
|
|
168
168
|
```
|
|
169
169
|
|
|
@@ -179,7 +179,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
179
179
|
settings before starting a Pi harness session:
|
|
180
180
|
|
|
181
181
|
```sh
|
|
182
|
-
pi install npm:relay-flow-plugin@0.2.
|
|
182
|
+
pi install npm:relay-flow-plugin@0.2.8-alpha
|
|
183
183
|
```
|
|
184
184
|
|
|
185
185
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -255,11 +255,23 @@ orca repo add --path /work/payments # displayName becomes "payments"
|
|
|
255
255
|
relay-flow repo register
|
|
256
256
|
```
|
|
257
257
|
|
|
258
|
-
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.
|
|
259
259
|
|
|
260
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.
|
|
261
261
|
|
|
262
|
-
For scripted Jira registration,
|
|
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.
|
|
263
275
|
|
|
264
276
|
### Beads task system
|
|
265
277
|
|
|
@@ -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
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/charmbracelet/huh"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// loadRepoRegistration asks the selected task plugin for the fields needed by
|
|
17
|
+
// registration. A second pass lets a plugin expose dependent choices after a
|
|
18
|
+
// value such as a project has been supplied.
|
|
19
|
+
func loadRepoRegistration(ctx context.Context, c *server.Client, supplied kvFlags) (task.Registration, error) {
|
|
20
|
+
initial, err := c.RepoRegistrationFields(ctx, nil)
|
|
21
|
+
if err != nil {
|
|
22
|
+
return task.Registration{}, err
|
|
23
|
+
}
|
|
24
|
+
if len(supplied) == 0 {
|
|
25
|
+
return initial, nil
|
|
26
|
+
}
|
|
27
|
+
dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(supplied))
|
|
28
|
+
if err != nil {
|
|
29
|
+
return task.Registration{}, err
|
|
30
|
+
}
|
|
31
|
+
return mergeRegistration(initial, dependent), nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func flatRegistrationValues(values kvFlags) config.RawValues {
|
|
35
|
+
out := make(config.RawValues, len(values))
|
|
36
|
+
for key, value := range values {
|
|
37
|
+
out[key] = value
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func mergeRegistration(first, second task.Registration) task.Registration {
|
|
43
|
+
fields := make([]task.RegistrationField, 0, len(first.Fields)+len(second.Fields))
|
|
44
|
+
positions := map[string]int{}
|
|
45
|
+
for _, field := range append(append([]task.RegistrationField(nil), first.Fields...), second.Fields...) {
|
|
46
|
+
if index, ok := positions[field.Key]; ok {
|
|
47
|
+
fields[index] = field
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
positions[field.Key] = len(fields)
|
|
51
|
+
fields = append(fields, field)
|
|
52
|
+
}
|
|
53
|
+
return task.Registration{Fields: fields}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// promptRepoRegistration renders only metadata returned by the task plugin.
|
|
57
|
+
// The core CLI does not know which values are Jira statuses or how they are
|
|
58
|
+
// validated.
|
|
59
|
+
func promptRepoRegistration(reg task.Registration, values kvFlags) error {
|
|
60
|
+
for _, field := range reg.Fields {
|
|
61
|
+
if field.Derived || values[field.Key] != "" {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
title := field.Title
|
|
65
|
+
if title == "" {
|
|
66
|
+
title = field.Key
|
|
67
|
+
}
|
|
68
|
+
if len(field.Options) > 0 {
|
|
69
|
+
selected := field.Default
|
|
70
|
+
if err := huh.NewForm(huh.NewGroup(
|
|
71
|
+
huh.NewSelect[string]().Title(title).Options(registrationSelectOptions(field)...).Value(&selected),
|
|
72
|
+
)).Run(); err != nil {
|
|
73
|
+
return err
|
|
74
|
+
}
|
|
75
|
+
if strings.TrimSpace(selected) == "" {
|
|
76
|
+
return fmt.Errorf("task field %q requires a value", field.Key)
|
|
77
|
+
}
|
|
78
|
+
values[field.Key] = selected
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
value := field.Default
|
|
82
|
+
input := huh.NewInput().Title(title).Value(&value).Validate(func(value string) error {
|
|
83
|
+
if strings.TrimSpace(value) == "" {
|
|
84
|
+
return fmt.Errorf("%s is required", title)
|
|
85
|
+
}
|
|
86
|
+
return nil
|
|
87
|
+
})
|
|
88
|
+
if err := huh.NewForm(huh.NewGroup(input)).Run(); err != nil {
|
|
89
|
+
return err
|
|
90
|
+
}
|
|
91
|
+
values[field.Key] = value
|
|
92
|
+
}
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func registrationSelectOptions(field task.RegistrationField) []huh.Option[string] {
|
|
97
|
+
options := make([]huh.Option[string], 0, len(field.Options)+1)
|
|
98
|
+
if field.Default == "" {
|
|
99
|
+
// Huh selects the first option when Enter is pressed. Keep an explicit
|
|
100
|
+
// empty choice first so a missing conventional default cannot silently
|
|
101
|
+
// become the first real Jira status.
|
|
102
|
+
options = append(options, huh.NewOption("Select a value...", ""))
|
|
103
|
+
}
|
|
104
|
+
for _, option := range field.Options {
|
|
105
|
+
options = append(options, huh.NewOption(option, option))
|
|
106
|
+
}
|
|
107
|
+
return options
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func registrationTaskConfig(reg task.Registration, supplied kvFlags, repoName string) (config.RawValues, error) {
|
|
111
|
+
known := make(map[string]task.RegistrationField, len(reg.Fields))
|
|
112
|
+
for _, field := range reg.Fields {
|
|
113
|
+
if strings.TrimSpace(field.Key) == "" {
|
|
114
|
+
return nil, errorsForRegistration("task plugin returned an empty registration key")
|
|
115
|
+
}
|
|
116
|
+
if _, exists := known[field.Key]; exists {
|
|
117
|
+
return nil, errorsForRegistration(fmt.Sprintf("duplicate task registration key %q", field.Key))
|
|
118
|
+
}
|
|
119
|
+
known[field.Key] = field
|
|
120
|
+
}
|
|
121
|
+
out := config.RawValues{}
|
|
122
|
+
for key, value := range supplied {
|
|
123
|
+
field, ok := known[key]
|
|
124
|
+
if !ok {
|
|
125
|
+
return nil, errorsForRegistration(fmt.Sprintf("unknown task key %q", key))
|
|
126
|
+
}
|
|
127
|
+
if field.Derived {
|
|
128
|
+
return nil, errorsForRegistration(fmt.Sprintf("task key %q is derived and cannot be overridden", key))
|
|
129
|
+
}
|
|
130
|
+
if strings.TrimSpace(value) == "" {
|
|
131
|
+
return nil, errorsForRegistration(fmt.Sprintf("task key %q requires a non-empty value", key))
|
|
132
|
+
}
|
|
133
|
+
if len(field.Options) > 0 && !containsRegistrationOption(field.Options, value) {
|
|
134
|
+
return nil, errorsForRegistration(fmt.Sprintf("task key %q value %q is not one of the available choices", key, value))
|
|
135
|
+
}
|
|
136
|
+
if err := setRegistrationValue(out, key, value); err != nil {
|
|
137
|
+
return nil, err
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for _, field := range reg.Fields {
|
|
141
|
+
if field.Derived {
|
|
142
|
+
if err := setRegistrationValue(out, field.Key, repoName); err != nil {
|
|
143
|
+
return nil, err
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if registrationValue(out, field.Key) == "" {
|
|
148
|
+
return nil, errorsForRegistration(fmt.Sprintf("missing required task key %q", field.Key))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out, nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func errorsForRegistration(message string) error { return fmt.Errorf("%s", message) }
|
|
155
|
+
|
|
156
|
+
func containsRegistrationOption(options []string, value string) bool {
|
|
157
|
+
for _, option := range options {
|
|
158
|
+
if option == value {
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func registrationValue(values config.RawValues, key string) string {
|
|
166
|
+
parts := strings.Split(key, ".")
|
|
167
|
+
var current any = map[string]any(values)
|
|
168
|
+
for _, part := range parts {
|
|
169
|
+
var next map[string]any
|
|
170
|
+
switch value := current.(type) {
|
|
171
|
+
case map[string]any:
|
|
172
|
+
next = value
|
|
173
|
+
case config.RawValues:
|
|
174
|
+
next = map[string]any(value)
|
|
175
|
+
default:
|
|
176
|
+
return ""
|
|
177
|
+
}
|
|
178
|
+
value, ok := next[part]
|
|
179
|
+
if !ok {
|
|
180
|
+
return ""
|
|
181
|
+
}
|
|
182
|
+
current = value
|
|
183
|
+
}
|
|
184
|
+
value, _ := current.(string)
|
|
185
|
+
return strings.TrimSpace(value)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func setRegistrationValue(values config.RawValues, key, value string) error {
|
|
189
|
+
parts := strings.Split(key, ".")
|
|
190
|
+
if len(parts) == 0 || parts[0] == "" {
|
|
191
|
+
return errorsForRegistration("task plugin returned an invalid registration key")
|
|
192
|
+
}
|
|
193
|
+
current := values
|
|
194
|
+
for _, part := range parts[:len(parts)-1] {
|
|
195
|
+
if part == "" {
|
|
196
|
+
return errorsForRegistration(fmt.Sprintf("task plugin returned an invalid registration key %q", key))
|
|
197
|
+
}
|
|
198
|
+
existing, ok := current[part]
|
|
199
|
+
if !ok {
|
|
200
|
+
nested := map[string]any{}
|
|
201
|
+
current[part] = nested
|
|
202
|
+
current = nested
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
var nested map[string]any
|
|
206
|
+
switch typed := existing.(type) {
|
|
207
|
+
case map[string]any:
|
|
208
|
+
nested = typed
|
|
209
|
+
case config.RawValues:
|
|
210
|
+
nested = map[string]any(typed)
|
|
211
|
+
current[part] = nested
|
|
212
|
+
default:
|
|
213
|
+
return errorsForRegistration(fmt.Sprintf("task registration key %q conflicts with another value", key))
|
|
214
|
+
}
|
|
215
|
+
current = nested
|
|
216
|
+
}
|
|
217
|
+
current[parts[len(parts)-1]] = value
|
|
218
|
+
return nil
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func registerSelectedReposDynamic(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, reg task.Registration, values kvFlags) error {
|
|
222
|
+
for _, index := range selected {
|
|
223
|
+
candidate := candidates[index]
|
|
224
|
+
taskCfg, err := registrationTaskConfig(reg, values, candidate.Name)
|
|
225
|
+
if err != nil {
|
|
226
|
+
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
227
|
+
}
|
|
228
|
+
info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: candidate.Name, Path: candidate.Path, TaskConfig: taskCfg})
|
|
229
|
+
if err != nil {
|
|
230
|
+
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
231
|
+
}
|
|
232
|
+
fmt.Println(info.Name)
|
|
233
|
+
}
|
|
234
|
+
return nil
|
|
235
|
+
}
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -565,8 +565,8 @@ func (d *serveDeps) RegisterNodeSession(ctx context.Context, registration runsvc
|
|
|
565
565
|
func (d *serveDeps) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
|
|
566
566
|
return d.repos.Discover(ctx)
|
|
567
567
|
}
|
|
568
|
-
func (d *serveDeps)
|
|
569
|
-
return d.repos.
|
|
568
|
+
func (d *serveDeps) TaskRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
569
|
+
return d.repos.RegistrationFields(ctx, values)
|
|
570
570
|
}
|
|
571
571
|
func (d *serveDeps) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
|
|
572
572
|
info, err := d.repos.Register(ctx, input)
|
|
@@ -101,6 +101,13 @@ repos:
|
|
|
101
101
|
# relay-flow repo name during repo registration.
|
|
102
102
|
project: PAY
|
|
103
103
|
component: payments
|
|
104
|
+
# These must be valid statuses in the PAY project. Interactive
|
|
105
|
+
# registration discovers them; scripted registration passes the same
|
|
106
|
+
# three keys with --set.
|
|
107
|
+
statusDefaults:
|
|
108
|
+
start: To Do
|
|
109
|
+
work: In Progress
|
|
110
|
+
end: Done
|
|
104
111
|
|
|
105
112
|
# Optional repo-level overrides inherit into workflows and nodes.
|
|
106
113
|
# assignee: relay-bot@example.com
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.2.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.8-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
package/internal/repo/service.go
CHANGED
|
@@ -79,6 +79,12 @@ func (s *Service) RequiredRepoKeys() []string {
|
|
|
79
79
|
return keys
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// RegistrationFields returns task-plugin-owned registration prompts and
|
|
83
|
+
// choices. Core repo management does not interpret the returned keys.
|
|
84
|
+
func (s *Service) RegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
85
|
+
return task.RegistrationFields(ctx, s.taskPlugin, values)
|
|
86
|
+
}
|
|
87
|
+
|
|
82
88
|
type RegisterInput struct {
|
|
83
89
|
Name string
|
|
84
90
|
Path string
|
|
@@ -116,6 +122,13 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
|
|
|
116
122
|
return Info{}, fmt.Errorf("repo %q: required task config key %q is missing", input.Name, k)
|
|
117
123
|
}
|
|
118
124
|
}
|
|
125
|
+
if err := task.ValidateRegistration(ctx, s.taskPlugin, task.RepoRegistrationSpec{
|
|
126
|
+
Name: input.Name,
|
|
127
|
+
RootConfig: cfg.TaskConfig,
|
|
128
|
+
RepoConfig: input.TaskConfig,
|
|
129
|
+
}); err != nil {
|
|
130
|
+
return Info{}, fmt.Errorf("repo %q: %w", input.Name, err)
|
|
131
|
+
}
|
|
119
132
|
// Duplicate registration identity: compute this repo's identity and compare
|
|
120
133
|
// against every registered repo. Most plugins use their physical task scope;
|
|
121
134
|
// Beads includes its derived repository label so distinct repositories may
|