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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /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, &
|
|
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, &
|
|
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, &
|
|
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 := &
|
|
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
|
-
//
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
193
|
+
t.Fatalf("normalization of captured Jira output failed: %v", err)
|
|
174
194
|
}
|
|
175
195
|
if len(tickets) == 0 {
|
|
176
|
-
t.Fatal("
|
|
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, &
|
|
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, &
|
|
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
|
|
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 *
|
|
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)
|
|
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)
|
|
28
|
-
return
|
|
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,
|
|
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)
|
|
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) {
|
|
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 {
|
|
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
|
|
58
|
-
func newSystemForTest(fake *
|
|
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
|
}
|