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.
@@ -264,7 +264,10 @@ func (errInvalidRepo) Error() string { return "invalid repo" }
264
264
 
265
265
  func TestRegisterAtomicallyPersists(t *testing.T) {
266
266
  fx := newServiceFixture(t, &fakeRunnerDiscovery{})
267
- if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
267
+ statusDefaults := map[string]any{"start": "Open", "work": "Working", "end": "Closed"}
268
+ if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{
269
+ "project": "PAY", "component": "api", "statusDefaults": statusDefaults,
270
+ }}); err != nil {
268
271
  t.Fatal(err)
269
272
  }
270
273
  cfg, err := config.LoadMachine(fx.cfgPath)
@@ -278,6 +281,16 @@ func TestRegisterAtomicallyPersists(t *testing.T) {
278
281
  if r.TaskConfig["project"] != "PAY" {
279
282
  t.Fatalf("repo taskConfig not persisted: %+v", r.TaskConfig)
280
283
  }
284
+ var gotDefaults map[string]any
285
+ switch values := r.TaskConfig["statusDefaults"].(type) {
286
+ case map[string]any:
287
+ gotDefaults = values
288
+ case config.RawValues:
289
+ gotDefaults = map[string]any(values)
290
+ }
291
+ if gotDefaults == nil || gotDefaults["start"] != "Open" || gotDefaults["work"] != "Working" || gotDefaults["end"] != "Closed" {
292
+ t.Fatalf("repo statusDefaults not persisted: %#v", r.TaskConfig["statusDefaults"])
293
+ }
281
294
  }
282
295
 
283
296
  func TestRegisterSetsUpHarnessRepo(t *testing.T) {
@@ -2,15 +2,19 @@ package server_test
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "context"
5
6
  "encoding/json"
6
7
  "fmt"
7
8
  "io"
8
9
  "net/http"
10
+ "path/filepath"
9
11
  "strings"
10
12
  "testing"
11
13
  "time"
12
14
 
15
+ "github.com/rajpopat27/relay-flow/internal/config"
13
16
  "github.com/rajpopat27/relay-flow/internal/run"
17
+ "github.com/rajpopat27/relay-flow/internal/server"
14
18
  "github.com/rajpopat27/relay-flow/internal/task"
15
19
  "github.com/rajpopat27/relay-flow/internal/workflow"
16
20
  )
@@ -287,6 +291,48 @@ func TestRunEndpointsExposeRetryDetails(t *testing.T) {
287
291
  }
288
292
  }
289
293
 
294
+ func TestRepoTaskFieldsSupportsInitialGetAndDependentPost(t *testing.T) {
295
+ fake := &fakeServices{}
296
+ c, cleanup := startHandler(t, fake)
297
+ defer cleanup()
298
+
299
+ code, env := do(t, c, http.MethodGet, "http://relay/repos/task-fields", nil)
300
+ if code != http.StatusOK || !env.OK || !bytes.Contains(env.Data, []byte(`"fields"`)) {
301
+ t.Fatalf("GET /repos/task-fields: code=%d env=%+v", code, env)
302
+ }
303
+ code, env = do(t, c, http.MethodPost, "http://relay/repos/task-fields", []byte(`{"values":{"project":"PAY"}}`))
304
+ if code != http.StatusOK || !env.OK || len(fake.registrationValues) != 2 {
305
+ t.Fatalf("POST /repos/task-fields: code=%d env=%+v values=%v", code, env, fake.registrationValues)
306
+ }
307
+ if fake.registrationValues[1]["project"] != "PAY" {
308
+ t.Fatalf("dependent registration values = %#v", fake.registrationValues[1])
309
+ }
310
+ }
311
+
312
+ func TestClientRepoRegistrationFieldsUsesGetThenPost(t *testing.T) {
313
+ fake := &fakeServices{}
314
+ dir := t.TempDir()
315
+ _, cleanup := startHandlerOnSocket(t, dir, fake)
316
+ defer cleanup()
317
+ client := server.NewClient(filepath.Join(dir, "server.sock"))
318
+ keys, err := client.RepoTaskFields(context.Background())
319
+ if err != nil || len(keys) != 2 || keys[0] != "project" || keys[1] != "component" {
320
+ t.Fatalf("documented task fields = %v, err=%v", keys, err)
321
+ }
322
+
323
+ initial, err := client.RepoRegistrationFields(context.Background(), nil)
324
+ if err != nil || len(initial.Fields) != 2 {
325
+ t.Fatalf("initial registration fields = %#v, err=%v", initial, err)
326
+ }
327
+ dependent, err := client.RepoRegistrationFields(context.Background(), config.RawValues{"project": "PAY"})
328
+ if err != nil || len(dependent.Fields) != 2 {
329
+ t.Fatalf("dependent registration fields = %#v, err=%v", dependent, err)
330
+ }
331
+ if len(fake.registrationValues) != 3 || fake.registrationValues[0] != nil || fake.registrationValues[1] != nil || fake.registrationValues[2]["project"] != "PAY" {
332
+ t.Fatalf("registration request values = %#v", fake.registrationValues)
333
+ }
334
+ }
335
+
290
336
  func TestRepoOperations(t *testing.T) {
291
337
  c, cleanup := startHandler(t, &fakeServices{})
292
338
  defer cleanup()
@@ -10,9 +10,11 @@ import (
10
10
  "net/http"
11
11
  "net/url"
12
12
 
13
+ "github.com/rajpopat27/relay-flow/internal/config"
13
14
  "github.com/rajpopat27/relay-flow/internal/repo"
14
15
  "github.com/rajpopat27/relay-flow/internal/run"
15
16
  "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/task"
16
18
  "github.com/rajpopat27/relay-flow/internal/workflow"
17
19
  )
18
20
 
@@ -134,8 +136,7 @@ func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, err
134
136
  return out, nil
135
137
  }
136
138
 
137
- // RepoTaskFields returns the task factory's required repo keys, matching
138
- // GET /repos/task-fields (server wraps the list as {"fields": [...]}).
139
+ // RepoTaskFields returns the documented initial required-key metadata.
139
140
  func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
140
141
  var out struct {
141
142
  Fields []string `json:"fields"`
@@ -146,6 +147,33 @@ func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
146
147
  return out.Fields, nil
147
148
  }
148
149
 
150
+ // RepoRegistrationFields returns task-plugin-owned repository prompts. Values
151
+ // are flat registration inputs so plugins can discover dependent choices (for
152
+ // example project statuses after project is selected). Initial discovery uses
153
+ // the documented GET endpoint; dependent discovery uses POST.
154
+ func (c *Client) RepoRegistrationFields(ctx context.Context, values config.RawValues) (task.Registration, error) {
155
+ if len(values) == 0 {
156
+ var out struct {
157
+ Registration task.Registration `json:"registration"`
158
+ }
159
+ if err := c.call(ctx, http.MethodGet, "/repos/task-fields", nil, &out); err != nil {
160
+ return task.Registration{}, err
161
+ }
162
+ return out.Registration, nil
163
+ }
164
+ payload, err := json.Marshal(struct {
165
+ Values config.RawValues `json:"values,omitempty"`
166
+ }{Values: values})
167
+ if err != nil {
168
+ return task.Registration{}, err
169
+ }
170
+ var out task.Registration
171
+ if err := c.call(ctx, http.MethodPost, "/repos/task-fields", payload, &out); err != nil {
172
+ return task.Registration{}, err
173
+ }
174
+ return out, nil
175
+ }
176
+
149
177
  // RegisterRepo registers a repo by name/path with optional task config.
150
178
  func (c *Client) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
151
179
  payload, _ := json.Marshal(input)
@@ -12,10 +12,12 @@ import (
12
12
  "path/filepath"
13
13
  "testing"
14
14
 
15
+ "github.com/rajpopat27/relay-flow/internal/config"
15
16
  "github.com/rajpopat27/relay-flow/internal/repo"
16
17
  "github.com/rajpopat27/relay-flow/internal/run"
17
18
  "github.com/rajpopat27/relay-flow/internal/runner"
18
19
  "github.com/rajpopat27/relay-flow/internal/server"
20
+ "github.com/rajpopat27/relay-flow/internal/task"
19
21
  "github.com/rajpopat27/relay-flow/internal/workflow"
20
22
  )
21
23
 
@@ -23,20 +25,21 @@ import (
23
25
  // seams. activeWorkflows seeds workflows with an active run (drives 409);
24
26
  // failRepos forces a 500; slowReport simulates a long-running report call.
25
27
  type fakeServices struct {
26
- activeWorkflows map[string]bool
27
- failRepos bool
28
- slowReport chan struct{}
29
- workflows map[string]*workflow.Workflow
30
- repos map[string]repo.Info
31
- runs []run.Run
32
- shutdownCh chan struct{}
33
- registrations []run.NodeRuntimeRegistration
34
- runtimeAck run.NodeRuntimeRegistrationAck
35
- processedReports map[string]bool
36
- submittedReports int
37
- restartRun run.Run
38
- restartErr error
39
- restarts []string
28
+ activeWorkflows map[string]bool
29
+ failRepos bool
30
+ slowReport chan struct{}
31
+ workflows map[string]*workflow.Workflow
32
+ repos map[string]repo.Info
33
+ runs []run.Run
34
+ shutdownCh chan struct{}
35
+ registrations []run.NodeRuntimeRegistration
36
+ runtimeAck run.NodeRuntimeRegistrationAck
37
+ processedReports map[string]bool
38
+ submittedReports int
39
+ restartRun run.Run
40
+ restartErr error
41
+ restarts []string
42
+ registrationValues []config.RawValues
40
43
  }
41
44
 
42
45
  func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
@@ -152,8 +155,9 @@ func (f *fakeServices) DiscoverRepos(context.Context) ([]runner.RepoCandidate, e
152
155
  return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
153
156
  }
154
157
 
155
- func (f *fakeServices) TaskFields(context.Context) ([]string, error) {
156
- return []string{"status", "labels"}, nil
158
+ func (f *fakeServices) TaskRegistrationFields(_ context.Context, values config.RawValues) ([]task.RegistrationField, error) {
159
+ f.registrationValues = append(f.registrationValues, values)
160
+ return []task.RegistrationField{{Key: "project", Title: "Project"}, {Key: "component", Title: "Component", Derived: true}}, nil
157
161
  }
158
162
 
159
163
  func (f *fakeServices) RegisterRepo(_ context.Context, input repo.RegisterInput) (repo.Info, error) {
@@ -12,9 +12,11 @@ import (
12
12
  "net/http"
13
13
  "strings"
14
14
 
15
+ "github.com/rajpopat27/relay-flow/internal/config"
15
16
  "github.com/rajpopat27/relay-flow/internal/repo"
16
17
  "github.com/rajpopat27/relay-flow/internal/run"
17
18
  "github.com/rajpopat27/relay-flow/internal/runner"
19
+ "github.com/rajpopat27/relay-flow/internal/task"
18
20
  "github.com/rajpopat27/relay-flow/internal/workflow"
19
21
  )
20
22
 
@@ -41,7 +43,7 @@ type Deps interface {
41
43
 
42
44
  // Repos
43
45
  DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error)
44
- TaskFields(ctx context.Context) ([]string, error)
46
+ TaskRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error)
45
47
  RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error)
46
48
  ListRepos(ctx context.Context) ([]repo.Info, error)
47
49
  GetRepo(ctx context.Context, name string) (repo.Info, error)
@@ -246,15 +248,48 @@ func (s *server) handleReposDiscover(w http.ResponseWriter, r *http.Request) {
246
248
  }
247
249
 
248
250
  func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
249
- if !methodOnly(w, r, http.MethodGet) {
251
+ if r.Method == http.MethodGet {
252
+ fields, err := s.deps.TaskRegistrationFields(r.Context(), nil)
253
+ if err != nil {
254
+ mapErr(w, err)
255
+ return
256
+ }
257
+ keys := make([]string, 0, len(fields))
258
+ for _, field := range fields {
259
+ keys = append(keys, field.Key)
260
+ }
261
+ // Keep the documented fields list for callers that only need the
262
+ // required keys, and include plugin-owned metadata for the CLI's
263
+ // initial prompt construction.
264
+ writeOK(w, http.StatusOK, map[string]any{
265
+ "fields": keys,
266
+ "registration": task.Registration{Fields: fields},
267
+ })
268
+ return
269
+ }
270
+ if !methodOnly(w, r, http.MethodPost) {
271
+ return
272
+ }
273
+ body, err := readBody(r)
274
+ if err != nil {
275
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
250
276
  return
251
277
  }
252
- fields, err := s.deps.TaskFields(r.Context())
278
+ var payload struct {
279
+ Values config.RawValues `json:"values,omitempty"`
280
+ }
281
+ if len(strings.TrimSpace(string(body))) > 0 {
282
+ if err := decodeStrict(body, &payload); err != nil {
283
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
284
+ return
285
+ }
286
+ }
287
+ fields, err := s.deps.TaskRegistrationFields(r.Context(), payload.Values)
253
288
  if err != nil {
254
289
  mapErr(w, err)
255
290
  return
256
291
  }
257
- writeOK(w, http.StatusOK, map[string]any{"fields": fields})
292
+ writeOK(w, http.StatusOK, task.Registration{Fields: fields})
258
293
  }
259
294
 
260
295
  func (s *server) handleRepos(w http.ResponseWriter, r *http.Request) {
@@ -81,10 +81,15 @@ func DefaultConfig() config.RawValues {
81
81
  }}
82
82
  }
83
83
 
84
+ func registrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
85
+ return []task.RegistrationField{{Key: "beadsDir", Title: "Beads workspace"}}, nil
86
+ }
87
+
84
88
  func init() {
85
89
  task.Register("beads", task.Factory{
86
- RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
- TaskScopeKey: beadsTaskScopeKey,
90
+ RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
91
+ RegistrationFields: registrationFields,
92
+ TaskScopeKey: beadsTaskScopeKey,
88
93
  RegistrationKey: func(spec task.RepoRegistrationSpec) (string, error) {
89
94
  label, err := RepositoryLabel(spec.Name)
90
95
  if err != nil {
@@ -29,6 +29,19 @@ func TestBeadsFactoryIsRegisteredWithBeadsDirRequirement(t *testing.T) {
29
29
  }
30
30
  }
31
31
 
32
+ func TestBeadsRegistrationDoesNotExposeJiraStatusFields(t *testing.T) {
33
+ fields, err := task.RegistrationFields(context.Background(), "beads", nil)
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if len(fields) != 1 || fields[0].Key != "beadsDir" {
38
+ t.Fatalf("registration fields = %#v, want only beadsDir", fields)
39
+ }
40
+ if len(fields[0].Options) != 0 || fields[0].Default != "" {
41
+ t.Fatalf("Beads registration field unexpectedly has status choices: %#v", fields[0])
42
+ }
43
+ }
44
+
32
45
  func TestBeadsConfigRejectsUnknownFields(t *testing.T) {
33
46
  for _, field := range []string{"unknownField", "project", "component"} {
34
47
  t.Run(field, func(t *testing.T) {
@@ -36,13 +36,15 @@ type RepoRegistrationSpec struct {
36
36
  // the complete registration identity when a plugin permits sharing a
37
37
  // physical scope with distinct logical repositories.
38
38
  type Factory struct {
39
- RequiredRepoKeys func() []string
40
- TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
41
- RegistrationKey func(RepoRegistrationSpec) (string, error)
42
- Auth func(context.Context, []string, io.Reader) error
43
- DefaultConfig func() config.RawValues
44
- ValidateTextConfig func(config.RawValues) error
45
- New func(context.Context, RepoSpec) (System, error)
39
+ RequiredRepoKeys func() []string
40
+ TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
41
+ RegistrationKey func(RepoRegistrationSpec) (string, error)
42
+ RegistrationFields func(context.Context, config.RawValues) ([]RegistrationField, error)
43
+ ValidateRegistration func(context.Context, RepoRegistrationSpec) error
44
+ Auth func(context.Context, []string, io.Reader) error
45
+ DefaultConfig func() config.RawValues
46
+ ValidateTextConfig func(config.RawValues) error
47
+ New func(context.Context, RepoSpec) (System, error)
46
48
  }
47
49
 
48
50
  var (
@@ -75,7 +75,15 @@ func TestAuthWritesLoadsAndNewUsesJiraOwnedCredentials(t *testing.T) {
75
75
  }
76
76
  if _, err := task.New(context.Background(), "jira", task.RepoSpec{
77
77
  Name: "payments", RootConfig: machine.TaskConfig,
78
- RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
78
+ RepoConfig: config.RawValues{
79
+ "project": "PAY",
80
+ "component": "api",
81
+ "statusDefaults": map[string]any{
82
+ "start": defaultStartParentStatus,
83
+ "work": defaultWorkTaskStatus,
84
+ "end": defaultEndParentStatus,
85
+ },
86
+ },
79
87
  }); err != nil {
80
88
  t.Fatalf("task.New did not load Jira-owned credentials: %v", err)
81
89
  }
@@ -284,7 +284,7 @@ func TestCompileFilterDoesNotUseEffectiveAssigneeAsFilter(t *testing.T) {
284
284
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
285
285
  Name: "payments",
286
286
  RootConfig: config.RawValues{"assignee": "root@example.com"},
287
- RepoConfig: config.RawValues{"project": "PAY", "component": "api", "assignee": "Repo.Bot@Example.com"},
287
+ RepoConfig: testRepoConfig(config.RawValues{"assignee": "Repo.Bot@Example.com"}),
288
288
  })
289
289
  if err != nil {
290
290
  t.Fatal(err)
@@ -304,7 +304,7 @@ func TestCompileFilterWorkflowAssigneesOverrideEffectiveAssignee(t *testing.T) {
304
304
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
305
305
  Name: "payments",
306
306
  RootConfig: config.RawValues{"assignee": "root@example.com"},
307
- RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
307
+ RepoConfig: testRepoConfig(nil),
308
308
  })
309
309
  if err != nil {
310
310
  t.Fatal(err)
@@ -3,6 +3,7 @@ package jira
3
3
  import (
4
4
  "context"
5
5
 
6
+ "github.com/rajpopat27/relay-flow/internal/config"
6
7
  "github.com/rajpopat27/relay-flow/internal/task"
7
8
  "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
8
9
  )
@@ -67,6 +68,18 @@ func (notFakedError) Error() string { return "not faked" }
67
68
 
68
69
  var errNotFaked = notFakedError{}
69
70
 
71
+ func testStatusDefaults() config.RawValues {
72
+ return config.RawValues{"statusDefaults": map[string]any{
73
+ "start": defaultStartParentStatus,
74
+ "work": defaultWorkTaskStatus,
75
+ "end": defaultEndParentStatus,
76
+ }}
77
+ }
78
+
79
+ func testRepoConfig(values config.RawValues) config.RawValues {
80
+ return config.Merge(config.RawValues{"project": "PAY", "component": "api"}, testStatusDefaults(), values)
81
+ }
82
+
70
83
  // newSystemForTest builds the adapter around the test-local REST seam.
71
84
  func newSystemForTest(fake *fakeJira) (task.System, error) {
72
85
  return newSystemForCLI(&fakeClient{fake: fake})