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 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.7-alpha
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.7-alpha
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.7-alpha"]
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.7-alpha"]
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.7-alpha
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, use `relay-flow repo register --name <name> --path <path> --set project=<project>`. Component is always derived from `--name` and cannot be overridden. Registration is rejected while another repo already holds the same canonical task scope.
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) TaskFields(context.Context) ([]string, error) {
643
+ func (s *registerServer) TaskRegistrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
644
644
  s.calls++
645
- return s.fields, nil
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 TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
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
- if got := registrationSharedFields([]string{"project", "component"}); fmt.Sprint(got) != "[project]" {
787
- t.Fatalf("prompted fields = %v, want project only", got)
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 := registerSelectedRepos(context.Background(), client, candidates, selected,
798
- []string{"project", "component"}, kvFlags{"project": "PAY"}); err != nil {
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
- err := registerSelectedRepos(context.Background(), client, candidates, []int{0, 1, 2},
822
- []string{"project", "component"}, kvFlags{"project": "PAY"})
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) TaskFields(context.Context) ([]string, error) { panic("unreachable") }
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
  }
@@ -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. Jira project is shared and component is always the repo name.
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
- if _, ok := sets["component"]; ok {
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 := repoTaskConfig(fields, sets, flagName)
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
- fields, err := c.RepoTaskFields(ctx)
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
- sharedFields := registrationSharedFields(fields)
868
- values := make([]string, len(sharedFields))
869
- inputs := make([]huh.Field, len(sharedFields))
870
- for i, field := range sharedFields {
871
- title := field
872
- if field == "project" {
873
- title = "Jira project"
874
- }
875
- inputs[i] = huh.NewInput().Title(title).Value(&values[i])
876
- }
877
- if len(inputs) > 0 {
878
- if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
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
- shared := kvFlags{}
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
+ }
@@ -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) TaskFields(context.Context) ([]string, error) {
569
- return d.repos.RequiredRepoKeys(), nil
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.7-alpha"
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")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.2.7-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.8-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -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