relay-flow 0.2.0-alpha → 0.2.1-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.
Files changed (55) hide show
  1. package/README.md +18 -6
  2. package/cmd/relay-flow/commands_test.go +519 -15
  3. package/cmd/relay-flow/main.go +331 -119
  4. package/cmd/relay-flow/scenario_test.go +209 -34
  5. package/cmd/relay-flow/serve.go +1 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/internal/execution/goworkflows/activities.go +65 -65
  8. package/internal/execution/goworkflows/engine.go +41 -8
  9. package/internal/execution/goworkflows/engine_test.go +73 -13
  10. package/internal/execution/goworkflows/fakes_test.go +13 -21
  11. package/internal/execution/goworkflows/interpreter.go +16 -8
  12. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  13. package/internal/execution/goworkflows/node_runtime_test.go +45 -21
  14. package/internal/execution/goworkflows/recovery_test.go +5 -5
  15. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  16. package/internal/harness/contract_test.go +5 -0
  17. package/internal/paths/paths.go +18 -16
  18. package/internal/repo/repo.go +13 -0
  19. package/internal/repo/service_test.go +4 -4
  20. package/internal/router/router.go +3 -2
  21. package/internal/router/router_test.go +87 -0
  22. package/internal/run/manager.go +14 -1
  23. package/internal/run/run_manager_test.go +21 -1
  24. package/internal/runner/contract_test.go +64 -26
  25. package/internal/runner/orca/orca.go +30 -54
  26. package/internal/runner/orca/orca_test.go +143 -4
  27. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  28. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  29. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  30. package/internal/runner/runner.go +15 -8
  31. package/internal/task/auth_test.go +48 -0
  32. package/internal/task/contract_test.go +2 -0
  33. package/internal/task/factory.go +16 -0
  34. package/internal/task/jira/auth.go +183 -0
  35. package/internal/task/jira/auth_test.go +107 -0
  36. package/internal/task/jira/effects_test.go +39 -0
  37. package/internal/task/jira/filters_test.go +36 -16
  38. package/internal/task/jira/helpers_test.go +29 -19
  39. package/internal/task/jira/jira.go +92 -61
  40. package/internal/task/jira/normalize.go +32 -14
  41. package/internal/task/jira/rest/adf.go +128 -0
  42. package/internal/task/jira/rest/client.go +573 -0
  43. package/internal/task/jira/rest/client_test.go +381 -0
  44. package/internal/task/jira/transition_defaults_test.go +18 -16
  45. package/internal/task/jira/validation_test.go +1 -1
  46. package/internal/workflow/workflow.go +9 -6
  47. package/internal/workflow/workflow_test.go +14 -12
  48. package/package.json +2 -1
  49. package/internal/task/jira/acli/acli.go +0 -306
  50. package/internal/task/jira/acli/acli_test.go +0 -208
  51. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  52. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  53. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  54. package/internal/task/jira/acli/testdata/search_success.json +0 -1
  55. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -0,0 +1,39 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/task"
9
+ )
10
+
11
+ func TestClaimUsesOneLabelAdd(t *testing.T) {
12
+ fake := &fakeJira{}
13
+ sys := newSystemWithFake(t, fake)
14
+ if err := sys.Claim(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow"); err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ if len(fake.labelCalls) != 1 || fake.labelCalls[0] != "PAY-1:wf:flow" {
18
+ t.Fatalf("label calls = %v, want one claim update", fake.labelCalls)
19
+ }
20
+ }
21
+
22
+ func TestCommentKeepsMarkerIdempotency(t *testing.T) {
23
+ fake := &fakeJira{comments: []string{"existing\n<!-- visit:summary -->"}}
24
+ sys := newSystemWithFake(t, fake)
25
+ target := task.Target{Parent: task.TicketRef{Key: "PAY-1"}}
26
+ if err := sys.Comment(context.Background(), target, "summary", "visit:summary"); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ if len(fake.addedComments) != 0 {
30
+ t.Fatal("duplicate marked comment was posted")
31
+ }
32
+ fake.comments = nil
33
+ if err := sys.Comment(context.Background(), target, "summary", "visit:summary"); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ if len(fake.addedComments) != 1 || !strings.Contains(fake.addedComments[0], "visit:summary") {
37
+ t.Fatalf("posted comments = %v", fake.addedComments)
38
+ }
39
+ }
@@ -30,7 +30,7 @@ func (p *pollCountingSystem) Poll(ctx context.Context) ([]task.Ticket, error) {
30
30
  }
31
31
 
32
32
  func TestCompileFilterMatchesNormalizedFields(t *testing.T) {
33
- sys := newSystemWithFake(t, &fakeACLI{})
33
+ sys := newSystemWithFake(t, &fakeJira{})
34
34
 
35
35
  match, err := sys.CompileFilter(config.RawValues{
36
36
  "filters": map[string]any{
@@ -57,7 +57,7 @@ func TestCompileFilterMatchesNormalizedFields(t *testing.T) {
57
57
  }
58
58
 
59
59
  func TestCompileFilterRejectsNonMatching(t *testing.T) {
60
- sys := newSystemWithFake(t, &fakeACLI{})
60
+ sys := newSystemWithFake(t, &fakeJira{})
61
61
  match, err := sys.CompileFilter(config.RawValues{
62
62
  "filters": map[string]any{"parentStatuses": []any{"To Do"}},
63
63
  })
@@ -80,7 +80,7 @@ func TestCompileFilterRejectsNonMatching(t *testing.T) {
80
80
  }
81
81
 
82
82
  func TestCompileFilterRejectsUnknownField(t *testing.T) {
83
- sys := newSystemWithFake(t, &fakeACLI{})
83
+ sys := newSystemWithFake(t, &fakeJira{})
84
84
  if _, err := sys.CompileFilter(config.RawValues{
85
85
  "filters": map[string]any{"jql": "project = PAY"},
86
86
  }); err == nil {
@@ -91,7 +91,7 @@ func TestCompileFilterRejectsUnknownField(t *testing.T) {
91
91
  func TestMatchingIsInMemoryNoRequery(t *testing.T) {
92
92
  // One repo poll fetches the batch; the compiled matcher then evaluates
93
93
  // every ticket in memory with no per-workflow re-query.
94
- fake := &fakeACLI{searchJSON: []byte(`[{"id":"1","key":"PAY-1","fields":{"summary":"a","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"2","key":"PAY-2","fields":{"summary":"b","status":{"name":"Done"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"3","key":"PAY-3","fields":{"summary":"c","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}}]`)}
94
+ fake := &fakeJira{searchJSON: []byte(`[{"id":"1","key":"PAY-1","fields":{"summary":"a","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"2","key":"PAY-2","fields":{"summary":"b","status":{"name":"Done"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"3","key":"PAY-3","fields":{"summary":"c","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}}]`)}
95
95
  base := newSystemWithFake(t, fake)
96
96
  counting := &pollCountingSystem{System: base}
97
97
 
@@ -130,7 +130,7 @@ func TestJiraJSONNormalization(t *testing.T) {
130
130
  // The adapter normalizes Jira search JSON (status, issue type, labels,
131
131
  // assignee) into task.Ticket.Fields. Package-local so it can call the
132
132
  // adapter's unexported normalization directly.
133
- // acli emits a BARE ARRAY of issue objects (no REST envelope), and the
133
+ // The REST boundary supplies a normalized array of issue objects, and the
134
134
  // normalized assignee is the user's email address — the stable identity
135
135
  // workflow filters match on, not the human-readable display name.
136
136
  raw := []byte(`[{"id":"1","key":"PAY-101","fields":{"summary":"parent","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":["coding"],"assignee":{"displayName":"Relay Bot","emailAddress":"relay@bot"}}}]`)
@@ -159,21 +159,41 @@ func TestJiraJSONNormalization(t *testing.T) {
159
159
  }
160
160
  }
161
161
 
162
- func TestJiraJSONNormalizationParsesRealAcliSearchShape(t *testing.T) {
163
- // 9.9: parse the REAL acli wire shape captured live from
164
- // acli jira workitem search --jql ... --fields key,summary,status,issuetype,labels,assignee --json
165
- // Fixture stored under testdata/ so regressions in the parser are
166
- // caught against the actual acli contract, not a hand-written envelope.
167
- raw, err := os.ReadFile("testdata/acli_search.json")
162
+ func TestJiraJSONNormalizationFiltersOnlyOpenInwardBlockers(t *testing.T) {
163
+ raw := []byte(`[
164
+ {"id":"1","key":"PAY-1","fields":{"summary":"open blocker","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[],"issuelinks":[
165
+ {"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-1","fields":{"status":{"statusCategory":{"key":"done"}}}}},
166
+ {"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-2","fields":{"status":{"statusCategory":{"key":"new"}}}}}
167
+ ]}},
168
+ {"id":"2","key":"PAY-2","fields":{"summary":"closed blockers","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[],"issuelinks":[
169
+ {"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-3","fields":{"status":{"statusCategory":{"key":"done"}}}}}
170
+ ]}},
171
+ {"id":"3","key":"PAY-3","fields":{"summary":"blocks another","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[],"issuelinks":[
172
+ {"type":{"name":"Blocks"},"outwardIssue":{"key":"OTHER-4","fields":{"status":{"statusCategory":{"key":"new"}}}}}
173
+ ]}}
174
+ ]`)
175
+ tickets, err := normalizeSearchResponse(raw)
176
+ if err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ if len(tickets) != 2 || tickets[0].Key != "PAY-2" || tickets[1].Key != "PAY-3" {
180
+ t.Fatalf("eligible tickets = %+v, want PAY-2 and PAY-3", tickets)
181
+ }
182
+ }
183
+
184
+ func TestJiraJSONNormalizationParsesCapturedIssueShape(t *testing.T) {
185
+ // Preserve the captured issue-field fixture while the REST client contract
186
+ // itself is exercised through strict HTTP tests.
187
+ raw, err := os.ReadFile("testdata/jira_search_issues.json")
168
188
  if err != nil {
169
- t.Fatalf("read acli fixture: %v", err)
189
+ t.Fatalf("read Jira fixture: %v", err)
170
190
  }
171
191
  tickets, err := normalizeSearchResponse(raw)
172
192
  if err != nil {
173
- t.Fatalf("normalization of real acli output failed: %v", err)
193
+ t.Fatalf("normalization of captured Jira output failed: %v", err)
174
194
  }
175
195
  if len(tickets) == 0 {
176
- t.Fatal("real acli fixture yielded no tickets")
196
+ t.Fatal("captured Jira fixture yielded no tickets")
177
197
  }
178
198
  for _, tk := range tickets {
179
199
  if tk.Key == "" || tk.ID == "" {
@@ -197,7 +217,7 @@ func TestJiraJSONNormalizationParsesRealAcliSearchShape(t *testing.T) {
197
217
  func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
198
218
  // 9.9: workflow assignee filters match the normalized EMAIL identity,
199
219
  // not the display name. e2e workflow.yaml filters on the user's email.
200
- sys := newSystemWithFake(t, &fakeACLI{})
220
+ sys := newSystemWithFake(t, &fakeJira{})
201
221
  match, err := sys.CompileFilter(config.RawValues{
202
222
  "filters": map[string]any{"assignees": []any{"raj.popat@example.com"}},
203
223
  })
@@ -218,7 +238,7 @@ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
218
238
  // assignee"); it matches the normalized ticket's assignee field.
219
239
  // NOTE: the filter key `assignees` follows the same naming as
220
240
  // parentStatuses/issueTypes/labels (plural); the docs do not pin the key.
221
- sys := newSystemWithFake(t, &fakeACLI{})
241
+ sys := newSystemWithFake(t, &fakeJira{})
222
242
  match, err := sys.CompileFilter(config.RawValues{
223
243
  "filters": map[string]any{"assignees": []any{"relay-bot@example.com"}},
224
244
  })
@@ -4,19 +4,21 @@ import (
4
4
  "context"
5
5
 
6
6
  "github.com/rajpopat27/relay-flow/internal/task"
7
+ "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
7
8
  )
8
9
 
9
- // fakeClient adapts the test-local fakeACLI (transitions + raw search batch)
10
- // to the production acli.Client seam.
10
+ // fakeClient adapts the test-local Jira fake to the production REST seam.
11
11
  type fakeClient struct {
12
- fake *fakeACLI
12
+ fake *fakeJira
13
13
  }
14
14
 
15
15
  func (f *fakeClient) Search(context.Context, string) ([]byte, error) {
16
16
  return f.fake.searchJSON, nil
17
17
  }
18
18
 
19
- func (f *fakeClient) ValidateAssignee(context.Context, string) error { return nil }
19
+ func (f *fakeClient) ValidateCredentials(context.Context) error { return nil }
20
+
21
+ func (f *fakeClient) ValidateAssignee(context.Context, string, string) error { return nil }
20
22
 
21
23
  func (f *fakeClient) ValidateStatus(context.Context, string, string) error { return nil }
22
24
 
@@ -24,29 +26,37 @@ func (f *fakeClient) View(context.Context, string) ([]byte, error) {
24
26
  return []byte(`{"fields":{"labels":[],"subtasks":[]}}`), nil
25
27
  }
26
28
 
27
- func (f *fakeClient) CreateSubtask(context.Context, string, string, string) (string, string, error) {
28
- return "", "", errNotFaked
29
- }
30
-
31
- func (f *fakeClient) Assign(_ context.Context, key, assignee string) error {
32
- f.fake.assignments = append(f.fake.assignments, key+":"+assignee)
33
- f.fake.events = append(f.fake.events, "assign")
34
- return f.fake.assignErr
29
+ func (f *fakeClient) CreateSubtasks(context.Context, string, string, string, []rest.SubtaskSpec) ([]rest.CreatedSubtask, error) {
30
+ return nil, errNotFaked
35
31
  }
36
32
 
37
- func (f *fakeClient) Transition(_ context.Context, key, status string) error {
33
+ func (f *fakeClient) Transition(_ context.Context, key, status, assignee string) error {
34
+ if assignee != "" {
35
+ f.fake.assignments = append(f.fake.assignments, key+":"+assignee)
36
+ if f.fake.assignErr != nil {
37
+ return f.fake.assignErr
38
+ }
39
+ }
38
40
  f.fake.events = append(f.fake.events, "transition")
39
41
  f.fake.transition(key, status)
40
42
  return nil
41
43
  }
42
44
 
43
- func (f *fakeClient) EnsureLabel(context.Context, string, string) error { return nil }
45
+ func (f *fakeClient) EnsureLabel(_ context.Context, key, label string) error {
46
+ f.fake.labelCalls = append(f.fake.labelCalls, key+":"+label)
47
+ return nil
48
+ }
44
49
 
45
- func (f *fakeClient) UpdateDescription(context.Context, string, string) error { return nil }
50
+ func (f *fakeClient) UpdateMailbox(context.Context, string, string, string) error { return nil }
46
51
 
47
- func (f *fakeClient) ListComments(context.Context, string) ([]string, error) { return nil, nil }
52
+ func (f *fakeClient) ListComments(context.Context, string) ([]string, error) {
53
+ return append([]string(nil), f.fake.comments...), nil
54
+ }
48
55
 
49
- func (f *fakeClient) AddComment(context.Context, string, string) error { return nil }
56
+ func (f *fakeClient) AddComment(_ context.Context, _ string, body string) error {
57
+ f.fake.addedComments = append(f.fake.addedComments, body)
58
+ return nil
59
+ }
50
60
 
51
61
  type notFakedError struct{}
52
62
 
@@ -54,7 +64,7 @@ func (notFakedError) Error() string { return "not faked" }
54
64
 
55
65
  var errNotFaked = notFakedError{}
56
66
 
57
- // newSystemForTest builds the adapter around the test-local fake ACLI seam.
58
- func newSystemForTest(fake *fakeACLI) (task.System, error) {
67
+ // newSystemForTest builds the adapter around the test-local REST seam.
68
+ func newSystemForTest(fake *fakeJira) (task.System, error) {
59
69
  return newSystemForCLI(&fakeClient{fake: fake})
60
70
  }
@@ -8,11 +8,12 @@ import (
8
8
  "log/slog"
9
9
  "sort"
10
10
  "strings"
11
+ "sync"
11
12
 
12
13
  "github.com/rajpopat27/relay-flow/internal/config"
13
14
  "github.com/rajpopat27/relay-flow/internal/retry"
14
15
  "github.com/rajpopat27/relay-flow/internal/task"
15
- "github.com/rajpopat27/relay-flow/internal/task/jira/acli"
16
+ jirarest "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
16
17
  )
17
18
 
18
19
  // Config is the adapter-owned typed config for every scope.
@@ -20,7 +21,6 @@ type Config struct {
20
21
  Assignee string `yaml:"assignee,omitempty"`
21
22
  Project string `yaml:"project,omitempty"`
22
23
  Component string `yaml:"component,omitempty"`
23
- Site string `yaml:"site,omitempty"`
24
24
  Filters Filters `yaml:"filters,omitempty"`
25
25
  Transition TransitionTo `yaml:"transitionTo,omitempty"`
26
26
  }
@@ -46,6 +46,14 @@ const (
46
46
  defaultEndParentStatus = "Done"
47
47
  )
48
48
 
49
+ var (
50
+ clientsMu sync.Mutex
51
+ clients = map[string]struct {
52
+ token string
53
+ client *jirarest.HTTPClient
54
+ }{}
55
+ )
56
+
49
57
  // claimLabel is the permanent workflow claim label.
50
58
  func claimLabel(workflow string) string { return "wf:" + workflow }
51
59
 
@@ -65,24 +73,60 @@ func init() {
65
73
  if proj == "" || comp == "" {
66
74
  return "", fmt.Errorf("jira task scope requires repo project and component")
67
75
  }
68
- return strings.Join([]string{root.Site, proj, comp}, "/"), nil
76
+ creds, err := loadCredentialsDefault()
77
+ if err != nil {
78
+ return "", fmt.Errorf("jira credentials: %w", err)
79
+ }
80
+ return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
69
81
  },
82
+ Auth: auth,
70
83
  New: func(ctx context.Context, spec task.RepoSpec) (task.System, error) {
71
- return newSystem(ctx, acli.New(), spec)
84
+ merged := config.Merge(spec.RootConfig, spec.RepoConfig)
85
+ var cfg Config
86
+ if err := config.DecodeStrict(merged, &cfg); err != nil {
87
+ return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
88
+ }
89
+ creds, err := loadCredentialsDefault()
90
+ if err != nil {
91
+ return nil, fmt.Errorf("jira credentials: %w", err)
92
+ }
93
+ client, err := sharedClient(creds.Site, creds.Email, creds.Token)
94
+ if err != nil {
95
+ return nil, err
96
+ }
97
+ return newSystem(ctx, client, spec)
72
98
  },
73
99
  })
74
100
  }
75
101
 
102
+ func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
103
+ key := strings.TrimRight(site, "/") + "\x00" + email
104
+ clientsMu.Lock()
105
+ defer clientsMu.Unlock()
106
+ if cached := clients[key]; cached.client != nil && cached.token == token {
107
+ return cached.client, nil
108
+ }
109
+ client, err := jirarest.New(site, email, token)
110
+ if err != nil {
111
+ return nil, err
112
+ }
113
+ clients[key] = struct {
114
+ token string
115
+ client *jirarest.HTTPClient
116
+ }{token: token, client: client}
117
+ return client, nil
118
+ }
119
+
76
120
  // system is the repo-bound Jira task.System. It is safe for concurrent use;
77
- // the ACLI client owns any subprocess serialization.
121
+ // the REST client owns connection reuse, caches, and request limiting.
78
122
  type system struct {
79
- cli acli.Client
123
+ cli jirarest.Client
80
124
  repoName string
81
125
  base config.RawValues
82
126
  effective Config // root+repo merged
83
127
  }
84
128
 
85
- func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*system, error) {
129
+ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
86
130
  if spec.Name == "" {
87
131
  return nil, fmt.Errorf("jira: repo name is required")
88
132
  }
@@ -92,7 +136,7 @@ func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*syste
92
136
  return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
93
137
  }
94
138
  if cfg.Assignee != "" {
95
- if err := cli.ValidateAssignee(ctx, cfg.Assignee); err != nil {
139
+ if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
96
140
  return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
97
141
  }
98
142
  }
@@ -110,11 +154,10 @@ func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*syste
110
154
  return s, nil
111
155
  }
112
156
 
113
- // newSystemForCLI constructs a system around an explicit CLI seam (tests).
114
- func newSystemForCLI(cli acli.Client) (task.System, error) {
157
+ // newSystemForCLI constructs a system around an explicit Jira client seam.
158
+ func newSystemForCLI(cli jirarest.Client) (task.System, error) {
115
159
  return newSystem(context.Background(), cli, task.RepoSpec{
116
160
  Name: "payments",
117
- RootConfig: config.RawValues{},
118
161
  RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
119
162
  })
120
163
  }
@@ -198,27 +241,10 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
198
241
 
199
242
  // --- Claim ---
200
243
 
201
- // Claim adds wf:<workflow> to the parent. It re-reads the parent's claims,
202
- // treats the same claim as idempotent, and rejects a conflicting claim.
244
+ // Claim adds wf:<workflow> using the claims already inspected by routing.
245
+ // Jira's label-add operation is idempotent.
203
246
  func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
204
- raw, err := s.cli.View(ctx, ticket.Key)
205
- if err != nil {
206
- return err
207
- }
208
- labels, err := labelsOf(raw)
209
- if err != nil {
210
- return err
211
- }
212
- want := claimLabel(workflow)
213
- for _, l := range labels {
214
- if l == want {
215
- return nil // idempotent
216
- }
217
- if strings.HasPrefix(l, "wf:") {
218
- return fmt.Errorf("ticket %s already claimed by %s", ticket.Key, l)
219
- }
220
- }
221
- return s.cli.EnsureLabel(ctx, ticket.Key, want)
247
+ return s.cli.EnsureLabel(ctx, ticket.Key, claimLabel(workflow))
222
248
  }
223
249
 
224
250
  // --- Config validation ---
@@ -231,7 +257,7 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
231
257
  if err != nil {
232
258
  return fmt.Errorf("workflow taskConfig: %w", err)
233
259
  }
234
- if err := s.validateAssignee(ctx, "workflow", workflowCfg.Assignee); err != nil {
260
+ if err := s.validateAssignee(ctx, "workflow", workflowCfg.Project, workflowCfg.Assignee); err != nil {
235
261
  return err
236
262
  }
237
263
  if err := s.validateTransition(ctx, "workflow", workflowCfg.Project, workflowCfg.Transition); err != nil {
@@ -247,7 +273,7 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
247
273
  if err != nil {
248
274
  return fmt.Errorf("node %q taskConfig: %w", n, err)
249
275
  }
250
- if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Assignee); err != nil {
276
+ if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Assignee); err != nil {
251
277
  return err
252
278
  }
253
279
  if err := s.validateTransition(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Transition); err != nil {
@@ -257,11 +283,11 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
257
283
  return nil
258
284
  }
259
285
 
260
- func (s *system) validateAssignee(ctx context.Context, scope, assignee string) error {
286
+ func (s *system) validateAssignee(ctx context.Context, scope, project, assignee string) error {
261
287
  if assignee == "" {
262
288
  return nil
263
289
  }
264
- if err := s.cli.ValidateAssignee(ctx, assignee); err != nil {
290
+ if err := s.cli.ValidateAssignee(ctx, project, assignee); err != nil {
265
291
  return fmt.Errorf("%s assignee %q: %w", scope, assignee, err)
266
292
  }
267
293
  return nil
@@ -347,28 +373,38 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
347
373
  return nil, err
348
374
  }
349
375
  out := map[string]task.Mailbox{}
376
+ missing := make([]task.MailboxSpec, 0)
350
377
  for _, spec := range specs {
351
378
  if mb, ok := existing[spec.Title]; ok {
352
- // Found existing: reconcile the workflow label and description,
353
- // and return the complete node-identified value.
354
- if err := s.cli.EnsureLabel(ctx, mb.Key, claimLabel(workflow)); err != nil {
355
- return nil, fmt.Errorf("label mailbox %q: %w", mb.Key, err)
356
- }
357
- if err := s.cli.UpdateDescription(ctx, mb.Key, spec.Description); err != nil {
358
- return nil, fmt.Errorf("describe mailbox %q: %w", mb.Key, err)
379
+ if err := s.cli.UpdateMailbox(ctx, mb.Key, spec.Description, claimLabel(workflow)); err != nil {
380
+ return nil, fmt.Errorf("reconcile mailbox %q: %w", mb.Key, err)
359
381
  }
360
382
  mb.Node = spec.Node
361
383
  out[spec.Node] = mb
362
384
  continue
363
385
  }
364
- id, key, err := s.cli.CreateSubtask(ctx, parent.Key, spec.Title, spec.Description)
365
- if err != nil {
366
- return nil, fmt.Errorf("create mailbox %q: %w", spec.Title, err)
367
- }
368
- if err := s.cli.EnsureLabel(ctx, key, claimLabel(workflow)); err != nil {
369
- return nil, fmt.Errorf("label mailbox %q: %w", key, err)
386
+ missing = append(missing, spec)
387
+ }
388
+ if len(missing) == 0 {
389
+ return out, nil
390
+ }
391
+ createSpecs := make([]jirarest.SubtaskSpec, 0, len(missing))
392
+ for _, spec := range missing {
393
+ createSpecs = append(createSpecs, jirarest.SubtaskSpec{Title: spec.Title, Description: spec.Description})
394
+ }
395
+ created, err := s.cli.CreateSubtasks(ctx, parent.Key, s.effective.Project, claimLabel(workflow), createSpecs)
396
+ if err != nil {
397
+ return nil, fmt.Errorf("create mailboxes: %w", err)
398
+ }
399
+ if len(created) != len(missing) {
400
+ return nil, fmt.Errorf("create mailboxes: created %d of %d", len(created), len(missing))
401
+ }
402
+ for i, mailbox := range created {
403
+ spec := missing[i]
404
+ if mailbox.ID == "" || mailbox.Key == "" {
405
+ return nil, fmt.Errorf("create mailbox %q: Jira returned no id/key", spec.Title)
370
406
  }
371
- out[spec.Node] = task.Mailbox{ID: id, Key: key, Node: spec.Node}
407
+ out[spec.Node] = task.Mailbox{ID: mailbox.ID, Key: mailbox.Key, Node: spec.Node}
372
408
  }
373
409
  return out, nil
374
410
  }
@@ -388,20 +424,15 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
388
424
  }
389
425
  tr := cfg.Transition
390
426
  if target.Mailbox != nil {
391
- if cfg.Assignee != "" {
392
- if err := s.cli.Assign(ctx, target.Mailbox.Key, cfg.Assignee); err != nil {
393
- return err
394
- }
395
- }
396
427
  status := tr.TaskStatus
397
428
  if status == "" {
398
429
  status = defaultWorkTaskStatus
399
430
  }
400
- if err := s.transition(ctx, target.Mailbox.Key, status); err != nil {
431
+ if err := s.transition(ctx, target.Mailbox.Key, status, cfg.Assignee); err != nil {
401
432
  return err
402
433
  }
403
434
  if tr.ParentStatus != "" {
404
- return s.transition(ctx, target.Parent.Key, tr.ParentStatus)
435
+ return s.transition(ctx, target.Parent.Key, tr.ParentStatus, "")
405
436
  }
406
437
  return nil
407
438
  }
@@ -410,13 +441,13 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
410
441
  if status == "" {
411
442
  status = defaultStartParentStatus
412
443
  }
413
- return s.transition(ctx, target.Parent.Key, status)
444
+ return s.transition(ctx, target.Parent.Key, status, "")
414
445
  }
415
446
 
416
447
  // transition applies a Jira status transition, mapping a human-incompatible
417
448
  // current state to a conflict.
418
- func (s *system) transition(ctx context.Context, key, status string) error {
419
- err := s.cli.Transition(ctx, key, status)
449
+ func (s *system) transition(ctx context.Context, key, status, assignee string) error {
450
+ err := s.cli.Transition(ctx, key, status, assignee)
420
451
  if err != nil && isConflict(err) {
421
452
  return retry.ConflictError(err)
422
453
  }
@@ -431,7 +462,7 @@ func isConflict(err error) bool {
431
462
 
432
463
  // CompleteMailbox marks the mailbox Done using task-system semantics.
433
464
  func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
434
- return s.transition(ctx, mailbox.Key, "Done")
465
+ return s.transition(ctx, mailbox.Key, "Done", "")
435
466
  }
436
467
 
437
468
  // --- Comments ---
@@ -480,7 +511,7 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
480
511
  // comments, labels, and history. No parent rollback runs.
481
512
  func (s *system) ResetForRecovery(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
482
513
  for _, mb := range mailboxes {
483
- if err := s.transition(ctx, mb.Key, "To Do"); err != nil {
514
+ if err := s.transition(ctx, mb.Key, "To Do", ""); err != nil {
484
515
  return fmt.Errorf("reset mailbox %s: %w", mb.Key, err)
485
516
  }
486
517
  }
@@ -3,13 +3,12 @@ package jira
3
3
  import (
4
4
  "encoding/json"
5
5
  "fmt"
6
+ "strings"
6
7
 
7
8
  "github.com/rajpopat27/relay-flow/internal/task"
8
9
  )
9
10
 
10
- // rawIssue matches one entry in acli's search output. acli emits a BARE
11
- // ARRAY of these (no {"issues":[...]} REST envelope) — the adapter owns
12
- // the acli wire contract, not the REST API's.
11
+ // rawIssue is the subset of Jira REST issue fields normalized by the adapter.
13
12
  type rawIssue struct {
14
13
  ID string `json:"id"`
15
14
  Key string `json:"key"`
@@ -33,11 +32,26 @@ type rawIssue struct {
33
32
  Summary string `json:"summary"`
34
33
  } `json:"fields"`
35
34
  } `json:"subtasks"`
35
+ IssueLinks []struct {
36
+ Type struct {
37
+ Name string `json:"name"`
38
+ } `json:"type"`
39
+ InwardIssue *struct {
40
+ Key string `json:"key"`
41
+ Fields struct {
42
+ Status struct {
43
+ StatusCategory struct {
44
+ Key string `json:"key"`
45
+ } `json:"statusCategory"`
46
+ } `json:"status"`
47
+ } `json:"fields"`
48
+ } `json:"inwardIssue"`
49
+ } `json:"issuelinks"`
36
50
  } `json:"fields"`
37
51
  }
38
52
 
39
- // normalizeSearchResponse converts raw acli search JSON (a bare array of
40
- // issue objects) into normalized parent tickets: status, issueType, labels,
53
+ // normalizeSearchResponse converts REST issue objects into normalized parent
54
+ // tickets: status, issueType, labels,
41
55
  // and assignee become plain Fields entries. Assignee is normalized to the
42
56
  // user's email address — the stable, machine-comparable identity workflow
43
57
  // filters match against (displayName is human-readable, not an identifier).
@@ -49,6 +63,9 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
49
63
  }
50
64
  out := make([]task.Ticket, 0, len(issues))
51
65
  for _, issue := range issues {
66
+ if hasOpenBlocker(issue) {
67
+ continue
68
+ }
52
69
  fields := map[string]any{
53
70
  "status": issue.Fields.Status.Name,
54
71
  "issueType": issue.Fields.IssueType.Name,
@@ -68,6 +85,16 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
68
85
  return out, nil
69
86
  }
70
87
 
88
+ func hasOpenBlocker(issue rawIssue) bool {
89
+ for _, link := range issue.Fields.IssueLinks {
90
+ if link.Type.Name == "Blocks" && link.InwardIssue != nil &&
91
+ !strings.EqualFold(link.InwardIssue.Fields.Status.StatusCategory.Key, "done") {
92
+ return true
93
+ }
94
+ }
95
+ return false
96
+ }
97
+
71
98
  func claimLabels(labels []string) []string {
72
99
  var out []string
73
100
  for _, l := range labels {
@@ -78,15 +105,6 @@ func claimLabels(labels []string) []string {
78
105
  return out
79
106
  }
80
107
 
81
- // labelsOf extracts label strings from a raw Jira issue view.
82
- func labelsOf(raw []byte) ([]string, error) {
83
- var issue rawIssue
84
- if err := json.Unmarshal(raw, &issue); err != nil {
85
- return nil, fmt.Errorf("jira view: parse json: %w", err)
86
- }
87
- return issue.Fields.Labels, nil
88
- }
89
-
90
108
  // subtasksOf maps existing subtask titles (<ticket>:<node>) to mailboxes.
91
109
  func subtasksOf(raw []byte) (map[string]task.Mailbox, error) {
92
110
  var issue rawIssue