relay-flow 0.3.13-alpha → 0.3.14-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 +39 -5
- package/cmd/relay-flow/commands_test.go +9 -0
- package/cmd/relay-flow/main.go +16 -7
- package/cmd/relay-flow/repo_registration.go +68 -0
- package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
- package/cmd/relay-flow/scenario_test.go +25 -0
- package/cmd/relay-flow/serve.go +33 -2
- package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
- package/cmd/relay-flow/startup_errors.go +85 -0
- package/cmd/relay-flow/startup_errors_test.go +88 -0
- package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
- package/cmd/relay-flow/startup_ordering_test.go +75 -0
- package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
- package/cmd/relay-flow/startup_readiness_test.go +116 -0
- package/cmd/relay-flow/startup_submission_test.go +123 -0
- package/internal/harness/opencode/opencode.go +14 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +14 -0
- package/internal/harness/startup.go +25 -0
- package/internal/harness/startup_test.go +85 -0
- package/internal/runner/herdr/herdr.go +9 -0
- package/internal/runner/herdr/herdrcli/exec.go +14 -0
- package/internal/runner/herdr/herdrcli/operations.go +8 -2
- package/internal/runner/herdr/herdrcli/response.go +6 -0
- package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
- package/internal/runner/herdr/startup_test.go +58 -0
- package/internal/runner/orca/orca.go +9 -0
- package/internal/runner/orca/orcacli/orcacli.go +99 -34
- package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
- package/internal/runner/orca/orcacli/startup_test.go +158 -0
- package/internal/runner/orca/startup_test.go +57 -0
- package/internal/runner/startup.go +27 -0
- package/internal/runner/startup_test.go +59 -0
- package/internal/task/beads/bdcli/bdcli.go +12 -0
- package/internal/task/beads/bdcli/startup.go +20 -0
- package/internal/task/beads/bdcli/startup_test.go +27 -0
- package/internal/task/beads/beads.go +1 -0
- package/internal/task/beads/startup.go +75 -0
- package/internal/task/beads/startup_test.go +155 -0
- package/internal/task/factory.go +6 -2
- package/internal/task/jira/jira.go +4 -3
- package/internal/task/jira/rest/client.go +40 -6
- package/internal/task/jira/rest/startup_redirect_test.go +23 -0
- package/internal/task/jira/rest/startup_test.go +133 -0
- package/internal/task/jira/startup.go +22 -0
- package/internal/task/jira/startup_test.go +78 -0
- package/internal/task/startup.go +25 -0
- package/internal/task/startup_test.go +51 -0
- package/package.json +1 -1
|
@@ -113,14 +113,27 @@ func New(site, email, token string) (*HTTPClient, error) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
func (c *HTTPClient) ValidateCredentials(ctx context.Context) error {
|
|
116
|
+
return c.validateCredentials(ctx, 4)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ValidateCredentialsStartup performs a single authenticated read, without
|
|
120
|
+
// inheriting runtime retries or Retry-After delays. The shorter caller budget
|
|
121
|
+
// wins over the five-second ceiling and the normal HTTP client's timeout.
|
|
122
|
+
func (c *HTTPClient) ValidateCredentialsStartup(ctx context.Context) error {
|
|
123
|
+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
124
|
+
defer cancel()
|
|
125
|
+
return c.validateCredentials(ctx, 0)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func (c *HTTPClient) validateCredentials(ctx context.Context, maxRetries int) error {
|
|
116
129
|
var me struct {
|
|
117
130
|
AccountID string `json:"accountId"`
|
|
118
131
|
}
|
|
119
|
-
if err := c.
|
|
132
|
+
if err := c.requestWithRetries(ctx, http.MethodGet, "/rest/api/3/myself", nil, nil, &me, true, maxRetries); err != nil {
|
|
120
133
|
return fmt.Errorf("validate Jira credentials: %w", err)
|
|
121
134
|
}
|
|
122
135
|
if me.AccountID == "" {
|
|
123
|
-
return errors.New("validate Jira credentials: Jira returned no account ID")
|
|
136
|
+
return errors.New("validate Jira credentials: malformed response: Jira returned no account ID")
|
|
124
137
|
}
|
|
125
138
|
return nil
|
|
126
139
|
}
|
|
@@ -525,6 +538,10 @@ func (c *HTTPClient) issueState(key string) issueState {
|
|
|
525
538
|
}
|
|
526
539
|
|
|
527
540
|
func (c *HTTPClient) request(ctx context.Context, method, path string, query url.Values, body any, out any, safe bool) error {
|
|
541
|
+
return c.requestWithRetries(ctx, method, path, query, body, out, safe, 4)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
func (c *HTTPClient) requestWithRetries(ctx context.Context, method, path string, query url.Values, body any, out any, safe bool, maxRetries int) error {
|
|
528
545
|
var payload []byte
|
|
529
546
|
var err error
|
|
530
547
|
if body != nil {
|
|
@@ -554,27 +571,41 @@ func (c *HTTPClient) request(ctx context.Context, method, path string, query url
|
|
|
554
571
|
req.Header.Set("Content-Type", "application/json")
|
|
555
572
|
}
|
|
556
573
|
slog.Debug("jira call", "method", method, "path", path)
|
|
557
|
-
|
|
574
|
+
client := c.http
|
|
575
|
+
if maxRetries == 0 {
|
|
576
|
+
// Startup is one authenticated read, not a redirect chain. Leave
|
|
577
|
+
// normal runtime client/redirect behavior unchanged.
|
|
578
|
+
singleAttempt := *c.http
|
|
579
|
+
singleAttempt.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
|
580
|
+
client = &singleAttempt
|
|
581
|
+
}
|
|
582
|
+
resp, callErr := client.Do(req)
|
|
558
583
|
<-requestSlots
|
|
559
584
|
if callErr != nil {
|
|
560
|
-
|
|
585
|
+
if ctx.Err() != nil {
|
|
586
|
+
return fmt.Errorf("Jira %s %s: timeout/cancellation: %w", method, path, ctx.Err())
|
|
587
|
+
}
|
|
588
|
+
return fmt.Errorf("Jira %s %s: connection failure: %s", method, path, redact(callErr.Error(), c.token, c.email))
|
|
561
589
|
}
|
|
562
590
|
raw, readErr := readBounded(resp.Body)
|
|
563
591
|
resp.Body.Close()
|
|
564
592
|
if readErr != nil {
|
|
593
|
+
if ctx.Err() != nil {
|
|
594
|
+
return fmt.Errorf("Jira %s %s: timeout/cancellation: %w", method, path, ctx.Err())
|
|
595
|
+
}
|
|
565
596
|
return fmt.Errorf("Jira %s %s: %w", method, path, readErr)
|
|
566
597
|
}
|
|
567
598
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
568
599
|
if out != nil && len(raw) > 0 {
|
|
569
600
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
570
|
-
return fmt.Errorf("Jira %s %s:
|
|
601
|
+
return fmt.Errorf("Jira %s %s: malformed response: %w", method, path, err)
|
|
571
602
|
}
|
|
572
603
|
}
|
|
573
604
|
slog.Info("jira outcome", "method", method, "path", path, "result", "ok")
|
|
574
605
|
return nil
|
|
575
606
|
}
|
|
576
607
|
retryable := resp.StatusCode == http.StatusTooManyRequests || (safe && resp.StatusCode >= 500)
|
|
577
|
-
if retryable && attempt <
|
|
608
|
+
if retryable && attempt < maxRetries {
|
|
578
609
|
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
|
|
579
610
|
select {
|
|
580
611
|
case <-ctx.Done():
|
|
@@ -592,6 +623,9 @@ func (c *HTTPClient) request(ctx context.Context, method, path string, query url
|
|
|
592
623
|
message = resp.Status
|
|
593
624
|
}
|
|
594
625
|
slog.Info("jira outcome", "method", method, "path", path, "result", "error", "status", resp.StatusCode)
|
|
626
|
+
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
627
|
+
return fmt.Errorf("Jira %s %s: authentication/permission failure: HTTP %d: %s", method, path, resp.StatusCode, message)
|
|
628
|
+
}
|
|
595
629
|
return fmt.Errorf("Jira %s %s: HTTP %d: %s", method, path, resp.StatusCode, message)
|
|
596
630
|
}
|
|
597
631
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package rest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestStartupCredentialsDoesNotFollowRedirects(t *testing.T) {
|
|
11
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
12
|
+
if r.URL.Path != "/rest/api/3/myself" {
|
|
13
|
+
t.Errorf("startup followed redirect to %s", r.URL.Path)
|
|
14
|
+
writeJSON(w, map[string]any{"accountId": "account"})
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
http.Redirect(w, r, "/unrelated-endpoint", http.StatusFound)
|
|
18
|
+
})
|
|
19
|
+
err := s.client(t).ValidateCredentialsStartup(context.Background())
|
|
20
|
+
if err == nil || !strings.Contains(err.Error(), "HTTP 302") || s.count(http.MethodGet, "/rest/api/3/myself") != 1 || s.count(http.MethodGet, "/unrelated-endpoint") != 0 {
|
|
21
|
+
t.Fatalf("startup redirect = %v", err)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
package rest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync/atomic"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestStartupCredentialsUsesOneAuthenticatedMyselfRead(t *testing.T) {
|
|
15
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
16
|
+
if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/myself" || r.URL.RawQuery != "" {
|
|
17
|
+
t.Errorf("unexpected startup request: %s %s", r.Method, r.URL)
|
|
18
|
+
}
|
|
19
|
+
writeJSON(w, map[string]any{"accountId": "account"})
|
|
20
|
+
})
|
|
21
|
+
if err := s.client(t).ValidateCredentialsStartup(context.Background()); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
if s.count(http.MethodGet, "/rest/api/3/myself") != 1 {
|
|
25
|
+
t.Fatal("startup did not make exactly one credential request")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestStartupCredentialsNeverRetriesRateLimitsOrServerErrors(t *testing.T) {
|
|
30
|
+
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable} {
|
|
31
|
+
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
32
|
+
s := newJiraServer(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) {
|
|
33
|
+
w.Header().Set("Retry-After", "60")
|
|
34
|
+
http.Error(w, "unavailable", status)
|
|
35
|
+
})
|
|
36
|
+
start := time.Now()
|
|
37
|
+
if err := s.client(t).ValidateCredentialsStartup(context.Background()); err == nil {
|
|
38
|
+
t.Fatal("startup accepted failed credentials check")
|
|
39
|
+
}
|
|
40
|
+
if s.count(http.MethodGet, "/rest/api/3/myself") != 1 || time.Since(start) > time.Second {
|
|
41
|
+
t.Fatal("startup inherited runtime retry count or delay")
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestStartupCredentialFailureCategoriesAndRedaction(t *testing.T) {
|
|
48
|
+
for _, tc := range []struct {
|
|
49
|
+
name, body, want string
|
|
50
|
+
status int
|
|
51
|
+
}{
|
|
52
|
+
{"unauthorized", "secret bot@example.com", "authentication", http.StatusUnauthorized},
|
|
53
|
+
{"forbidden", "secret bot@example.com", "authentication", http.StatusForbidden},
|
|
54
|
+
{"invalid-json", "not-json", "malformed", http.StatusOK},
|
|
55
|
+
{"missing-account", `{}`, "malformed", http.StatusOK},
|
|
56
|
+
{"empty-response", "", "malformed", http.StatusOK},
|
|
57
|
+
} {
|
|
58
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
59
|
+
s := newJiraServer(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) {
|
|
60
|
+
w.WriteHeader(tc.status)
|
|
61
|
+
_, _ = w.Write([]byte(tc.body))
|
|
62
|
+
})
|
|
63
|
+
err := s.client(t).ValidateCredentialsStartup(context.Background())
|
|
64
|
+
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
65
|
+
t.Fatalf("%s = %v, want category %s", tc.name, err, tc.want)
|
|
66
|
+
}
|
|
67
|
+
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
|
|
68
|
+
t.Fatalf("credentials leaked: %v", err)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
t.Run("connection", func(t *testing.T) {
|
|
73
|
+
s := httptest.NewServer(http.NotFoundHandler())
|
|
74
|
+
s.Close()
|
|
75
|
+
c, err := New(s.URL, "bot@example.com", "secret")
|
|
76
|
+
if err != nil {
|
|
77
|
+
t.Fatal(err)
|
|
78
|
+
}
|
|
79
|
+
if err := c.ValidateCredentialsStartup(context.Background()); err == nil || !strings.Contains(err.Error(), "connection") {
|
|
80
|
+
t.Fatalf("connection failure = %v", err)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestStartupCredentialsHonorsEarlierDeadlineAndCancellation(t *testing.T) {
|
|
86
|
+
var calls atomic.Int32
|
|
87
|
+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
88
|
+
calls.Add(1)
|
|
89
|
+
<-r.Context().Done()
|
|
90
|
+
}))
|
|
91
|
+
defer s.Close()
|
|
92
|
+
c, err := New(s.URL, "bot@example.com", "secret")
|
|
93
|
+
if err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
|
97
|
+
defer cancel()
|
|
98
|
+
start := time.Now()
|
|
99
|
+
if err := c.ValidateCredentialsStartup(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
|
100
|
+
t.Fatalf("deadline = %v", err)
|
|
101
|
+
}
|
|
102
|
+
if time.Since(start) > time.Second || calls.Load() != 1 {
|
|
103
|
+
t.Fatalf("timeout exceeded caller budget or retried: calls=%d", calls.Load())
|
|
104
|
+
}
|
|
105
|
+
ctx, cancelNow := context.WithCancel(context.Background())
|
|
106
|
+
cancelNow()
|
|
107
|
+
if err := c.ValidateCredentialsStartup(ctx); !errors.Is(err, context.Canceled) || calls.Load() != 1 {
|
|
108
|
+
t.Fatalf("cancellation = %v, calls=%d", err, calls.Load())
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type deadlineTransport struct {
|
|
113
|
+
deadline time.Time
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (d *deadlineTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
117
|
+
d.deadline, _ = r.Context().Deadline()
|
|
118
|
+
return nil, errors.New("transport unavailable")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestStartupCredentialsCapsRequestDeadlineAtFiveSeconds(t *testing.T) {
|
|
122
|
+
c, err := New("https://jira.example.invalid", "bot@example.com", "secret")
|
|
123
|
+
if err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
transport := &deadlineTransport{}
|
|
127
|
+
c.http.Transport = transport
|
|
128
|
+
start := time.Now()
|
|
129
|
+
_ = c.ValidateCredentialsStartup(context.Background())
|
|
130
|
+
if transport.deadline.IsZero() || transport.deadline.After(start.Add(5*time.Second+time.Millisecond)) {
|
|
131
|
+
t.Fatalf("request deadline = %v", transport.deadline)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// probeStartup uses system-wide credentials once, even without registered
|
|
11
|
+
// repositories. It does not need project, component, assignee or status data.
|
|
12
|
+
func probeStartup(ctx context.Context, _ config.RawValues, _ map[string]config.Repo) error {
|
|
13
|
+
creds, err := loadCredentialsDefault()
|
|
14
|
+
if err != nil {
|
|
15
|
+
return fmt.Errorf("jira startup credentials: %w", err)
|
|
16
|
+
}
|
|
17
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
18
|
+
if err != nil {
|
|
19
|
+
return fmt.Errorf("jira startup credentials: %w", err)
|
|
20
|
+
}
|
|
21
|
+
return client.ValidateCredentialsStartup(ctx)
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync/atomic"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func TestSelectedJiraStartupValidatesSystemCredentialsOnceEvenWithoutRepos(t *testing.T) {
|
|
18
|
+
for _, withRepos := range []bool{false, true} {
|
|
19
|
+
t.Run(map[bool]string{false: "no-repos", true: "many-repos"}[withRepos], func(t *testing.T) {
|
|
20
|
+
root := t.TempDir()
|
|
21
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
22
|
+
var calls atomic.Int32
|
|
23
|
+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
24
|
+
calls.Add(1)
|
|
25
|
+
user, token, ok := r.BasicAuth()
|
|
26
|
+
if !ok || user != "bot@example.com" || token != "secret" || r.Method != "GET" || r.URL.Path != "/rest/api/3/myself" {
|
|
27
|
+
t.Errorf("unexpected credential request: %s %s", r.Method, r.URL.Path)
|
|
28
|
+
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
_, _ = w.Write([]byte(`{"accountId":"account"}`))
|
|
32
|
+
}))
|
|
33
|
+
defer s.Close()
|
|
34
|
+
path := filepath.Join(root, "credentials.yaml")
|
|
35
|
+
if err := saveCredentials(path, credentials{Site: s.URL, Email: "bot@example.com", Token: "secret"}); err != nil {
|
|
36
|
+
t.Fatal(err)
|
|
37
|
+
}
|
|
38
|
+
before, err := os.ReadFile(path)
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatal(err)
|
|
41
|
+
}
|
|
42
|
+
var repos map[string]config.Repo
|
|
43
|
+
if withRepos {
|
|
44
|
+
// Probe must not construct/validate dummy repo mappings or queries.
|
|
45
|
+
repos = map[string]config.Repo{"one": {Path: "/absent/one"}, "two": {Path: "/absent/two"}}
|
|
46
|
+
}
|
|
47
|
+
if err := task.ProbeStartup(context.Background(), "jira", config.RawValues{"assignee": "unverified-assignee"}, repos); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
if calls.Load() != 1 {
|
|
51
|
+
t.Fatalf("system credential requests = %d", calls.Load())
|
|
52
|
+
}
|
|
53
|
+
after, err := os.ReadFile(path)
|
|
54
|
+
if err != nil || string(after) != string(before) {
|
|
55
|
+
t.Fatalf("startup changed credentials: %v", err)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func TestSelectedJiraStartupRejectsMissingAndInvalidCredentials(t *testing.T) {
|
|
62
|
+
root := t.TempDir()
|
|
63
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
64
|
+
if err := task.ProbeStartup(context.Background(), "jira", nil, nil); err == nil || !strings.Contains(err.Error(), "credentials") {
|
|
65
|
+
t.Fatalf("missing credentials = %v", err)
|
|
66
|
+
}
|
|
67
|
+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
68
|
+
http.Error(w, "secret bot@example.com", http.StatusUnauthorized)
|
|
69
|
+
}))
|
|
70
|
+
defer s.Close()
|
|
71
|
+
if err := saveCredentials(filepath.Join(root, "credentials.yaml"), credentials{Site: s.URL, Email: "bot@example.com", Token: "secret"}); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
err := task.ProbeStartup(context.Background(), "jira", nil, nil)
|
|
75
|
+
if err == nil || !strings.Contains(err.Error(), "authentication") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "bot@example.com") {
|
|
76
|
+
t.Fatalf("invalid credentials not safely rejected: %v", err)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package task
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// ProbeStartup dispatches only the selected plugin's machine prerequisite
|
|
11
|
+
// checks, even without registered repos. The caller supplies the aggregate
|
|
12
|
+
// deadline; the adapter owns connection deduplication and per-probe limits.
|
|
13
|
+
func ProbeStartup(ctx context.Context, name string, root config.RawValues, repos map[string]config.Repo) error {
|
|
14
|
+
f, err := lookup(name)
|
|
15
|
+
if err != nil {
|
|
16
|
+
return err
|
|
17
|
+
}
|
|
18
|
+
if f.ProbeStartup == nil {
|
|
19
|
+
return fmt.Errorf("task plugin %q has no startup probe", name)
|
|
20
|
+
}
|
|
21
|
+
if err := ctx.Err(); err != nil {
|
|
22
|
+
return err
|
|
23
|
+
}
|
|
24
|
+
return f.ProbeStartup(ctx, config.Merge(defaultConfig(f), root), repos)
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package task_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestStartupDispatchCallsOnlySelectedPluginWithoutConstructingSystems(t *testing.T) {
|
|
14
|
+
selected := t.Name() + t.TempDir()
|
|
15
|
+
other := selected + "-other"
|
|
16
|
+
calls := 0
|
|
17
|
+
failure := errors.New("connection unavailable")
|
|
18
|
+
task.Register(other, task.Factory{ProbeStartup: func(context.Context, config.RawValues, map[string]config.Repo) error {
|
|
19
|
+
t.Fatal("called unselected plugin")
|
|
20
|
+
return nil
|
|
21
|
+
}})
|
|
22
|
+
task.Register(selected, task.Factory{
|
|
23
|
+
DefaultConfig: func() config.RawValues { return config.RawValues{"default": "value"} },
|
|
24
|
+
New: func(context.Context, task.RepoSpec) (task.System, error) {
|
|
25
|
+
t.Fatal("startup constructed a repo system")
|
|
26
|
+
return nil, nil
|
|
27
|
+
},
|
|
28
|
+
ProbeStartup: func(ctx context.Context, root config.RawValues, repos map[string]config.Repo) error {
|
|
29
|
+
calls++
|
|
30
|
+
if root["default"] != "value" || root["explicit"] != "override" || repos["app"].Path != "/work/app" {
|
|
31
|
+
t.Fatalf("startup inputs = %v, %v", root, repos)
|
|
32
|
+
}
|
|
33
|
+
return failure
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
err := task.ProbeStartup(context.Background(), selected, config.RawValues{"explicit": "override"}, map[string]config.Repo{"app": {Path: "/work/app"}})
|
|
37
|
+
if !errors.Is(err, failure) || calls != 1 {
|
|
38
|
+
t.Fatalf("dispatch = %v, calls=%d", err, calls)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestStartupDispatchRequiresProbeSupportEvenWithoutRepos(t *testing.T) {
|
|
43
|
+
name := t.Name() + t.TempDir()
|
|
44
|
+
task.Register(name, task.Factory{})
|
|
45
|
+
if err := task.ProbeStartup(context.Background(), name, nil, nil); err == nil || !strings.Contains(err.Error(), "startup probe") {
|
|
46
|
+
t.Fatalf("missing startup callback = %v", err)
|
|
47
|
+
}
|
|
48
|
+
if err := task.ProbeStartup(context.Background(), "unknown-startup-plugin", nil, nil); err == nil || !strings.Contains(err.Error(), "registered:") {
|
|
49
|
+
t.Fatalf("unknown plugin = %v", err)
|
|
50
|
+
}
|
|
51
|
+
}
|