relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -0,0 +1,209 @@
1
+ package jira
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "errors"
7
+ "flag"
8
+ "fmt"
9
+ "io"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+
14
+ "github.com/charmbracelet/huh"
15
+ "github.com/mattn/go-isatty"
16
+ "github.com/rajpopat27/relay-flow/internal/config"
17
+ jirarest "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
18
+ "gopkg.in/yaml.v3"
19
+ )
20
+
21
+ type credentials struct {
22
+ Site string `yaml:"site"`
23
+ Email string `yaml:"email"`
24
+ Token string `yaml:"token"`
25
+ }
26
+
27
+ func auth(ctx context.Context, args []string, stdin io.Reader) error {
28
+ fs := flag.NewFlagSet("task auth", flag.ContinueOnError)
29
+ fs.SetOutput(io.Discard)
30
+ site := fs.String("site", "", "Jira site URL")
31
+ email := fs.String("email", "", "Jira account email")
32
+ token := fs.String("token", "", "Jira API token")
33
+ if err := fs.Parse(args); err != nil {
34
+ return err
35
+ }
36
+ if fs.NArg() != 0 {
37
+ return errors.New("unexpected positional arguments")
38
+ }
39
+
40
+ values := credentials{Site: strings.TrimSpace(*site), Email: strings.TrimSpace(*email), Token: *token}
41
+ flagged := values.Site != "" || values.Email != "" || values.Token != ""
42
+ if flagged && (values.Site == "" || values.Email == "" || values.Token == "") {
43
+ return errors.New("--site, --email, and --token must be given together")
44
+ }
45
+ if !flagged {
46
+ if isTTY(stdin) {
47
+ var err error
48
+ values, err = promptCredentials(values)
49
+ if err != nil {
50
+ return err
51
+ }
52
+ } else {
53
+ var ok bool
54
+ values, ok = readCredentialLines(stdin)
55
+ if !ok {
56
+ return errors.New("expected Jira site, email, and API token on stdin (or pass --site, --email, and --token)")
57
+ }
58
+ }
59
+ }
60
+
61
+ client, err := jirarest.New(values.Site, values.Email, values.Token)
62
+ if err != nil {
63
+ return err
64
+ }
65
+ if err := client.ValidateCredentials(ctx); err != nil {
66
+ return err
67
+ }
68
+ values.Site = strings.TrimRight(clientSite(values.Site), "/")
69
+ path, err := credentialsPath()
70
+ if err != nil {
71
+ return err
72
+ }
73
+ _, statErr := os.Stat(path)
74
+ firstAuth := os.IsNotExist(statErr)
75
+ if statErr != nil && !firstAuth {
76
+ return fmt.Errorf("stat credentials %s: %w", path, statErr)
77
+ }
78
+ if err := saveCredentials(path, values); err != nil {
79
+ return err
80
+ }
81
+ if !firstAuth {
82
+ return nil
83
+ }
84
+ return defaultAssignee(filepath.Join(filepath.Dir(path), "config.yaml"), values.Email)
85
+ }
86
+
87
+ func defaultAssignee(path, email string) error {
88
+ cfg, err := config.LoadMachine(path)
89
+ if err != nil {
90
+ return err
91
+ }
92
+ if _, configured := cfg.TaskConfig["assignee"]; configured {
93
+ return nil
94
+ }
95
+ if cfg.TaskConfig == nil {
96
+ cfg.TaskConfig = config.RawValues{}
97
+ }
98
+ cfg.TaskConfig["assignee"] = email
99
+ return config.SaveMachine(path, cfg)
100
+ }
101
+
102
+ func isTTY(stdin io.Reader) bool {
103
+ f, ok := stdin.(*os.File)
104
+ return ok && isatty.IsTerminal(f.Fd())
105
+ }
106
+
107
+ func promptCredentials(values credentials) (credentials, error) {
108
+ required := func(name string) func(string) error {
109
+ return func(value string) error {
110
+ if strings.TrimSpace(value) == "" {
111
+ return fmt.Errorf("%s is required", name)
112
+ }
113
+ return nil
114
+ }
115
+ }
116
+ form := huh.NewForm(huh.NewGroup(
117
+ huh.NewInput().Title("Jira site").Description("Your Atlassian site URL.").Placeholder("https://company.atlassian.net").Validate(required("Jira site")).Value(&values.Site),
118
+ huh.NewInput().Title("Jira email").Description("Email for the Jira API token.").Placeholder("you@company.com").Validate(required("Jira email")).Value(&values.Email),
119
+ huh.NewInput().Title("Jira API token").Description("Stored locally and never displayed.").EchoMode(huh.EchoModePassword).Validate(required("Jira API token")).Value(&values.Token),
120
+ ).Title("Configure Jira"))
121
+ if err := form.Run(); err != nil {
122
+ return credentials{}, err
123
+ }
124
+ values.Site = strings.TrimSpace(values.Site)
125
+ values.Email = strings.TrimSpace(values.Email)
126
+ return values, nil
127
+ }
128
+
129
+ func readCredentialLines(stdin io.Reader) (credentials, bool) {
130
+ values := make([]string, 0, 3)
131
+ scanner := bufio.NewScanner(stdin)
132
+ for len(values) < 3 && scanner.Scan() {
133
+ line := strings.TrimSpace(scanner.Text())
134
+ if line != "" {
135
+ values = append(values, line)
136
+ }
137
+ }
138
+ if len(values) != 3 {
139
+ return credentials{}, false
140
+ }
141
+ return credentials{Site: values[0], Email: values[1], Token: values[2]}, true
142
+ }
143
+
144
+ func clientSite(site string) string {
145
+ if !strings.Contains(site, "://") {
146
+ return "https://" + site
147
+ }
148
+ return site
149
+ }
150
+
151
+ func credentialsPath() (string, error) {
152
+ if root := os.Getenv("RELAY_FLOW_HOME"); root != "" {
153
+ return filepath.Join(root, "credentials.yaml"), nil
154
+ }
155
+ home, err := os.UserHomeDir()
156
+ if err != nil {
157
+ return "", fmt.Errorf("resolve user home: %w", err)
158
+ }
159
+ return filepath.Join(home, ".relay-flow", "credentials.yaml"), nil
160
+ }
161
+
162
+ func loadCredentialsDefault() (credentials, error) {
163
+ path, err := credentialsPath()
164
+ if err != nil {
165
+ return credentials{}, err
166
+ }
167
+ return loadCredentials(path)
168
+ }
169
+
170
+ func loadCredentials(path string) (credentials, error) {
171
+ info, err := os.Stat(path)
172
+ if err != nil {
173
+ return credentials{}, fmt.Errorf("stat credentials %s: %w", path, err)
174
+ }
175
+ if info.Mode().Perm() != 0o600 {
176
+ return credentials{}, fmt.Errorf("credentials %s must have mode 0600", path)
177
+ }
178
+ raw, err := os.ReadFile(path)
179
+ if err != nil {
180
+ return credentials{}, fmt.Errorf("read credentials %s: %w", path, err)
181
+ }
182
+ var out credentials
183
+ decoder := yaml.NewDecoder(strings.NewReader(string(raw)))
184
+ decoder.KnownFields(true)
185
+ if err := decoder.Decode(&out); err != nil {
186
+ return credentials{}, fmt.Errorf("parse credentials %s: %w", path, err)
187
+ }
188
+ if out.Site == "" || out.Email == "" || out.Token == "" {
189
+ return credentials{}, errors.New("Jira site, email, and API token are required")
190
+ }
191
+ return out, nil
192
+ }
193
+
194
+ func saveCredentials(path string, value credentials) error {
195
+ if value.Site == "" || value.Email == "" || value.Token == "" {
196
+ return errors.New("Jira site, email, and API token are required")
197
+ }
198
+ raw, err := yaml.Marshal(value)
199
+ if err != nil {
200
+ return fmt.Errorf("marshal credentials: %w", err)
201
+ }
202
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
203
+ return fmt.Errorf("create credentials directory: %w", err)
204
+ }
205
+ if err := config.WriteAtomic(path, raw, 0o600); err != nil {
206
+ return fmt.Errorf("write credentials: %w", err)
207
+ }
208
+ return nil
209
+ }
@@ -0,0 +1,160 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/rajpopat27/relay-flow/internal/config"
14
+ "github.com/rajpopat27/relay-flow/internal/task"
15
+ )
16
+
17
+ func authServer(t *testing.T, valid bool) *httptest.Server {
18
+ t.Helper()
19
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20
+ user, token, ok := r.BasicAuth()
21
+ if !ok || user != "bot@example.com" || token != "secret" || !valid {
22
+ http.Error(w, "invalid bot@example.com secret", http.StatusUnauthorized)
23
+ return
24
+ }
25
+ w.Header().Set("Content-Type", "application/json")
26
+ switch r.URL.Path {
27
+ case "/rest/api/3/myself":
28
+ fmt.Fprint(w, `{"accountId":"bot"}`)
29
+ case "/rest/api/3/user/assignable/search":
30
+ fmt.Fprint(w, `[{"accountId":"bot","emailAddress":"bot@example.com","displayName":"Relay Bot"}]`)
31
+ case "/rest/api/3/project/PAY/statuses":
32
+ fmt.Fprint(w, `[{"id":"1","subtask":true,"statuses":[{"name":"To Do"},{"name":"In Progress"},{"name":"Done"}]}]`)
33
+ default:
34
+ http.NotFound(w, r)
35
+ }
36
+ }))
37
+ t.Cleanup(server.Close)
38
+ return server
39
+ }
40
+
41
+ func TestAuthWritesLoadsAndNewUsesJiraOwnedCredentials(t *testing.T) {
42
+ root := t.TempDir()
43
+ t.Setenv("RELAY_FLOW_HOME", root)
44
+ configPath := filepath.Join(root, "config.yaml")
45
+ if err := config.SaveMachine(configPath, &config.Machine{TaskPlugin: "jira"}); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ server := authServer(t, true)
49
+ if err := task.Auth(context.Background(), "jira", []string{
50
+ "--site", server.URL, "--email", "bot@example.com", "--token", "secret",
51
+ }, strings.NewReader("")); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ path := filepath.Join(root, "credentials.yaml")
55
+ info, err := os.Stat(path)
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ if info.Mode().Perm() != 0o600 {
60
+ t.Fatalf("credentials mode = %o, want 600", info.Mode().Perm())
61
+ }
62
+ got, err := loadCredentials(path)
63
+ if err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ if got.Site != server.URL || got.Email != "bot@example.com" || got.Token != "secret" {
67
+ t.Fatalf("credentials = %+v", got)
68
+ }
69
+ machine, err := config.LoadMachine(configPath)
70
+ if err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ if machine.TaskConfig["assignee"] != "bot@example.com" {
74
+ t.Fatalf("default assignee = %v", machine.TaskConfig["assignee"])
75
+ }
76
+ if _, err := task.New(context.Background(), "jira", task.RepoSpec{
77
+ Name: "payments", RootConfig: machine.TaskConfig,
78
+ RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
79
+ }); err != nil {
80
+ t.Fatalf("task.New did not load Jira-owned credentials: %v", err)
81
+ }
82
+ }
83
+
84
+ func TestAuthPreservesConfiguredAndLaterRemovedAssignee(t *testing.T) {
85
+ for _, tc := range []struct {
86
+ name string
87
+ firstAuth bool
88
+ taskConfig config.RawValues
89
+ want string
90
+ wantKey bool
91
+ }{
92
+ {name: "configured on first auth", firstAuth: true, taskConfig: config.RawValues{"assignee": "configured@example.com"}, want: "configured@example.com", wantKey: true},
93
+ {name: "configured on later auth", taskConfig: config.RawValues{"assignee": "configured@example.com"}, want: "configured@example.com", wantKey: true},
94
+ {name: "removed on later auth", taskConfig: config.RawValues{}, wantKey: false},
95
+ } {
96
+ t.Run(tc.name, func(t *testing.T) {
97
+ root := t.TempDir()
98
+ t.Setenv("RELAY_FLOW_HOME", root)
99
+ if err := config.SaveMachine(filepath.Join(root, "config.yaml"), &config.Machine{TaskPlugin: "jira", TaskConfig: tc.taskConfig}); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ if !tc.firstAuth {
103
+ if err := saveCredentials(filepath.Join(root, "credentials.yaml"), credentials{Site: "https://old.example.com", Email: "old@example.com", Token: "old"}); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ }
107
+ server := authServer(t, true)
108
+ if err := auth(context.Background(), []string{"--site", server.URL, "--email", "bot@example.com", "--token", "secret"}, strings.NewReader("")); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ machine, err := config.LoadMachine(filepath.Join(root, "config.yaml"))
112
+ if err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ got, exists := machine.TaskConfig["assignee"]
116
+ if exists != tc.wantKey || (exists && got != tc.want) {
117
+ t.Fatalf("assignee = %v, exists = %v; want %q, exists = %v", got, exists, tc.want, tc.wantKey)
118
+ }
119
+ })
120
+ }
121
+ }
122
+
123
+ func TestAuthRejectsInvalidCredentialsWithoutWriting(t *testing.T) {
124
+ root := t.TempDir()
125
+ t.Setenv("RELAY_FLOW_HOME", root)
126
+ server := authServer(t, false)
127
+ err := auth(context.Background(), []string{
128
+ "--site", server.URL, "--email", "bot@example.com", "--token", "secret",
129
+ }, strings.NewReader(""))
130
+ if err == nil {
131
+ t.Fatal("invalid credentials accepted")
132
+ }
133
+ if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
134
+ t.Fatalf("credential error exposed a secret: %v", err)
135
+ }
136
+ if _, statErr := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(statErr) {
137
+ t.Fatalf("invalid auth wrote credentials: %v", statErr)
138
+ }
139
+ }
140
+
141
+ func TestLoadCredentialsRejectsPermissionsAndMalformedSecrets(t *testing.T) {
142
+ t.Run("permissions", func(t *testing.T) {
143
+ path := filepath.Join(t.TempDir(), "credentials.yaml")
144
+ if err := os.WriteFile(path, []byte("site: https://jira.example.com\nemail: bot@example.com\ntoken: secret\n"), 0o644); err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ if _, err := loadCredentials(path); err == nil || !strings.Contains(err.Error(), "0600") {
148
+ t.Fatalf("permission error = %v", err)
149
+ }
150
+ })
151
+ t.Run("redaction", func(t *testing.T) {
152
+ path := filepath.Join(t.TempDir(), "credentials.yaml")
153
+ if err := os.WriteFile(path, []byte("token: [super-secret"), 0o600); err != nil {
154
+ t.Fatal(err)
155
+ }
156
+ if _, err := loadCredentials(path); err == nil || strings.Contains(err.Error(), "super-secret") {
157
+ t.Fatalf("malformed credential error = %v", err)
158
+ }
159
+ })
160
+ }
@@ -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
  })
@@ -232,3 +252,63 @@ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
232
252
  t.Fatal("non-matching assignee accepted")
233
253
  }
234
254
  }
255
+
256
+ func TestCompileFilterInheritsEffectiveAssigneeCaseInsensitively(t *testing.T) {
257
+ sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
258
+ Name: "payments",
259
+ RootConfig: config.RawValues{"assignee": "root@example.com"},
260
+ RepoConfig: config.RawValues{"project": "PAY", "component": "api", "assignee": "Repo.Bot@Example.com"},
261
+ })
262
+ if err != nil {
263
+ t.Fatal(err)
264
+ }
265
+ match, err := sys.CompileFilter(nil)
266
+ if err != nil {
267
+ t.Fatal(err)
268
+ }
269
+ if !match(task.Ticket{Fields: map[string]any{"assignee": "repo.bot@example.COM"}}) {
270
+ t.Fatal("effective repo assignee did not match normalized email case-insensitively")
271
+ }
272
+ if match(task.Ticket{Fields: map[string]any{"assignee": "other@example.com"}}) {
273
+ t.Fatal("ticket assigned to another user matched effective assignee")
274
+ }
275
+ }
276
+
277
+ func TestCompileFilterWorkflowAssigneesOverrideEffectiveAssignee(t *testing.T) {
278
+ sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
279
+ Name: "payments",
280
+ RootConfig: config.RawValues{"assignee": "root@example.com"},
281
+ RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
282
+ })
283
+ if err != nil {
284
+ t.Fatal(err)
285
+ }
286
+ match, err := sys.CompileFilter(config.RawValues{
287
+ "assignee": "workflow@example.com",
288
+ "filters": map[string]any{"assignees": []any{"selected@example.com"}},
289
+ })
290
+ if err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ if !match(task.Ticket{Fields: map[string]any{"assignee": "SELECTED@example.com"}}) {
294
+ t.Fatal("explicit workflow assignee filter did not win")
295
+ }
296
+ for _, email := range []string{"root@example.com", "workflow@example.com"} {
297
+ if match(task.Ticket{Fields: map[string]any{"assignee": email}}) {
298
+ t.Fatalf("effective assignee %q overrode explicit filter", email)
299
+ }
300
+ }
301
+ }
302
+
303
+ func TestCompileFilterWithoutAssigneeDoesNotFilter(t *testing.T) {
304
+ sys := newSystemWithFake(t, &fakeJira{})
305
+ match, err := sys.CompileFilter(config.RawValues{
306
+ "filters": map[string]any{"assignees": []any{}},
307
+ })
308
+ if err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ if !match(task.Ticket{Fields: map[string]any{"assignee": "anyone@example.com"}}) {
312
+ t.Fatal("empty explicit assignee filter rejected a ticket")
313
+ }
314
+ }
@@ -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
  }