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.
@@ -35,6 +35,14 @@ type CreatedSubtask struct {
35
35
  Key string
36
36
  }
37
37
 
38
+ // StatusProvider discovers the actual statuses configured for a Jira project.
39
+ // It is intentionally separate from Client so existing adapter test doubles
40
+ // and replaceable REST clients do not need provider-specific discovery unless
41
+ // registration needs it.
42
+ type StatusProvider interface {
43
+ Statuses(context.Context, string) ([]string, error)
44
+ }
45
+
38
46
  type Client interface {
39
47
  ValidateCredentials(context.Context) error
40
48
  Search(context.Context, string) ([]byte, error)
@@ -70,6 +78,7 @@ type HTTPClient struct {
70
78
  transitions map[string]transitionInfo
71
79
  accounts map[string]string
72
80
  statuses map[string]map[string]bool
81
+ statusNames map[string][]string
73
82
  subtaskTypes map[string]string
74
83
  }
75
84
 
@@ -97,6 +106,7 @@ func New(site, email, token string) (*HTTPClient, error) {
97
106
  transitions: map[string]transitionInfo{},
98
107
  accounts: map[string]string{},
99
108
  statuses: map[string]map[string]bool{},
109
+ statusNames: map[string][]string{},
100
110
  subtaskTypes: map[string]string{},
101
111
  }, nil
102
112
  }
@@ -186,43 +196,72 @@ func (c *HTTPClient) accountID(ctx context.Context, project, assignee string) (s
186
196
  return "", fmt.Errorf("assignee %q is not assignable in project %s", assignee, project)
187
197
  }
188
198
 
199
+ // Statuses returns the status names configured for all issue types in a Jira
200
+ // project, preserving Jira's response order and removing case-insensitive
201
+ // duplicates. The same discovery populates the subtask issue-type cache used
202
+ // when mailboxes are created.
203
+ func (c *HTTPClient) Statuses(ctx context.Context, project string) ([]string, error) {
204
+ _, names, err := c.loadStatuses(ctx, project)
205
+ if err != nil {
206
+ return nil, err
207
+ }
208
+ return append([]string(nil), names...), nil
209
+ }
210
+
189
211
  func (c *HTTPClient) ValidateStatus(ctx context.Context, project, status string) error {
212
+ statuses, _, err := c.loadStatuses(ctx, project)
213
+ if err != nil {
214
+ return err
215
+ }
216
+ if !statuses[strings.ToLower(status)] {
217
+ return fmt.Errorf("status %q is not valid in project %s", status, project)
218
+ }
219
+ return nil
220
+ }
221
+
222
+ func (c *HTTPClient) loadStatuses(ctx context.Context, project string) (map[string]bool, []string, error) {
190
223
  c.mu.Lock()
191
224
  statuses, ok := c.statuses[project]
225
+ names := append([]string(nil), c.statusNames[project]...)
192
226
  c.mu.Unlock()
193
- if !ok {
194
- var issueTypes []struct {
195
- ID string `json:"id"`
196
- Subtask bool `json:"subtask"`
197
- Statuses []struct {
198
- Name string `json:"name"`
199
- } `json:"statuses"`
200
- }
201
- path := "/rest/api/3/project/" + url.PathEscape(project) + "/statuses"
202
- if err := c.request(ctx, http.MethodGet, path, nil, nil, &issueTypes, true); err != nil {
203
- return err
204
- }
205
- statuses = map[string]bool{}
206
- subtaskType := ""
207
- for _, issueType := range issueTypes {
208
- if issueType.Subtask && subtaskType == "" {
209
- subtaskType = issueType.ID
210
- }
211
- for _, candidate := range issueType.Statuses {
212
- statuses[strings.ToLower(candidate.Name)] = true
227
+ if ok {
228
+ return statuses, names, nil
229
+ }
230
+ var issueTypes []struct {
231
+ ID string `json:"id"`
232
+ Subtask bool `json:"subtask"`
233
+ Statuses []struct {
234
+ Name string `json:"name"`
235
+ } `json:"statuses"`
236
+ }
237
+ path := "/rest/api/3/project/" + url.PathEscape(project) + "/statuses"
238
+ if err := c.request(ctx, http.MethodGet, path, nil, nil, &issueTypes, true); err != nil {
239
+ return nil, nil, err
240
+ }
241
+ statuses = map[string]bool{}
242
+ names = make([]string, 0)
243
+ subtaskType := ""
244
+ for _, issueType := range issueTypes {
245
+ if issueType.Subtask && subtaskType == "" {
246
+ subtaskType = issueType.ID
247
+ }
248
+ for _, candidate := range issueType.Statuses {
249
+ name := strings.TrimSpace(candidate.Name)
250
+ if name == "" || statuses[strings.ToLower(name)] {
251
+ continue
213
252
  }
253
+ statuses[strings.ToLower(name)] = true
254
+ names = append(names, name)
214
255
  }
215
- c.mu.Lock()
216
- c.statuses[project] = statuses
217
- if subtaskType != "" {
218
- c.subtaskTypes[project] = subtaskType
219
- }
220
- c.mu.Unlock()
221
256
  }
222
- if !statuses[strings.ToLower(status)] {
223
- return fmt.Errorf("status %q is not valid in project %s", status, project)
257
+ c.mu.Lock()
258
+ c.statuses[project] = statuses
259
+ c.statusNames[project] = append([]string(nil), names...)
260
+ if subtaskType != "" {
261
+ c.subtaskTypes[project] = subtaskType
224
262
  }
225
- return nil
263
+ c.mu.Unlock()
264
+ return statuses, names, nil
226
265
  }
227
266
 
228
267
  func (c *HTTPClient) View(ctx context.Context, key string) ([]byte, error) {
@@ -251,7 +290,7 @@ func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label
251
290
  subtaskType := c.subtaskTypes[project]
252
291
  c.mu.Unlock()
253
292
  if subtaskType == "" {
254
- if err := c.ValidateStatus(ctx, project, "To Do"); err != nil {
293
+ if _, _, err := c.loadStatuses(ctx, project); err != nil {
255
294
  return nil, err
256
295
  }
257
296
  c.mu.Lock()
@@ -290,7 +329,10 @@ func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label
290
329
  }
291
330
  created = append(created, result.Issues...)
292
331
  for _, issue := range result.Issues {
293
- c.setIssueState(issue.Key, "Sub-task", "To Do")
332
+ // Jira chooses the subtask's initial status from the project
333
+ // workflow. Leave the status unknown so the first transition reads
334
+ // the actual state instead of assuming a conventional name.
335
+ c.setIssueState(issue.Key, "Sub-task", "")
294
336
  }
295
337
  }
296
338
  return created, nil
@@ -7,6 +7,7 @@ import (
7
7
  "io"
8
8
  "net/http"
9
9
  "net/http/httptest"
10
+ "reflect"
10
11
  "strings"
11
12
  "sync"
12
13
  "testing"
@@ -93,6 +94,30 @@ func TestNewRejectsNonHTTPSRemoteSite(t *testing.T) {
93
94
  }
94
95
  }
95
96
 
97
+ func TestStatusesDiscoversProjectWorkflowStatuses(t *testing.T) {
98
+ s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
99
+ if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/project/PAY/statuses" {
100
+ http.NotFound(w, r)
101
+ return
102
+ }
103
+ writeJSON(w, []any{
104
+ map[string]any{"id": "100", "subtask": true, "statuses": []any{
105
+ map[string]any{"name": "Open"},
106
+ map[string]any{"name": "Working"},
107
+ map[string]any{"name": "Closed"},
108
+ }},
109
+ })
110
+ })
111
+ statuses, err := s.client(t).Statuses(context.Background(), "PAY")
112
+ if err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ want := []string{"Open", "Working", "Closed"}
116
+ if !reflect.DeepEqual(statuses, want) {
117
+ t.Fatalf("statuses = %v, want %v", statuses, want)
118
+ }
119
+ }
120
+
96
121
  func TestSearchPaginatesAndRequestsIssueLinks(t *testing.T) {
97
122
  s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
98
123
  if r.URL.Path != "/rest/api/3/search/jql" || r.Method != http.MethodPost {
@@ -0,0 +1,134 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/config"
10
+ "github.com/rajpopat27/relay-flow/internal/task"
11
+ )
12
+
13
+ func TestStatusRegistrationDefaultsOnlySelectAvailableConventions(t *testing.T) {
14
+ withConventions := statusRegistrationField("statusDefaults.start", "Start", []string{"Open", "In Progress", "Closed"}, defaultStartParentStatus)
15
+ if withConventions.Default != defaultStartParentStatus {
16
+ t.Fatalf("default = %q, want %q", withConventions.Default, defaultStartParentStatus)
17
+ }
18
+ withoutConventions := statusRegistrationField("statusDefaults.end", "End", []string{"Open", "Working", "Closed"}, defaultEndParentStatus)
19
+ if withoutConventions.Default != "" {
20
+ t.Fatalf("missing conventional default = %q, want empty", withoutConventions.Default)
21
+ }
22
+ if !reflect.DeepEqual(withoutConventions.Options, []string{"Open", "Working", "Closed"}) {
23
+ t.Fatalf("options = %v", withoutConventions.Options)
24
+ }
25
+ }
26
+
27
+ func TestRepositoryStatusDefaultsDriveLifecycleAndMailboxCompletion(t *testing.T) {
28
+ fake := &fakeJira{}
29
+ sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
30
+ Name: "payments",
31
+ RepoConfig: config.RawValues{
32
+ "project": "PAY",
33
+ "component": "api",
34
+ "statusDefaults": map[string]any{
35
+ "start": "Open",
36
+ "work": "Working",
37
+ "end": "Closed",
38
+ },
39
+ },
40
+ })
41
+ if err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ defaults := task.LifecycleDefaults(sys)
45
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
46
+ mailbox := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
47
+
48
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, defaults.StartDefaults()); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mailbox}, defaults.WorkDefaults()); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if err := sys.CompleteMailbox(context.Background(), mailbox); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Open" {
58
+ t.Fatalf("parent transitions = %v, want [Open]", fake.parentTransitions)
59
+ }
60
+ if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "Working" || fake.taskTransitions[1] != "Closed" {
61
+ t.Fatalf("mailbox transitions = %v, want [Working Closed]", fake.taskTransitions)
62
+ }
63
+ }
64
+
65
+ func TestJiraRegistrationRejectsComponentOverride(t *testing.T) {
66
+ err := validateJiraRegistration(context.Background(), task.RepoRegistrationSpec{
67
+ Name: "payments",
68
+ RepoConfig: config.RawValues{
69
+ "project": "PAY",
70
+ "component": "other-component",
71
+ "statusDefaults": map[string]any{
72
+ "start": "Open", "work": "Working", "end": "Closed",
73
+ },
74
+ },
75
+ })
76
+ if err == nil || !strings.Contains(err.Error(), "must match repository name") {
77
+ t.Fatalf("component override error = %v", err)
78
+ }
79
+ }
80
+
81
+ func TestJiraConstructionRejectsRootStatusDefaults(t *testing.T) {
82
+ _, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
83
+ Name: "payments",
84
+ RootConfig: config.RawValues{"statusDefaults": map[string]any{}},
85
+ RepoConfig: testRepoConfig(nil),
86
+ })
87
+ if err == nil || !strings.Contains(err.Error(), "configured per repository") {
88
+ t.Fatalf("root statusDefaults error = %v", err)
89
+ }
90
+ }
91
+
92
+ func TestJiraValidateConfigRejectsNonRepositoryStatusDefaults(t *testing.T) {
93
+ sys := newSystemWithFake(t, &fakeJira{})
94
+ for _, tc := range []struct {
95
+ name string
96
+ node bool
97
+ }{
98
+ {name: "workflow"},
99
+ {name: "node", node: true},
100
+ } {
101
+ t.Run(tc.name, func(t *testing.T) {
102
+ workflowConfig := config.RawValues{}
103
+ nodeConfig := map[string]config.RawValues{}
104
+ if tc.node {
105
+ nodeConfig["coding"] = config.RawValues{"statusDefaults": map[string]any{}}
106
+ } else {
107
+ workflowConfig["statusDefaults"] = map[string]any{}
108
+ }
109
+ err := sys.ValidateConfig(context.Background(), workflowConfig, nodeConfig)
110
+ if err == nil || !strings.Contains(err.Error(), "configured per repository") {
111
+ t.Fatalf("validation error = %v", err)
112
+ }
113
+ })
114
+ }
115
+ }
116
+
117
+ func TestJiraConstructionRequiresCompleteRepositoryStatusDefaults(t *testing.T) {
118
+ for _, repoConfig := range []config.RawValues{
119
+ {"project": "PAY", "component": "api"},
120
+ {"project": "PAY", "component": "api", "statusDefaults": map[string]any{}},
121
+ {"project": "PAY", "component": "api", "statusDefaults": map[string]any{
122
+ "start": "Open", "work": "Working",
123
+ }},
124
+ } {
125
+ client := &validationClient{}
126
+ _, err := newSystem(context.Background(), client, task.RepoSpec{Name: "payments", RepoConfig: repoConfig})
127
+ if err == nil {
128
+ t.Fatalf("repo config %#v was accepted without complete status defaults", repoConfig)
129
+ }
130
+ if len(client.statuses) != 0 {
131
+ t.Fatalf("status probes = %v for invalid repository config, want none", client.statuses)
132
+ }
133
+ }
134
+ }
@@ -21,7 +21,7 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
21
21
  "summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
22
22
  "feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
23
23
  }},
24
- RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
24
+ RepoConfig: testRepoConfig(nil),
25
25
  })
26
26
  if err != nil {
27
27
  t.Fatal(err)
@@ -91,7 +91,7 @@ func TestTaskTemplatesRenderSharedReportContractValues(t *testing.T) {
91
91
  }
92
92
  fixture := fixtures["work"]
93
93
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
94
- Name: "payments", RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
94
+ Name: "payments", RepoConfig: testRepoConfig(nil),
95
95
  })
96
96
  if err != nil {
97
97
  t.Fatal(err)
@@ -10,11 +10,9 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/task"
11
11
  )
12
12
 
13
- // 3.5: Jira transition defaults per specs/workflow-definition "Jira
14
- // transition defaults are deterministic": omitted transitions default to
15
- // parent In Progress at start, mailbox In Progress at agent/HITL work
16
- // nodes, and parent Done at end; an omitted work-node parent transition
17
- // leaves the parent unchanged.
13
+ // Jira repository status defaults are deterministic: omitted transitions use
14
+ // the registered start/work/end values, and an omitted work-node parent
15
+ // transition leaves the parent unchanged.
18
16
  //
19
17
  // This test is package-local (package jira) so it can drive the adapter's
20
18
  // ApplyTaskConfig against a test-local fake client without inventing an
@@ -128,10 +126,8 @@ func TestEndDefaultParentDone(t *testing.T) {
128
126
  sys := newSystemWithFake(t, fake)
129
127
  parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
130
128
 
131
- // end: omitted transitionTo. The end effective config carries no
132
- // transition; the adapter must still default the parent to Done. Run
133
- // orchestration merges EndDefaults under the end node config before
134
- // applying it to the parent target.
129
+ // end: omitted transitionTo. Run orchestration merges the registered
130
+ // EndDefaults under the end node config before applying it to the parent.
135
131
  endCfg := config.Merge(sys.(task.LifecycleDefaults).EndDefaults(), config.RawValues{})
136
132
  if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endCfg); err != nil {
137
133
  t.Fatalf("ApplyTaskConfig end failed: %v", err)
@@ -156,8 +152,8 @@ func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
156
152
  if err := preparer.PrepareRestart(context.Background(), parent, mailboxes); err != nil {
157
153
  t.Fatalf("PrepareRestart failed: %v", err)
158
154
  }
159
- if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "To Do" || fake.taskTransitions[1] != "To Do" {
160
- t.Fatalf("mailbox transitions = %v, want two To Do transitions", fake.taskTransitions)
155
+ if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != defaultStartParentStatus || fake.taskTransitions[1] != defaultStartParentStatus {
156
+ t.Fatalf("mailbox transitions = %v, want two %s transitions", fake.taskTransitions, defaultStartParentStatus)
161
157
  }
162
158
  if len(fake.parentTransitions) != 0 {
163
159
  t.Fatalf("parent transitions = %v, want none (start owns parent status)", fake.parentTransitions)
@@ -165,7 +161,7 @@ func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
165
161
  }
166
162
 
167
163
  func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
168
- fake := &fakeJira{transitionErr: errors.New(`transition to "To Do" is not available for PAY-102`)}
164
+ fake := &fakeJira{transitionErr: errors.New(`transition to "In Progress" is not available for PAY-102`)}
169
165
  sys := newSystemWithFake(t, fake)
170
166
  preparer := sys.(task.RestartPreparer)
171
167
  err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "PAY-101"}, []task.Mailbox{
@@ -88,7 +88,9 @@ func TestValidateConfigProbesWorkflowAssignee(t *testing.T) {
88
88
 
89
89
  func taskSpec(root, repo config.RawValues) task.RepoSpec {
90
90
  if repo == nil {
91
- repo = config.RawValues{"project": "PAY", "component": "api"}
91
+ repo = testRepoConfig(nil)
92
+ } else {
93
+ repo = config.Merge(testRepoConfig(nil), repo)
92
94
  }
93
95
  return task.RepoSpec{Name: "payments", RootConfig: root, RepoConfig: repo}
94
96
  }
@@ -0,0 +1,59 @@
1
+ package task
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ )
9
+
10
+ // RegistrationField describes one task-plugin-owned repository-registration
11
+ // value. The CLI only renders this metadata; it does not interpret provider
12
+ // names or status vocabulary. A field with Options is rendered as a select,
13
+ // otherwise it is rendered as free text. Derived fields are filled from the
14
+ // runner candidate name and are never prompted.
15
+ type RegistrationField struct {
16
+ Key string `json:"key"`
17
+ Title string `json:"title"`
18
+ Options []string `json:"options,omitempty"`
19
+ Default string `json:"default,omitempty"`
20
+ Derived bool `json:"derived,omitempty"`
21
+ }
22
+
23
+ // Registration describes the fields needed by a task plugin for one repo
24
+ // registration.
25
+ type Registration struct {
26
+ Fields []RegistrationField `json:"fields"`
27
+ }
28
+
29
+ // RegistrationFields returns task-plugin-owned registration metadata for the
30
+ // supplied values. Values use the plugin's registration keys (including
31
+ // dotted keys such as statusDefaults.start) and are intentionally opaque to
32
+ // core.
33
+ func RegistrationFields(ctx context.Context, name string, values config.RawValues) ([]RegistrationField, error) {
34
+ f, err := lookup(name)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ if f.RegistrationFields == nil {
39
+ return nil, fmt.Errorf("task plugin %q does not provide repository registration fields", name)
40
+ }
41
+ return f.RegistrationFields(ctx, values)
42
+ }
43
+
44
+ // ValidateRegistration lets a task plugin validate registration values,
45
+ // including remote project/workflow metadata, before the repository is
46
+ // persisted. Plugins that do not need this phase remain unchanged.
47
+ func ValidateRegistration(ctx context.Context, name string, spec RepoRegistrationSpec) error {
48
+ f, err := lookup(name)
49
+ if err != nil {
50
+ return err
51
+ }
52
+ if f.ValidateRegistration == nil {
53
+ return nil
54
+ }
55
+ if err := f.ValidateRegistration(ctx, spec); err != nil {
56
+ return fmt.Errorf("task registration: %w", err)
57
+ }
58
+ return nil
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.6-alpha",
3
+ "version": "0.2.8-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",