From 7484c81d1a062f31d48b5f25d58fb13f9d74077a Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:10:11 -0400 Subject: [PATCH 01/31] feat(jira): adapter copied from jira-green, trimmed to tickets and transitions Assisted-by: Claude Code:claude-opus-5-5 --- internal/jira/client.go | 111 ++++++++++ internal/jira/errors.go | 96 +++++++++ internal/jira/search.go | 88 ++++++++ internal/jira/testdata/search.json | 23 +++ internal/jira/testdata/search_page1.json | 15 ++ internal/jira/testdata/search_page2.json | 14 ++ internal/jira/tickets.go | 51 +++++ internal/jira/tickets_test.go | 245 +++++++++++++++++++++++ internal/jira/time.go | 41 ++++ internal/jira/transitions.go | 43 ++++ 10 files changed, 727 insertions(+) create mode 100644 internal/jira/client.go create mode 100644 internal/jira/errors.go create mode 100644 internal/jira/search.go create mode 100644 internal/jira/testdata/search.json create mode 100644 internal/jira/testdata/search_page1.json create mode 100644 internal/jira/testdata/search_page2.json create mode 100644 internal/jira/tickets.go create mode 100644 internal/jira/tickets_test.go create mode 100644 internal/jira/time.go create mode 100644 internal/jira/transitions.go diff --git a/internal/jira/client.go b/internal/jira/client.go new file mode 100644 index 0000000..cfefb1a --- /dev/null +++ b/internal/jira/client.go @@ -0,0 +1,111 @@ +// Copied from jira-green internal/jira/client.go (2026-10-06). + +// Package jira is a thin Jira Cloud REST client covering only what +// app-green needs. +package jira + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Client talks to one Jira Cloud site with basic auth (email + API token). +type Client struct { + site, email, token string + http *http.Client + now func() time.Time // reads a Retry-After HTTP-date +} + +// New returns a Client for site, e.g. "https://example.atlassian.net". +func New(site, email, token string) *Client { + return &Client{ + site: strings.TrimRight(site, "/"), + email: email, + token: token, + http: &http.Client{Timeout: 20 * time.Second}, + now: time.Now, + } +} + +// Site is the Jira base URL, without a trailing slash. +func (c *Client) Site() string { return c.site } + +// BrowseURL is the web URL for an issue key. +func (c *Client) BrowseURL(key string) string { return c.site + "/browse/" + key } + +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.site+path, rdr) + if err != nil { + return err + } + req.SetBasicAuth(c.email, c.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + retry := parseRetryAfter(resp.Header.Get("Retry-After"), c.now()) + if retry == 0 && resp.StatusCode == http.StatusTooManyRequests { + retry = defaultRetryAfter + } + return &APIError{ + Status: resp.StatusCode, + Messages: errorMessages(b), + Body: strings.TrimSpace(string(b)), + RetryAfter: retry, + } + } + if out == nil || resp.StatusCode == http.StatusNoContent { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("jira: decode %s: %w", path, err) + } + return nil +} + +// User is a Jira account. +type User struct { + AccountID string `json:"accountId"` + DisplayName string `json:"displayName"` +} + +// Myself returns the authenticated user. +func (c *Client) Myself(ctx context.Context) (User, error) { + var u User + err := c.do(ctx, http.MethodGet, "/rest/api/3/myself", nil, &u) + return u, err +} + +// API is the subset of Client the resolver and UI use; tests supply fakes. +type API interface { + Myself(ctx context.Context) (User, error) + MyTickets(ctx context.Context, projects []string) ([]model.Ticket, error) + TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, error) + Transitions(ctx context.Context, key string) ([]Transition, error) + DoTransition(ctx context.Context, key, transitionID string) error +} + +var _ API = (*Client)(nil) diff --git a/internal/jira/errors.go b/internal/jira/errors.go new file mode 100644 index 0000000..98df9bf --- /dev/null +++ b/internal/jira/errors.go @@ -0,0 +1,96 @@ +// Copied from jira-green internal/jira/errors.go (2026-10-06). + +package jira + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "slices" + "strconv" + "strings" + "time" +) + +// APIError is a non-2xx response. +type APIError struct { + Status int + // Messages is Jira's error envelope flattened: errorMessages first, then + // message, then "field: msg" for each entry of errors, sorted by field. + Messages []string + // Body is the raw (size-limited) response body, kept for debugging. + Body string + RetryAfter time.Duration +} + +// maxErrorLen caps Error() so a verbose Jira response fits a status line. +const maxErrorLen = 200 + +// Error is "jira: HTTP 400: msg1; field: msg2", cut to about 200 +// characters, or "jira: HTTP 502 Bad Gateway" when the body had no Jira +// error envelope (an HTML proxy page, say). +func (e *APIError) Error() string { + if len(e.Messages) == 0 { + return strings.TrimSpace(fmt.Sprintf("jira: HTTP %d %s", e.Status, http.StatusText(e.Status))) + } + s := fmt.Sprintf("jira: HTTP %d: %s", e.Status, strings.Join(e.Messages, "; ")) + if r := []rune(s); len(r) > maxErrorLen { + s = string(r[:maxErrorLen-1]) + "…" + } + return s +} + +// errorMessages parses Jira's {"errorMessages":[...],"errors":{...}} +// envelope, and the {"message":"..."} shape some endpoints (the Agile API, +// the gateway) use. It returns nil when body is neither. +func errorMessages(body []byte) []string { + var env struct { + ErrorMessages []string `json:"errorMessages"` + Errors map[string]string `json:"errors"` + Message string `json:"message"` + } + if json.Unmarshal(body, &env) != nil { + return nil + } + out := slices.Clone(env.ErrorMessages) + if env.Message != "" { + out = append(out, env.Message) + } + for _, k := range slices.Sorted(maps.Keys(env.Errors)) { + out = append(out, k+": "+env.Errors[k]) + } + return out +} + +// IsAuth reports whether err is a 401 Unauthorized: the credentials are bad, +// so polling should stop rather than retry. A 403 Forbidden is deliberately +// not an auth error: it usually means one resource (a board, an issue) is +// off-limits while the token still works, so polling keeps going. +func IsAuth(err error) bool { + var ae *APIError + return errors.As(err, &ae) && ae.Status == http.StatusUnauthorized +} + +// defaultRetryAfter is the backoff for a 429 with no usable Retry-After. +const defaultRetryAfter = 60 * time.Second + +// maxRetryAfter caps a server's Retry-After, so a bogus or hostile value +// cannot stop the dashboard for hours. +const maxRetryAfter = 15 * time.Minute + +// parseRetryAfter reads a Retry-After header: whole seconds or an HTTP-date, +// measured from now. +// It returns 0 for a missing, unparseable, or negative value, and at most +// maxRetryAfter. +func parseRetryAfter(h string, now time.Time) time.Duration { + h = strings.TrimSpace(h) + var d time.Duration + if n, err := strconv.Atoi(h); err == nil { + d = time.Duration(min(n, int(maxRetryAfter/time.Second))) * time.Second // no overflow + } else if t, err := http.ParseTime(h); err == nil { + d = t.Sub(now) + } + return min(max(d, 0), maxRetryAfter) +} diff --git a/internal/jira/search.go b/internal/jira/search.go new file mode 100644 index 0000000..2f22ceb --- /dev/null +++ b/internal/jira/search.go @@ -0,0 +1,88 @@ +// Copied from jira-green internal/jira/search.go (2026-10-06). + +package jira + +import ( + "context" + "fmt" + "net/http" +) + +// maxPages caps every paginated call. Hitting it is an error, never a +// silently truncated result. +const maxPages = 50 + +var searchFields = []string{"summary", "status", "updated", "statuscategorychangedate"} + +// issue holds only the fields app-green reads from a search hit. +type issue struct { + Key string + Summary string + StatusName string + StatusCategory string // statusCategory.key: "new", "indeterminate", "done" + Updated Time + StatusCategoryChangedDate Time +} + +type apiIssue struct { + Key string `json:"key"` + Fields struct { + Summary string `json:"summary"` + Status struct { + Name string `json:"name"` + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` + } `json:"status"` + Updated Time `json:"updated"` + StatusCategoryChangeDate Time `json:"statuscategorychangedate"` + } `json:"fields"` +} + +type searchResp struct { + Issues []apiIssue `json:"issues"` + NextPageToken string `json:"nextPageToken"` + IsLast bool `json:"isLast"` +} + +// search runs JQL and returns every matching issue, following +// nextPageToken. +func (c *Client) search(ctx context.Context, jql string) ([]issue, error) { + var out []issue + token := "" + seen := map[string]bool{} + for range maxPages { + body := map[string]any{"jql": jql, "fields": searchFields, "maxResults": 100} + if token != "" { + body["nextPageToken"] = token + } + var r searchResp + if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", body, &r); err != nil { + return nil, err + } + for _, ai := range r.Issues { + out = append(out, convert(ai)) + } + if r.IsLast || r.NextPageToken == "" { + return out, nil + } + if seen[r.NextPageToken] { + return nil, fmt.Errorf("jira: search: nextPageToken %q repeated", r.NextPageToken) + } + seen[r.NextPageToken] = true + token = r.NextPageToken + } + return nil, fmt.Errorf("jira: search: more than %d pages", maxPages) +} + +func convert(ai apiIssue) issue { + f := ai.Fields + return issue{ + Key: ai.Key, + Summary: f.Summary, + StatusName: f.Status.Name, + StatusCategory: f.Status.StatusCategory.Key, + Updated: f.Updated, + StatusCategoryChangedDate: f.StatusCategoryChangeDate, + } +} diff --git a/internal/jira/testdata/search.json b/internal/jira/testdata/search.json new file mode 100644 index 0000000..dcc2bbc --- /dev/null +++ b/internal/jira/testdata/search.json @@ -0,0 +1,23 @@ +{ + "issues": [ + { + "key": "ABC-1", + "fields": { + "summary": "Add widget export", + "status": {"id": "3", "name": "Code Review", "statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress"}}, + "updated": "2026-10-05T09:00:00.000-0400", + "statuscategorychangedate": "2026-10-01T08:30:00.000-0400" + } + }, + { + "key": "ABC-2", + "fields": { + "summary": "Fix gadget rounding", + "status": {"id": "10001", "name": "Done", "statusCategory": {"id": 3, "key": "done", "name": "Done"}}, + "updated": "2026-10-04T15:00:00.000-0400", + "statuscategorychangedate": "2026-10-03T12:00:00.000-0400" + } + } + ], + "isLast": true +} diff --git a/internal/jira/testdata/search_page1.json b/internal/jira/testdata/search_page1.json new file mode 100644 index 0000000..d713ec5 --- /dev/null +++ b/internal/jira/testdata/search_page1.json @@ -0,0 +1,15 @@ +{ + "issues": [ + { + "key": "ABC-1", + "fields": { + "summary": "Add widget export", + "status": {"id": "3", "name": "In Progress", "statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress"}}, + "updated": "2026-10-05T09:00:00.000-0400", + "statuscategorychangedate": "2026-10-01T08:30:00.000-0400" + } + } + ], + "nextPageToken": "p2", + "isLast": false +} diff --git a/internal/jira/testdata/search_page2.json b/internal/jira/testdata/search_page2.json new file mode 100644 index 0000000..b549568 --- /dev/null +++ b/internal/jira/testdata/search_page2.json @@ -0,0 +1,14 @@ +{ + "issues": [ + { + "key": "ABC-3", + "fields": { + "summary": "Tidy sprocket logs", + "status": {"id": "3", "name": "In Progress", "statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress"}}, + "updated": "2026-10-02T11:00:00.000-0400", + "statuscategorychangedate": null + } + } + ], + "isLast": true +} diff --git a/internal/jira/tickets.go b/internal/jira/tickets.go new file mode 100644 index 0000000..9ee87ce --- /dev/null +++ b/internal/jira/tickets.go @@ -0,0 +1,51 @@ +package jira + +import ( + "context" + "fmt" + "strings" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// MyTickets returns my tickets in projects that are not To Do: everything in +// progress, plus Done tickets that finished in the last 14 days (their +// changes may still be on the way to prod). The JQL uses Jira's fixed status +// category IDs (4 = In Progress, 3 = Done), so a site that renames or +// translates its categories cannot break it. +func (c *Client) MyTickets(ctx context.Context, projects []string) ([]model.Ticket, error) { + jql := fmt.Sprintf(`assignee = currentUser() AND project in (%s) AND `+ + `(statusCategory = 4 OR (statusCategory = 3 AND statusCategoryChangedDate >= -14d)) `+ + `ORDER BY updated DESC`, strings.Join(projects, ",")) + return c.tickets(ctx, jql) +} + +// TicketsByKey returns the named tickets, whoever they are assigned to. The +// resolver uses it for tickets named on my PRs that MyTickets missed. No keys +// means no request ("key in ()" is invalid JQL). +func (c *Client) TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, error) { + if len(keys) == 0 { + return nil, nil + } + return c.tickets(ctx, fmt.Sprintf("key in (%s)", strings.Join(keys, ","))) +} + +func (c *Client) tickets(ctx context.Context, jql string) ([]model.Ticket, error) { + issues, err := c.search(ctx, jql) + if err != nil { + return nil, err + } + out := make([]model.Ticket, len(issues)) + for i, is := range issues { + out[i] = model.Ticket{ + Key: is.Key, + Title: is.Summary, + Status: is.StatusName, + StatusCategory: model.StatusCategory(is.StatusCategory), + URL: c.BrowseURL(is.Key), + Updated: is.Updated.Time, + StatusSince: is.StatusCategoryChangedDate.Time, + } + } + return out, nil +} diff --git a/internal/jira/tickets_test.go b/internal/jira/tickets_test.go new file mode 100644 index 0000000..06c90a8 --- /dev/null +++ b/internal/jira/tickets_test.go @@ -0,0 +1,245 @@ +package jira_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/model" +) + +func TestMyTickets(t *testing.T) { + var gotJQL string + var gotFields []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + JQL string `json:"jql"` + Fields []string `json:"fields"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotJQL, gotFields = body.JQL, body.Fields + http.ServeFile(w, r, "testdata/search.json") + })) + defer srv.Close() + c := jira.New(srv.URL, "me@example.com", "tok") + ts, err := c.MyTickets(context.Background(), []string{"ABC", "XYZ"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "assignee = currentUser()", + "project in (ABC,XYZ)", + "(statusCategory = 4 OR (statusCategory = 3 AND statusCategoryChangedDate >= -14d))", + "ORDER BY updated DESC", + } { + if !strings.Contains(gotJQL, want) { + t.Errorf("jql = %s, missing %q", gotJQL, want) + } + } + if strings.Join(gotFields, ",") != "summary,status,updated,statuscategorychangedate" { + t.Errorf("fields = %v", gotFields) + } + if len(ts) != 2 { + t.Fatalf("got %d tickets, want 2: %+v", len(ts), ts) + } + edt := time.FixedZone("", -4*3600) + want := model.Ticket{ + Key: "ABC-1", + Title: "Add widget export", + Status: "Code Review", + StatusCategory: model.StatusInProgress, + URL: srv.URL + "/browse/ABC-1", + Updated: time.Date(2026, 10, 5, 9, 0, 0, 0, edt), + StatusSince: time.Date(2026, 10, 1, 8, 30, 0, 0, edt), + } + if !ticketEqual(ts[0], want) { + t.Errorf("ts[0] = %+v, want %+v", ts[0], want) + } + if ts[1].Key != "ABC-2" || ts[1].StatusCategory != model.StatusDone || + !ts[1].StatusSince.Equal(time.Date(2026, 10, 3, 12, 0, 0, 0, edt)) { + t.Errorf("ts[1] = %+v", ts[1]) + } +} + +// ticketEqual compares times with Equal, so zones need not match. +func ticketEqual(a, b model.Ticket) bool { + return a.Key == b.Key && a.Title == b.Title && a.Status == b.Status && + a.StatusCategory == b.StatusCategory && a.URL == b.URL && + a.Updated.Equal(b.Updated) && a.StatusSince.Equal(b.StatusSince) +} + +func TestMyTicketsFollowsPages(t *testing.T) { + var tokens []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + NextPageToken string `json:"nextPageToken"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + tokens = append(tokens, body.NextPageToken) + if body.NextPageToken == "p2" { + http.ServeFile(w, r, "testdata/search_page2.json") + return + } + http.ServeFile(w, r, "testdata/search_page1.json") + })) + defer srv.Close() + ts, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(tokens, ",") != ",p2" { + t.Errorf("page tokens sent = %q, want first page then p2", tokens) + } + if len(ts) != 2 || ts[0].Key != "ABC-1" || ts[1].Key != "ABC-3" { + t.Fatalf("tickets = %+v", ts) + } + if !ts[1].StatusSince.IsZero() { + t.Errorf("null statuscategorychangedate gave StatusSince %v, want zero", ts[1].StatusSince) + } +} + +func TestMyTicketsRepeatedPageTokenIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/search_page1.json") // always says "next is p2" + })) + defer srv.Close() + _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + if err == nil || !strings.Contains(err.Error(), "repeated") { + t.Fatalf("err = %v, want repeated-token error", err) + } +} + +func TestTicketsByKey(t *testing.T) { + var gotJQL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + JQL string `json:"jql"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotJQL = body.JQL + http.ServeFile(w, r, "testdata/search.json") + })) + defer srv.Close() + ts, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), []string{"ABC-1", "ABC-2"}) + if err != nil { + t.Fatal(err) + } + if gotJQL != "key in (ABC-1,ABC-2)" { + t.Errorf("jql = %q", gotJQL) + } + if len(ts) != 2 || ts[0].Key != "ABC-1" || ts[1].StatusCategory != model.StatusDone { + t.Errorf("tickets = %+v", ts) + } +} + +func TestTicketsByKeyEmptyMakesNoRequest(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + http.ServeFile(w, r, "testdata/search.json") + })) + defer srv.Close() + ts, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), nil) + if err != nil || len(ts) != 0 || calls != 0 { + t.Errorf("tickets = %+v, err = %v, requests = %d; want none, nil, 0", ts, err, calls) + } +} + +func TestUnauthorizedIsAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"errorMessages":["Client must be authenticated"]}`)) + })) + defer srv.Close() + _, err := jira.New(srv.URL, "me@example.com", "bad").MyTickets(context.Background(), []string{"ABC"}) + if !jira.IsAuth(err) { + t.Fatalf("err = %v, want IsAuth", err) + } +} + +func TestForbiddenIsNotAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + if err == nil || jira.IsAuth(err) { + t.Fatalf("err = %v, want a non-auth error", err) + } +} + +func TestRateLimitCarriesRetryAfter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + var ae *jira.APIError + if !errors.As(err, &ae) || ae.Status != http.StatusTooManyRequests || ae.RetryAfter != 30*time.Second { + t.Fatalf("err = %#v, want *APIError 429 with RetryAfter 30s", err) + } + if jira.IsAuth(err) { + t.Error("429 reported as auth error") + } +} + +func TestRateLimitWithoutRetryAfterDefaultsToMinute(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + _, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), []string{"ABC-1"}) + var ae *jira.APIError + if !errors.As(err, &ae) || ae.RetryAfter != time.Minute { + t.Fatalf("err = %#v, want RetryAfter 1m", err) + } +} + +func TestTransitions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/rest/api/3/issue/ABC-1/transitions" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"transitions":[ + {"id":"21","name":"Start review","to":{"name":"Code Review"}}, + {"id":"31","name":"Finish","to":{"name":"Done"}}]}`)) + })) + defer srv.Close() + got, err := jira.New(srv.URL, "me@example.com", "tok").Transitions(context.Background(), "ABC-1") + if err != nil { + t.Fatal(err) + } + want := []jira.Transition{{ID: "21", Name: "Start review", ToName: "Code Review"}, {ID: "31", Name: "Finish", ToName: "Done"}} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("transitions = %+v, want %+v", got, want) + } +} + +func TestDoTransition(t *testing.T) { + var method, path, id string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + var body struct { + Transition struct { + ID string `json:"id"` + } `json:"transition"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + id = body.Transition.ID + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + if err := jira.New(srv.URL, "me@example.com", "tok").DoTransition(context.Background(), "ABC-1", "31"); err != nil { + t.Fatal(err) + } + if method != http.MethodPost || path != "/rest/api/3/issue/ABC-1/transitions" || id != "31" { + t.Errorf("got %s %s id=%q", method, path, id) + } +} diff --git a/internal/jira/time.go b/internal/jira/time.go new file mode 100644 index 0000000..4664295 --- /dev/null +++ b/internal/jira/time.go @@ -0,0 +1,41 @@ +// Copied from jira-green internal/jira/time.go (2026-10-06). + +package jira + +import ( + "bytes" + "fmt" + "strconv" + "time" +) + +// Time parses Jira's timestamp format, which is not RFC 3339 +// ("2026-09-30T10:00:00.000-0400"). +type Time struct{ time.Time } + +// layouts are tried in order: Jira's usual format, Jira without +// milliseconds, then RFC 3339. +var layouts = []string{"2006-01-02T15:04:05.000-0700", "2006-01-02T15:04:05-0700", time.RFC3339} + +// UnmarshalJSON accepts null (the zero time) or a JSON string in Jira's +// layout (with or without milliseconds) or RFC 3339. Anything else is an error. +func (t *Time) UnmarshalJSON(b []byte) error { + t.Time = time.Time{} + if bytes.Equal(b, []byte("null")) { + return nil + } + if len(b) == 0 || b[0] != '"' { + return fmt.Errorf("jira: time %s is not a JSON string", b) + } + s, err := strconv.Unquote(string(b)) + if err != nil { + return fmt.Errorf("jira: time %s is not a JSON string", b) + } + for _, l := range layouts { + if p, err := time.Parse(l, s); err == nil { + t.Time = p + return nil + } + } + return fmt.Errorf("jira: unparseable time %q", s) +} diff --git a/internal/jira/transitions.go b/internal/jira/transitions.go new file mode 100644 index 0000000..c267a2c --- /dev/null +++ b/internal/jira/transitions.go @@ -0,0 +1,43 @@ +// Copied from jira-green internal/jira/transitions.go (2026-10-06). + +package jira + +import ( + "context" + "net/http" + "net/url" +) + +// Transition is a workflow move available from an issue's current status. +type Transition struct { + ID string + Name string + ToName string +} + +// Transitions lists the transitions Jira allows from the issue's current status. +func (c *Client) Transitions(ctx context.Context, key string) ([]Transition, error) { + var r struct { + Transitions []struct { + ID string `json:"id"` + Name string `json:"name"` + To struct { + Name string `json:"name"` + } `json:"to"` + } `json:"transitions"` + } + if err := c.do(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key)+"/transitions", nil, &r); err != nil { + return nil, err + } + out := make([]Transition, 0, len(r.Transitions)) + for _, t := range r.Transitions { + out = append(out, Transition{ID: t.ID, Name: t.Name, ToName: t.To.Name}) + } + return out, nil +} + +// DoTransition moves the issue through the given transition. +func (c *Client) DoTransition(ctx context.Context, key, transitionID string) error { + body := map[string]any{"transition": map[string]string{"id": transitionID}} + return c.do(ctx, http.MethodPost, "/rest/api/3/issue/"+url.PathEscape(key)+"/transitions", body, nil) +} From 137a05daa0cdd442b5db380ad5f8d66d1021e7ab Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:12:44 -0400 Subject: [PATCH 02/31] fix(jira): a malformed field blanks only that field Assisted-by: Claude Code:claude-opus-5-5 --- internal/jira/search.go | 50 +++++++++++++------- internal/jira/testdata/search_malformed.json | 23 +++++++++ internal/jira/tickets_test.go | 35 ++++++++++++++ 3 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 internal/jira/testdata/search_malformed.json diff --git a/internal/jira/search.go b/internal/jira/search.go index 2f22ceb..f6b1dbe 100644 --- a/internal/jira/search.go +++ b/internal/jira/search.go @@ -4,6 +4,7 @@ package jira import ( "context" + "encoding/json" "fmt" "net/http" ) @@ -25,18 +26,15 @@ type issue struct { } type apiIssue struct { - Key string `json:"key"` - Fields struct { - Summary string `json:"summary"` - Status struct { - Name string `json:"name"` - StatusCategory struct { - Key string `json:"key"` - } `json:"statusCategory"` - } `json:"status"` - Updated Time `json:"updated"` - StatusCategoryChangeDate Time `json:"statuscategorychangedate"` - } `json:"fields"` + Key string `json:"key"` + Fields map[string]json.RawMessage `json:"fields"` +} + +type apiStatus struct { + Name string `json:"name"` + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` } type searchResp struct { @@ -75,14 +73,30 @@ func (c *Client) search(ctx context.Context, jql string) ([]issue, error) { return nil, fmt.Errorf("jira: search: more than %d pages", maxPages) } +// decodeField decodes fields[name] into a fresh T, so one malformed field +// does not blank the others. Missing, null and malformed fields all give the +// zero value (never a partial one). +func decodeField[T any](fields map[string]json.RawMessage, name string) T { + var v T + raw, ok := fields[name] + if !ok || string(raw) == "null" { + return v + } + if json.Unmarshal(raw, &v) != nil { + var zero T + return zero + } + return v +} + func convert(ai apiIssue) issue { - f := ai.Fields + st := decodeField[apiStatus](ai.Fields, "status") return issue{ Key: ai.Key, - Summary: f.Summary, - StatusName: f.Status.Name, - StatusCategory: f.Status.StatusCategory.Key, - Updated: f.Updated, - StatusCategoryChangedDate: f.StatusCategoryChangeDate, + Summary: decodeField[string](ai.Fields, "summary"), + StatusName: st.Name, + StatusCategory: st.StatusCategory.Key, + Updated: decodeField[Time](ai.Fields, "updated"), + StatusCategoryChangedDate: decodeField[Time](ai.Fields, "statuscategorychangedate"), } } diff --git a/internal/jira/testdata/search_malformed.json b/internal/jira/testdata/search_malformed.json new file mode 100644 index 0000000..e624b4e --- /dev/null +++ b/internal/jira/testdata/search_malformed.json @@ -0,0 +1,23 @@ +{ + "issues": [ + { + "key": "ABC-1", + "fields": { + "summary": "Add widget export", + "status": {"id": "3", "name": "Code Review", "statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress"}}, + "updated": "not-a-date", + "statuscategorychangedate": "2026-10-01T08:30:00.000-0400" + } + }, + { + "key": "ABC-2", + "fields": { + "summary": "Fix gadget rounding", + "status": {"id": "10001", "name": "Done", "statusCategory": {"id": 3, "key": "done", "name": "Done"}}, + "updated": "2026-10-04T15:00:00.000-0400", + "statuscategorychangedate": 12345 + } + } + ], + "isLast": true +} diff --git a/internal/jira/tickets_test.go b/internal/jira/tickets_test.go index 06c90a8..7ef00bd 100644 --- a/internal/jira/tickets_test.go +++ b/internal/jira/tickets_test.go @@ -243,3 +243,38 @@ func TestDoTransition(t *testing.T) { t.Errorf("got %s %s id=%q", method, path, id) } } + +func TestMalformedFieldBlanksOnlyThatField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/search_malformed.json") + })) + defer srv.Close() + ts, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + if err != nil { + t.Fatal(err) + } + if len(ts) != 2 { + t.Fatalf("got %d tickets, want 2: %+v", len(ts), ts) + } + edt := time.FixedZone("", -4*3600) + want := []model.Ticket{{ + Key: "ABC-1", + Title: "Add widget export", + Status: "Code Review", + StatusCategory: model.StatusInProgress, + URL: srv.URL + "/browse/ABC-1", + StatusSince: time.Date(2026, 10, 1, 8, 30, 0, 0, edt), // Updated is bad: zero + }, { + Key: "ABC-2", + Title: "Fix gadget rounding", + Status: "Done", + StatusCategory: model.StatusDone, + URL: srv.URL + "/browse/ABC-2", + Updated: time.Date(2026, 10, 4, 15, 0, 0, 0, edt), // StatusSince is bad: zero + }} + for i := range want { + if !ticketEqual(ts[i], want[i]) { + t.Errorf("ts[%d] = %+v, want %+v", i, ts[i], want[i]) + } + } +} From 6612a4a25a3b4a11ac581a5c7b668c81f115d830 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:19:50 -0400 Subject: [PATCH 03/31] fix(jira): validate keys, report decode errors, review fixes Assisted-by: Claude Code:claude-opus-5-5 --- internal/jira/client.go | 7 +- internal/jira/client_test.go | 264 ++++++++++++++++++++++++++++++++++ internal/jira/export_test.go | 8 ++ internal/jira/search.go | 54 ++++--- internal/jira/tickets.go | 50 +++++-- internal/jira/tickets_test.go | 134 +++++++++++++++-- internal/model/model.go | 2 +- 7 files changed, 474 insertions(+), 45 deletions(-) create mode 100644 internal/jira/client_test.go create mode 100644 internal/jira/export_test.go diff --git a/internal/jira/client.go b/internal/jira/client.go index cfefb1a..a436b33 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -78,11 +78,14 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err } } if out == nil || resp.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, resp.Body) return nil } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("jira: decode %s: %w", path, err) } + // Read to the end so the keep-alive connection can be reused. + _, _ = io.Copy(io.Discard, resp.Body) return nil } @@ -102,8 +105,8 @@ func (c *Client) Myself(ctx context.Context) (User, error) { // API is the subset of Client the resolver and UI use; tests supply fakes. type API interface { Myself(ctx context.Context) (User, error) - MyTickets(ctx context.Context, projects []string) ([]model.Ticket, error) - TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, error) + MyTickets(ctx context.Context, projects []string) ([]model.Ticket, []string, error) + TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, []string, error) Transitions(ctx context.Context, key string) ([]Transition, error) DoTransition(ctx context.Context, key, transitionID string) error } diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go new file mode 100644 index 0000000..608d5a1 --- /dev/null +++ b/internal/jira/client_test.go @@ -0,0 +1,264 @@ +// Copied from jira-green internal/jira/client_test.go (2026-10-06). + +package jira_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/ericdahl-dev/app-green/internal/jira" +) + +func newTest(t *testing.T, h http.HandlerFunc) *jira.Client { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return jira.New(srv.URL, "me@example.com", "tok") +} + +func TestTimeParsesJiraFormat(t *testing.T) { + var jt jira.Time + if err := jt.UnmarshalJSON([]byte(`"2026-09-30T10:00:00.000-0400"`)); err != nil { + t.Fatal(err) + } + if !jt.Equal(time.Date(2026, 9, 30, 14, 0, 0, 0, time.UTC)) { + t.Errorf("got %v", jt.Time) + } + if err := jt.UnmarshalJSON([]byte(`null`)); err != nil || !jt.IsZero() { + t.Errorf("null should be zero") + } +} + +func TestMyselfSendsBasicAuth(t *testing.T) { + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok || u != "me@example.com" || p != "tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path != "/rest/api/3/myself" { + t.Errorf("path %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"accountId":"acct-me","displayName":"Me"}`)) + }) + me, err := c.Myself(context.Background()) + if err != nil || me.AccountID != "acct-me" { + t.Fatalf("%+v %v", me, err) + } +} + +func TestAuthErrorIsDetectable(t *testing.T) { + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + _, err := c.Myself(context.Background()) + if !jira.IsAuth(err) { + t.Fatalf("want auth error, got %v", err) + } +} + +func TestRateLimitCarriesRetryAfter(t *testing.T) { + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + }) + _, err := c.Myself(context.Background()) + var ae *jira.APIError + if !errors.As(err, &ae) || ae.RetryAfter != 30*time.Second { + t.Fatalf("got %v", err) + } +} + +func TestForbiddenIsNotAuth(t *testing.T) { + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + _, err := c.Myself(context.Background()) + if err == nil || jira.IsAuth(err) { + t.Fatalf("403 should be an error but not an auth error, got %v", err) + } +} + +func TestAPIErrorSummarizesJiraEnvelope(t *testing.T) { + body := `{"errorMessages":["The JQL is bad."],"errors":{"summary":"required","assignee":"unknown user"}}` + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(body)) + }) + _, err := c.Myself(context.Background()) + var ae *jira.APIError + if !errors.As(err, &ae) { + t.Fatalf("got %v", err) + } + want := []string{"The JQL is bad.", "assignee: unknown user", "summary: required"} + if !slices.Equal(ae.Messages, want) { + t.Errorf("messages %q, want %q", ae.Messages, want) + } + if got := err.Error(); got != "jira: HTTP 400: The JQL is bad.; assignee: unknown user; summary: required" { + t.Errorf("Error() = %q", got) + } + if ae.Body != body { + t.Errorf("raw body %q", ae.Body) + } +} + +func TestAPIErrorHTMLBodyUsesStatusText(t *testing.T) { + body := "

502 Bad Gateway

" + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(body)) + }) + _, err := c.Myself(context.Background()) + var ae *jira.APIError + if !errors.As(err, &ae) || ae.Body != body || len(ae.Messages) != 0 { + t.Fatalf("got %#v", err) + } + if got := err.Error(); got != "jira: HTTP 502 Bad Gateway" { + t.Errorf("Error() = %q", got) + } +} + +func TestAPIErrorTruncatesLongMessages(t *testing.T) { + long := strings.Repeat("é", 500) + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, `{"errorMessages":[%q]}`, long) + }) + _, err := c.Myself(context.Background()) + got := err.Error() + if n := utf8.RuneCountInString(got); n > 205 || !strings.HasPrefix(got, "jira: HTTP 400: éé") { + t.Errorf("Error() is %d runes: %q", n, got) + } + if !utf8.ValidString(got) { + t.Errorf("truncation split a rune: %q", got) + } +} + +// retryClock is the client's clock in the Retry-After tests. +var retryClock = time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + +func retryAfter(t *testing.T, status int, header string, set bool) time.Duration { + t.Helper() + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + if set { + w.Header().Set("Retry-After", header) + } + w.WriteHeader(status) + }) + jira.SetNow(c, func() time.Time { return retryClock }) + _, err := c.Myself(context.Background()) + var ae *jira.APIError + if !errors.As(err, &ae) { + t.Fatalf("got %v", err) + } + return ae.RetryAfter +} + +func TestRetryAfterTrimsWhitespace(t *testing.T) { + if got := retryAfter(t, http.StatusTooManyRequests, " 30 ", true); got != 30*time.Second { + t.Errorf("got %v", got) + } +} + +func TestRetryAfterHTTPDate(t *testing.T) { + date := retryClock.Add(120 * time.Second).Format(http.TimeFormat) + if got := retryAfter(t, http.StatusTooManyRequests, date, true); got != 120*time.Second { + t.Errorf("got %v, want 120s", got) + } +} + +func TestRetryAfterMissingOn429DefaultsToMinute(t *testing.T) { + if got := retryAfter(t, http.StatusTooManyRequests, "", false); got != 60*time.Second { + t.Errorf("got %v, want 60s", got) + } + if got := retryAfter(t, http.StatusServiceUnavailable, "", false); got != 0 { + t.Errorf("503 without header: got %v, want 0", got) + } +} + +func TestRetryAfterUnusableOn429DefaultsToMinute(t *testing.T) { + past := retryClock.Add(-time.Hour).Format(http.TimeFormat) + for _, h := range []string{"soon", "-5", "1.5", past} { + if got := retryAfter(t, http.StatusTooManyRequests, h, true); got != 60*time.Second { + t.Errorf("Retry-After %q: got %v, want 60s", h, got) + } + } +} + +func TestTimeRejectsGarbage(t *testing.T) { + for _, in := range []string{`1`, `""`, `"garbage"`, `x2026-09-30T10:00:00.000-0400x`, `"2026-09-30T10:00:00.000-0400`, `true`} { + var jt jira.Time + if err := jt.UnmarshalJSON([]byte(in)); err == nil { + t.Errorf("%s: want an error, got %v", in, jt.Time) + } + } +} + +func TestTimeRFC3339Fallback(t *testing.T) { + var jt jira.Time + if err := jt.UnmarshalJSON([]byte(`"2026-09-30T10:00:00-04:00"`)); err != nil { + t.Fatal(err) + } + if !jt.Equal(time.Date(2026, 9, 30, 14, 0, 0, 0, time.UTC)) { + t.Errorf("got %v", jt.Time) + } +} + +func TestTimeWithoutMilliseconds(t *testing.T) { + var jt jira.Time + if err := jt.UnmarshalJSON([]byte(`"2026-09-30T10:00:00-0400"`)); err != nil { + t.Fatal(err) + } + if !jt.Equal(time.Date(2026, 9, 30, 14, 0, 0, 0, time.UTC)) { + t.Errorf("got %v", jt.Time) + } +} + +func TestNon2xxBelow200IsAPIError(t *testing.T) { + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + conn, buf, err := http.NewResponseController(w).Hijack() + if err != nil { + t.Errorf("hijack: %v", err) + return + } + defer func() { _ = conn.Close() }() + _, _ = buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: x\r\n\r\n") + _ = buf.Flush() + }) + _, err := c.Myself(context.Background()) + var ae *jira.APIError + if !errors.As(err, &ae) || ae.Status != http.StatusSwitchingProtocols { + t.Fatalf("want APIError 101, got %v", err) + } +} + +func TestRetryAfterClampedTo15Minutes(t *testing.T) { + for _, h := range []string{"86400", "9223372036854775807"} { + if got := retryAfter(t, http.StatusTooManyRequests, h, true); got != 15*time.Minute { + t.Errorf("Retry-After %s: got %v, want 15m", h, got) + } + } +} + +func TestAPIErrorReadsMessageShape(t *testing.T) { + c := newTest(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Board does not exist","status-code":404}`)) + }) + _, err := c.Myself(context.Background()) + var ae *jira.APIError + if !errors.As(err, &ae) || !slices.Equal(ae.Messages, []string{"Board does not exist"}) { + t.Fatalf("got %#v", err) + } + if got := err.Error(); got != "jira: HTTP 404: Board does not exist" { + t.Errorf("Error() = %q", got) + } +} diff --git a/internal/jira/export_test.go b/internal/jira/export_test.go new file mode 100644 index 0000000..a16cf70 --- /dev/null +++ b/internal/jira/export_test.go @@ -0,0 +1,8 @@ +// Copied from jira-green internal/jira/export_test.go (2026-10-06). + +package jira + +import "time" + +// SetNow replaces the client's clock for a test. +func SetNow(c *Client, now func() time.Time) { c.now = now } diff --git a/internal/jira/search.go b/internal/jira/search.go index f6b1dbe..1e9db37 100644 --- a/internal/jira/search.go +++ b/internal/jira/search.go @@ -44,9 +44,9 @@ type searchResp struct { } // search runs JQL and returns every matching issue, following -// nextPageToken. -func (c *Client) search(ctx context.Context, jql string) ([]issue, error) { - var out []issue +// nextPageToken. A malformed field is left zero and reported in warnings as +// "KEY field: error"; it never fails the search. +func (c *Client) search(ctx context.Context, jql string) (issues []issue, warnings []string, err error) { token := "" seen := map[string]bool{} for range maxPages { @@ -56,47 +56,61 @@ func (c *Client) search(ctx context.Context, jql string) ([]issue, error) { } var r searchResp if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", body, &r); err != nil { - return nil, err + return nil, nil, err } for _, ai := range r.Issues { - out = append(out, convert(ai)) + is, w := convert(ai) + issues = append(issues, is) + warnings = append(warnings, w...) } if r.IsLast || r.NextPageToken == "" { - return out, nil + return issues, warnings, nil } if seen[r.NextPageToken] { - return nil, fmt.Errorf("jira: search: nextPageToken %q repeated", r.NextPageToken) + return nil, nil, fmt.Errorf("jira: search: nextPageToken %q repeated", r.NextPageToken) } seen[r.NextPageToken] = true token = r.NextPageToken } - return nil, fmt.Errorf("jira: search: more than %d pages", maxPages) + return nil, nil, fmt.Errorf("jira: search: more than %d pages", maxPages) } -// decodeField decodes fields[name] into a fresh T, so one malformed field -// does not blank the others. Missing, null and malformed fields all give the -// zero value (never a partial one). -func decodeField[T any](fields map[string]json.RawMessage, name string) T { +// fieldDecoder decodes one issue's fields one at a time, so one malformed +// field does not blank the others. It collects "KEY field: err" for each +// failure. +type fieldDecoder struct { + key string + fields map[string]json.RawMessage + errs []string +} + +// decodeField decodes fields[name] into a fresh T. Missing and null fields +// give the zero value with no error; a decode failure gives the zero value +// (never a partial one) and records the error. +func decodeField[T any](d *fieldDecoder, name string) T { var v T - raw, ok := fields[name] + raw, ok := d.fields[name] if !ok || string(raw) == "null" { return v } - if json.Unmarshal(raw, &v) != nil { + if err := json.Unmarshal(raw, &v); err != nil { + d.errs = append(d.errs, fmt.Sprintf("%s %s: %v", d.key, name, err)) var zero T return zero } return v } -func convert(ai apiIssue) issue { - st := decodeField[apiStatus](ai.Fields, "status") - return issue{ +func convert(ai apiIssue) (issue, []string) { + d := &fieldDecoder{key: ai.Key, fields: ai.Fields} + st := decodeField[apiStatus](d, "status") + is := issue{ Key: ai.Key, - Summary: decodeField[string](ai.Fields, "summary"), + Summary: decodeField[string](d, "summary"), StatusName: st.Name, StatusCategory: st.StatusCategory.Key, - Updated: decodeField[Time](ai.Fields, "updated"), - StatusCategoryChangedDate: decodeField[Time](ai.Fields, "statuscategorychangedate"), + Updated: decodeField[Time](d, "updated"), + StatusCategoryChangedDate: decodeField[Time](d, "statuscategorychangedate"), } + return is, d.errs } diff --git a/internal/jira/tickets.go b/internal/jira/tickets.go index 9ee87ce..40a5fef 100644 --- a/internal/jira/tickets.go +++ b/internal/jira/tickets.go @@ -2,18 +2,39 @@ package jira import ( "context" + "errors" "fmt" + "regexp" "strings" "github.com/ericdahl-dev/app-green/internal/model" ) +// Keys are checked before they go into JQL, so a typo or a stray quote is +// reported instead of building a broken or widened query. +var ( + projectKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]+$`) + ticketKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]+-\d+$`) +) + +// MyTickets and TicketsByKey return tickets plus warnings: one +// "KEY field: error" line per malformed field, which was left zero. The +// resolver logs them once per poll. + // MyTickets returns my tickets in projects that are not To Do: everything in // progress, plus Done tickets that finished in the last 14 days (their // changes may still be on the way to prod). The JQL uses Jira's fixed status // category IDs (4 = In Progress, 3 = Done), so a site that renames or // translates its categories cannot break it. -func (c *Client) MyTickets(ctx context.Context, projects []string) ([]model.Ticket, error) { +func (c *Client) MyTickets(ctx context.Context, projects []string) ([]model.Ticket, []string, error) { + if len(projects) == 0 { + return nil, nil, errors.New("jira: no projects configured") + } + for _, p := range projects { + if !projectKey.MatchString(p) { + return nil, nil, fmt.Errorf("jira: bad project key %q", p) + } + } jql := fmt.Sprintf(`assignee = currentUser() AND project in (%s) AND `+ `(statusCategory = 4 OR (statusCategory = 3 AND statusCategoryChangedDate >= -14d)) `+ `ORDER BY updated DESC`, strings.Join(projects, ",")) @@ -22,30 +43,39 @@ func (c *Client) MyTickets(ctx context.Context, projects []string) ([]model.Tick // TicketsByKey returns the named tickets, whoever they are assigned to. The // resolver uses it for tickets named on my PRs that MyTickets missed. No keys -// means no request ("key in ()" is invalid JQL). -func (c *Client) TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, error) { +// means no request ("key in ()" is invalid JQL). A well-formed key that does +// not exist, even in an unknown project, is not an error: Jira returns 200 +// and skips it (checked against Jira Cloud, 2026-10-06), so the missing +// ticket just has no row. +func (c *Client) TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, []string, error) { if len(keys) == 0 { - return nil, nil + return nil, nil, nil + } + for _, k := range keys { + if !ticketKey.MatchString(k) { + return nil, nil, fmt.Errorf("jira: bad ticket key %q", k) + } } return c.tickets(ctx, fmt.Sprintf("key in (%s)", strings.Join(keys, ","))) } -func (c *Client) tickets(ctx context.Context, jql string) ([]model.Ticket, error) { - issues, err := c.search(ctx, jql) +func (c *Client) tickets(ctx context.Context, jql string) ([]model.Ticket, []string, error) { + issues, warnings, err := c.search(ctx, jql) if err != nil { - return nil, err + return nil, nil, err } out := make([]model.Ticket, len(issues)) for i, is := range issues { + key := strings.ToUpper(is.Key) // the linker and rules match uppercase keys out[i] = model.Ticket{ - Key: is.Key, + Key: key, Title: is.Summary, Status: is.StatusName, StatusCategory: model.StatusCategory(is.StatusCategory), - URL: c.BrowseURL(is.Key), + URL: c.BrowseURL(key), Updated: is.Updated.Time, StatusSince: is.StatusCategoryChangedDate.Time, } } - return out, nil + return out, warnings, nil } diff --git a/internal/jira/tickets_test.go b/internal/jira/tickets_test.go index 7ef00bd..ed4fc2e 100644 --- a/internal/jira/tickets_test.go +++ b/internal/jira/tickets_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "errors" + "fmt" + "net" "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -28,7 +31,7 @@ func TestMyTickets(t *testing.T) { })) defer srv.Close() c := jira.New(srv.URL, "me@example.com", "tok") - ts, err := c.MyTickets(context.Background(), []string{"ABC", "XYZ"}) + ts, _, err := c.MyTickets(context.Background(), []string{"ABC", "XYZ"}) if err != nil { t.Fatal(err) } @@ -89,7 +92,7 @@ func TestMyTicketsFollowsPages(t *testing.T) { http.ServeFile(w, r, "testdata/search_page1.json") })) defer srv.Close() - ts, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + ts, _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) if err != nil { t.Fatal(err) } @@ -109,7 +112,7 @@ func TestMyTicketsRepeatedPageTokenIsError(t *testing.T) { http.ServeFile(w, r, "testdata/search_page1.json") // always says "next is p2" })) defer srv.Close() - _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + _, _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) if err == nil || !strings.Contains(err.Error(), "repeated") { t.Fatalf("err = %v, want repeated-token error", err) } @@ -126,7 +129,7 @@ func TestTicketsByKey(t *testing.T) { http.ServeFile(w, r, "testdata/search.json") })) defer srv.Close() - ts, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), []string{"ABC-1", "ABC-2"}) + ts, _, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), []string{"ABC-1", "ABC-2"}) if err != nil { t.Fatal(err) } @@ -145,7 +148,7 @@ func TestTicketsByKeyEmptyMakesNoRequest(t *testing.T) { http.ServeFile(w, r, "testdata/search.json") })) defer srv.Close() - ts, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), nil) + ts, _, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), nil) if err != nil || len(ts) != 0 || calls != 0 { t.Errorf("tickets = %+v, err = %v, requests = %d; want none, nil, 0", ts, err, calls) } @@ -157,30 +160,30 @@ func TestUnauthorizedIsAuth(t *testing.T) { _, _ = w.Write([]byte(`{"errorMessages":["Client must be authenticated"]}`)) })) defer srv.Close() - _, err := jira.New(srv.URL, "me@example.com", "bad").MyTickets(context.Background(), []string{"ABC"}) + _, _, err := jira.New(srv.URL, "me@example.com", "bad").MyTickets(context.Background(), []string{"ABC"}) if !jira.IsAuth(err) { t.Fatalf("err = %v, want IsAuth", err) } } -func TestForbiddenIsNotAuth(t *testing.T) { +func TestSearchForbiddenIsNotAuth(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer srv.Close() - _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + _, _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) if err == nil || jira.IsAuth(err) { t.Fatalf("err = %v, want a non-auth error", err) } } -func TestRateLimitCarriesRetryAfter(t *testing.T) { +func TestSearchRateLimitCarriesRetryAfter(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "30") w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() - _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + _, _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) var ae *jira.APIError if !errors.As(err, &ae) || ae.Status != http.StatusTooManyRequests || ae.RetryAfter != 30*time.Second { t.Fatalf("err = %#v, want *APIError 429 with RetryAfter 30s", err) @@ -195,7 +198,7 @@ func TestRateLimitWithoutRetryAfterDefaultsToMinute(t *testing.T) { w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() - _, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), []string{"ABC-1"}) + _, _, err := jira.New(srv.URL, "me@example.com", "tok").TicketsByKey(context.Background(), []string{"ABC-1"}) var ae *jira.APIError if !errors.As(err, &ae) || ae.RetryAfter != time.Minute { t.Fatalf("err = %#v, want RetryAfter 1m", err) @@ -249,10 +252,14 @@ func TestMalformedFieldBlanksOnlyThatField(t *testing.T) { http.ServeFile(w, r, "testdata/search_malformed.json") })) defer srv.Close() - ts, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + ts, warnings, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) if err != nil { t.Fatal(err) } + if len(warnings) != 2 || !strings.HasPrefix(warnings[0], "ABC-1 updated: ") || + !strings.HasPrefix(warnings[1], "ABC-2 statuscategorychangedate: ") { + t.Errorf("warnings = %q", warnings) + } if len(ts) != 2 { t.Fatalf("got %d tickets, want 2: %+v", len(ts), ts) } @@ -278,3 +285,106 @@ func TestMalformedFieldBlanksOnlyThatField(t *testing.T) { } } } + +// countingServer counts requests and answers with the two-ticket fixture. +func countingServer(t *testing.T, calls *int) *jira.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *calls++ + http.ServeFile(w, r, "testdata/search.json") + })) + t.Cleanup(srv.Close) + return jira.New(srv.URL, "me@example.com", "tok") +} + +func TestMyTicketsNoProjectsIsError(t *testing.T) { + calls := 0 + _, _, err := countingServer(t, &calls).MyTickets(context.Background(), nil) + if err == nil || calls != 0 { + t.Errorf("err = %v, requests = %d; want an error and no request", err, calls) + } +} + +func TestMyTicketsBadProjectKeyIsError(t *testing.T) { + for _, bad := range []string{"abc", "A", "1AB", "AB) OR (1=1", "AB-1", ""} { + calls := 0 + _, _, err := countingServer(t, &calls).MyTickets(context.Background(), []string{"ABC", bad}) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%q", bad)) || calls != 0 { + t.Errorf("project %q: err = %v, requests = %d; want an error naming it and no request", bad, err, calls) + } + } +} + +func TestTicketsByKeyBadKeyIsError(t *testing.T) { + for _, bad := range []string{"abc-1", "ABC", "ABC-", "ABC-1x", "ABC-1) OR (1=1", ""} { + calls := 0 + _, _, err := countingServer(t, &calls).TicketsByKey(context.Background(), []string{"ABC-1", bad}) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%q", bad)) || calls != 0 { + t.Errorf("key %q: err = %v, requests = %d; want an error naming it and no request", bad, err, calls) + } + } +} + +func TestTicketKeyIsUppercased(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"issues":[{"key":"abc-7","fields":{"summary":"x"}}],"isLast":true}`)) + })) + defer srv.Close() + ts, _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + if err != nil || len(ts) != 1 || ts[0].Key != "ABC-7" || ts[0].URL != srv.URL+"/browse/ABC-7" { + t.Fatalf("tickets = %+v, err = %v; want key ABC-7", ts, err) + } +} + +func TestSearchPageCapIsError(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + _, _ = fmt.Fprintf(w, `{"issues":[],"nextPageToken":"p%d","isLast":false}`, calls) + })) + defer srv.Close() + _, _, err := jira.New(srv.URL, "me@example.com", "tok").MyTickets(context.Background(), []string{"ABC"}) + if err == nil || !strings.Contains(err.Error(), "more than 50 pages") || calls != 50 { + t.Fatalf("err = %v after %d requests; want page-cap error after 50", err, calls) + } +} + +func TestCancelledContext(t *testing.T) { + calls := 0 + c := countingServer(t, &calls) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, err := c.MyTickets(ctx, []string{"ABC"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +// A success body is read to the end, so the keep-alive connection is reused. +func TestSuccessBodyIsDrainedForReuse(t *testing.T) { + var mu sync.Mutex + conns := 0 + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"accountId":"acct-me"}` + strings.Repeat(" ", 64<<10))) + })) + srv.Config.ConnState = func(_ net.Conn, s http.ConnState) { + if s == http.StateNew { + mu.Lock() + conns++ + mu.Unlock() + } + } + srv.Start() + defer srv.Close() + c := jira.New(srv.URL, "me@example.com", "tok") + for range 3 { + if _, err := c.Myself(context.Background()); err != nil { + t.Fatal(err) + } + } + mu.Lock() + defer mu.Unlock() + if conns != 1 { + t.Errorf("opened %d connections for 3 requests, want 1", conns) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index da2e351..a2946c4 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -15,7 +15,7 @@ type Ticket struct { StatusCategory StatusCategory URL string Updated time.Time - StatusSince time.Time // when it entered Status; zero if unknown + StatusSince time.Time // when it entered StatusCategory (Jira statuscategorychangedate); zero if unknown } // StatusCategory is Jira's statusCategory.key, which stays the same whatever From aafda4f9dd92b0956d78d291936ecdc4e13afc6b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:24:43 -0400 Subject: [PATCH 04/31] feat(rules): draft PRs skip review flags model.PR gains IsDraft. A draft open PR gets no ready-to-merge, no-reviewer or stale-review flag; failing checks and requested changes still flag it red. Assisted-by: Claude Code:claude-opus-5-5 --- internal/model/model.go | 1 + internal/rules/flags.go | 3 +++ internal/rules/flags_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/internal/model/model.go b/internal/model/model.go index a2946c4..ac9af15 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -86,6 +86,7 @@ type PR struct { BaseRef string URL string State PRState + IsDraft bool // a GitHub draft PR; rules skips its review flags MergeSHA string // set only when State == PRMerged MergedAt time.Time OpenedAt time.Time diff --git a/internal/rules/flags.go b/internal/rules/flags.go index d23371a..88cb867 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -66,6 +66,9 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { add(model.Red, model.FlagCheckFailed, fmt.Sprintf("PR #%d %s failing", p.Number, checkNames(p.Failing)), p, nil) case p.Review == model.ReviewChangesRequested: add(model.Red, model.FlagChangesRequested, fmt.Sprintf("PR #%d changes requested", p.Number), p, nil) + case p.IsDraft: + // A draft is not asking for review yet: no ready-to-merge, no-reviewer + // or stale-review flag. The red flags above still apply. case p.Review == model.ReviewApproved && (p.Checks == model.ChecksPassing || p.Checks == model.ChecksNone): add(model.Yellow, model.FlagReadyToMerge, fmt.Sprintf("PR #%d approved, not merged", p.Number), p, nil) case p.Reviewers == 0 && p.Checks != model.ChecksPending: diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index 04f7179..c7f7d06 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -208,3 +208,29 @@ func TestStrandedPRFlagTargetsItsPRAtRed(t *testing.T) { t.Errorf("flag %+v, want red stranded on PR #3 with reason %q", f, "PR #3 merged into a dead branch") } } + +func TestDraftPRsSkipReviewFlags(t *testing.T) { + th := rules.Thresholds{StaleReview: 48 * time.Hour} + old := now.Add(-72 * time.Hour) + cases := []struct { + name string + pr model.PR + want []model.FlagKind + }{ + {"an approved, green draft is not ready to merge", model.PR{IsDraft: true, Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1, OpenedAt: old}, nil}, + {"a draft with no reviewer is not waiting on review", model.PR{IsDraft: true, Checks: model.ChecksPassing, OpenedAt: now}, nil}, + {"a draft past StaleReview is not stale", model.PR{IsDraft: true, Checks: model.ChecksPassing, Reviewers: 1, Review: model.ReviewRequired, OpenedAt: old}, nil}, + {"a draft with failing checks is still flagged", model.PR{IsDraft: true, Checks: model.ChecksFailing, OpenedAt: now}, []model.FlagKind{model.FlagCheckFailed}}, + {"a draft with changes requested is still flagged", model.PR{IsDraft: true, Checks: model.ChecksPassing, Review: model.ReviewChangesRequested, Reviewers: 1, OpenedAt: now}, + []model.FlagKind{model.FlagChangesRequested}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.pr.State = model.PROpen + got := kinds(rules.Flags(model.Chain{Stage: model.StagePROpen, PRs: []model.PR{tc.pr}}, now, th)) + if !slices.Equal(got, tc.want) { + t.Errorf("flags %v, want %v", got, tc.want) + } + }) + } +} From db9a51e9f5e6be73279e200313089c5893bb0b6c Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:32:24 -0400 Subject: [PATCH 05/31] feat(github): recent PRs via GraphQL, base-branch PRs, compare, rerun net/http client with an ETag cache for GETs (copied from git-green), typed APIError (401 auth, Retry-After / rate-limit backoff), and: - RecentPRs: one author's open and merged PRs, paged until GitHub has no more or the page's last PR predates since. Repo lowercased, rollup state passed through, failing CheckRuns and StatusContexts with CodeScanning and RunID, Reviewers, MergeSHA only when merged, IsDraft. - BasePRs: the PR for each missing non-default base branch, any author, as aliases in one query per round, following the stack down. - Compare: ahead/identical included, behind/diverged not, 404 unknown. - RerunFailedJobs: ported from git-green, whole-run fallback when GitHub reports no failed jobs. Assisted-by: Claude Code:claude-opus-5-5 --- internal/github/base.go | 128 +++++++++++++ internal/github/base_test.go | 130 +++++++++++++ internal/github/client.go | 90 +++++++++ internal/github/client_test.go | 64 +++++++ internal/github/compare.go | 35 ++++ internal/github/compare_test.go | 75 ++++++++ internal/github/errors.go | 76 ++++++++ internal/github/etag.go | 124 +++++++++++++ internal/github/graphql.go | 39 ++++ internal/github/prs.go | 248 +++++++++++++++++++++++++ internal/github/prs_test.go | 202 ++++++++++++++++++++ internal/github/rerun.go | 41 ++++ internal/github/rerun_test.go | 58 ++++++ internal/github/testdata/base_prs.json | 23 +++ internal/github/testdata/prs.json | 87 +++++++++ 15 files changed, 1420 insertions(+) create mode 100644 internal/github/base.go create mode 100644 internal/github/base_test.go create mode 100644 internal/github/client.go create mode 100644 internal/github/client_test.go create mode 100644 internal/github/compare.go create mode 100644 internal/github/compare_test.go create mode 100644 internal/github/errors.go create mode 100644 internal/github/etag.go create mode 100644 internal/github/graphql.go create mode 100644 internal/github/prs.go create mode 100644 internal/github/prs_test.go create mode 100644 internal/github/rerun.go create mode 100644 internal/github/rerun_test.go create mode 100644 internal/github/testdata/base_prs.json create mode 100644 internal/github/testdata/prs.json diff --git a/internal/github/base.go b/internal/github/base.go new file mode 100644 index 0000000..8b583cf --- /dev/null +++ b/internal/github/base.go @@ -0,0 +1,128 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// basePRsQuery builds one query with an aliased pullRequests lookup per +// branch, b0..bN, each bound to its own variable. +func basePRsQuery(n int) string { + var vars, aliases strings.Builder + for i := range n { + fmt.Fprintf(&vars, ",$b%d:String!", i) + fmt.Fprintf(&aliases, "\n b%d:pullRequests(headRefName:$b%d,states:[OPEN,MERGED],first:5,orderBy:{field:CREATED_AT,direction:DESC}){nodes{...pr}}", i, i) + } + return "query($owner:String!,$name:String!" + vars.String() + "){\n repository(owner:$owner,name:$name){\n nameWithOwner\n defaultBranchRef{name}" + + aliases.String() + "\n }\n}" + prFields +} + +// missingBases lists, once each and in order, the base branches of prs' +// open and merged PRs in repo that are not the default branch and that no +// such PR in prs has as its head. +func missingBases(repo string, prs []model.PR) []string { + live := func(p model.PR) bool { return p.State != model.PRClosed && strings.EqualFold(p.Repo, repo) } + heads := map[string]bool{} + for _, p := range prs { + if live(p) { + heads[p.HeadRef] = true + } + } + var out []string + for _, p := range prs { + if !live(p) || p.BaseRef == "" || p.BaseRef == p.DefaultBranch || heads[p.BaseRef] { + continue + } + heads[p.BaseRef] = true // once each + out = append(out, p.BaseRef) + } + return out +} + +// maxBaseRounds caps how deep BasePRs follows a stack of other people's +// branches. +const maxBaseRounds = 5 + +// BasePRs returns the PR for each base branch of prs in owner/name that is +// not the default branch and has no PR in prs, whoever wrote it. link needs +// them: without a base branch's PR a stacked PR looks Stranded. It repeats +// for the bases of the PRs it found, so a stack whose lower branches belong +// to someone else resolves down to the default branch (at most +// maxBaseRounds queries, one branch asked once). Each branch gets the newest +// open or merged PR whose head is that branch in this repo (a fork's PR with +// the same branch name is skipped); a branch with none gets nothing. +// Warnings are GraphQL errors that came with usable data. +func (c *Client) BasePRs(ctx context.Context, owner, name string, prs []model.PR) ([]model.PR, []string, error) { + repo := strings.ToLower(owner + "/" + name) + asked := map[string]bool{} + all := slices.Clone(prs) + var out []model.PR + var warns []string + for range maxBaseRounds { + var branches []string + for _, b := range missingBases(repo, all) { + if !asked[b] { + asked[b] = true + branches = append(branches, b) + } + } + if len(branches) == 0 { + break + } + found, w, err := c.basePRs(ctx, owner, name, branches) + warns = append(warns, w...) + if err != nil { + return nil, warns, err + } + out = append(out, found...) + all = append(all, found...) + } + return out, warns, nil +} + +// basePRs runs one aliased query for branches. +func (c *Client) basePRs(ctx context.Context, owner, name string, branches []string) ([]model.PR, []string, error) { + vars := map[string]any{"owner": owner, "name": name} + for i, b := range branches { + vars[fmt.Sprintf("b%d", i)] = b + } + var data struct { + Repository map[string]json.RawMessage `json:"repository"` + } + warns, err := c.graphql(ctx, basePRsQuery(len(branches)), vars, &data) + if err != nil { + return nil, warns, err + } + if data.Repository == nil { + return nil, warns, fmt.Errorf("github: repository %s/%s not found", owner, name) + } + var info repoInfo + _ = json.Unmarshal(data.Repository["nameWithOwner"], &info.NameWithOwner) + _ = json.Unmarshal(data.Repository["defaultBranchRef"], &info.DefaultBranchRef) + var out []model.PR + for i := range branches { + var conn struct { + Nodes []prNode `json:"nodes"` + } + raw, ok := data.Repository[fmt.Sprintf("b%d", i)] + if !ok { + continue + } + if err := json.Unmarshal(raw, &conn); err != nil { + warns = append(warns, fmt.Sprintf("github: base branch %s: %v", branches[i], err)) + continue + } + for _, n := range conn.Nodes { + if !n.IsCrossRepository { + out = append(out, n.toPR(info)) + break + } + } + } + return out, warns, nil +} diff --git a/internal/github/base_test.go b/internal/github/base_test.go new file mode 100644 index 0000000..934f31c --- /dev/null +++ b/internal/github/base_test.go @@ -0,0 +1,130 @@ +package github_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/ericdahl-dev/app-green/internal/link" + "github.com/ericdahl-dev/app-green/internal/model" +) + +func TestBasePRsFetchesAnotherAuthorsBasePR(t *testing.T) { + calls := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + req := decodeGQL(t, r) + if req.Variables["owner"] != "acme" || req.Variables["name"] != "app" || req.Variables["b0"] != "s1" || len(req.Variables) != 3 { + t.Errorf("variables = %v, want owner, name and b0=s1", req.Variables) + } + if !strings.Contains(req.Query, "b0:pullRequests(headRefName:$b0") { + t.Errorf("query has no b0 alias:\n%s", req.Query) + } + http.ServeFile(w, r, "testdata/base_prs.json") + }) + mine := []model.PR{ + {Repo: "acme/app", DefaultBranch: "main", Number: 2, HeadRef: "abc-2", BaseRef: "s1", State: model.PRMerged}, + {Repo: "acme/app", DefaultBranch: "main", Number: 4, HeadRef: "abc-4", BaseRef: "main", State: model.PROpen}, + } + got, warns, err := c.BasePRs(context.Background(), "acme", "app", mine) + if err != nil || len(warns) != 0 { + t.Fatalf("err %v, warnings %v", err, warns) + } + want := model.PR{ + Repo: "acme/app", DefaultBranch: "main", Number: 1, Title: "Stack base", HeadRef: "s1", BaseRef: "main", + URL: "https://github.com/Acme/App/pull/1", State: model.PRMerged, MergeSHA: "aaaa111", + MergedAt: ts("2026-09-22T09:00:00Z"), OpenedAt: ts("2026-09-20T09:00:00Z"), + Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1, + } + if len(got) != 1 || !equalPR(got[0], want) { + t.Errorf("got %+v\nwant %+v", got, want) + } + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +// baseNode is a PR node for a base-branch response. +func baseNode(number int, head, base, login string, fork bool) map[string]any { + return map[string]any{"number": number, "headRefName": head, "baseRefName": base, "state": "OPEN", + "isCrossRepository": fork, "author": map[string]any{"login": login}, + "reviewRequests": map[string]any{"totalCount": 0}, "reviews": map[string]any{"totalCount": 0}, "commits": map[string]any{"nodes": []any{}}} +} + +func TestBasePRsWalksDownTheStack(t *testing.T) { + // mine: #3 on s2. s2's PR (#2, someone else) is on s1, s1's (#1) on main. + // "gone" has no PR at all, and a fork's PR on s2 is skipped. + var asked [][]string + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + req := decodeGQL(t, r) + var branches []string + repo := map[string]any{"nameWithOwner": "acme/app", "defaultBranchRef": map[string]any{"name": "main"}} + for i := 0; ; i++ { + b, ok := req.Variables[fmt.Sprintf("b%d", i)].(string) + if !ok { + break + } + branches = append(branches, b) + var nodes []any + switch b { + case "s2": + nodes = []any{baseNode(9, "s2", "main", "forker", true), baseNode(2, "s2", "s1", "other", false)} + case "s1": + nodes = []any{baseNode(1, "s1", "main", "other", false)} + } + repo[fmt.Sprintf("b%d", i)] = map[string]any{"nodes": nodes} + } + asked = append(asked, branches) + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": repo}}) + }) + mine := []model.PR{ + {Repo: "acme/app", DefaultBranch: "main", Number: 3, HeadRef: "abc-3", BaseRef: "s2", State: model.PROpen}, + {Repo: "acme/app", DefaultBranch: "main", Number: 5, HeadRef: "abc-5", BaseRef: "gone", State: model.PRMerged}, + {Repo: "acme/app", DefaultBranch: "main", Number: 6, HeadRef: "abc-6", BaseRef: "s2", State: model.PROpen}, + {Repo: "acme/app", DefaultBranch: "main", Number: 7, HeadRef: "abc-7", BaseRef: "dead", State: model.PRClosed}, + } + got, _, err := c.BasePRs(context.Background(), "acme", "app", mine) + if err != nil { + t.Fatal(err) + } + var nums []int + for _, p := range got { + nums = append(nums, p.Number) + } + if fmt.Sprint(nums) != "[2 1]" { + t.Errorf("numbers = %v, want [2 1]", nums) + } + if fmt.Sprint(asked) != "[[s2 gone] [s1]]" { + t.Errorf("asked = %v, want [[s2 gone] [s1]]", asked) + } +} + +func TestBasePRsAsksNothingWhenNoBaseIsMissing(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { t.Errorf("unexpected request") }) + mine := []model.PR{ + {Repo: "acme/app", DefaultBranch: "main", Number: 2, HeadRef: "s1", BaseRef: "main", State: model.PRMerged}, + {Repo: "acme/app", DefaultBranch: "main", Number: 3, HeadRef: "abc-3", BaseRef: "s1", State: model.PROpen}, + } + got, _, err := c.BasePRs(context.Background(), "acme", "app", mine) + if err != nil || got != nil { + t.Errorf("got %v, %v; want nothing", got, err) + } +} + +func TestBasePRsKeepALaterStackPRFromLookingStranded(t *testing.T) { + c := serve(t, fixture(t, "base_prs.json")) + mine := []model.PR{{Repo: "acme/app", DefaultBranch: "main", Number: 2, Title: "ABC-2 on s1", HeadRef: "abc-2", BaseRef: "s1", + State: model.PRMerged, MergeSHA: "bbbb222", MergedAt: ts("2026-09-21T09:00:00Z")}} + bases, _, err := c.BasePRs(context.Background(), "acme", "app", mine) + if err != nil { + t.Fatal(err) + } + chains, _ := link.Link([]model.Ticket{{Key: "ABC-2"}}, append(mine, bases...), []string{"ABC"}) + p := chains[0].PRs[0] + if p.Stranded || p.EffectiveSHA != "aaaa111" { + t.Errorf("#2 = %+v, want carried to main by #1's merge aaaa111", p) + } +} diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..9a8e731 --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,90 @@ +// Package github is a small GitHub client covering only what app-green +// needs: recent PRs and their base-branch PRs over GraphQL, commit compare, +// and re-running failed Actions jobs. It uses net/http directly. +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Client talks to the GitHub API with one token. +type Client struct { + http *http.Client + token string + api string + now func() time.Time // reads X-RateLimit-Reset +} + +// New returns a Client for token. GET responses are cached by ETag, so an +// unchanged compare costs nothing against the rate limit. +func New(token string) *Client { + return &Client{ + http: &http.Client{Timeout: 20 * time.Second, Transport: newETagTransport(http.DefaultTransport, 500)}, + token: token, + api: "https://api.github.com", + now: time.Now, + } +} + +// SetAPI points the client at another API base URL (a test server, or a +// GitHub Enterprise "https://host/api/v3"). +func (c *Client) SetAPI(base string) { c.api = strings.TrimRight(base, "/") } + +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.api+path, rdr) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return newAPIError(resp, b, c.now()) + } + if out == nil || resp.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("github: decode %s: %w", path, err) + } + // Read to the end so the keep-alive connection can be reused. + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +// API is the subset of Client the resolver and UI use; tests supply fakes. +type API interface { + RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, []string, error) + BasePRs(ctx context.Context, owner, name string, prs []model.PR) ([]model.PR, []string, error) + Compare(ctx context.Context, repo, base, head string) (model.Inclusion, error) + RerunFailedJobs(ctx context.Context, repo string, runID int64) error +} + +var _ API = (*Client)(nil) diff --git a/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..92812f8 --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,64 @@ +package github_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/github" +) + +func serve(t *testing.T, h http.HandlerFunc) *github.Client { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + c := github.New("tok") + c.SetAPI(srv.URL) + return c +} + +func TestErrorsAreTyped(t *testing.T) { + reset := time.Now().Add(90 * time.Second).Unix() + cases := []struct { + name string + status int + header map[string]string + auth bool + retryMin time.Duration + retryMax time.Duration + wantMsg string + }{ + {"401 is an auth error", 401, nil, true, 0, 0, "Bad credentials"}, + {"403 is not an auth error", 403, nil, false, 0, 0, "Bad credentials"}, + {"429 with Retry-After backs off that long", 429, map[string]string{"Retry-After": "30"}, false, 30 * time.Second, 30 * time.Second, "Bad credentials"}, + {"429 without Retry-After backs off a minute", 429, nil, false, time.Minute, time.Minute, "Bad credentials"}, + {"403 rate limit backs off until the reset", 403, map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(reset, 10)}, false, 80 * time.Second, 91 * time.Second, "Bad credentials"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + for k, v := range tc.header { + w.Header().Set(k, v) + } + w.WriteHeader(tc.status) + _, _ = fmt.Fprint(w, `{"message":"Bad credentials"}`) + }) + _, err := c.Compare(context.Background(), "acme/app", "aaaa", "bbbb") + var ae *github.APIError + if !errors.As(err, &ae) { + t.Fatalf("err = %v, want *APIError", err) + } + if ae.Status != tc.status || ae.Message != tc.wantMsg || github.IsAuth(err) != tc.auth { + t.Errorf("err = %+v, IsAuth %v; want status %d auth %v", ae, github.IsAuth(err), tc.status, tc.auth) + } + if ae.RetryAfter < tc.retryMin || ae.RetryAfter > tc.retryMax { + t.Errorf("RetryAfter = %v, want %v..%v", ae.RetryAfter, tc.retryMin, tc.retryMax) + } + }) + } +} diff --git a/internal/github/compare.go b/internal/github/compare.go new file mode 100644 index 0000000..ef4dd11 --- /dev/null +++ b/internal/github/compare.go @@ -0,0 +1,35 @@ +package github + +import ( + "context" + "errors" + "net/http" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Compare answers whether base is included in head within repo +// ("owner/name"): Included when head is ahead of or identical to base, +// NotIncluded when it is behind or has diverged. A 404 (an unknown or +// force-pushed-away commit) is InclusionUnknown with no error; other +// failures return the error. +func (c *Client) Compare(ctx context.Context, repo, base, head string) (model.Inclusion, error) { + var out struct { + Status string `json:"status"` + } + err := c.do(ctx, http.MethodGet, "/repos/"+repo+"/compare/"+base+"..."+head+"?per_page=1", nil, &out) + var ae *APIError + if errors.As(err, &ae) && ae.Status == http.StatusNotFound { + return model.InclusionUnknown, nil + } + if err != nil { + return model.InclusionUnknown, err + } + switch out.Status { + case "ahead", "identical": + return model.Included, nil + case "behind", "diverged": + return model.NotIncluded, nil + } + return model.InclusionUnknown, nil +} diff --git a/internal/github/compare_test.go b/internal/github/compare_test.go new file mode 100644 index 0000000..ca560a6 --- /dev/null +++ b/internal/github/compare_test.go @@ -0,0 +1,75 @@ +package github_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/model" +) + +func TestCompare(t *testing.T) { + cases := []struct { + status int + body string + want model.Inclusion + }{ + {200, `{"status":"ahead"}`, model.Included}, + {200, `{"status":"identical"}`, model.Included}, + {200, `{"status":"behind"}`, model.NotIncluded}, + {200, `{"status":"diverged"}`, model.NotIncluded}, + {404, `{"message":"Not Found"}`, model.InclusionUnknown}, + } + for _, tc := range cases { + t.Run(tc.body, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/repos/acme/app/compare/aaaa...bbbb" || r.URL.Query().Get("per_page") != "1" { + t.Errorf("got %s %s", r.Method, r.URL) + } + if r.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + w.WriteHeader(tc.status) + _, _ = fmt.Fprint(w, tc.body) + })) + defer srv.Close() + c := github.New("tok") + c.SetAPI(srv.URL + "/") + got, err := c.Compare(context.Background(), "acme/app", "aaaa", "bbbb") + if err != nil || got != tc.want { + t.Errorf("Compare = %v, %v; want %v, nil", got, err, tc.want) + } + }) + } +} + +func TestCompareReusesCachedBodyOn304(t *testing.T) { + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if hits == 2 { + if r.Header.Get("If-None-Match") != `"v1"` { + t.Errorf("If-None-Match = %q", r.Header.Get("If-None-Match")) + } + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", `"v1"`) + _, _ = fmt.Fprint(w, `{"status":"ahead"}`) + })) + defer srv.Close() + c := github.New("tok") + c.SetAPI(srv.URL) + for i := range 2 { + got, err := c.Compare(context.Background(), "acme/app", "aaaa", "bbbb") + if err != nil || got != model.Included { + t.Errorf("call %d: Compare = %v, %v; want Included", i+1, got, err) + } + } + if hits != 2 { + t.Errorf("hits = %d, want 2", hits) + } +} diff --git a/internal/github/errors.go b/internal/github/errors.go new file mode 100644 index 0000000..29f9f2e --- /dev/null +++ b/internal/github/errors.go @@ -0,0 +1,76 @@ +package github + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// APIError is a non-2xx response. +type APIError struct { + Status int + Message string // GitHub's "message", or the trimmed body when there is none + // RetryAfter is how long to back off: set on a 429, and on a 403 that + // GitHub marks as a rate limit. Zero otherwise. + RetryAfter time.Duration +} + +func (e *APIError) Error() string { + if e.Message == "" { + return strings.TrimSpace(fmt.Sprintf("github: HTTP %d %s", e.Status, http.StatusText(e.Status))) + } + return fmt.Sprintf("github: HTTP %d: %s", e.Status, e.Message) +} + +func newAPIError(resp *http.Response, body []byte, now time.Time) *APIError { + var env struct { + Message string `json:"message"` + } + msg := strings.TrimSpace(string(body)) + if json.Unmarshal(body, &env) == nil { + msg = env.Message + } + if r := []rune(msg); len(r) > 200 { + msg = string(r[:199]) + "…" + } + return &APIError{Status: resp.StatusCode, Message: msg, RetryAfter: retryAfter(resp, now)} +} + +// defaultRetryAfter is the backoff for a 429 with no usable Retry-After. +const defaultRetryAfter = 60 * time.Second + +// maxRetryAfter caps the backoff, so a bogus header cannot stop polling for +// hours. +const maxRetryAfter = 15 * time.Minute + +// retryAfter reads GitHub's rate-limit signals: Retry-After (whole seconds, +// sent with 429s and secondary-limit 403s), then an exhausted primary limit +// (X-RateLimit-Remaining: 0, back off until X-RateLimit-Reset). A 429 with +// neither backs off defaultRetryAfter. Anything else is 0. +func retryAfter(resp *http.Response, now time.Time) time.Duration { + var d time.Duration + if n, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil { + d = time.Duration(min(n, int(maxRetryAfter/time.Second))) * time.Second + } else if resp.Header.Get("X-RateLimit-Remaining") == "0" { + if reset, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil { + d = time.Unix(reset, 0).Sub(now) + } + } + if d <= 0 && resp.StatusCode == http.StatusTooManyRequests { + d = defaultRetryAfter + } + return min(max(d, 0), maxRetryAfter) +} + +// IsAuth reports whether err is a 401 Unauthorized: the token is bad, so +// polling should stop rather than retry. A 403 is not an auth error: it +// usually means one repo or action is off-limits (a read-only token asked to +// re-run a job) while the token still works. +func IsAuth(err error) bool { + var ae *APIError + return errors.As(err, &ae) && ae.Status == http.StatusUnauthorized +} diff --git a/internal/github/etag.go b/internal/github/etag.go new file mode 100644 index 0000000..e7ec02d --- /dev/null +++ b/internal/github/etag.go @@ -0,0 +1,124 @@ +// Copied from git-green internal/github/etag.go (2026-10-06); Pool, fromCache and +// maxCachedResponses dropped, cache header renamed. + +package github + +import ( + "bytes" + "io" + "net/http" + "strings" + "sync" +) + +// cacheHeader marks a response replayed from the ETag cache. +const cacheHeader = "X-App-Green-Cache" + +// etagTransport makes unchanged GET responses free. It sends the ETag from the +// last response for a URL as If-None-Match; when GitHub answers 304 Not +// Modified, it replays the stored body as a 200 so callers see ordinary data. +// +// One transport serves one token, so cached responses never cross tokens. +type etagTransport struct { + base http.RoundTripper + max int + + mu sync.Mutex + entries map[string]*cachedResponse + clock uint64 // bumped on every use, for least-recently-used eviction +} + +type cachedResponse struct { + etag string + header http.Header + body []byte + used uint64 +} + +func newETagTransport(base http.RoundTripper, max int) *etagTransport { + return &etagTransport{base: base, max: max, entries: make(map[string]*cachedResponse)} +} + +func (t *etagTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet { + return t.base.RoundTrip(req) + } + key := req.URL.String() + + t.mu.Lock() + cached := t.entries[key] + if cached != nil { + t.clock++ + cached.used = t.clock + } + t.mu.Unlock() + + if cached != nil { + req = req.Clone(req.Context()) + req.Header.Set("If-None-Match", cached.etag) + } + + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusNotModified && cached != nil { + _ = resp.Body.Close() + return replay(req, resp, cached), nil + } + + etag := resp.Header.Get("ETag") + if resp.StatusCode != http.StatusOK || etag == "" { + return resp, nil + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + t.store(key, &cachedResponse{etag: etag, header: resp.Header.Clone(), body: body}) + return resp, nil +} + +// replay builds a 200 from the cached body, taking the rate-limit headers from +// the fresh 304 so they stay current. +func replay(req *http.Request, notModified *http.Response, cached *cachedResponse) *http.Response { + header := cached.header.Clone() + for k, v := range notModified.Header { + if strings.HasPrefix(strings.ToLower(k), "x-ratelimit-") { + header[k] = v + } + } + header.Set(cacheHeader, "1") + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Proto: notModified.Proto, + ProtoMajor: notModified.ProtoMajor, + ProtoMinor: notModified.ProtoMinor, + Header: header, + Body: io.NopCloser(bytes.NewReader(cached.body)), + ContentLength: int64(len(cached.body)), + Request: req, + } +} + +func (t *etagTransport) store(key string, c *cachedResponse) { + t.mu.Lock() + defer t.mu.Unlock() + t.clock++ + c.used = t.clock + t.entries[key] = c + if len(t.entries) <= t.max { + return + } + oldest, oldestUse := "", ^uint64(0) + for k, e := range t.entries { + if e.used < oldestUse { + oldest, oldestUse = k, e.used + } + } + delete(t.entries, oldest) +} diff --git a/internal/github/graphql.go b/internal/github/graphql.go new file mode 100644 index 0000000..204c415 --- /dev/null +++ b/internal/github/graphql.go @@ -0,0 +1,39 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +// graphql POSTs query to /graphql and decodes its data into out. GitHub +// answers most query problems with HTTP 200 and an errors list: with no data +// that is an error, and next to partial data each message is a warning. +func (c *Client) graphql(ctx context.Context, query string, vars map[string]any, out any) ([]string, error) { + var resp struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := c.do(ctx, http.MethodPost, "/graphql", map[string]any{"query": query, "variables": vars}, &resp); err != nil { + return nil, err + } + var msgs []string + for _, e := range resp.Errors { + msgs = append(msgs, "github: graphql: "+e.Message) + } + if len(resp.Data) == 0 || string(resp.Data) == "null" { + if len(msgs) == 0 { + return nil, errors.New("github: graphql: empty response") + } + return nil, errors.New(strings.Join(msgs, "; ")) + } + if err := json.Unmarshal(resp.Data, out); err != nil { + return msgs, fmt.Errorf("github: graphql: decode: %w", err) + } + return msgs, nil +} diff --git a/internal/github/prs.go b/internal/github/prs.go new file mode 100644 index 0000000..dd8c114 --- /dev/null +++ b/internal/github/prs.go @@ -0,0 +1,248 @@ +package github + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// prFields is every PR field the adapter maps, shared by the recent-PR and +// base-branch queries so the two cannot drift. +const prFields = ` +fragment pr on PullRequest { + number title url headRefName baseRefName state isDraft createdAt mergedAt + isCrossRepository + author{login} + mergeCommit{oid} + reviewDecision + reviewRequests{totalCount} + reviews{totalCount} + commits(last:1){nodes{commit{statusCheckRollup{state + contexts(first:50){nodes{ + __typename + ... on CheckRun{name conclusion detailsUrl checkSuite{workflowRun{databaseId}} app{slug}} + ... on StatusContext{context state targetUrl} + }}}}}} +}` + +const recentPRsQuery = `query($owner:String!,$name:String!,$after:String){ + repository(owner:$owner,name:$name){ + nameWithOwner + defaultBranchRef{name} + pullRequests(first:50,after:$after,states:[OPEN,MERGED],orderBy:{field:UPDATED_AT,direction:DESC}){ + pageInfo{hasNextPage endCursor} + nodes{...pr} + } + } +}` + prFields + +type repoInfo struct { + NameWithOwner string `json:"nameWithOwner"` + DefaultBranchRef *struct { + Name string `json:"name"` + } `json:"defaultBranchRef"` +} + +func (r repoInfo) defaultBranch() string { + if r.DefaultBranchRef == nil { + return "" + } + return r.DefaultBranchRef.Name +} + +type prNode struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + HeadRefName string `json:"headRefName"` + BaseRefName string `json:"baseRefName"` + State string `json:"state"` + IsDraft bool `json:"isDraft"` + CreatedAt time.Time `json:"createdAt"` + MergedAt *time.Time `json:"mergedAt"` + IsCrossRepository bool `json:"isCrossRepository"` + Author *struct { + Login string `json:"login"` + } `json:"author"` + MergeCommit *struct { + OID string `json:"oid"` + } `json:"mergeCommit"` + ReviewDecision *string `json:"reviewDecision"` + ReviewRequests totalCount `json:"reviewRequests"` + Reviews totalCount `json:"reviews"` + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + Contexts struct { + Nodes []checkNode `json:"nodes"` + } `json:"contexts"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` +} + +type totalCount struct { + TotalCount int `json:"totalCount"` +} + +// checkNode is a CheckRun or a StatusContext, told apart by Typename. +type checkNode struct { + Typename string `json:"__typename"` + Name string `json:"name"` + Conclusion *string `json:"conclusion"` + DetailsURL *string `json:"detailsUrl"` + CheckSuite *struct { + WorkflowRun *struct { + DatabaseID int64 `json:"databaseId"` + } `json:"workflowRun"` + } `json:"checkSuite"` + App *struct { + Slug string `json:"slug"` + } `json:"app"` + Context string `json:"context"` + State string `json:"state"` + TargetURL *string `json:"targetUrl"` +} + +func (n prNode) login() string { + if n.Author == nil { + return "" + } + return n.Author.Login +} + +// toPR maps a node into model.PR for the repo it came from. +func (n prNode) toPR(repo repoInfo) model.PR { + p := model.PR{ + Repo: strings.ToLower(repo.NameWithOwner), + DefaultBranch: repo.defaultBranch(), + Number: n.Number, + Title: n.Title, + HeadRef: n.HeadRefName, + BaseRef: n.BaseRefName, + URL: n.URL, + State: model.PRState(n.State), + IsDraft: n.IsDraft, + OpenedAt: n.CreatedAt, + Reviewers: n.ReviewRequests.TotalCount + n.Reviews.TotalCount, + } + if n.ReviewDecision != nil { + p.Review = model.ReviewState(*n.ReviewDecision) + } + if p.State == model.PRMerged { + if n.MergeCommit != nil { + p.MergeSHA = n.MergeCommit.OID + } + if n.MergedAt != nil { + p.MergedAt = *n.MergedAt + } + } + if len(n.Commits.Nodes) > 0 { + if r := n.Commits.Nodes[len(n.Commits.Nodes)-1].Commit.StatusCheckRollup; r != nil { + // GitHub's rollup state passes straight through: rules treats + // anything but SUCCESS, PENDING or none (ERROR, EXPECTED, FAILURE) + // as failing. + p.Checks = model.ChecksState(r.State) + for _, c := range r.Contexts.Nodes { + if chk, ok := c.failing(); ok { + p.Failing = append(p.Failing, chk) + } + } + } + } + return p +} + +// failing returns the check as a model.Check when it has failed: a CheckRun +// that concluded FAILURE, TIMED_OUT, CANCELLED or ACTION_REQUIRED, or a +// StatusContext in FAILURE or ERROR. +func (c checkNode) failing() (model.Check, bool) { + switch c.Typename { + case "CheckRun": + if c.Conclusion == nil { + return model.Check{}, false + } + switch *c.Conclusion { + case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED": + default: + return model.Check{}, false + } + chk := model.Check{Name: c.Name, URL: deref(c.DetailsURL)} + if c.CheckSuite != nil && c.CheckSuite.WorkflowRun != nil { + chk.RunID = c.CheckSuite.WorkflowRun.DatabaseID + } + // A code scanning alert is not fixed by a re-run. The CodeQL Actions + // job ("CodeQL / Analyze") is an ordinary run and can be re-run. + chk.CodeScanning = (c.App != nil && c.App.Slug == "github-code-scanning") || strings.HasPrefix(c.Name, "Code scanning") + return chk, true + case "StatusContext": + if c.State != "FAILURE" && c.State != "ERROR" { + return model.Check{}, false + } + return model.Check{Name: c.Context, URL: deref(c.TargetURL)}, true + } + return model.Check{}, false +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +type prPage struct { + Repository *struct { + repoInfo + PullRequests struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []prNode `json:"nodes"` + } `json:"pullRequests"` + } `json:"repository"` +} + +// maxPages caps one RecentPRs call at 1,000 PRs, so a busy repo with a +// distant since cannot page forever. +const maxPages = 20 + +// RecentPRs returns author's open and merged PRs in owner/name, most +// recently updated first, with logins compared ignoring case (an empty author +// matches nothing). It pages while GitHub has more and the last PR on the +// page was created after since; since only stops paging and filters nothing. +// Warnings are GraphQL errors that came with usable data. +func (c *Client) RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, []string, error) { + var out []model.PR + var warns []string + var after any // nil asks for the first page + for range maxPages { + var page prPage + w, err := c.graphql(ctx, recentPRsQuery, map[string]any{"owner": owner, "name": name, "after": after}, &page) + warns = append(warns, w...) + if err != nil { + return nil, warns, err + } + if page.Repository == nil { + return nil, warns, fmt.Errorf("github: repository %s/%s not found", owner, name) + } + prs := page.Repository.PullRequests + for _, n := range prs.Nodes { + if author != "" && strings.EqualFold(n.login(), author) { + out = append(out, n.toPR(page.Repository.repoInfo)) + } + } + if !prs.PageInfo.HasNextPage || len(prs.Nodes) == 0 || !prs.Nodes[len(prs.Nodes)-1].CreatedAt.After(since) { + break + } + after = prs.PageInfo.EndCursor + } + return out, warns, nil +} diff --git a/internal/github/prs_test.go b/internal/github/prs_test.go new file mode 100644 index 0000000..2328464 --- /dev/null +++ b/internal/github/prs_test.go @@ -0,0 +1,202 @@ +package github_test + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +var since = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +func decodeGQL(t *testing.T, r *http.Request) gqlRequest { + t.Helper() + if r.Method != http.MethodPost || r.URL.Path != "/graphql" { + t.Errorf("got %s %s, want POST /graphql", r.Method, r.URL.Path) + } + var req gqlRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + return req +} + +func fixture(t *testing.T, name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + decodeGQL(t, r) + http.ServeFile(w, r, "testdata/"+name) + } +} + +func ts(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +func TestRecentPRsMapsEveryField(t *testing.T) { + c := serve(t, fixture(t, "prs.json")) + prs, warns, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil || len(warns) != 0 { + t.Fatalf("err %v, warnings %v", err, warns) + } + if len(prs) < 2 { + t.Fatalf("prs = %+v", prs) + } + want3 := model.PR{ + Repo: "acme/app", DefaultBranch: "main", Number: 3, Title: "ABC-1 add widget", + HeadRef: "abc-1-widget", BaseRef: "main", URL: "https://github.com/Acme/App/pull/3", + State: model.PROpen, IsDraft: true, OpenedAt: ts("2026-10-01T10:00:00Z"), + Checks: model.ChecksFailing, Review: model.ReviewRequired, Reviewers: 3, + Failing: []model.Check{ + {Name: "Code scanning results / CodeQL", CodeScanning: true, URL: "https://example.test/scan"}, + {Name: "rspec", RunID: 77, URL: "https://example.test/run/77"}, + {Name: "CodeQL / Analyze (go)", RunID: 78, URL: "https://example.test/run/78"}, + {Name: "Code scanning results / Semgrep", CodeScanning: true, URL: "https://example.test/semgrep"}, + {Name: "deploy-preview", RunID: 79, URL: "https://example.test/run/79"}, + {Name: "ci/legacy", URL: "https://example.test/legacy"}, + {Name: "ci/other", URL: "https://example.test/other"}, + }, + } + if !reflect.DeepEqual(prs[0], want3) { + t.Errorf("#3 =\n%+v\nwant\n%+v", prs[0], want3) + } + want2 := model.PR{ + Repo: "acme/app", DefaultBranch: "main", Number: 2, Title: "ABC-2 fix thing", + HeadRef: "abc-2-fix", BaseRef: "main", URL: "https://github.com/Acme/App/pull/2", + State: model.PRMerged, MergeSHA: "bbbb222", MergedAt: ts("2026-09-29T15:30:00Z"), + OpenedAt: ts("2026-09-28T09:00:00Z"), Checks: model.ChecksPassing, + Review: model.ReviewApproved, Reviewers: 1, + } + if !reflect.DeepEqual(prs[1], want2) { + t.Errorf("#2 =\n%+v\nwant\n%+v", prs[1], want2) + } +} + +func TestRecentPRsKeepsOnlyAuthorsPRs(t *testing.T) { + c := serve(t, fixture(t, "prs.json")) + prs, _, err := c.RecentPRs(context.Background(), "acme", "app", "ME-DEV", since) + if err != nil { + t.Fatal(err) + } + var nums []int + for _, p := range prs { + nums = append(nums, p.Number) + } + // #1 is someone else's and #7's author was deleted; logins match ignoring case. + if !reflect.DeepEqual(nums, []int{3, 2, 5, 6}) { + t.Errorf("numbers = %v, want [3 2 5 6]", nums) + } +} + +func TestRecentPRsPassesRollupAndReviewStatesThrough(t *testing.T) { + c := serve(t, fixture(t, "prs.json")) + prs, _, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil || len(prs) != 4 { + t.Fatalf("prs %+v, err %v", prs, err) + } + if p := prs[2]; p.Checks != "EXPECTED" || p.Review != model.ReviewChangesRequested || p.Failing != nil { + t.Errorf("#5 = %+v, want Checks EXPECTED, changes requested", p) + } + if p := prs[3]; p.Checks != model.ChecksNone || p.Review != model.ReviewNone { + t.Errorf("#6 = %+v, want no checks, no review", p) + } +} + +// page builds one recent-PRs response page of PRs by me-dev. +func page(next bool, cursor string, prs ...map[string]any) map[string]any { + nodes := []map[string]any{} + for _, p := range prs { + n := map[string]any{"headRefName": "h", "baseRefName": "main", "state": "OPEN", "author": map[string]any{"login": "me-dev"}, + "reviewRequests": map[string]any{"totalCount": 1}, "reviews": map[string]any{"totalCount": 0}, "commits": map[string]any{"nodes": []any{}}} + for k, v := range p { + n[k] = v + } + nodes = append(nodes, n) + } + return map[string]any{"data": map[string]any{"repository": map[string]any{ + "nameWithOwner": "acme/app", "defaultBranchRef": map[string]any{"name": "main"}, + "pullRequests": map[string]any{"pageInfo": map[string]any{"hasNextPage": next, "endCursor": cursor}, "nodes": nodes}, + }}} +} + +func TestRecentPRsPagesUntilPRsPredateSince(t *testing.T) { + var afters []any + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + req := decodeGQL(t, r) + if req.Variables["owner"] != "acme" || req.Variables["name"] != "app" { + t.Errorf("variables = %v", req.Variables) + } + after := req.Variables["after"] + afters = append(afters, after) + var body map[string]any + switch after { + case nil: + body = page(true, "p1", map[string]any{"number": 10, "createdAt": "2026-09-20T00:00:00Z"}) + case "p1": + // the last PR here was created before since: stop, though GitHub has more + body = page(true, "p2", map[string]any{"number": 9, "createdAt": "2026-09-10T00:00:00Z"}, map[string]any{"number": 8, "createdAt": "2026-08-01T00:00:00Z"}) + default: + t.Errorf("fetched page after %v", after) + body = page(false, "") + } + _ = json.NewEncoder(w).Encode(body) + }) + prs, _, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil { + t.Fatal(err) + } + if len(prs) != 3 || prs[2].Number != 8 { + t.Errorf("prs = %+v, want #10, #9, #8", prs) + } + if !reflect.DeepEqual(afters, []any{nil, "p1"}) { + t.Errorf("after = %v, want [nil p1]", afters) + } +} + +func TestRecentPRsStopsWhenGitHubHasNoMore(t *testing.T) { + calls := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + decodeGQL(t, r) + calls++ + _ = json.NewEncoder(w).Encode(page(false, "p1", map[string]any{"number": 10, "createdAt": "2026-09-20T00:00:00Z"})) + }) + if _, _, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since); err != nil || calls != 1 { + t.Errorf("err %v, calls %d, want 1", err, calls) + } +} + +func TestRecentPRsGraphQLErrors(t *testing.T) { + cases := []struct { + name string + body string + wantErr bool + warns int + }{ + {"errors and no data fail", `{"data":null,"errors":[{"message":"Something went wrong"}]}`, true, 0}, + {"an unknown repository fails", `{"data":{"repository":null},"errors":[{"type":"NOT_FOUND","message":"Could not resolve to a Repository"}]}`, true, 1}, + {"errors beside data are warnings", `{"data":{"repository":{"nameWithOwner":"acme/app","defaultBranchRef":{"name":"main"},"pullRequests":{"pageInfo":{"hasNextPage":false},"nodes":[]}}},"errors":[{"message":"partial"}]}`, false, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(tc.body)) }) + _, warns, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if (err != nil) != tc.wantErr || len(warns) != tc.warns { + t.Errorf("err %v, warnings %v; want error %v, %d warnings", err, warns, tc.wantErr, tc.warns) + } + }) + } +} + +func equalPR(a, b model.PR) bool { return reflect.DeepEqual(a, b) } diff --git a/internal/github/rerun.go b/internal/github/rerun.go new file mode 100644 index 0000000..2514723 --- /dev/null +++ b/internal/github/rerun.go @@ -0,0 +1,41 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" +) + +// Ported from git-green internal/github/client.go (RerunFailedJobs), onto net/http. + +// noFailedJobsFragment is what GitHub says when rerun-failed-jobs has nothing +// to retry individually: every job either passed or never started. Re-running +// the whole run is the right call in that case. +const noFailedJobsFragment = "no failed jobs" + +// isNoFailedJobs reports whether err is GitHub declining a rerun-failed-jobs +// request because the run has no individually failed jobs. +func isNoFailedJobs(err error) bool { + var ae *APIError + return errors.As(err, &ae) && strings.Contains(strings.ToLower(ae.Message), noFailedJobsFragment) +} + +// RerunFailedJobs re-runs the failed jobs of an Actions run in repo +// ("owner/name"), falling back to re-running the whole run when GitHub +// reports there were none. A read-only token fails here with a 403 +// *APIError, returned so the UI can show it. +func (c *Client) RerunFailedJobs(ctx context.Context, repo string, runID int64) error { + err := c.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/actions/runs/%d/rerun-failed-jobs", repo, runID), nil, nil) + if err == nil { + return nil + } + if !isNoFailedJobs(err) { + return fmt.Errorf("re-running failed jobs for run %d in %s: %w", runID, repo, err) + } + if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/actions/runs/%d/rerun", repo, runID), nil, nil); err != nil { + return fmt.Errorf("re-running run %d in %s: %w", runID, repo, err) + } + return nil +} diff --git a/internal/github/rerun_test.go b/internal/github/rerun_test.go new file mode 100644 index 0000000..4d1f5d8 --- /dev/null +++ b/internal/github/rerun_test.go @@ -0,0 +1,58 @@ +package github_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/ericdahl-dev/app-green/internal/github" +) + +func TestRerunFailedJobs(t *testing.T) { + var calls []string + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, `{}`) + }) + if err := c.RerunFailedJobs(context.Background(), "acme/app", 77); err != nil { + t.Fatal(err) + } + if len(calls) != 1 || calls[0] != "POST /repos/acme/app/actions/runs/77/rerun-failed-jobs" { + t.Errorf("calls = %v", calls) + } +} + +func TestRerunFallsBackToWholeRunWhenNoJobFailed(t *testing.T) { + var calls []string + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.URL.Path == "/repos/acme/app/actions/runs/77/rerun-failed-jobs" { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"message":"This workflow run has No Failed Jobs to re-run."}`) + return + } + w.WriteHeader(http.StatusCreated) + }) + if err := c.RerunFailedJobs(context.Background(), "acme/app", 77); err != nil { + t.Fatal(err) + } + want := []string{"POST /repos/acme/app/actions/runs/77/rerun-failed-jobs", "POST /repos/acme/app/actions/runs/77/rerun"} + if fmt.Sprint(calls) != fmt.Sprint(want) { + t.Errorf("calls = %v, want %v", calls, want) + } +} + +func TestRerunReturnsOtherErrorsTyped(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"message":"Resource not accessible by integration"}`) + }) + err := c.RerunFailedJobs(context.Background(), "acme/app", 77) + var ae *github.APIError + if !errors.As(err, &ae) || ae.Status != http.StatusForbidden || github.IsAuth(err) { + t.Errorf("err = %v, want a non-auth 403 *APIError", err) + } +} diff --git a/internal/github/testdata/base_prs.json b/internal/github/testdata/base_prs.json new file mode 100644 index 0000000..2e3c89c --- /dev/null +++ b/internal/github/testdata/base_prs.json @@ -0,0 +1,23 @@ +{ + "data": { + "repository": { + "nameWithOwner": "Acme/App", + "defaultBranchRef": {"name": "main"}, + "b0": { + "nodes": [ + { + "number": 1, "title": "Stack base", "url": "https://github.com/Acme/App/pull/1", + "headRefName": "s1", "baseRefName": "main", "state": "MERGED", "isDraft": false, + "isCrossRepository": false, + "createdAt": "2026-09-20T09:00:00Z", "mergedAt": "2026-09-22T09:00:00Z", + "author": {"login": "someone-else"}, + "mergeCommit": {"oid": "aaaa111"}, + "reviewDecision": "APPROVED", + "reviewRequests": {"totalCount": 0}, "reviews": {"totalCount": 1}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS", "contexts": {"nodes": []}}}}]} + } + ] + } + } + } +} diff --git a/internal/github/testdata/prs.json b/internal/github/testdata/prs.json new file mode 100644 index 0000000..35c95c8 --- /dev/null +++ b/internal/github/testdata/prs.json @@ -0,0 +1,87 @@ +{ + "data": { + "repository": { + "nameWithOwner": "Acme/App", + "defaultBranchRef": {"name": "main"}, + "pullRequests": { + "pageInfo": {"hasNextPage": false, "endCursor": "c1"}, + "nodes": [ + { + "number": 3, "title": "ABC-1 add widget", "url": "https://github.com/Acme/App/pull/3", + "headRefName": "abc-1-widget", "baseRefName": "main", "state": "OPEN", "isDraft": true, + "createdAt": "2026-10-01T10:00:00Z", "mergedAt": null, + "author": {"login": "Me-Dev"}, + "mergeCommit": {"oid": "cccc333"}, + "reviewDecision": "REVIEW_REQUIRED", + "reviewRequests": {"totalCount": 1}, + "reviews": {"totalCount": 2}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": { + "state": "FAILURE", + "contexts": {"nodes": [ + {"__typename": "CheckRun", "name": "Code scanning results / CodeQL", "conclusion": "FAILURE", "detailsUrl": "https://example.test/scan", "checkSuite": {"workflowRun": null}, "app": {"slug": "github-code-scanning"}}, + {"__typename": "CheckRun", "name": "rspec", "conclusion": "FAILURE", "detailsUrl": "https://example.test/run/77", "checkSuite": {"workflowRun": {"databaseId": 77}}, "app": {"slug": "github-actions"}}, + {"__typename": "CheckRun", "name": "CodeQL / Analyze (go)", "conclusion": "TIMED_OUT", "detailsUrl": "https://example.test/run/78", "checkSuite": {"workflowRun": {"databaseId": 78}}, "app": {"slug": "github-actions"}}, + {"__typename": "CheckRun", "name": "Code scanning results / Semgrep", "conclusion": "ACTION_REQUIRED", "detailsUrl": "https://example.test/semgrep", "checkSuite": {"workflowRun": null}, "app": {"slug": "semgrep-app"}}, + {"__typename": "CheckRun", "name": "deploy-preview", "conclusion": "CANCELLED", "detailsUrl": "https://example.test/run/79", "checkSuite": {"workflowRun": {"databaseId": 79}}, "app": {"slug": "github-actions"}}, + {"__typename": "CheckRun", "name": "lint", "conclusion": "SUCCESS", "detailsUrl": "https://example.test/run/80", "checkSuite": {"workflowRun": {"databaseId": 80}}, "app": {"slug": "github-actions"}}, + {"__typename": "CheckRun", "name": "optional", "conclusion": "NEUTRAL", "detailsUrl": "", "checkSuite": {"workflowRun": null}, "app": {"slug": "other"}}, + {"__typename": "CheckRun", "name": "still-running", "conclusion": null, "detailsUrl": "", "checkSuite": {"workflowRun": {"databaseId": 81}}, "app": {"slug": "github-actions"}}, + {"__typename": "StatusContext", "context": "ci/legacy", "state": "ERROR", "targetUrl": "https://example.test/legacy"}, + {"__typename": "StatusContext", "context": "ci/other", "state": "FAILURE", "targetUrl": "https://example.test/other"}, + {"__typename": "StatusContext", "context": "ci/ok", "state": "SUCCESS", "targetUrl": "https://example.test/ok"}, + {"__typename": "StatusContext", "context": "ci/wait", "state": "PENDING", "targetUrl": null} + ]} + }}}]} + }, + { + "number": 2, "title": "ABC-2 fix thing", "url": "https://github.com/Acme/App/pull/2", + "headRefName": "abc-2-fix", "baseRefName": "main", "state": "MERGED", "isDraft": false, + "createdAt": "2026-09-28T09:00:00Z", "mergedAt": "2026-09-29T15:30:00Z", + "author": {"login": "me-dev"}, + "mergeCommit": {"oid": "bbbb222"}, + "reviewDecision": "APPROVED", + "reviewRequests": {"totalCount": 0}, + "reviews": {"totalCount": 1}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS", "contexts": {"nodes": []}}}}]} + }, + { + "number": 1, "title": "ABC-3 not mine", "url": "https://github.com/Acme/App/pull/1", + "headRefName": "other", "baseRefName": "main", "state": "OPEN", "isDraft": false, + "createdAt": "2026-09-27T09:00:00Z", "mergedAt": null, + "author": {"login": "someone-else"}, + "mergeCommit": null, "reviewDecision": null, + "reviewRequests": {"totalCount": 0}, "reviews": {"totalCount": 0}, + "commits": {"nodes": []} + }, + { + "number": 5, "title": "ABC-4 expected", "url": "https://github.com/Acme/App/pull/5", + "headRefName": "abc-4", "baseRefName": "main", "state": "OPEN", "isDraft": false, + "createdAt": "2026-09-26T09:00:00Z", "mergedAt": null, + "author": {"login": "me-dev"}, + "mergeCommit": null, "reviewDecision": "CHANGES_REQUESTED", + "reviewRequests": {"totalCount": 0}, "reviews": {"totalCount": 1}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "EXPECTED", "contexts": {"nodes": []}}}}]} + }, + { + "number": 6, "title": "ABC-5 no checks", "url": "https://github.com/Acme/App/pull/6", + "headRefName": "abc-5", "baseRefName": "main", "state": "OPEN", "isDraft": false, + "createdAt": "2026-09-25T09:00:00Z", "mergedAt": null, + "author": {"login": "me-dev"}, + "mergeCommit": null, "reviewDecision": null, + "reviewRequests": {"totalCount": 0}, "reviews": {"totalCount": 0}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": null}}]} + }, + { + "number": 7, "title": "ABC-6 deleted user", "url": "https://github.com/Acme/App/pull/7", + "headRefName": "ghost", "baseRefName": "main", "state": "OPEN", "isDraft": false, + "createdAt": "2026-09-24T09:00:00Z", "mergedAt": null, + "author": null, + "mergeCommit": null, "reviewDecision": null, + "reviewRequests": {"totalCount": 0}, "reviews": {"totalCount": 0}, + "commits": {"nodes": []} + } + ] + } + } + } +} From 5ae03adfd373f4b8db4025223284f1cb56076187 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:36:27 -0400 Subject: [PATCH 06/31] fix(github): read a check run's app from its check suite CheckRun has no app field; GitHub rejected the whole query. The slug lives on checkSuite{app{slug}}. Checked against a public repo's live GraphQL schema; the fixture now uses the real shape. Assisted-by: Claude Code:claude-opus-5-5 --- internal/github/prs.go | 20 +++++++++++++------- internal/github/prs_test.go | 2 +- internal/github/testdata/prs.json | 16 ++++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/github/prs.go b/internal/github/prs.go index dd8c114..329bd5a 100644 --- a/internal/github/prs.go +++ b/internal/github/prs.go @@ -23,7 +23,7 @@ fragment pr on PullRequest { commits(last:1){nodes{commit{statusCheckRollup{state contexts(first:50){nodes{ __typename - ... on CheckRun{name conclusion detailsUrl checkSuite{workflowRun{databaseId}} app{slug}} + ... on CheckRun{name conclusion detailsUrl checkSuite{app{slug} workflowRun{databaseId}}} ... on StatusContext{context state targetUrl} }}}}}} }` @@ -98,13 +98,13 @@ type checkNode struct { Conclusion *string `json:"conclusion"` DetailsURL *string `json:"detailsUrl"` CheckSuite *struct { + App *struct { + Slug string `json:"slug"` + } `json:"app"` WorkflowRun *struct { DatabaseID int64 `json:"databaseId"` } `json:"workflowRun"` } `json:"checkSuite"` - App *struct { - Slug string `json:"slug"` - } `json:"app"` Context string `json:"context"` State string `json:"state"` TargetURL *string `json:"targetUrl"` @@ -174,12 +174,18 @@ func (c checkNode) failing() (model.Check, bool) { return model.Check{}, false } chk := model.Check{Name: c.Name, URL: deref(c.DetailsURL)} - if c.CheckSuite != nil && c.CheckSuite.WorkflowRun != nil { - chk.RunID = c.CheckSuite.WorkflowRun.DatabaseID + slug := "" + if s := c.CheckSuite; s != nil { + if s.WorkflowRun != nil { + chk.RunID = s.WorkflowRun.DatabaseID + } + if s.App != nil { + slug = s.App.Slug + } } // A code scanning alert is not fixed by a re-run. The CodeQL Actions // job ("CodeQL / Analyze") is an ordinary run and can be re-run. - chk.CodeScanning = (c.App != nil && c.App.Slug == "github-code-scanning") || strings.HasPrefix(c.Name, "Code scanning") + chk.CodeScanning = slug == "github-code-scanning" || strings.HasPrefix(c.Name, "Code scanning") return chk, true case "StatusContext": if c.State != "FAILURE" && c.State != "ERROR" { diff --git a/internal/github/prs_test.go b/internal/github/prs_test.go index 2328464..694f269 100644 --- a/internal/github/prs_test.go +++ b/internal/github/prs_test.go @@ -60,7 +60,7 @@ func TestRecentPRsMapsEveryField(t *testing.T) { State: model.PROpen, IsDraft: true, OpenedAt: ts("2026-10-01T10:00:00Z"), Checks: model.ChecksFailing, Review: model.ReviewRequired, Reviewers: 3, Failing: []model.Check{ - {Name: "Code scanning results / CodeQL", CodeScanning: true, URL: "https://example.test/scan"}, + {Name: "CodeQL", CodeScanning: true, URL: "https://example.test/scan"}, {Name: "rspec", RunID: 77, URL: "https://example.test/run/77"}, {Name: "CodeQL / Analyze (go)", RunID: 78, URL: "https://example.test/run/78"}, {Name: "Code scanning results / Semgrep", CodeScanning: true, URL: "https://example.test/semgrep"}, diff --git a/internal/github/testdata/prs.json b/internal/github/testdata/prs.json index 35c95c8..a8e5f78 100644 --- a/internal/github/testdata/prs.json +++ b/internal/github/testdata/prs.json @@ -18,14 +18,14 @@ "commits": {"nodes": [{"commit": {"statusCheckRollup": { "state": "FAILURE", "contexts": {"nodes": [ - {"__typename": "CheckRun", "name": "Code scanning results / CodeQL", "conclusion": "FAILURE", "detailsUrl": "https://example.test/scan", "checkSuite": {"workflowRun": null}, "app": {"slug": "github-code-scanning"}}, - {"__typename": "CheckRun", "name": "rspec", "conclusion": "FAILURE", "detailsUrl": "https://example.test/run/77", "checkSuite": {"workflowRun": {"databaseId": 77}}, "app": {"slug": "github-actions"}}, - {"__typename": "CheckRun", "name": "CodeQL / Analyze (go)", "conclusion": "TIMED_OUT", "detailsUrl": "https://example.test/run/78", "checkSuite": {"workflowRun": {"databaseId": 78}}, "app": {"slug": "github-actions"}}, - {"__typename": "CheckRun", "name": "Code scanning results / Semgrep", "conclusion": "ACTION_REQUIRED", "detailsUrl": "https://example.test/semgrep", "checkSuite": {"workflowRun": null}, "app": {"slug": "semgrep-app"}}, - {"__typename": "CheckRun", "name": "deploy-preview", "conclusion": "CANCELLED", "detailsUrl": "https://example.test/run/79", "checkSuite": {"workflowRun": {"databaseId": 79}}, "app": {"slug": "github-actions"}}, - {"__typename": "CheckRun", "name": "lint", "conclusion": "SUCCESS", "detailsUrl": "https://example.test/run/80", "checkSuite": {"workflowRun": {"databaseId": 80}}, "app": {"slug": "github-actions"}}, - {"__typename": "CheckRun", "name": "optional", "conclusion": "NEUTRAL", "detailsUrl": "", "checkSuite": {"workflowRun": null}, "app": {"slug": "other"}}, - {"__typename": "CheckRun", "name": "still-running", "conclusion": null, "detailsUrl": "", "checkSuite": {"workflowRun": {"databaseId": 81}}, "app": {"slug": "github-actions"}}, + {"__typename": "CheckRun", "name": "CodeQL", "conclusion": "FAILURE", "detailsUrl": "https://example.test/scan", "checkSuite": {"workflowRun": null, "app": {"slug": "github-code-scanning"}}}, + {"__typename": "CheckRun", "name": "rspec", "conclusion": "FAILURE", "detailsUrl": "https://example.test/run/77", "checkSuite": {"workflowRun": {"databaseId": 77}, "app": {"slug": "github-actions"}}}, + {"__typename": "CheckRun", "name": "CodeQL / Analyze (go)", "conclusion": "TIMED_OUT", "detailsUrl": "https://example.test/run/78", "checkSuite": {"workflowRun": {"databaseId": 78}, "app": {"slug": "github-actions"}}}, + {"__typename": "CheckRun", "name": "Code scanning results / Semgrep", "conclusion": "ACTION_REQUIRED", "detailsUrl": "https://example.test/semgrep", "checkSuite": {"workflowRun": null, "app": {"slug": "semgrep-app"}}}, + {"__typename": "CheckRun", "name": "deploy-preview", "conclusion": "CANCELLED", "detailsUrl": "https://example.test/run/79", "checkSuite": {"workflowRun": {"databaseId": 79}, "app": {"slug": "github-actions"}}}, + {"__typename": "CheckRun", "name": "lint", "conclusion": "SUCCESS", "detailsUrl": "https://example.test/run/80", "checkSuite": {"workflowRun": {"databaseId": 80}, "app": {"slug": "github-actions"}}}, + {"__typename": "CheckRun", "name": "optional", "conclusion": "NEUTRAL", "detailsUrl": "", "checkSuite": {"workflowRun": null, "app": {"slug": "other"}}}, + {"__typename": "CheckRun", "name": "still-running", "conclusion": null, "detailsUrl": "", "checkSuite": {"workflowRun": {"databaseId": 81}, "app": {"slug": "github-actions"}}}, {"__typename": "StatusContext", "context": "ci/legacy", "state": "ERROR", "targetUrl": "https://example.test/legacy"}, {"__typename": "StatusContext", "context": "ci/other", "state": "FAILURE", "targetUrl": "https://example.test/other"}, {"__typename": "StatusContext", "context": "ci/ok", "state": "SUCCESS", "targetUrl": "https://example.test/ok"}, From fe30574ef4084999fe58ae49953e9df006848466 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 15:40:55 -0400 Subject: [PATCH 07/31] fix(github): paging by update time, code-scanning slug, startup failures, GraphQL rate limit - RecentPRs stops paging once the last PR on a page was updated before since; the list is ordered by update time, so createdAt could stop early and miss PRs. - Checks from the github-advanced-security app count as code scanning; CodeQL Actions jobs stay ordinary re-runnable checks. - A STARTUP_FAILURE conclusion is failing; STALE is not. - A RATE_LIMITED GraphQL error is a 429 *APIError backing off until rateLimit.resetAt (now in both queries), else 60s. - rules.Thresholds.StaleReview doc notes drafts skip review flags. Assisted-by: Claude Code:claude-opus-5-5 --- internal/github/base.go | 2 +- internal/github/graphql.go | 24 +++++++++- internal/github/prs.go | 24 ++++++---- internal/github/prs_test.go | 96 ++++++++++++++++++++++++++++++++++--- internal/rules/flags.go | 1 + 5 files changed, 130 insertions(+), 17 deletions(-) diff --git a/internal/github/base.go b/internal/github/base.go index 8b583cf..350d7be 100644 --- a/internal/github/base.go +++ b/internal/github/base.go @@ -18,7 +18,7 @@ func basePRsQuery(n int) string { fmt.Fprintf(&vars, ",$b%d:String!", i) fmt.Fprintf(&aliases, "\n b%d:pullRequests(headRefName:$b%d,states:[OPEN,MERGED],first:5,orderBy:{field:CREATED_AT,direction:DESC}){nodes{...pr}}", i, i) } - return "query($owner:String!,$name:String!" + vars.String() + "){\n repository(owner:$owner,name:$name){\n nameWithOwner\n defaultBranchRef{name}" + + return "query($owner:String!,$name:String!" + vars.String() + "){\n rateLimit{resetAt}\n repository(owner:$owner,name:$name){\n nameWithOwner\n defaultBranchRef{name}" + aliases.String() + "\n }\n}" + prFields } diff --git a/internal/github/graphql.go b/internal/github/graphql.go index 204c415..a1690e9 100644 --- a/internal/github/graphql.go +++ b/internal/github/graphql.go @@ -7,15 +7,20 @@ import ( "fmt" "net/http" "strings" + "time" ) // graphql POSTs query to /graphql and decodes its data into out. GitHub // answers most query problems with HTTP 200 and an errors list: with no data -// that is an error, and next to partial data each message is a warning. +// that is an error, and next to partial data each message is a warning. A +// RATE_LIMITED error is a 429 *APIError, so callers back off as for REST: +// until the data's rateLimit.resetAt when the query asked for it, else +// defaultRetryAfter. func (c *Client) graphql(ctx context.Context, query string, vars map[string]any, out any) ([]string, error) { var resp struct { Data json.RawMessage `json:"data"` Errors []struct { + Type string `json:"type"` Message string `json:"message"` } `json:"errors"` } @@ -24,6 +29,9 @@ func (c *Client) graphql(ctx context.Context, query string, vars map[string]any, } var msgs []string for _, e := range resp.Errors { + if e.Type == "RATE_LIMITED" { + return nil, c.graphqlRateLimit(resp.Data, e.Message) + } msgs = append(msgs, "github: graphql: "+e.Message) } if len(resp.Data) == 0 || string(resp.Data) == "null" { @@ -37,3 +45,17 @@ func (c *Client) graphql(ctx context.Context, query string, vars map[string]any, } return msgs, nil } + +// graphqlRateLimit builds the 429 for a RATE_LIMITED GraphQL error. +func (c *Client) graphqlRateLimit(data json.RawMessage, msg string) *APIError { + retry := defaultRetryAfter + var rl struct { + RateLimit *struct { + ResetAt time.Time `json:"resetAt"` + } `json:"rateLimit"` + } + if json.Unmarshal(data, &rl) == nil && rl.RateLimit != nil && !rl.RateLimit.ResetAt.IsZero() { + retry = min(max(rl.RateLimit.ResetAt.Sub(c.now()), 0), maxRetryAfter) + } + return &APIError{Status: http.StatusTooManyRequests, Message: msg, RetryAfter: retry} +} diff --git a/internal/github/prs.go b/internal/github/prs.go index 329bd5a..ee869ec 100644 --- a/internal/github/prs.go +++ b/internal/github/prs.go @@ -13,7 +13,7 @@ import ( // base-branch queries so the two cannot drift. const prFields = ` fragment pr on PullRequest { - number title url headRefName baseRefName state isDraft createdAt mergedAt + number title url headRefName baseRefName state isDraft createdAt updatedAt mergedAt isCrossRepository author{login} mergeCommit{oid} @@ -29,6 +29,7 @@ fragment pr on PullRequest { }` const recentPRsQuery = `query($owner:String!,$name:String!,$after:String){ + rateLimit{resetAt} repository(owner:$owner,name:$name){ nameWithOwner defaultBranchRef{name} @@ -62,6 +63,7 @@ type prNode struct { State string `json:"state"` IsDraft bool `json:"isDraft"` CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` MergedAt *time.Time `json:"mergedAt"` IsCrossRepository bool `json:"isCrossRepository"` Author *struct { @@ -160,8 +162,8 @@ func (n prNode) toPR(repo repoInfo) model.PR { } // failing returns the check as a model.Check when it has failed: a CheckRun -// that concluded FAILURE, TIMED_OUT, CANCELLED or ACTION_REQUIRED, or a -// StatusContext in FAILURE or ERROR. +// that concluded FAILURE, TIMED_OUT, CANCELLED, ACTION_REQUIRED or +// STARTUP_FAILURE (not STALE), or a StatusContext in FAILURE or ERROR. func (c checkNode) failing() (model.Check, bool) { switch c.Typename { case "CheckRun": @@ -169,7 +171,7 @@ func (c checkNode) failing() (model.Check, bool) { return model.Check{}, false } switch *c.Conclusion { - case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED": + case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE": default: return model.Check{}, false } @@ -183,9 +185,11 @@ func (c checkNode) failing() (model.Check, bool) { slug = s.App.Slug } } - // A code scanning alert is not fixed by a re-run. The CodeQL Actions - // job ("CodeQL / Analyze") is an ordinary run and can be re-run. - chk.CodeScanning = slug == "github-code-scanning" || strings.HasPrefix(c.Name, "Code scanning") + // A code scanning alert is not fixed by a re-run. Its check comes + // from the github-code-scanning or github-advanced-security app. The + // CodeQL Actions jobs ("CodeQL / Analyze", "CodeQL-Build") come from + // github-actions: ordinary runs that can be re-run. + chk.CodeScanning = slug == "github-code-scanning" || slug == "github-advanced-security" || strings.HasPrefix(c.Name, "Code scanning") return chk, true case "StatusContext": if c.State != "FAILURE" && c.State != "ERROR" { @@ -223,7 +227,9 @@ const maxPages = 20 // RecentPRs returns author's open and merged PRs in owner/name, most // recently updated first, with logins compared ignoring case (an empty author // matches nothing). It pages while GitHub has more and the last PR on the -// page was created after since; since only stops paging and filters nothing. +// page was updated after since: the list is ordered by update time, so every +// later PR was updated (and created) before since. since only stops paging +// and filters nothing. // Warnings are GraphQL errors that came with usable data. func (c *Client) RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, []string, error) { var out []model.PR @@ -245,7 +251,7 @@ func (c *Client) RecentPRs(ctx context.Context, owner, name, author string, sinc out = append(out, n.toPR(page.Repository.repoInfo)) } } - if !prs.PageInfo.HasNextPage || len(prs.Nodes) == 0 || !prs.Nodes[len(prs.Nodes)-1].CreatedAt.After(since) { + if !prs.PageInfo.HasNextPage || len(prs.Nodes) == 0 || !prs.Nodes[len(prs.Nodes)-1].UpdatedAt.After(since) { break } after = prs.PageInfo.EndCursor diff --git a/internal/github/prs_test.go b/internal/github/prs_test.go index 694f269..d2feaef 100644 --- a/internal/github/prs_test.go +++ b/internal/github/prs_test.go @@ -3,11 +3,14 @@ package github_test import ( "context" "encoding/json" + "errors" "net/http" "reflect" + "strings" "testing" "time" + "github.com/ericdahl-dev/app-green/internal/github" "github.com/ericdahl-dev/app-green/internal/model" ) @@ -131,7 +134,7 @@ func page(next bool, cursor string, prs ...map[string]any) map[string]any { }}} } -func TestRecentPRsPagesUntilPRsPredateSince(t *testing.T) { +func TestRecentPRsPagesUntilUpdatesPredateSince(t *testing.T) { var afters []any c := serve(t, func(w http.ResponseWriter, r *http.Request) { req := decodeGQL(t, r) @@ -143,10 +146,12 @@ func TestRecentPRsPagesUntilPRsPredateSince(t *testing.T) { var body map[string]any switch after { case nil: - body = page(true, "p1", map[string]any{"number": 10, "createdAt": "2026-09-20T00:00:00Z"}) + // created long before since but updated recently: keep paging + body = page(true, "p1", map[string]any{"number": 10, "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2026-09-20T00:00:00Z"}) case "p1": - // the last PR here was created before since: stop, though GitHub has more - body = page(true, "p2", map[string]any{"number": 9, "createdAt": "2026-09-10T00:00:00Z"}, map[string]any{"number": 8, "createdAt": "2026-08-01T00:00:00Z"}) + // #9 was created recently; #8 was last updated before since: stop, though GitHub has more + body = page(true, "p2", map[string]any{"number": 9, "createdAt": "2026-09-10T00:00:00Z", "updatedAt": "2026-09-12T00:00:00Z"}, + map[string]any{"number": 8, "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-08-01T00:00:00Z"}) default: t.Errorf("fetched page after %v", after) body = page(false, "") @@ -157,7 +162,7 @@ func TestRecentPRsPagesUntilPRsPredateSince(t *testing.T) { if err != nil { t.Fatal(err) } - if len(prs) != 3 || prs[2].Number != 8 { + if len(prs) != 3 || prs[1].Number != 9 || prs[2].Number != 8 { t.Errorf("prs = %+v, want #10, #9, #8", prs) } if !reflect.DeepEqual(afters, []any{nil, "p1"}) { @@ -170,7 +175,7 @@ func TestRecentPRsStopsWhenGitHubHasNoMore(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { decodeGQL(t, r) calls++ - _ = json.NewEncoder(w).Encode(page(false, "p1", map[string]any{"number": 10, "createdAt": "2026-09-20T00:00:00Z"})) + _ = json.NewEncoder(w).Encode(page(false, "p1", map[string]any{"number": 10, "createdAt": "2026-09-20T00:00:00Z", "updatedAt": "2026-09-20T00:00:00Z"})) }) if _, _, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since); err != nil || calls != 1 { t.Errorf("err %v, calls %d, want 1", err, calls) @@ -200,3 +205,82 @@ func TestRecentPRsGraphQLErrors(t *testing.T) { } func equalPR(a, b model.PR) bool { return reflect.DeepEqual(a, b) } + +// failingOf serves one PR by me-dev whose rollup holds contexts, and returns +// its Failing checks. +func failingOf(t *testing.T, contexts ...map[string]any) []model.Check { + t.Helper() + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + decodeGQL(t, r) + rollup := map[string]any{"state": "FAILURE", "contexts": map[string]any{"nodes": contexts}} + _ = json.NewEncoder(w).Encode(page(false, "", map[string]any{"number": 1, "createdAt": "2026-09-20T00:00:00Z", + "commits": map[string]any{"nodes": []any{map[string]any{"commit": map[string]any{"statusCheckRollup": rollup}}}}})) + }) + prs, _, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil || len(prs) != 1 { + t.Fatalf("prs %+v, err %v", prs, err) + } + return prs[0].Failing +} + +func checkRun(name, conclusion, slug string, runID int64) map[string]any { + suite := map[string]any{"app": map[string]any{"slug": slug}, "workflowRun": nil} + if runID != 0 { + suite["workflowRun"] = map[string]any{"databaseId": runID} + } + return map[string]any{"__typename": "CheckRun", "name": name, "conclusion": conclusion, "checkSuite": suite} +} + +func TestAdvancedSecurityCheckIsCodeScanning(t *testing.T) { + got := failingOf(t, checkRun("CodeQL", "FAILURE", "github-advanced-security", 0), checkRun("CodeQL-Build (go)", "FAILURE", "github-actions", 42)) + want := []model.Check{{Name: "CodeQL", CodeScanning: true}, {Name: "CodeQL-Build (go)", RunID: 42}} + if !reflect.DeepEqual(got, want) { + t.Errorf("failing = %+v, want %+v", got, want) + } +} + +func TestStartupFailureFailsAndStaleDoesNot(t *testing.T) { + got := failingOf(t, checkRun("build", "STARTUP_FAILURE", "github-actions", 43), checkRun("old", "STALE", "github-actions", 44)) + want := []model.Check{{Name: "build", RunID: 43}} + if !reflect.DeepEqual(got, want) { + t.Errorf("failing = %+v, want %+v", got, want) + } +} + +func TestGraphQLRateLimitIsA429(t *testing.T) { + reset := time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339) + cases := []struct { + name string + body string + min, max time.Duration + }{ + {"no data backs off a minute", `{"data":null,"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`, time.Minute, time.Minute}, + {"rateLimit.resetAt sets the backoff", `{"data":{"rateLimit":{"resetAt":"` + reset + `"},"repository":null},"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`, + 4 * time.Minute, 5 * time.Minute}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var queries []string + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, decodeGQL(t, r).Query) + _, _ = w.Write([]byte(tc.body)) + }) + _, _, errRecent := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + _, _, errBase := c.BasePRs(context.Background(), "acme", "app", []model.PR{{Repo: "acme/app", DefaultBranch: "main", HeadRef: "h", BaseRef: "s1", State: model.PROpen}}) + for _, err := range []error{errRecent, errBase} { + var ae *github.APIError + if !errors.As(err, &ae) || ae.Status != http.StatusTooManyRequests || github.IsAuth(err) { + t.Fatalf("err = %v, want a 429 *APIError", err) + } + if ae.RetryAfter < tc.min || ae.RetryAfter > tc.max { + t.Errorf("RetryAfter = %v, want %v..%v", ae.RetryAfter, tc.min, tc.max) + } + } + for _, q := range queries { + if !strings.Contains(q, "rateLimit{resetAt}") { + t.Errorf("query does not ask for rateLimit{resetAt}:\n%s", q) + } + } + }) + } +} diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 88cb867..0d30f74 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -14,6 +14,7 @@ import ( type Thresholds struct { // StaleReview: an open PR with no approval this long after it opened is // yellow (a PR with no reviewer at all is flagged whatever this is). + // Draft PRs get neither flag, nor ready to merge. StaleReview time.Duration // DoneGrace: a ticket Done in Jira this long (since StatusSince, else // Updated) without being in prod is yellow. From 8d0235bd7180d1af4a65e89d208fde4fc03f8e5f Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 16:13:46 -0400 Subject: [PATCH 08/31] feat(link): context PRs feed stack walking only Link takes a separate context list (the GitHub adapter's base-branch PRs). They are walked when following a stack to the default branch but never linked to a ticket and never returned as unlinked, so another author's base PR naming my key does not join my row. Assisted-by: Claude Code:claude-opus-5-5 --- internal/e2e/e2e_test.go | 28 +++++++++++++++++++++- internal/github/base.go | 5 ++-- internal/github/base_test.go | 2 +- internal/link/link.go | 15 +++++++++--- internal/link/link_test.go | 46 ++++++++++++++++++++++++++++-------- 5 files changed, 79 insertions(+), 17 deletions(-) diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 83ce0d3..b9e24c7 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -85,7 +85,14 @@ func everywhere() []model.EnvHistory { // is false when Evaluate dropped it. func run(t *testing.T, ticket model.Ticket, prs []model.PR, hs []model.EnvHistory) (row model.Chain, ok bool) { t.Helper() - chains, unlinked := link.Link([]model.Ticket{ticket}, prs, []string{"ABC"}) + return runWith(t, ticket, prs, nil, hs) +} + +// runWith is run with context PRs: base-branch PRs used only for stack +// walking, as the GitHub adapter's BasePRs returns them. +func runWith(t *testing.T, ticket model.Ticket, prs, context []model.PR, hs []model.EnvHistory) (row model.Chain, ok bool) { + t.Helper() + chains, unlinked := link.Link([]model.Ticket{ticket}, prs, context, []string{"ABC"}) if len(chains) != 1 || len(unlinked) != 0 { t.Fatalf("link: chains %d, unlinked %d, want 1 and 0", len(chains), len(unlinked)) } @@ -197,6 +204,25 @@ func TestStackedSeriesLandedIsInProd(t *testing.T) { want(t, c, model.StageInProd, model.None, -1) } +func TestStackOnSomeoneElsesBaseIsInProd(t *testing.T) { + // My #2 merged into s1; someone else's #1 (context, naming my key too) + // then merged s1 into main as b, which is deployed everywhere. + mine := []model.PR{merged(2, "s2", "s1", "q", now.Add(-12*time.Hour))} + theirs := merged(1, "s1", "main", "b", now.Add(-10*time.Hour)) + hs := everywhere() + for i := range hs { + hs[i].Deploys = append([]model.Deploy{ok("b", now.Add(-time.Duration(6+i)*time.Hour))}, hs[i].Deploys...) + } + c, ok := runWith(t, done, mine, []model.PR{theirs}, hs) + if !ok { + t.Fatal("row dropped, want it kept inside FadeAfter") + } + want(t, c, model.StageInProd, model.None, -1) + if len(c.PRs) != 1 || c.PRs[0].Number != 2 { + t.Errorf("row PRs = %+v, want only my #2", c.PRs) + } +} + func TestStrandedStackPRIsRed(t *testing.T) { // #1 merged s1 into main as a; #2 merged into s1 an hour later, so its // commits never reach main. diff --git a/internal/github/base.go b/internal/github/base.go index 350d7be..c5d5456 100644 --- a/internal/github/base.go +++ b/internal/github/base.go @@ -49,8 +49,9 @@ func missingBases(repo string, prs []model.PR) []string { const maxBaseRounds = 5 // BasePRs returns the PR for each base branch of prs in owner/name that is -// not the default branch and has no PR in prs, whoever wrote it. link needs -// them: without a base branch's PR a stacked PR looks Stranded. It repeats +// not the default branch and has no PR in prs, whoever wrote it. Pass them +// to link.Link as its context PRs: they feed stack walking only (without a +// base branch's PR a stacked PR looks Stranded) and never join a row. It repeats // for the bases of the PRs it found, so a stack whose lower branches belong // to someone else resolves down to the default branch (at most // maxBaseRounds queries, one branch asked once). Each branch gets the newest diff --git a/internal/github/base_test.go b/internal/github/base_test.go index 934f31c..534a135 100644 --- a/internal/github/base_test.go +++ b/internal/github/base_test.go @@ -122,7 +122,7 @@ func TestBasePRsKeepALaterStackPRFromLookingStranded(t *testing.T) { if err != nil { t.Fatal(err) } - chains, _ := link.Link([]model.Ticket{{Key: "ABC-2"}}, append(mine, bases...), []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-2"}}, mine, bases, []string{"ABC"}) p := chains[0].PRs[0] if p.Stranded || p.EffectiveSHA != "aaaa111" { t.Errorf("#2 = %+v, want carried to main by #1's merge aaaa111", p) diff --git a/internal/link/link.go b/internal/link/link.go index 1c1c9a4..788ce26 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -1,14 +1,23 @@ package link -import "github.com/ericdahl-dev/app-green/internal/model" +import ( + "slices" + + "github.com/ericdahl-dev/app-green/internal/model" +) // Link groups prs under the tickets whose keys they name, in ticket order. // A PR that names one of tickets links to it, even if it also names keys // outside tickets. A PR that names no configured-project key is returned as // unlinked. A PR whose keys are all outside tickets is dropped: it is not my // work. Closed (unmerged) PRs are ignored everywhere, including stack walking. -func Link(tickets []model.Ticket, prs []model.PR, projects []string) ([]model.Chain, []model.PR) { - prs = withEffectiveSHAs(prs) +// +// context holds PRs that matter only as stack bases: the PR for each base +// branch of prs, whoever wrote it (github.Client.BasePRs). They are used for +// stack walking alone, never linked to a ticket and never returned as +// unlinked, so another author's PR naming my key does not join my row. +func Link(tickets []model.Ticket, prs, context []model.PR, projects []string) ([]model.Chain, []model.PR) { + prs = withEffectiveSHAs(slices.Concat(prs, context))[:len(prs)] byKey := map[string][]model.PR{} var unlinked []model.PR for _, p := range prs { diff --git a/internal/link/link_test.go b/internal/link/link_test.go index 2eb8cf3..1355f3f 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -20,7 +20,7 @@ func TestLinkGroupsPRsByKey(t *testing.T) { pr(11, "ABC-1: follow-up", "abc-1-b", "main", model.PROpen, ""), pr(12, "Bump deps", "dependabot/x", "main", model.PROpen, ""), } - chains, unlinked := link.Link(tickets, prs, []string{"ABC"}) + chains, unlinked := link.Link(tickets, prs, nil, []string{"ABC"}) if len(chains) != 2 || len(chains[0].PRs) != 2 || len(chains[1].PRs) != 0 { t.Fatalf("chains = %+v", chains) } @@ -34,7 +34,7 @@ func TestLinkGroupsPRsByKey(t *testing.T) { func TestLinkKeyWithoutTicketIsDropped(t *testing.T) { // The PR names a key that is not one of my tickets (someone else's, or Done long ago). - chains, unlinked := link.Link(nil, []model.PR{pr(5, "ABC-99: x", "abc-99", "main", model.PROpen, "")}, []string{"ABC"}) + chains, unlinked := link.Link(nil, []model.PR{pr(5, "ABC-99: x", "abc-99", "main", model.PROpen, "")}, nil, []string{"ABC"}) if len(chains) != 0 || len(unlinked) != 0 { t.Errorf("a PR for a ticket outside my rows is ignored, not unlinked: %v %v", chains, unlinked) } @@ -48,7 +48,7 @@ func TestLinkStackedPRs(t *testing.T) { pr(2, "[2/3] ABC-5: b", "s2", "s1", model.PRMerged, "cccc333"), pr(1, "[1/3] ABC-5: a", "s1", "main", model.PRMerged, "bbbb222"), } - chains, _ := link.Link([]model.Ticket{{Key: "ABC-5"}}, prs, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-5"}}, prs, nil, []string{"ABC"}) for _, p := range chains[0].PRs { if p.EffectiveSHA != "bbbb222" || p.StackPending { t.Errorf("#%d EffectiveSHA=%q pending=%v, want bbbb222/false", p.Number, p.EffectiveSHA, p.StackPending) @@ -61,7 +61,7 @@ func TestLinkStackNotYetOnMain(t *testing.T) { pr(2, "ABC-6: b", "s2", "s1", model.PRMerged, "dddd444"), pr(1, "ABC-6: a", "s1", "main", model.PROpen, ""), } - chains, _ := link.Link([]model.Ticket{{Key: "ABC-6"}}, prs, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-6"}}, prs, nil, []string{"ABC"}) got := chains[0].PRs[0] // #2 if got.Number != 2 || got.EffectiveSHA != "" || !got.StackPending { t.Errorf("#2 = %+v, want pending with no EffectiveSHA", got) @@ -78,7 +78,7 @@ func TestLinkStackEdgeCases(t *testing.T) { // Open PR: neither set. pr(4, "ABC-7: open", "op", "main", model.PROpen, ""), } - chains, _ := link.Link([]model.Ticket{{Key: "ABC-7"}}, prs, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-7"}}, prs, nil, []string{"ABC"}) for _, p := range chains[0].PRs { wantPending, wantStranded := p.State == model.PRMerged, p.Number == 1 if p.EffectiveSHA != "" || p.StackPending != wantPending || p.Stranded != wantStranded { @@ -93,7 +93,7 @@ func TestLinkSharedHeadPrefersMerged(t *testing.T) { nine := pr(9, "ABC-8: a again", "s1", "main", model.PROpen, "") two := pr(2, "ABC-8: b", "s2", "s1", model.PRMerged, "bbbb222") for _, prs := range [][]model.PR{{one, nine, two}, {nine, one, two}, {two, nine, one}} { - chains, _ := link.Link([]model.Ticket{{Key: "ABC-8"}}, prs, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-8"}}, prs, nil, []string{"ABC"}) for _, p := range chains[0].PRs { if p.Number == 2 && (p.EffectiveSHA != "aaaa111" || p.StackPending) { t.Errorf("order %d,%d,%d: #2 EffectiveSHA=%q pending=%v, want aaaa111/false", @@ -107,7 +107,7 @@ func TestLinkSharedHeadIgnoresClosed(t *testing.T) { one := pr(1, "ABC-8: a", "s1", "main", model.PRMerged, "aaaa111") closed := pr(9, "ABC-8: a old", "s1", "main", model.PRClosed, "") two := pr(2, "ABC-8: b", "s2", "s1", model.PRMerged, "bbbb222") - chains, _ := link.Link([]model.Ticket{{Key: "ABC-8"}}, []model.PR{one, two, closed}, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-8"}}, []model.PR{one, two, closed}, nil, []string{"ABC"}) for _, p := range chains[0].PRs { if p.Number == 2 && (p.EffectiveSHA != "aaaa111" || p.StackPending) { t.Errorf("#2 EffectiveSHA=%q pending=%v, want aaaa111/false", p.EffectiveSHA, p.StackPending) @@ -132,7 +132,7 @@ func TestLinkMergedIntoStackAfterBaseMerged(t *testing.T) { one.MergedAt = t0.Add(time.Hour) two := pr(2, "ABC-9: b", "s2", "s1", model.PRMerged, "bbbb222") two.MergedAt = c.twoAt - chains, _ := link.Link([]model.Ticket{{Key: "ABC-9"}}, []model.PR{two, one}, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-9"}}, []model.PR{two, one}, nil, []string{"ABC"}) got := chains[0].PRs[0] if got.Number != 2 || got.EffectiveSHA != c.wantSHA || got.StackPending != c.wantPending || got.Stranded != c.wantStranded { t.Errorf("%s: #%d EffectiveSHA=%q pending=%v stranded=%v, want %q/%v/%v", @@ -144,7 +144,7 @@ func TestLinkMergedIntoStackAfterBaseMerged(t *testing.T) { func TestLinkStrandedWhenBaseBranchHasNoPR(t *testing.T) { // #1 merged into "gone", which no PR (open or merged) will ever bring to main. prs := []model.PR{pr(1, "ABC-10: a", "o1", "gone", model.PRMerged, "eeee555")} - chains, _ := link.Link([]model.Ticket{{Key: "ABC-10"}}, prs, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-10"}}, prs, nil, []string{"ABC"}) if got := chains[0].PRs[0]; !got.Stranded || !got.StackPending || got.EffectiveSHA != "" { t.Errorf("#1 = %+v, want stranded and pending with no EffectiveSHA", got) } @@ -155,8 +155,34 @@ func TestLinkWaitingOnOpenBaseIsNotStranded(t *testing.T) { pr(2, "ABC-11: b", "s2", "s1", model.PRMerged, "dddd444"), pr(1, "ABC-11: a", "s1", "main", model.PROpen, ""), } - chains, _ := link.Link([]model.Ticket{{Key: "ABC-11"}}, prs, []string{"ABC"}) + chains, _ := link.Link([]model.Ticket{{Key: "ABC-11"}}, prs, nil, []string{"ABC"}) if got := chains[0].PRs[0]; got.Number != 2 || got.Stranded || !got.StackPending { t.Errorf("#2 = %+v, want pending but not stranded: its base PR is still open", got) } } + +func TestLinkContextPRsOnlyFeedStackWalking(t *testing.T) { + t1 := time.Date(2026, 9, 21, 9, 0, 0, 0, time.UTC) + mine := []model.PR{pr(2, "ABC-12: on someone's branch", "abc-12", "s1", model.PRMerged, "bbbb222")} + mine[0].MergedAt = t1 + base := pr(1, "ABC-12: their base names my key", "s1", "main", model.PRMerged, "aaaa111") + base.MergedAt = t1.Add(time.Hour) + noKey := pr(3, "Refactor", "s9", "main", model.PROpen, "") + + chains, unlinked := link.Link([]model.Ticket{{Key: "ABC-12"}}, mine, []model.PR{base, noKey}, []string{"ABC"}) + if len(chains) != 1 || len(chains[0].PRs) != 1 || chains[0].PRs[0].Number != 2 { + t.Fatalf("chains = %+v, want only my #2", chains) + } + if len(unlinked) != 0 { + t.Errorf("unlinked = %+v, want context PRs left out", unlinked) + } + if p := chains[0].PRs[0]; p.EffectiveSHA != "aaaa111" || p.StackPending || p.Stranded { + t.Errorf("#2 = %+v, want carried to main by context #1's aaaa111", p) + } + + // Without the context PR, #2's base has no PR: stranded. + chains, _ = link.Link([]model.Ticket{{Key: "ABC-12"}}, mine, nil, []string{"ABC"}) + if !chains[0].PRs[0].Stranded { + t.Errorf("without context: #2 = %+v, want Stranded", chains[0].PRs[0]) + } +} From a4254d43e9a92fffa848885cd2813cae184f3c89 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 16:15:03 -0400 Subject: [PATCH 09/31] feat(rules): a required check that has not reported is a yellow wait GitHub's EXPECTED rollup state no longer counts as a failing check. It raises the new yellow FlagCheckExpected, "PR #n waiting on a required check", ordered after partial prod and before ready to merge. Assisted-by: Claude Code:claude-opus-5-5 --- internal/github/prs.go | 6 +++--- internal/model/model.go | 6 +++++- internal/rules/flags.go | 9 ++++++--- internal/rules/flags_test.go | 21 ++++++++++++++++++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/github/prs.go b/internal/github/prs.go index ee869ec..8deb1f1 100644 --- a/internal/github/prs.go +++ b/internal/github/prs.go @@ -147,9 +147,9 @@ func (n prNode) toPR(repo repoInfo) model.PR { } if len(n.Commits.Nodes) > 0 { if r := n.Commits.Nodes[len(n.Commits.Nodes)-1].Commit.StatusCheckRollup; r != nil { - // GitHub's rollup state passes straight through: rules treats - // anything but SUCCESS, PENDING or none (ERROR, EXPECTED, FAILURE) - // as failing. + // GitHub's rollup state passes straight through: rules flags + // EXPECTED yellow and anything but SUCCESS, PENDING or none + // (ERROR, FAILURE) red. p.Checks = model.ChecksState(r.State) for _, c := range r.Contexts.Nodes { if chk, ok := c.failing(); ok { diff --git a/internal/model/model.go b/internal/model/model.go index ac9af15..c2a75c5 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -57,6 +57,8 @@ const ( ChecksPending ChecksState = "PENDING" ChecksPassing ChecksState = "SUCCESS" ChecksFailing ChecksState = "FAILURE" + // ChecksExpected: a required check has not reported yet. Not a failure. + ChecksExpected ChecksState = "EXPECTED" ) type ReviewState string @@ -246,7 +248,8 @@ const ( FlagCheckFailed FlagChangesRequested FlagAwaitingApproval - FlagPartialProd // live in some prod Envs, not all, for too long + FlagPartialProd // live in some prod Envs, not all, for too long + FlagCheckExpected // an open PR waits on a required check that has not reported FlagReadyToMerge FlagStaleReview FlagStatusMismatch @@ -262,6 +265,7 @@ var flagKindNames = [...]string{ "changes requested", "awaiting approval", "partial prod", + "check expected", "ready to merge", "stale review", "status mismatch", diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 0d30f74..9a2bf44 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -67,6 +67,8 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { add(model.Red, model.FlagCheckFailed, fmt.Sprintf("PR #%d %s failing", p.Number, checkNames(p.Failing)), p, nil) case p.Review == model.ReviewChangesRequested: add(model.Red, model.FlagChangesRequested, fmt.Sprintf("PR #%d changes requested", p.Number), p, nil) + case p.Checks == model.ChecksExpected: + add(model.Yellow, model.FlagCheckExpected, fmt.Sprintf("PR #%d waiting on a required check", p.Number), p, nil) case p.IsDraft: // A draft is not asking for review yet: no ready-to-merge, no-reviewer // or stale-review flag. The red flags above still apply. @@ -209,11 +211,12 @@ func orDefault(s, d string) string { return s } -// checksFailing treats every state other than passing, pending or none as a -// failure: GitHub also sends ERROR and EXPECTED, and neither is green. +// checksFailing treats every state other than passing, pending, expected or +// none as a failure: GitHub also sends ERROR, which is not green. EXPECTED (a +// required check has not reported) is a wait, flagged yellow on its own. func checksFailing(s model.ChecksState) bool { switch s { - case model.ChecksPassing, model.ChecksPending, model.ChecksNone: + case model.ChecksPassing, model.ChecksPending, model.ChecksExpected, model.ChecksNone: return false } return true diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index c7f7d06..0fffb84 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -32,8 +32,10 @@ func TestFlags(t *testing.T) { {"an open PR with checks running and a reviewer needs nothing", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPending, Reviewers: 1, OpenedAt: now}}}, nil}, {"a failing check flags the PR", model.Chain{PRs: []model.PR{failing}}, []model.FlagKind{model.FlagCheckFailed}}, {"a failing code scan flags the PR like any check", model.Chain{PRs: []model.PR{scanning}}, []model.FlagKind{model.FlagCheckFailed}}, - {"checks in any other state (EXPECTED, ERROR) count as failing, even when approved", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: "EXPECTED", Review: model.ReviewApproved, Reviewers: 1}}}, + {"checks in any other state (ERROR) count as failing, even when approved", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: "ERROR", Review: model.ReviewApproved, Reviewers: 1}}}, []model.FlagKind{model.FlagCheckFailed}}, + {"a required check that has not reported is a yellow wait, not a failure", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksExpected, Review: model.ReviewApproved, Reviewers: 1}}}, + []model.FlagKind{model.FlagCheckExpected}}, {"requested changes flag the PR", model.Chain{PRs: []model.PR{{State: model.PROpen, Review: model.ReviewChangesRequested, Reviewers: 1}}}, []model.FlagKind{model.FlagChangesRequested}}, {"an approved, green PR that is not merged is ready to merge", model.Chain{PRs: []model.PR{{State: model.PROpen, Checks: model.ChecksPassing, Review: model.ReviewApproved, Reviewers: 1}}}, @@ -234,3 +236,20 @@ func TestDraftPRsSkipReviewFlags(t *testing.T) { }) } } + +func TestCheckExpectedFlagIsYellowWithItsOwnReason(t *testing.T) { + c := model.Chain{Stage: model.StagePROpen, PRs: []model.PR{{Number: 7, State: model.PROpen, Checks: model.ChecksExpected, OpenedAt: now}}} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) != 1 { + t.Fatalf("flags %v, want one", kinds(fs)) + } + if f := fs[0]; f.Kind != model.FlagCheckExpected || f.Level != model.Yellow || f.PR == nil || f.Reason != "PR #7 waiting on a required check" { + t.Errorf("flag %+v, want yellow check expected on PR #7", f) + } +} + +func TestCheckExpectedSortsAfterPartialProdBeforeReadyToMerge(t *testing.T) { + if model.FlagCheckExpected <= model.FlagPartialProd || model.FlagCheckExpected >= model.FlagReadyToMerge { + t.Errorf("FlagCheckExpected = %d, want between FlagPartialProd and FlagReadyToMerge", model.FlagCheckExpected) + } +} From 31538c09e32506641ed6a5be4ff2b4c5095b170c Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 16:19:55 -0400 Subject: [PATCH 10/31] fix(github): review fixes for forks, caching, caps, backoff, SHAs - RecentPRs skips PRs from forks, so a fork's branch cannot hide a missing base. - The ETag cache skips bodies over 256KB, and Compare bypasses it: the resolver caches compare results by SHA pair. - RecentPRs warns when it stops at the page cap, BasePRs when the round cap leaves bases unfetched; BasePRs asks at most 20 branches a query. - A rate-limit signal with a zero or past reset backs off 60s. - Compare rejects a base or head that is not a 7-40 character hex SHA before building the URL. - A PR with more than 50 checks is a warning. Warnings are deduplicated. Assisted-by: Claude Code:claude-opus-5-5 --- internal/github/base.go | 49 ++++++++++++++++++---------- internal/github/base_test.go | 48 +++++++++++++++++++++++++++ internal/github/client.go | 14 +++++--- internal/github/client_test.go | 5 ++- internal/github/compare.go | 13 ++++++-- internal/github/compare_test.go | 44 +++++++++++++++++-------- internal/github/errors.go | 29 +++++++++++++---- internal/github/etag.go | 20 ++++++++++-- internal/github/etag_test.go | 47 +++++++++++++++++++++++++++ internal/github/graphql.go | 6 ++-- internal/github/prs.go | 57 +++++++++++++++++++++++++++------ internal/github/prs_test.go | 56 ++++++++++++++++++++++++++++++++ 12 files changed, 328 insertions(+), 60 deletions(-) create mode 100644 internal/github/etag_test.go diff --git a/internal/github/base.go b/internal/github/base.go index c5d5456..d619c1b 100644 --- a/internal/github/base.go +++ b/internal/github/base.go @@ -24,7 +24,8 @@ func basePRsQuery(n int) string { // missingBases lists, once each and in order, the base branches of prs' // open and merged PRs in repo that are not the default branch and that no -// such PR in prs has as its head. +// such PR in prs has as its head. Fork PRs never reach prs (RecentPRs and +// BasePRs drop them), so a fork's same-named branch cannot hide a base. func missingBases(repo string, prs []model.PR) []string { live := func(p model.PR) bool { return p.State != model.PRClosed && strings.EqualFold(p.Repo, repo) } heads := map[string]bool{} @@ -44,6 +45,9 @@ func missingBases(repo string, prs []model.PR) []string { return out } +// maxBranchesPerQuery caps the aliases in one base-branch query. +const maxBranchesPerQuery = 20 + // maxBaseRounds caps how deep BasePRs follows a stack of other people's // branches. const maxBaseRounds = 5 @@ -51,39 +55,50 @@ const maxBaseRounds = 5 // BasePRs returns the PR for each base branch of prs in owner/name that is // not the default branch and has no PR in prs, whoever wrote it. Pass them // to link.Link as its context PRs: they feed stack walking only (without a -// base branch's PR a stacked PR looks Stranded) and never join a row. It repeats -// for the bases of the PRs it found, so a stack whose lower branches belong -// to someone else resolves down to the default branch (at most -// maxBaseRounds queries, one branch asked once). Each branch gets the newest -// open or merged PR whose head is that branch in this repo (a fork's PR with -// the same branch name is skipped); a branch with none gets nothing. -// Warnings are GraphQL errors that came with usable data. +// base branch's PR a stacked PR looks Stranded) and never join a row. +// +// It repeats for the bases of the PRs it found, so a stack whose lower +// branches belong to someone else resolves down to the default branch: at +// most maxBaseRounds rounds, maxBranchesPerQuery branches per query, each +// branch asked once. Each branch gets the newest open or merged PR whose +// head is that branch in this repo (a fork's PR with the same branch name is +// skipped); a branch with none gets nothing. Warnings, deduplicated, are +// GraphQL errors that came with usable data and a note when the round cap +// left bases unfetched. func (c *Client) BasePRs(ctx context.Context, owner, name string, prs []model.PR) ([]model.PR, []string, error) { repo := strings.ToLower(owner + "/" + name) asked := map[string]bool{} all := slices.Clone(prs) var out []model.PR var warns []string - for range maxBaseRounds { + for round := 0; ; round++ { var branches []string for _, b := range missingBases(repo, all) { if !asked[b] { - asked[b] = true branches = append(branches, b) } } if len(branches) == 0 { break } - found, w, err := c.basePRs(ctx, owner, name, branches) - warns = append(warns, w...) - if err != nil { - return nil, warns, err + if round == maxBaseRounds { + warns = append(warns, fmt.Sprintf("github: %s: stopped after %d rounds of base-branch PRs; not fetched: %s", repo, maxBaseRounds, strings.Join(branches, ", "))) + break + } + for _, b := range branches { + asked[b] = true + } + for chunk := range slices.Chunk(branches, maxBranchesPerQuery) { + found, w, err := c.basePRs(ctx, owner, name, chunk) + warns = append(warns, w...) + if err != nil { + return nil, dedupe(warns), err + } + out = append(out, found...) + all = append(all, found...) } - out = append(out, found...) - all = append(all, found...) } - return out, warns, nil + return out, dedupe(warns), nil } // basePRs runs one aliased query for branches. diff --git a/internal/github/base_test.go b/internal/github/base_test.go index 534a135..cdab294 100644 --- a/internal/github/base_test.go +++ b/internal/github/base_test.go @@ -128,3 +128,51 @@ func TestBasePRsKeepALaterStackPRFromLookingStranded(t *testing.T) { t.Errorf("#2 = %+v, want carried to main by #1's merge aaaa111", p) } } + +func TestBasePRsWarnsWhenItStopsAtTheRoundCap(t *testing.T) { + // s1's PR is on s2, s2's on s3, and so on: deeper than five rounds. + rounds := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + req := decodeGQL(t, r) + rounds++ + repo := map[string]any{"nameWithOwner": "acme/app", "defaultBranchRef": map[string]any{"name": "main"}} + b := req.Variables["b0"].(string) + var n int + _, _ = fmt.Sscanf(b, "s%d", &n) + repo["b0"] = map[string]any{"nodes": []any{baseNode(100+n, b, fmt.Sprintf("s%d", n+1), "other", false)}} + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": repo}}) + }) + mine := []model.PR{{Repo: "acme/app", DefaultBranch: "main", Number: 1, HeadRef: "abc-1", BaseRef: "s1", State: model.PROpen}} + got, warns, err := c.BasePRs(context.Background(), "acme", "app", mine) + if err != nil || rounds != 5 || len(got) != 5 { + t.Fatalf("err %v, rounds %d, got %d; want 5", err, rounds, len(got)) + } + if len(warns) != 1 || !strings.Contains(warns[0], "s6") { + t.Errorf("warnings = %q, want one naming the unfetched base s6", warns) + } +} + +func TestBasePRsAsksAtMost20BranchesPerQuery(t *testing.T) { + var sizes []int + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + req := decodeGQL(t, r) + sizes = append(sizes, len(req.Variables)-2) + repo := map[string]any{"nameWithOwner": "acme/app", "defaultBranchRef": map[string]any{"name": "main"}} + for i := range len(req.Variables) - 2 { + b := req.Variables[fmt.Sprintf("b%d", i)].(string) + repo[fmt.Sprintf("b%d", i)] = map[string]any{"nodes": []any{baseNode(1000+len(sizes)*100+i, b, "main", "other", false)}} + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": repo}}) + }) + var mine []model.PR + for i := range 25 { + mine = append(mine, model.PR{Repo: "acme/app", DefaultBranch: "main", Number: i + 1, HeadRef: fmt.Sprintf("abc-%d", i), BaseRef: fmt.Sprintf("s%d", i), State: model.PROpen}) + } + got, _, err := c.BasePRs(context.Background(), "acme", "app", mine) + if err != nil || len(got) != 25 { + t.Fatalf("err %v, got %d; want 25", err, len(got)) + } + if fmt.Sprint(sizes) != "[20 5]" { + t.Errorf("branches per query = %v, want [20 5]", sizes) + } +} diff --git a/internal/github/client.go b/internal/github/client.go index 9a8e731..2d2196d 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -18,17 +18,19 @@ import ( // Client talks to the GitHub API with one token. type Client struct { - http *http.Client + http *http.Client // GETs cached by ETag + plain *http.Client // no cache: Compare, whose results the resolver caches by SHA pair token string api string now func() time.Time // reads X-RateLimit-Reset } -// New returns a Client for token. GET responses are cached by ETag, so an -// unchanged compare costs nothing against the rate limit. +// New returns a Client for token. GET responses other than Compare's are +// cached by ETag, so an unchanged one costs nothing against the rate limit. func New(token string) *Client { return &Client{ http: &http.Client{Timeout: 20 * time.Second, Transport: newETagTransport(http.DefaultTransport, 500)}, + plain: &http.Client{Timeout: 20 * time.Second}, token: token, api: "https://api.github.com", now: time.Now, @@ -40,6 +42,10 @@ func New(token string) *Client { func (c *Client) SetAPI(base string) { c.api = strings.TrimRight(base, "/") } func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + return c.doWith(ctx, c.http, method, path, body, out) +} + +func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out any) error { var rdr io.Reader if body != nil { b, err := json.Marshal(body) @@ -58,7 +64,7 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err if body != nil { req.Header.Set("Content-Type", "application/json") } - resp, err := c.http.Do(req) + resp, err := hc.Do(req) if err != nil { return err } diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 92812f8..ce1a878 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -37,6 +37,9 @@ func TestErrorsAreTyped(t *testing.T) { {"403 is not an auth error", 403, nil, false, 0, 0, "Bad credentials"}, {"429 with Retry-After backs off that long", 429, map[string]string{"Retry-After": "30"}, false, 30 * time.Second, 30 * time.Second, "Bad credentials"}, {"429 without Retry-After backs off a minute", 429, nil, false, time.Minute, time.Minute, "Bad credentials"}, + {"403 rate limit with a past reset backs off a minute", 403, map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)}, false, time.Minute, time.Minute, "Bad credentials"}, + {"403 rate limit with no reset backs off a minute", 403, map[string]string{"X-RateLimit-Remaining": "0"}, false, time.Minute, time.Minute, "Bad credentials"}, + {"403 Retry-After of zero backs off a minute", 403, map[string]string{"Retry-After": "0"}, false, time.Minute, time.Minute, "Bad credentials"}, {"403 rate limit backs off until the reset", 403, map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(reset, 10)}, false, 80 * time.Second, 91 * time.Second, "Bad credentials"}, } for _, tc := range cases { @@ -48,7 +51,7 @@ func TestErrorsAreTyped(t *testing.T) { w.WriteHeader(tc.status) _, _ = fmt.Fprint(w, `{"message":"Bad credentials"}`) }) - _, err := c.Compare(context.Background(), "acme/app", "aaaa", "bbbb") + _, err := c.Compare(context.Background(), "acme/app", "aaaa111", "bbbb222") var ae *github.APIError if !errors.As(err, &ae) { t.Fatalf("err = %v, want *APIError", err) diff --git a/internal/github/compare.go b/internal/github/compare.go index ef4dd11..fc834c8 100644 --- a/internal/github/compare.go +++ b/internal/github/compare.go @@ -3,21 +3,30 @@ package github import ( "context" "errors" + "fmt" "net/http" + "regexp" "github.com/ericdahl-dev/app-green/internal/model" ) +// shaRE is a full or abbreviated commit SHA. +var shaRE = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`) + // Compare answers whether base is included in head within repo // ("owner/name"): Included when head is ahead of or identical to base, // NotIncluded when it is behind or has diverged. A 404 (an unknown or // force-pushed-away commit) is InclusionUnknown with no error; other -// failures return the error. +// failures return the error, as does a base or head that is not a 7 to 40 +// character hex SHA (checked before any request). func (c *Client) Compare(ctx context.Context, repo, base, head string) (model.Inclusion, error) { + if !shaRE.MatchString(base) || !shaRE.MatchString(head) { + return model.InclusionUnknown, fmt.Errorf("github: compare %s: %q...%q are not commit SHAs", repo, base, head) + } var out struct { Status string `json:"status"` } - err := c.do(ctx, http.MethodGet, "/repos/"+repo+"/compare/"+base+"..."+head+"?per_page=1", nil, &out) + err := c.doWith(ctx, c.plain, http.MethodGet, "/repos/"+repo+"/compare/"+base+"..."+head+"?per_page=1", nil, &out) var ae *APIError if errors.As(err, &ae) && ae.Status == http.StatusNotFound { return model.InclusionUnknown, nil diff --git a/internal/github/compare_test.go b/internal/github/compare_test.go index ca560a6..bc01b4d 100644 --- a/internal/github/compare_test.go +++ b/internal/github/compare_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/ericdahl-dev/app-green/internal/github" @@ -26,7 +27,7 @@ func TestCompare(t *testing.T) { for _, tc := range cases { t.Run(tc.body, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/repos/acme/app/compare/aaaa...bbbb" || r.URL.Query().Get("per_page") != "1" { + if r.Method != http.MethodGet || r.URL.Path != "/repos/acme/app/compare/aaaa111...bbbb222" || r.URL.Query().Get("per_page") != "1" { t.Errorf("got %s %s", r.Method, r.URL) } if r.Header.Get("Authorization") != "Bearer tok" { @@ -38,7 +39,7 @@ func TestCompare(t *testing.T) { defer srv.Close() c := github.New("tok") c.SetAPI(srv.URL + "/") - got, err := c.Compare(context.Background(), "acme/app", "aaaa", "bbbb") + got, err := c.Compare(context.Background(), "acme/app", "aaaa111", "bbbb222") if err != nil || got != tc.want { t.Errorf("Compare = %v, %v; want %v, nil", got, err, tc.want) } @@ -46,26 +47,22 @@ func TestCompare(t *testing.T) { } } -func TestCompareReusesCachedBodyOn304(t *testing.T) { +func TestCompareSkipsTheETagCache(t *testing.T) { + // The resolver caches compare results by SHA pair, so Compare never sends + // If-None-Match. hits := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { hits++ - if hits == 2 { - if r.Header.Get("If-None-Match") != `"v1"` { - t.Errorf("If-None-Match = %q", r.Header.Get("If-None-Match")) - } + if inm := r.Header.Get("If-None-Match"); inm != "" { + t.Errorf("call %d sent If-None-Match %q", hits, inm) w.WriteHeader(http.StatusNotModified) return } w.Header().Set("ETag", `"v1"`) _, _ = fmt.Fprint(w, `{"status":"ahead"}`) - })) - defer srv.Close() - c := github.New("tok") - c.SetAPI(srv.URL) + }) for i := range 2 { - got, err := c.Compare(context.Background(), "acme/app", "aaaa", "bbbb") - if err != nil || got != model.Included { + if got, err := c.Compare(context.Background(), "acme/app", "aaaa111", "bbbb222"); err != nil || got != model.Included { t.Errorf("call %d: Compare = %v, %v; want Included", i+1, got, err) } } @@ -73,3 +70,22 @@ func TestCompareReusesCachedBodyOn304(t *testing.T) { t.Errorf("hits = %d, want 2", hits) } } + +func TestCompareRejectsNonSHAs(t *testing.T) { + calls := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + _, _ = fmt.Fprint(w, `{"status":"ahead"}`) + }) + for _, pair := range [][2]string{{"main", "bbbb222"}, {"aaaa111", "../../x"}, {"aaa", "bbbb222"}, {"aaaa111", strings.Repeat("b", 41)}, {"aaaa111", "bbbb22g"}} { + if got, err := c.Compare(context.Background(), "acme/app", pair[0], pair[1]); err == nil || got != model.InclusionUnknown { + t.Errorf("Compare(%q, %q) = %v, %v; want an error", pair[0], pair[1], got, err) + } + } + if calls != 0 { + t.Errorf("%d requests for invalid SHAs, want none", calls) + } + if got, err := c.Compare(context.Background(), "acme/app", "AAAA111", strings.Repeat("b", 40)); err != nil || got != model.Included { + t.Errorf("upper-case and 40-character SHAs: %v, %v; want Included", got, err) + } +} diff --git a/internal/github/errors.go b/internal/github/errors.go index 29f9f2e..e173944 100644 --- a/internal/github/errors.go +++ b/internal/github/errors.go @@ -49,21 +49,36 @@ const maxRetryAfter = 15 * time.Minute // retryAfter reads GitHub's rate-limit signals: Retry-After (whole seconds, // sent with 429s and secondary-limit 403s), then an exhausted primary limit -// (X-RateLimit-Remaining: 0, back off until X-RateLimit-Reset). A 429 with -// neither backs off defaultRetryAfter. Anything else is 0. +// (X-RateLimit-Remaining: 0, back off until X-RateLimit-Reset). A 429, or a +// signal whose wait is zero, past or unreadable, backs off +// defaultRetryAfter. With no signal it is 0. func retryAfter(resp *http.Response, now time.Time) time.Duration { var d time.Duration - if n, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil { - d = time.Duration(min(n, int(maxRetryAfter/time.Second))) * time.Second + limited := resp.StatusCode == http.StatusTooManyRequests + if h := strings.TrimSpace(resp.Header.Get("Retry-After")); h != "" { + limited = true + if n, err := strconv.Atoi(h); err == nil { + d = time.Duration(min(n, int(maxRetryAfter/time.Second))) * time.Second + } } else if resp.Header.Get("X-RateLimit-Remaining") == "0" { + limited = true if reset, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil { d = time.Unix(reset, 0).Sub(now) } } - if d <= 0 && resp.StatusCode == http.StatusTooManyRequests { - d = defaultRetryAfter + return backoff(limited, d) +} + +// backoff is d capped at maxRetryAfter, or defaultRetryAfter when limited +// but d is not positive. Not limited is 0. +func backoff(limited bool, d time.Duration) time.Duration { + if !limited { + return 0 + } + if d <= 0 { + return defaultRetryAfter } - return min(max(d, 0), maxRetryAfter) + return min(d, maxRetryAfter) } // IsAuth reports whether err is a 401 Unauthorized: the token is bad, so diff --git a/internal/github/etag.go b/internal/github/etag.go index e7ec02d..0e255ea 100644 --- a/internal/github/etag.go +++ b/internal/github/etag.go @@ -1,5 +1,6 @@ // Copied from git-green internal/github/etag.go (2026-10-06); Pool, fromCache and -// maxCachedResponses dropped, cache header renamed. +// maxCachedResponses dropped, cache header renamed, bodies over +// maxCachedBody not cached. package github @@ -11,6 +12,10 @@ import ( "sync" ) +// maxCachedBody is the largest body the cache keeps; a bigger response +// passes through uncached so a few large ones cannot pin megabytes. +const maxCachedBody = 256 << 10 + // cacheHeader marks a response replayed from the ETag cache. const cacheHeader = "X-App-Green-Cache" @@ -72,11 +77,20 @@ func (t *etagTransport) RoundTrip(req *http.Request) (*http.Response, error) { if resp.StatusCode != http.StatusOK || etag == "" { return resp, nil } - body, err := io.ReadAll(resp.Body) - _ = resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxCachedBody+1)) if err != nil { + _ = resp.Body.Close() return nil, err } + if len(body) > maxCachedBody { + // Too big to cache: hand back what was read followed by the rest. + resp.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(body), resp.Body), resp.Body} + return resp, nil + } + _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(body)) t.store(key, &cachedResponse{etag: etag, header: resp.Header.Clone(), body: body}) return resp, nil diff --git a/internal/github/etag_test.go b/internal/github/etag_test.go new file mode 100644 index 0000000..32a3414 --- /dev/null +++ b/internal/github/etag_test.go @@ -0,0 +1,47 @@ +package github + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestETagTransportSkipsLargeBodies(t *testing.T) { + cases := []struct { + name string + size int + cached bool + }{ + {"a small body is cached", 1024, true}, + {"a body over 256KB is not", maxCachedBody + 1, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := strings.Repeat("x", tc.size) + var inm []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inm = append(inm, r.Header.Get("If-None-Match")) + w.Header().Set("ETag", `"v1"`) + _, _ = io.WriteString(w, body) + })) + defer srv.Close() + hc := &http.Client{Transport: newETagTransport(http.DefaultTransport, 10)} + for i := range 2 { + resp, err := hc.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if len(got) != tc.size { + t.Errorf("call %d: body %d bytes, want %d", i+1, len(got), tc.size) + } + } + if sent := inm[1] != ""; sent != tc.cached { + t.Errorf("second If-None-Match = %q, want cached %v", inm[1], tc.cached) + } + }) + } +} diff --git a/internal/github/graphql.go b/internal/github/graphql.go index a1690e9..9595fe0 100644 --- a/internal/github/graphql.go +++ b/internal/github/graphql.go @@ -48,14 +48,14 @@ func (c *Client) graphql(ctx context.Context, query string, vars map[string]any, // graphqlRateLimit builds the 429 for a RATE_LIMITED GraphQL error. func (c *Client) graphqlRateLimit(data json.RawMessage, msg string) *APIError { - retry := defaultRetryAfter + var d time.Duration var rl struct { RateLimit *struct { ResetAt time.Time `json:"resetAt"` } `json:"rateLimit"` } if json.Unmarshal(data, &rl) == nil && rl.RateLimit != nil && !rl.RateLimit.ResetAt.IsZero() { - retry = min(max(rl.RateLimit.ResetAt.Sub(c.now()), 0), maxRetryAfter) + d = rl.RateLimit.ResetAt.Sub(c.now()) } - return &APIError{Status: http.StatusTooManyRequests, Message: msg, RetryAfter: retry} + return &APIError{Status: http.StatusTooManyRequests, Message: msg, RetryAfter: backoff(true, d)} } diff --git a/internal/github/prs.go b/internal/github/prs.go index 8deb1f1..c763a14 100644 --- a/internal/github/prs.go +++ b/internal/github/prs.go @@ -9,6 +9,9 @@ import ( "github.com/ericdahl-dev/app-green/internal/model" ) +// maxChecks is how many checks per PR the queries read. +const maxChecks = 50 + // prFields is every PR field the adapter maps, shared by the recent-PR and // base-branch queries so the two cannot drift. const prFields = ` @@ -21,7 +24,7 @@ fragment pr on PullRequest { reviewRequests{totalCount} reviews{totalCount} commits(last:1){nodes{commit{statusCheckRollup{state - contexts(first:50){nodes{ + contexts(first:50){pageInfo{hasNextPage} nodes{ __typename ... on CheckRun{name conclusion detailsUrl checkSuite{app{slug} workflowRun{databaseId}}} ... on StatusContext{context state targetUrl} @@ -81,6 +84,9 @@ type prNode struct { StatusCheckRollup *struct { State string `json:"state"` Contexts struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` Nodes []checkNode `json:"nodes"` } `json:"contexts"` } `json:"statusCheckRollup"` @@ -119,6 +125,19 @@ func (n prNode) login() string { return n.Author.Login } +// checksWarning is a warning when the PR has more checks than the query +// reads (its Failing list may then miss some), else "". +func (n prNode) checksWarning(repo repoInfo) string { + if len(n.Commits.Nodes) == 0 { + return "" + } + r := n.Commits.Nodes[len(n.Commits.Nodes)-1].Commit.StatusCheckRollup + if r == nil || !r.Contexts.PageInfo.HasNextPage { + return "" + } + return fmt.Sprintf("github: %s#%d: more than %d checks, only the first %d read", strings.ToLower(repo.NameWithOwner), n.Number, maxChecks, maxChecks) +} + // toPR maps a node into model.PR for the repo it came from. func (n prNode) toPR(repo repoInfo) model.PR { p := model.PR{ @@ -226,16 +245,17 @@ const maxPages = 20 // RecentPRs returns author's open and merged PRs in owner/name, most // recently updated first, with logins compared ignoring case (an empty author -// matches nothing). It pages while GitHub has more and the last PR on the -// page was updated after since: the list is ordered by update time, so every -// later PR was updated (and created) before since. since only stops paging -// and filters nothing. -// Warnings are GraphQL errors that came with usable data. +// matches nothing). PRs from forks are skipped. It pages while GitHub has +// more and the last PR on the page was updated after since: the list is +// ordered by update time, so every later PR was updated (and created) before +// since. since only stops paging and filters nothing. Warnings, deduplicated, +// are GraphQL errors that came with usable data, PRs with more than +// maxChecks checks, and a note when maxPages stopped paging early. func (c *Client) RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, []string, error) { var out []model.PR var warns []string var after any // nil asks for the first page - for range maxPages { + for i := range maxPages { var page prPage w, err := c.graphql(ctx, recentPRsQuery, map[string]any{"owner": owner, "name": name, "after": after}, &page) warns = append(warns, w...) @@ -247,14 +267,33 @@ func (c *Client) RecentPRs(ctx context.Context, owner, name, author string, sinc } prs := page.Repository.PullRequests for _, n := range prs.Nodes { - if author != "" && strings.EqualFold(n.login(), author) { + if author != "" && !n.IsCrossRepository && strings.EqualFold(n.login(), author) { out = append(out, n.toPR(page.Repository.repoInfo)) + if w := n.checksWarning(page.Repository.repoInfo); w != "" { + warns = append(warns, w) + } } } if !prs.PageInfo.HasNextPage || len(prs.Nodes) == 0 || !prs.Nodes[len(prs.Nodes)-1].UpdatedAt.After(since) { break } + if i == maxPages-1 { + warns = append(warns, fmt.Sprintf("github: %s/%s: stopped after %d pages of PRs; older ones updated since %s are missing", owner, name, maxPages, since.Format(time.DateOnly))) + } after = prs.PageInfo.EndCursor } - return out, warns, nil + return out, dedupe(warns), nil +} + +// dedupe drops repeated warnings, keeping the first of each in order. +func dedupe(ws []string) []string { + var out []string + seen := map[string]bool{} + for _, w := range ws { + if !seen[w] { + seen[w] = true + out = append(out, w) + } + } + return out } diff --git a/internal/github/prs_test.go b/internal/github/prs_test.go index d2feaef..a6891a1 100644 --- a/internal/github/prs_test.go +++ b/internal/github/prs_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "reflect" "strings" @@ -257,6 +258,8 @@ func TestGraphQLRateLimitIsA429(t *testing.T) { {"no data backs off a minute", `{"data":null,"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`, time.Minute, time.Minute}, {"rateLimit.resetAt sets the backoff", `{"data":{"rateLimit":{"resetAt":"` + reset + `"},"repository":null},"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`, 4 * time.Minute, 5 * time.Minute}, + {"a past rateLimit.resetAt backs off a minute", `{"data":{"rateLimit":{"resetAt":"2020-01-01T00:00:00Z"},"repository":null},"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`, + time.Minute, time.Minute}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -284,3 +287,56 @@ func TestGraphQLRateLimitIsA429(t *testing.T) { }) } } + +func TestRecentPRsWarnsWhenItStopsAtThePageCap(t *testing.T) { + calls := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + decodeGQL(t, r) + calls++ + _ = json.NewEncoder(w).Encode(page(true, fmt.Sprintf("p%d", calls), map[string]any{"number": calls, "createdAt": "2026-09-20T00:00:00Z", "updatedAt": "2026-09-20T00:00:00Z"})) + }) + prs, warns, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil || calls != 20 || len(prs) != 20 { + t.Fatalf("err %v, calls %d, prs %d; want 20 pages", err, calls, len(prs)) + } + if len(warns) != 1 || !strings.Contains(warns[0], "20 pages") { + t.Errorf("warnings = %q, want one about stopping at 20 pages", warns) + } +} + +func TestRecentPRsSkipsForkPRs(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + decodeGQL(t, r) + _ = json.NewEncoder(w).Encode(page(false, "", + map[string]any{"number": 2, "headRefName": "s1", "createdAt": "2026-09-20T00:00:00Z", "updatedAt": "2026-09-20T00:00:00Z", "isCrossRepository": true}, + map[string]any{"number": 3, "headRefName": "abc-3", "createdAt": "2026-09-20T00:00:00Z", "updatedAt": "2026-09-20T00:00:00Z"})) + }) + prs, _, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil || len(prs) != 1 || prs[0].Number != 3 { + t.Errorf("prs = %+v, err %v; want only #3 (#2 is from a fork)", prs, err) + } +} + +func TestRecentPRsWarnsOncePerPRWithMoreThan50Checks(t *testing.T) { + more := map[string]any{"commits": map[string]any{"nodes": []any{map[string]any{"commit": map[string]any{"statusCheckRollup": map[string]any{ + "state": "SUCCESS", "contexts": map[string]any{"pageInfo": map[string]any{"hasNextPage": true}, "nodes": []any{}}}}}}}, + "createdAt": "2026-09-20T00:00:00Z", "updatedAt": "2026-09-20T00:00:00Z"} + calls := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + decodeGQL(t, r) + calls++ + n := map[string]any{"number": 4} + for k, v := range more { + n[k] = v + } + // the same PR on both pages, as can happen when it updates mid-paging + _ = json.NewEncoder(w).Encode(page(calls == 1, "p1", n)) + }) + _, warns, err := c.RecentPRs(context.Background(), "acme", "app", "me-dev", since) + if err != nil || calls != 2 { + t.Fatalf("err %v, calls %d", err, calls) + } + if len(warns) != 1 || !strings.Contains(warns[0], "acme/app#4") || !strings.Contains(warns[0], "50") { + t.Errorf("warnings = %q, want one for acme/app#4 about 50 checks", warns) + } +} From 1c22cd720f8ac287d404870a14a8842302e1daaa Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 16:32:19 -0400 Subject: [PATCH 11/31] feat(aws): pipeline sources, stage history with approvals, ECS health, approve Sources reads a pipeline's CodeStar source actions into lowercased repo -> action name, and Repos gives the list for Env.Repos. History builds each stage's deploys newest first from one read of the pipeline's executions and action executions (bounded paging), mapping revisions through the sources. A run waiting at the approval that gates a stage is an AwaitingApproval deploy carrying the token from GetPipelineState. Superseded and canceled runs and abandoned actions are dropped. Health sums desired and running tasks across an Env's ECS services and is unknown when any is missing. Approve wraps PutApprovalResult with typed stale-token and access-denied errors. IsSSOExpired spots an expired SSO session. Assisted-by: Claude Code:claude-opus-5-5 --- go.mod | 21 ++ go.sum | 30 +++ internal/aws/approve.go | 72 +++++++ internal/aws/approve_test.go | 78 ++++++++ internal/aws/client.go | 75 ++++++++ internal/aws/client_test.go | 53 ++++++ internal/aws/discover.go | 45 +++++ internal/aws/discover_test.go | 58 ++++++ internal/aws/fake_test.go | 98 ++++++++++ internal/aws/health.go | 73 +++++++ internal/aws/health_test.go | 90 +++++++++ internal/aws/history.go | 264 +++++++++++++++++++++++++ internal/aws/history_test.go | 349 ++++++++++++++++++++++++++++++++++ 13 files changed, 1306 insertions(+) create mode 100644 go.sum create mode 100644 internal/aws/approve.go create mode 100644 internal/aws/approve_test.go create mode 100644 internal/aws/client.go create mode 100644 internal/aws/client_test.go create mode 100644 internal/aws/discover.go create mode 100644 internal/aws/discover_test.go create mode 100644 internal/aws/fake_test.go create mode 100644 internal/aws/health.go create mode 100644 internal/aws/health_test.go create mode 100644 internal/aws/history.go create mode 100644 internal/aws/history_test.go diff --git a/go.mod b/go.mod index 1e75e3c..1d57c0b 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,24 @@ module github.com/ericdahl-dev/app-green go 1.25.0 toolchain go1.25.10 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.29.14 + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 + github.com/aws/aws-sdk-go-v2/service/codepipeline v1.40.1 + github.com/aws/aws-sdk-go-v2/service/ecs v1.79.1 + github.com/aws/smithy-go v1.25.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e8f392e --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/codepipeline v1.40.1 h1:gUKS/hsRPGMPLJHapanzWILEnqQ6XlO4D7pZ6+eBSHc= +github.com/aws/aws-sdk-go-v2/service/codepipeline v1.40.1/go.mod h1:DbwgOhGcyAQbyKZDXbErngumtUExzwvd1uyMbKQcXto= +github.com/aws/aws-sdk-go-v2/service/ecs v1.79.1 h1:tQNU4tC4cMoZo1e+7J8j3/GWM7PJFdXCN0VzEFwFqUE= +github.com/aws/aws-sdk-go-v2/service/ecs v1.79.1/go.mod h1:TIKZ9zIFS6W2k9FeW+r5sGVnlxp+aUt9oQ/St3Suj1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= diff --git a/internal/aws/approve.go b/internal/aws/approve.go new file mode 100644 index 0000000..07b105e --- /dev/null +++ b/internal/aws/approve.go @@ -0,0 +1,72 @@ +package aws + +// Error folding adapted from aws-green internal/fix/actioner_aws.go +// (approvalError) and the summaries from internal/fix/fix.go. + +import ( + "context" + "errors" + "fmt" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" +) + +// ErrApprovalAlreadyDecided means the approval token went stale between the +// poll and the decision: someone decided elsewhere, or the run moved on. +// Refresh rather than report a failure. +var ErrApprovalAlreadyDecided = errors.New("aws: approval already decided") + +// ErrApprovalNotPermitted means the profile can see the approval but may not +// decide it (no codepipeline:PutApprovalResult). +var ErrApprovalNotPermitted = errors.New("aws: profile not permitted to decide approvals") + +// Approve approves (ok) or rejects the waiting approval action identified by +// token. An empty summary gets a default naming app-green. +func (c *Client) Approve(ctx context.Context, pipeline, stage, action, token string, ok bool, summary string) error { + if token == "" { + return fmt.Errorf("aws: approve %s/%s/%s: no approval token", pipeline, stage, action) + } + status := types.ApprovalStatusApproved + if !ok { + status = types.ApprovalStatusRejected + } + if strings.TrimSpace(summary) == "" { + summary = "Approved via app-green" + if !ok { + summary = "Rejected via app-green" + } + } + _, err := c.cp.PutApprovalResult(ctx, &codepipeline.PutApprovalResultInput{ + PipelineName: awssdk.String(pipeline), + StageName: awssdk.String(stage), + ActionName: awssdk.String(action), + Token: awssdk.String(token), + Result: &types.ApprovalResult{ + Status: status, + Summary: awssdk.String(summary), + }, + }) + return approvalError(err) +} + +// approvalError folds AWS's two "someone already decided" errors into +// ErrApprovalAlreadyDecided and access denied into ErrApprovalNotPermitted, +// keeping the original error in the chain. +func approvalError(err error) error { + if err == nil { + return nil + } + var invalid *types.InvalidApprovalTokenException + var completed *types.ApprovalAlreadyCompletedException + if errors.As(err, &invalid) || errors.As(err, &completed) { + return fmt.Errorf("%w: %w", ErrApprovalAlreadyDecided, err) + } + var apiErr interface{ ErrorCode() string } + if errors.As(err, &apiErr) && strings.HasPrefix(apiErr.ErrorCode(), "AccessDenied") { + return fmt.Errorf("%w: %w", ErrApprovalNotPermitted, err) + } + return fmt.Errorf("aws: PutApprovalResult: %w", err) +} diff --git a/internal/aws/approve_test.go b/internal/aws/approve_test.go new file mode 100644 index 0000000..1fa7165 --- /dev/null +++ b/internal/aws/approve_test.go @@ -0,0 +1,78 @@ +package aws + +import ( + "context" + "errors" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" + "github.com/aws/smithy-go" +) + +func TestApprove(t *testing.T) { + cases := []struct { + ok bool + summary string + wantStatus types.ApprovalStatus + wantSummary string + }{ + {true, "ABC-1 looks good in test", types.ApprovalStatusApproved, "ABC-1 looks good in test"}, + {false, "", types.ApprovalStatusRejected, "Rejected via app-green"}, + {true, " ", types.ApprovalStatusApproved, "Approved via app-green"}, + } + for _, tc := range cases { + f := &fakePipeline{} + err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ManualApprovalOfTestEnvironment", "tok-3", tc.ok, tc.summary) + if err != nil { + t.Fatal(err) + } + in := f.approval + if in == nil { + t.Fatal("PutApprovalResult not called") + } + if awssdk.ToString(in.PipelineName) != "app-pipeline" || awssdk.ToString(in.StageName) != "Test" || + awssdk.ToString(in.ActionName) != "ManualApprovalOfTestEnvironment" || awssdk.ToString(in.Token) != "tok-3" { + t.Errorf("input = %+v", in) + } + if in.Result == nil || in.Result.Status != tc.wantStatus || awssdk.ToString(in.Result.Summary) != tc.wantSummary { + t.Errorf("result = %+v, want %s %q", in.Result, tc.wantStatus, tc.wantSummary) + } + } +} + +func TestApproveNeedsToken(t *testing.T) { + f := &fakePipeline{} + if err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ManualApprovalOfTestEnvironment", "", true, ""); err == nil { + t.Error("want an error for an empty token") + } + if f.approval != nil { + t.Error("PutApprovalResult called without a token") + } +} + +func TestApproveErrors(t *testing.T) { + cases := []struct { + name string + err error + want error + }{ + {"stale token", &types.InvalidApprovalTokenException{Message: awssdk.String("bad token")}, ErrApprovalAlreadyDecided}, + {"already done", &types.ApprovalAlreadyCompletedException{Message: awssdk.String("done")}, ErrApprovalAlreadyDecided}, + {"access denied", &smithy.OperationError{Err: &smithy.GenericAPIError{Code: "AccessDeniedException"}}, ErrApprovalNotPermitted}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &fakePipeline{err: tc.err} + err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ManualApprovalOfTestEnvironment", "tok-3", true, "") + if !errors.Is(err, tc.want) { + t.Errorf("err = %v, want %v", err, tc.want) + } + }) + } + boom := errors.New("boom") + err := (&Client{cp: &fakePipeline{err: boom}}).Approve(context.Background(), "app-pipeline", "Test", "a", "tok", false, "") + if !errors.Is(err, boom) || errors.Is(err, ErrApprovalAlreadyDecided) || errors.Is(err, ErrApprovalNotPermitted) { + t.Errorf("err = %v, want boom only", err) + } +} diff --git a/internal/aws/client.go b/internal/aws/client.go new file mode 100644 index 0000000..abd4036 --- /dev/null +++ b/internal/aws/client.go @@ -0,0 +1,75 @@ +// Package aws reads CodePipeline and ECS for one account and turns them into +// model types: the repos a pipeline deploys, each stage's deploy history +// (approvals included), the runtime health of an Env's services, and the +// approve/reject action. +package aws + +import ( + "context" + "errors" + "strings" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/ecs" +) + +// pipelineAPI is the part of the CodePipeline client the adapter uses, so +// tests can pass a fake. +type pipelineAPI interface { + GetPipeline(context.Context, *codepipeline.GetPipelineInput, ...func(*codepipeline.Options)) (*codepipeline.GetPipelineOutput, error) + ListPipelineExecutions(context.Context, *codepipeline.ListPipelineExecutionsInput, ...func(*codepipeline.Options)) (*codepipeline.ListPipelineExecutionsOutput, error) + ListActionExecutions(context.Context, *codepipeline.ListActionExecutionsInput, ...func(*codepipeline.Options)) (*codepipeline.ListActionExecutionsOutput, error) + GetPipelineState(context.Context, *codepipeline.GetPipelineStateInput, ...func(*codepipeline.Options)) (*codepipeline.GetPipelineStateOutput, error) + PutApprovalResult(context.Context, *codepipeline.PutApprovalResultInput, ...func(*codepipeline.Options)) (*codepipeline.PutApprovalResultOutput, error) +} + +// ecsAPI is the part of the ECS client the adapter uses. +type ecsAPI interface { + DescribeServices(context.Context, *ecs.DescribeServicesInput, ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) +} + +// Client talks to one account (one SSO profile). +type Client struct { + Account string + cp pipelineAPI + ecs ecsAPI +} + +// New builds a Client from a shared-config profile. It does not call AWS, so +// an expired SSO session shows up on the first request, not here. +func New(ctx context.Context, account, profile, region string) (*Client, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile(profile), config.WithRegion(region)) + if err != nil { + return nil, err + } + return &Client{Account: account, cp: codepipeline.NewFromConfig(cfg), ecs: ecs.NewFromConfig(cfg)}, nil +} + +// ssoExpiredMessages are the SDK's wordings for an SSO session that needs a +// new `aws sso login`, for errors that arrive without the typed error. +var ssoExpiredMessages = []string{ + "the sso session has expired", + "token has expired", + "cached sso token is expired", +} + +// IsSSOExpired reports whether err means the profile's SSO session has +// expired, so the UI can tell the user to run `aws sso login`. +func IsSSOExpired(err error) bool { + if err == nil { + return false + } + var invalid *ssocreds.InvalidTokenError + if errors.As(err, &invalid) { + return true + } + msg := strings.ToLower(err.Error()) + for _, m := range ssoExpiredMessages { + if strings.Contains(msg, m) { + return true + } + } + return false +} diff --git a/internal/aws/client_test.go b/internal/aws/client_test.go new file mode 100644 index 0000000..af8b748 --- /dev/null +++ b/internal/aws/client_test.go @@ -0,0 +1,53 @@ +package aws + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" + "github.com/aws/smithy-go" +) + +func TestIsSSOExpired(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"typed", &ssocreds.InvalidTokenError{}, true}, + {"wrapped typed", &smithy.OperationError{ServiceID: "CodePipeline", OperationName: "GetPipeline", Err: fmt.Errorf("get identity: %w", &ssocreds.InvalidTokenError{Err: errors.New("x")})}, true}, + {"session message", errors.New("operation error: failed to refresh cached credentials, the SSO session has expired or is invalid"), true}, + {"token expired message", errors.New("refresh cached SSO token failed, unable to refresh SSO token, token has expired"), true}, + {"cached token", errors.New("cached SSO token is expired, or not present, and cannot be refreshed"), true}, + {"access denied", &smithy.GenericAPIError{Code: "AccessDeniedException", Message: "not authorized"}, false}, + {"other", errors.New("pipeline not found"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsSSOExpired(tc.err); got != tc.want { + t.Errorf("IsSSOExpired(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestSSOExpiredThroughAdapterErrors(t *testing.T) { + expired := &smithy.OperationError{ServiceID: "CodePipeline", Err: &ssocreds.InvalidTokenError{}} + c := &Client{cp: &fakePipeline{err: expired}, ecs: &fakeECS{err: expired}} + ctx := context.Background() + if _, err := c.Sources(ctx, "app-pipeline"); !IsSSOExpired(err) { + t.Errorf("Sources err = %v, want SSO expired", err) + } + if _, _, err := c.History(ctx, "app-pipeline", testSources, testSpec); !IsSSOExpired(err) { + t.Errorf("History err = %v, want SSO expired", err) + } + if _, _, err := c.Health(ctx, []Service{{Cluster: "c1", Name: "s1"}}); !IsSSOExpired(err) { + t.Errorf("Health err = %v, want SSO expired", err) + } + if err := c.Approve(ctx, "app-pipeline", "Test", "a", "tok", true, ""); !IsSSOExpired(err) { + t.Errorf("Approve err = %v, want SSO expired", err) + } +} diff --git a/internal/aws/discover.go b/internal/aws/discover.go new file mode 100644 index 0000000..d84a557 --- /dev/null +++ b/internal/aws/discover.go @@ -0,0 +1,45 @@ +package aws + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" +) + +// Sources returns the repos a pipeline builds from: lowercased "owner/name" +// → the source action's name. It reads every Source-category action with a +// FullRepositoryId (CodeStar connections to GitHub). +func (c *Client) Sources(ctx context.Context, pipeline string) (map[string]string, error) { + out, err := c.cp.GetPipeline(ctx, &codepipeline.GetPipelineInput{Name: awssdk.String(pipeline)}) + if err != nil { + return nil, fmt.Errorf("aws: GetPipeline %s: %w", pipeline, err) + } + sources := map[string]string{} + if out.Pipeline == nil { + return sources, nil + } + for _, st := range out.Pipeline.Stages { + for _, a := range st.Actions { + if a.ActionTypeId == nil || a.ActionTypeId.Category != types.ActionCategorySource { + continue + } + repo := strings.ToLower(strings.TrimSpace(a.Configuration["FullRepositoryId"])) + if repo == "" { + continue + } + sources[repo] = awssdk.ToString(a.Name) + } + } + return sources, nil +} + +// Repos returns the repos in sources, sorted: the value for Env.Repos. +func Repos(sources map[string]string) []string { + return slices.Sorted(maps.Keys(sources)) +} diff --git a/internal/aws/discover_test.go b/internal/aws/discover_test.go new file mode 100644 index 0000000..82882bc --- /dev/null +++ b/internal/aws/discover_test.go @@ -0,0 +1,58 @@ +package aws + +import ( + "context" + "maps" + "slices" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" +) + +func sourceAction(name, repo string) types.ActionDeclaration { + return types.ActionDeclaration{ + Name: awssdk.String(name), + ActionTypeId: &types.ActionTypeId{ + Category: types.ActionCategorySource, + Owner: types.ActionOwnerAws, + Provider: awssdk.String("CodeStarSourceConnection"), + Version: awssdk.String("1"), + }, + Configuration: map[string]string{"FullRepositoryId": repo, "BranchName": "main"}, + } +} + +func testPipeline() *codepipeline.GetPipelineOutput { + return &codepipeline.GetPipelineOutput{Pipeline: &types.PipelineDeclaration{ + Name: awssdk.String("app-pipeline"), + Stages: []types.StageDeclaration{ + {Name: awssdk.String("Source"), Actions: []types.ActionDeclaration{ + sourceAction("AppCode", "Acme/App"), + sourceAction("InfraCode", "acme/infra"), + sourceAction("ReportsCode", "acme/reports"), + }}, + {Name: awssdk.String("Test"), Actions: []types.ActionDeclaration{{ + Name: awssdk.String("Deploy"), + ActionTypeId: &types.ActionTypeId{Category: types.ActionCategoryDeploy}, + Configuration: map[string]string{"FullRepositoryId": "acme/not-a-source"}, + }}}, + }, + }} +} + +func TestSources(t *testing.T) { + c := &Client{cp: &fakePipeline{pipeline: testPipeline()}} + got, err := c.Sources(context.Background(), "app-pipeline") + if err != nil { + t.Fatal(err) + } + want := map[string]string{"acme/app": "AppCode", "acme/infra": "InfraCode", "acme/reports": "ReportsCode"} + if !maps.Equal(got, want) { + t.Errorf("Sources = %v, want %v", got, want) + } + if repos := Repos(got); !slices.Equal(repos, []string{"acme/app", "acme/infra", "acme/reports"}) { + t.Errorf("Repos = %v", repos) + } +} diff --git a/internal/aws/fake_test.go b/internal/aws/fake_test.go new file mode 100644 index 0000000..b47f89b --- /dev/null +++ b/internal/aws/fake_test.go @@ -0,0 +1,98 @@ +package aws + +import ( + "context" + "errors" + + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/ecs" +) + +// fakePipeline is a pipelineAPI that returns canned outputs and records the +// inputs it was given. +type fakePipeline struct { + pipeline *codepipeline.GetPipelineOutput + execs *codepipeline.ListPipelineExecutionsOutput + // actionPages are returned in turn, one per ListActionExecutions call. + actionPages []*codepipeline.ListActionExecutionsOutput + state *codepipeline.GetPipelineStateOutput + err error + + actionInputs []*codepipeline.ListActionExecutionsInput + execInputs []*codepipeline.ListPipelineExecutionsInput + stateCalls int + approval *codepipeline.PutApprovalResultInput +} + +func (f *fakePipeline) GetPipeline(_ context.Context, in *codepipeline.GetPipelineInput, _ ...func(*codepipeline.Options)) (*codepipeline.GetPipelineOutput, error) { + if f.err != nil { + return nil, f.err + } + return f.pipeline, nil +} + +func (f *fakePipeline) ListPipelineExecutions(_ context.Context, in *codepipeline.ListPipelineExecutionsInput, _ ...func(*codepipeline.Options)) (*codepipeline.ListPipelineExecutionsOutput, error) { + f.execInputs = append(f.execInputs, in) + if f.err != nil { + return nil, f.err + } + if f.execs == nil { + return &codepipeline.ListPipelineExecutionsOutput{}, nil + } + return f.execs, nil +} + +func (f *fakePipeline) ListActionExecutions(_ context.Context, in *codepipeline.ListActionExecutionsInput, _ ...func(*codepipeline.Options)) (*codepipeline.ListActionExecutionsOutput, error) { + f.actionInputs = append(f.actionInputs, in) + if f.err != nil { + return nil, f.err + } + i := len(f.actionInputs) - 1 + if i >= len(f.actionPages) { + if len(f.actionPages) == 0 { + return &codepipeline.ListActionExecutionsOutput{}, nil + } + // Past the last canned page: repeat it, so a fake that always + // returns a NextToken pages forever. + i = len(f.actionPages) - 1 + } + return f.actionPages[i], nil +} + +func (f *fakePipeline) GetPipelineState(_ context.Context, _ *codepipeline.GetPipelineStateInput, _ ...func(*codepipeline.Options)) (*codepipeline.GetPipelineStateOutput, error) { + f.stateCalls++ + if f.err != nil { + return nil, f.err + } + if f.state == nil { + return &codepipeline.GetPipelineStateOutput{}, nil + } + return f.state, nil +} + +func (f *fakePipeline) PutApprovalResult(_ context.Context, in *codepipeline.PutApprovalResultInput, _ ...func(*codepipeline.Options)) (*codepipeline.PutApprovalResultOutput, error) { + f.approval = in + if f.err != nil { + return nil, f.err + } + return &codepipeline.PutApprovalResultOutput{}, nil +} + +// fakeECS is an ecsAPI keyed by cluster. +type fakeECS struct { + byCluster map[string]*ecs.DescribeServicesOutput + err error + inputs []*ecs.DescribeServicesInput +} + +func (f *fakeECS) DescribeServices(_ context.Context, in *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { + f.inputs = append(f.inputs, in) + if f.err != nil { + return nil, f.err + } + out, ok := f.byCluster[*in.Cluster] + if !ok { + return nil, errors.New("no such cluster") + } + return out, nil +} diff --git a/internal/aws/health.go b/internal/aws/health.go new file mode 100644 index 0000000..2f46b0a --- /dev/null +++ b/internal/aws/health.go @@ -0,0 +1,73 @@ +package aws + +import ( + "context" + "fmt" + "slices" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Service is one ECS service an Env runs. +type Service struct { + Cluster string + Name string +} + +// describeLimit is how many services one DescribeServices call takes. +const describeLimit = 10 + +// Health sums desired and running tasks across services: one Health per Env. +// It is unknown when no services are configured, and when any configured +// service is missing or not ACTIVE (with a warning naming it), so a +// half-found Env never looks healthy or unhealthy on partial counts. +func (c *Client) Health(ctx context.Context, services []Service) (model.Health, []string, error) { + if len(services) == 0 { + return model.Health{}, nil, nil + } + var clusters []string + names := map[string][]string{} + for _, s := range services { + if _, ok := names[s.Cluster]; !ok { + clusters = append(clusters, s.Cluster) + } + if !slices.Contains(names[s.Cluster], s.Name) { + names[s.Cluster] = append(names[s.Cluster], s.Name) + } + } + + h := model.Health{Known: true} + var warns []string + for _, cluster := range clusters { + for chunk := range slices.Chunk(names[cluster], describeLimit) { + out, err := c.ecs.DescribeServices(ctx, &ecs.DescribeServicesInput{ + Cluster: awssdk.String(cluster), + Services: chunk, + }) + if err != nil { + return model.Health{}, warns, fmt.Errorf("aws: DescribeServices %s: %w", cluster, err) + } + found := map[string]bool{} + for _, s := range out.Services { + if awssdk.ToString(s.Status) != "ACTIVE" { + continue + } + found[awssdk.ToString(s.ServiceName)] = true + h.Desired += int(s.DesiredCount) + h.Healthy += int(s.RunningCount) + } + for _, name := range chunk { + if !found[name] { + warns = append(warns, fmt.Sprintf("aws: ECS service %s/%s not found or not active", cluster, name)) + } + } + } + } + if len(warns) > 0 { + return model.Health{}, warns, nil + } + return h, nil, nil +} diff --git a/internal/aws/health_test.go b/internal/aws/health_test.go new file mode 100644 index 0000000..0a1694d --- /dev/null +++ b/internal/aws/health_test.go @@ -0,0 +1,90 @@ +package aws + +import ( + "context" + "errors" + "slices" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +func svc(name string, desired, running int32) ecstypes.Service { + return ecstypes.Service{ServiceName: awssdk.String(name), Status: awssdk.String("ACTIVE"), DesiredCount: desired, RunningCount: running} +} + +func TestHealthOneService(t *testing.T) { + f := &fakeECS{byCluster: map[string]*ecs.DescribeServicesOutput{ + "c1": {Services: []ecstypes.Service{svc("s1", 2, 1)}}, + }} + got, warns, err := (&Client{ecs: f}).Health(context.Background(), []Service{{Cluster: "c1", Name: "s1"}}) + if err != nil { + t.Fatal(err) + } + if want := (model.Health{Known: true, Desired: 2, Healthy: 1}); got != want || len(warns) > 0 { + t.Errorf("Health = %+v %v, want %+v", got, warns, want) + } + if in := f.inputs[0]; awssdk.ToString(in.Cluster) != "c1" || !slices.Equal(in.Services, []string{"s1"}) { + t.Errorf("input = %s %v", awssdk.ToString(in.Cluster), in.Services) + } +} + +func TestHealthSumsServicesOneCallPerCluster(t *testing.T) { + f := &fakeECS{byCluster: map[string]*ecs.DescribeServicesOutput{ + "c1": {Services: []ecstypes.Service{svc("s1", 2, 2), svc("s2", 3, 1)}}, + "c2": {Services: []ecstypes.Service{svc("s3", 1, 1)}}, + }} + got, warns, err := (&Client{ecs: f}).Health(context.Background(), []Service{ + {Cluster: "c1", Name: "s1"}, {Cluster: "c2", Name: "s3"}, {Cluster: "c1", Name: "s2"}, + }) + if err != nil { + t.Fatal(err) + } + if want := (model.Health{Known: true, Desired: 6, Healthy: 4}); got != want || len(warns) > 0 { + t.Errorf("Health = %+v %v, want %+v", got, warns, want) + } + if len(f.inputs) != 2 || !slices.Equal(f.inputs[0].Services, []string{"s1", "s2"}) { + t.Errorf("DescribeServices calls = %d, first %v", len(f.inputs), f.inputs[0].Services) + } +} + +func TestHealthMissingServiceIsUnknown(t *testing.T) { + inactive := svc("s3", 1, 0) + inactive.Status = awssdk.String("INACTIVE") + cases := map[string]*ecs.DescribeServicesOutput{ + "failure": {Services: []ecstypes.Service{svc("s1", 2, 2)}, Failures: []ecstypes.Failure{{Arn: awssdk.String("arn:aws:ecs:us-east-1:111111111111:service/c1/s2"), Reason: awssdk.String("MISSING")}}}, + "absent": {Services: []ecstypes.Service{svc("s1", 2, 2)}}, + "inactive": {Services: []ecstypes.Service{svc("s1", 2, 2), func() ecstypes.Service { s := inactive; s.ServiceName = awssdk.String("s2"); return s }()}}, + } + for name, out := range cases { + t.Run(name, func(t *testing.T) { + f := &fakeECS{byCluster: map[string]*ecs.DescribeServicesOutput{"c1": out}} + got, warns, err := (&Client{ecs: f}).Health(context.Background(), []Service{{Cluster: "c1", Name: "s1"}, {Cluster: "c1", Name: "s2"}}) + if err != nil { + t.Fatal(err) + } + if got.Known || len(warns) != 1 { + t.Errorf("Health = %+v warnings %v, want unknown with one warning", got, warns) + } + }) + } +} + +func TestHealthNoServicesIsUnknown(t *testing.T) { + got, _, err := (&Client{ecs: &fakeECS{}}).Health(context.Background(), nil) + if err != nil || got.Known { + t.Errorf("Health = %+v, %v; want unknown, nil", got, err) + } +} + +func TestHealthError(t *testing.T) { + boom := errors.New("boom") + got, _, err := (&Client{ecs: &fakeECS{err: boom}}).Health(context.Background(), []Service{{Cluster: "c1", Name: "s1"}}) + if !errors.Is(err, boom) || got.Known { + t.Errorf("Health = %+v, %v; want unknown, boom", got, err) + } +} diff --git a/internal/aws/history.go b/internal/aws/history.go new file mode 100644 index 0000000..1e0f30a --- /dev/null +++ b/internal/aws/history.go @@ -0,0 +1,264 @@ +package aws + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "strings" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// StageSpec names one Env's stage in a pipeline. Action names are matched +// exactly (CodePipeline names are case-sensitive). +type StageSpec struct { + Stage string // "Production" + DeployAction string // the action whose status is the Env's deploy status + // ApprovalStage and ApprovalAction name the manual approval that gates + // this stage, which may sit at the end of the previous stage. Empty when + // no approval gates it. + ApprovalStage string + ApprovalAction string +} + +// maxExecutions is how many pipeline executions one History call reads. +const maxExecutions = 50 + +// maxActionPages bounds ListActionExecutions paging. With no execution +// filter it lists every action of every run (about nine per run in a +// Source → Test → Production pipeline), so 100 per page covers about eleven +// runs; five pages cover the execution window. +const maxActionPages = 5 + +// History returns each spec's deploys, keyed by StageSpec.Stage, newest +// first. sources is the repo → source action map from Sources. An execution +// that has not run the spec's deploy action but waits at its approval action +// is a DeployAwaitingApproval deploy carrying the approval token. A warning +// means the history is usable but incomplete. +func (c *Client) History(ctx context.Context, pipeline string, sources map[string]string, specs ...StageSpec) (map[string][]model.Deploy, []string, error) { + execList, err := c.cp.ListPipelineExecutions(ctx, &codepipeline.ListPipelineExecutionsInput{ + PipelineName: awssdk.String(pipeline), + MaxResults: awssdk.Int32(maxExecutions), + }) + if err != nil { + return nil, nil, fmt.Errorf("aws: ListPipelineExecutions %s: %w", pipeline, err) + } + acts, err := c.actionExecutions(ctx, pipeline, oldestStart(execList.PipelineExecutionSummaries)) + if err != nil { + return nil, nil, err + } + execs := executions(execList.PipelineExecutionSummaries, sources) + + var warns []string + var state *codepipeline.GetPipelineStateOutput // fetched once, on the first pending approval + out := map[string][]model.Deploy{} + for _, spec := range specs { + deploys, pending := stageDeploys(spec, acts, execs) + for _, p := range pending { + if state == nil { + state, err = c.cp.GetPipelineState(ctx, &codepipeline.GetPipelineStateInput{Name: awssdk.String(pipeline)}) + if err != nil { + return nil, warns, fmt.Errorf("aws: GetPipelineState %s: %w", pipeline, err) + } + } + d := model.Deploy{ + ExecutionID: awssdk.ToString(p.PipelineExecutionId), + Status: model.DeployAwaitingApproval, + Revisions: maps.Clone(execs[awssdk.ToString(p.PipelineExecutionId)]), + FinishedAt: awssdk.ToTime(p.LastUpdateTime), + ApprovalToken: approvalToken(state, p), + } + if d.ApprovalToken == "" { + warns = append(warns, fmt.Sprintf("aws: %s: no approval token for %s/%s in execution %s", pipeline, spec.ApprovalStage, spec.ApprovalAction, d.ExecutionID)) + } + deploys = append(deploys, d) + } + sortNewestFirst(deploys) + out[spec.Stage] = deploys + } + return out, warns, nil +} + +// actionExecutions lists the pipeline's action executions, newest first, +// paging until AWS has no more, a page reaches actions that started before +// oldest (the oldest execution in the window; zero means unknown), or +// maxActionPages. +func (c *Client) actionExecutions(ctx context.Context, pipeline string, oldest time.Time) ([]types.ActionExecutionDetail, error) { + var all []types.ActionExecutionDetail + var next *string + for range maxActionPages { + out, err := c.cp.ListActionExecutions(ctx, &codepipeline.ListActionExecutionsInput{ + PipelineName: awssdk.String(pipeline), + MaxResults: awssdk.Int32(100), + NextToken: next, + }) + if err != nil { + return nil, fmt.Errorf("aws: ListActionExecutions %s: %w", pipeline, err) + } + all = append(all, out.ActionExecutionDetails...) + if out.NextToken == nil || pastWindow(out.ActionExecutionDetails, oldest) { + break + } + next = out.NextToken + } + return all, nil +} + +// pastWindow reports whether page holds an action that started before +// oldest, so later pages hold only runs older than the execution window. +func pastWindow(page []types.ActionExecutionDetail, oldest time.Time) bool { + if oldest.IsZero() { + return false + } + for _, a := range page { + if a.StartTime != nil && a.StartTime.Before(oldest) { + return true + } + } + return false +} + +// oldestStart is the earliest StartTime among summaries, zero if any is +// missing one. +func oldestStart(summaries []types.PipelineExecutionSummary) time.Time { + var oldest time.Time + for _, e := range summaries { + if e.StartTime == nil { + return time.Time{} + } + if oldest.IsZero() || e.StartTime.Before(oldest) { + oldest = *e.StartTime + } + } + return oldest +} + +// stageDeploys returns spec's deploys from its deploy action's executions, +// and the approval action executions still waiting for a run that has not +// reached the deploy action. Runs whose execution is not in execs (outside +// the window, superseded or canceled) are dropped. +func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[string]map[string]string) ([]model.Deploy, []types.ActionExecutionDetail) { + deploys := []model.Deploy{} + deployed := map[string]bool{} + var waiting []types.ActionExecutionDetail + for _, a := range acts { + id := awssdk.ToString(a.PipelineExecutionId) + revs, known := execs[id] + if !known { + continue + } + stage, action := awssdk.ToString(a.StageName), awssdk.ToString(a.ActionName) + switch { + case stage == spec.Stage && action == spec.DeployAction: + status, ok := deployStatus(a.Status) + if !ok { + continue + } + deployed[id] = true + deploys = append(deploys, model.Deploy{ + ExecutionID: id, + Status: status, + Revisions: maps.Clone(revs), + FinishedAt: awssdk.ToTime(a.LastUpdateTime), + }) + case spec.ApprovalAction != "" && stage == spec.ApprovalStage && action == spec.ApprovalAction && + a.Status == types.ActionExecutionStatusInProgress: + waiting = append(waiting, a) + } + } + var pending []types.ActionExecutionDetail + for _, a := range waiting { + if !deployed[awssdk.ToString(a.PipelineExecutionId)] { + pending = append(pending, a) + } + } + return deploys, pending +} + +// approvalToken finds the token for the waiting approval a in the pipeline's +// current state. Only the approval that is waiting right now has one. The +// state's action execution ID ties it to a; when AWS leaves that ID out, the +// stage's current execution must be a's. +func approvalToken(state *codepipeline.GetPipelineStateOutput, a types.ActionExecutionDetail) string { + for _, st := range state.StageStates { + if awssdk.ToString(st.StageName) != awssdk.ToString(a.StageName) { + continue + } + for _, as := range st.ActionStates { + le := as.LatestExecution + if awssdk.ToString(as.ActionName) != awssdk.ToString(a.ActionName) || le == nil || + le.Status != types.ActionExecutionStatusInProgress { + continue + } + if id := awssdk.ToString(le.ActionExecutionId); id != "" { + if id == awssdk.ToString(a.ActionExecutionId) { + return awssdk.ToString(le.Token) + } + continue + } + if st.LatestExecution != nil && awssdk.ToString(st.LatestExecution.PipelineExecutionId) == awssdk.ToString(a.PipelineExecutionId) { + return awssdk.ToString(le.Token) + } + } + } + return "" +} + +// executions maps each execution ID to the commits it carries, repo → SHA, +// using sources (repo → source action) inverted. A source action no repo +// maps to is ignored, and a repo with no revision is left out. Superseded and +// canceled executions are left out (see model.Deploy), so their action +// executions are dropped like those outside the window. +func executions(summaries []types.PipelineExecutionSummary, sources map[string]string) map[string]map[string]string { + repoOf := make(map[string]string, len(sources)) + for repo, action := range sources { + repoOf[action] = strings.ToLower(repo) + } + out := make(map[string]map[string]string, len(summaries)) + for _, e := range summaries { + if e.Status == types.PipelineExecutionStatusSuperseded || e.Status == types.PipelineExecutionStatusCancelled { + continue + } + revs := map[string]string{} + for _, r := range e.SourceRevisions { + repo, ok := repoOf[awssdk.ToString(r.ActionName)] + if sha := awssdk.ToString(r.RevisionId); ok && sha != "" { + revs[repo] = sha + } + } + out[awssdk.ToString(e.PipelineExecutionId)] = revs + } + return out +} + +// deployStatus maps an action status to a deploy status. Abandoned and any +// status AWS adds later report false: they never deployed anything. +func deployStatus(s types.ActionExecutionStatus) (model.DeployStatus, bool) { + switch s { + case types.ActionExecutionStatusSucceeded: + return model.DeploySucceeded, true + case types.ActionExecutionStatusFailed: + return model.DeployFailed, true + case types.ActionExecutionStatusInProgress: + return model.DeployInProgress, true + } + return "", false +} + +// sortNewestFirst orders deploys by FinishedAt, newest first; ties keep a +// stable order by execution ID so output does not flicker between polls. +func sortNewestFirst(deploys []model.Deploy) { + slices.SortFunc(deploys, func(a, b model.Deploy) int { + if c := b.FinishedAt.Compare(a.FinishedAt); c != 0 { + return c + } + return cmp.Compare(b.ExecutionID, a.ExecutionID) + }) +} diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go new file mode 100644 index 0000000..95847ba --- /dev/null +++ b/internal/aws/history_test.go @@ -0,0 +1,349 @@ +package aws + +import ( + "context" + "maps" + "testing" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + +func at(min int) *time.Time { t := t0.Add(time.Duration(min) * time.Minute); return &t } + +var testSources = map[string]string{"acme/app": "AppCode", "acme/infra": "InfraCode", "acme/reports": "ReportsCode"} + +var ( + prodSpec = StageSpec{Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ManualApprovalOfTestEnvironment"} + testSpec = StageSpec{Stage: "Test", DeployAction: "Deploy"} +) + +func execSummary(id string, status types.PipelineExecutionStatus, revs ...string) types.PipelineExecutionSummary { + s := types.PipelineExecutionSummary{PipelineExecutionId: awssdk.String(id), Status: status} + for i := 0; i+1 < len(revs); i += 2 { + s.SourceRevisions = append(s.SourceRevisions, types.SourceRevision{ActionName: awssdk.String(revs[i]), RevisionId: awssdk.String(revs[i+1])}) + } + return s +} + +func actionExec(exec, stage, action string, status types.ActionExecutionStatus, updated *time.Time) types.ActionExecutionDetail { + return types.ActionExecutionDetail{ + PipelineExecutionId: awssdk.String(exec), + ActionExecutionId: awssdk.String(exec + "-" + action), + StageName: awssdk.String(stage), + ActionName: awssdk.String(action), + Status: status, + StartTime: updated, + LastUpdateTime: updated, + } +} + +func allRevs(sha string) []string { + return []string{"AppCode", sha, "InfraCode", sha + "i", "ReportsCode", sha + "r"} +} + +func TestHistoryStatuses(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec3", types.PipelineExecutionStatusInProgress, allRevs("ccc")...), + execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...), + execSummary("exec1", types.PipelineExecutionStatusFailed, allRevs("aaa")...), + }}, + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec3", "Production", "deploy", types.ActionExecutionStatusInProgress, at(30)), + actionExec("exec2", "Production", "deploy", types.ActionExecutionStatusSucceeded, at(20)), + actionExec("exec1", "Production", "deploy", types.ActionExecutionStatusFailed, at(10)), + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(25)), + }}}, + } + c := &Client{cp: f} + got, _, err := c.History(context.Background(), "app-pipeline", testSources, StageSpec{Stage: "Production", DeployAction: "deploy"}) + if err != nil { + t.Fatal(err) + } + want := []model.DeployStatus{model.DeployInProgress, model.DeploySucceeded, model.DeployFailed} + assertStatuses(t, got["Production"], []string{"exec3", "exec2", "exec1"}, want) +} + +func assertStatuses(t *testing.T, got []model.Deploy, ids []string, statuses []model.DeployStatus) { + t.Helper() + if len(got) != len(ids) { + t.Fatalf("got %d deploys %+v, want %v", len(got), got, ids) + } + for i, d := range got { + if d.ExecutionID != ids[i] || d.Status != statuses[i] { + t.Errorf("deploy %d = %s %s, want %s %s", i, d.ExecutionID, d.Status, ids[i], statuses[i]) + } + } +} + +func TestHistoryNewestFirst(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec3", types.PipelineExecutionStatusSucceeded, allRevs("ccc")...), + execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...), + execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), + }}, + // AWS orders by start time; a retried older run can finish last. + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(5)), + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(40)), + }}}, + } + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + if err != nil { + t.Fatal(err) + } + d := got["Test"] + ok := model.DeploySucceeded + assertStatuses(t, d, []string{"exec2", "exec1", "exec3"}, []model.DeployStatus{ok, ok, ok}) + if !d[0].FinishedAt.Equal(*at(40)) { + t.Errorf("FinishedAt = %v, want %v", d[0].FinishedAt, *at(40)) + } +} + +func TestHistoryRevisions(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + // exec2 has no ReportsCode revision, plus a source action no + // configured repo maps to. + execSummary("exec2", types.PipelineExecutionStatusSucceeded, "AppCode", "bbb", "InfraCode", "bbbi", "OtherCode", "zzz"), + execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), + }}, + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), + // exec0 is older than the execution window: its revisions are + // unknown, so it is left out rather than shown carrying nothing. + actionExec("exec0", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(5)), + }}}, + } + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + if err != nil { + t.Fatal(err) + } + d := got["Test"] + ok := model.DeploySucceeded + assertStatuses(t, d, []string{"exec2", "exec1"}, []model.DeployStatus{ok, ok}) + if want := map[string]string{"acme/app": "bbb", "acme/infra": "bbbi"}; !maps.Equal(d[0].Revisions, want) { + t.Errorf("exec2 Revisions = %v, want %v", d[0].Revisions, want) + } + if want := map[string]string{"acme/app": "aaa", "acme/infra": "aaai", "acme/reports": "aaar"}; !maps.Equal(d[1].Revisions, want) { + t.Errorf("exec1 Revisions = %v, want %v", d[1].Revisions, want) + } +} + +func TestHistoryDropsRunsThatNeverDeployed(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec5", types.PipelineExecutionStatusStopped, allRevs("eee")...), + execSummary("exec4", types.PipelineExecutionStatusSuperseded, allRevs("ddd")...), + execSummary("exec3", types.PipelineExecutionStatusCancelled, allRevs("ccc")...), + execSummary("exec2", types.PipelineExecutionStatusStopped, allRevs("bbb")...), + execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), + }}, + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec5", "Test", "Deploy", types.ActionExecutionStatusAbandoned, at(50)), + actionExec("exec4", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(40)), + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusFailed, at(30)), + // Stopped after the deploy finished: the deploy still counts. + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), + }}}, + } + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + if err != nil { + t.Fatal(err) + } + ok := model.DeploySucceeded + assertStatuses(t, got["Test"], []string{"exec2", "exec1"}, []model.DeployStatus{ok, ok}) +} + +// approvalFake is the plan's scenario: exec3 passed Test/Deploy and waits at +// the Test approval; exec2 reached Production; exec1 failed there. +func approvalFake() *fakePipeline { + return &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec3", types.PipelineExecutionStatusInProgress, allRevs("ccc")...), + execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...), + execSummary("exec1", types.PipelineExecutionStatusFailed, allRevs("aaa")...), + }}, + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec3", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusInProgress, at(36)), + actionExec("exec3", "Test", "SmokeTests", types.ActionExecutionStatusSucceeded, at(35)), + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(34)), + actionExec("exec2", "Production", "deploy", types.ActionExecutionStatusSucceeded, at(25)), + actionExec("exec2", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusSucceeded, at(23)), + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + actionExec("exec1", "Production", "deploy", types.ActionExecutionStatusFailed, at(15)), + actionExec("exec1", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusSucceeded, at(12)), + actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), + }}}, + state: &codepipeline.GetPipelineStateOutput{StageStates: []types.StageState{ + { + StageName: awssdk.String("Test"), + LatestExecution: &types.StageExecution{PipelineExecutionId: awssdk.String("exec3"), Status: types.StageExecutionStatusInProgress}, + ActionStates: []types.ActionState{ + {ActionName: awssdk.String("Deploy"), LatestExecution: &types.ActionExecution{Status: types.ActionExecutionStatusSucceeded}}, + {ActionName: awssdk.String("ManualApprovalOfTestEnvironment"), LatestExecution: &types.ActionExecution{ + ActionExecutionId: awssdk.String("exec3-ManualApprovalOfTestEnvironment"), + Status: types.ActionExecutionStatusInProgress, + Token: awssdk.String("tok-3"), + }}, + }, + }, + { + StageName: awssdk.String("Production"), + LatestExecution: &types.StageExecution{PipelineExecutionId: awssdk.String("exec2"), Status: types.StageExecutionStatusSucceeded}, + }, + }}, + } +} + +func TestHistoryAwaitingApproval(t *testing.T) { + f := approvalFake() + got, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec, prodSpec) + if err != nil { + t.Fatal(err) + } + if len(warns) > 0 { + t.Errorf("warnings: %v", warns) + } + prod := got["Production"] + assertStatuses(t, prod, []string{"exec3", "exec2", "exec1"}, + []model.DeployStatus{model.DeployAwaitingApproval, model.DeploySucceeded, model.DeployFailed}) + if prod[0].ApprovalToken != "tok-3" { + t.Errorf("token = %q, want tok-3", prod[0].ApprovalToken) + } + if prod[0].Revisions["acme/app"] != "ccc" { + t.Errorf("exec3 Revisions = %v", prod[0].Revisions) + } + if !prod[0].FinishedAt.Equal(*at(36)) { + t.Errorf("exec3 FinishedAt = %v, want the approval's update time", prod[0].FinishedAt) + } + if prod[1].ApprovalToken != "" { + t.Errorf("exec2 token = %q, want none", prod[1].ApprovalToken) + } + ok := model.DeploySucceeded + assertStatuses(t, got["Test"], []string{"exec3", "exec2", "exec1"}, []model.DeployStatus{ok, ok, ok}) + if f.stateCalls != 1 { + t.Errorf("GetPipelineState calls = %d, want 1", f.stateCalls) + } +} + +func TestHistoryApprovalTokenMatching(t *testing.T) { + cases := []struct { + name string + edit func(*types.ActionExecution, *types.StageState) + wantToken string + }{ + {"no action execution ID, stage on exec3", func(ae *types.ActionExecution, _ *types.StageState) { ae.ActionExecutionId = nil }, "tok-3"}, + {"no action execution ID, stage on another run", func(ae *types.ActionExecution, st *types.StageState) { + ae.ActionExecutionId = nil + st.LatestExecution.PipelineExecutionId = awssdk.String("exec9") + }, ""}, + {"state shows another run's approval", func(ae *types.ActionExecution, _ *types.StageState) { ae.ActionExecutionId = awssdk.String("exec9-x") }, ""}, + {"approval no longer waiting", func(ae *types.ActionExecution, _ *types.StageState) { ae.Status = types.ActionExecutionStatusSucceeded }, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := approvalFake() + st := &f.state.StageStates[0] + tc.edit(st.ActionStates[1].LatestExecution, st) + got, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, prodSpec) + if err != nil { + t.Fatal(err) + } + prod := got["Production"] + if prod[0].Status != model.DeployAwaitingApproval || prod[0].ApprovalToken != tc.wantToken { + t.Errorf("deploy 0 = %s token %q, want AwaitingApproval token %q", prod[0].Status, prod[0].ApprovalToken, tc.wantToken) + } + if wantWarn := tc.wantToken == ""; (len(warns) > 0) != wantWarn { + t.Errorf("warnings = %v, want some: %v", warns, wantWarn) + } + }) + } +} + +func TestHistoryNoStateCallWithoutPendingApproval(t *testing.T) { + f := approvalFake() + if _, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec); err != nil { + t.Fatal(err) + } + if f.stateCalls != 0 { + t.Errorf("GetPipelineState calls = %d, want 0", f.stateCalls) + } +} + +func TestHistoryPagesActionExecutions(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...), + execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), + }}, + actionPages: []*codepipeline.ListActionExecutionsOutput{ + {NextToken: awssdk.String("p2"), ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + }}, + {ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), + }}, + }, + } + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + if err != nil { + t.Fatal(err) + } + ok := model.DeploySucceeded + assertStatuses(t, got["Test"], []string{"exec2", "exec1"}, []model.DeployStatus{ok, ok}) + if len(f.actionInputs) != 2 || awssdk.ToString(f.actionInputs[1].NextToken) != "p2" { + t.Errorf("ListActionExecutions inputs = %d, second NextToken %q", len(f.actionInputs), awssdk.ToString(f.actionInputs[len(f.actionInputs)-1].NextToken)) + } +} + +func TestHistoryActionPagingIsBounded(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), + }}, + // Always another page. + actionPages: []*codepipeline.ListActionExecutionsOutput{{NextToken: awssdk.String("more"), ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec1", "Test", "SmokeTests", types.ActionExecutionStatusSucceeded, at(10)), + }}}, + } + if _, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec); err != nil { + t.Fatal(err) + } + if len(f.actionInputs) != maxActionPages { + t.Errorf("ListActionExecutions calls = %d, want %d", len(f.actionInputs), maxActionPages) + } +} + +func TestHistoryActionPagingStopsPastTheWindow(t *testing.T) { + exec2 := execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...) + exec2.StartTime = at(15) + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{exec2}}, + actionPages: []*codepipeline.ListActionExecutionsOutput{{NextToken: awssdk.String("more"), ActionExecutionDetails: []types.ActionExecutionDetail{ + // Started after the window was read: not a reason to stop. + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusInProgress, at(30)), + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + // Started before the oldest execution in the window: later pages + // are older still. + actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), + }}}, + } + if _, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec); err != nil { + t.Fatal(err) + } + if len(f.actionInputs) != 1 { + t.Errorf("ListActionExecutions calls = %d, want 1", len(f.actionInputs)) + } +} From eaaee9140bd13d4536ca708fb022e7f751bd6a7b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 16:35:22 -0400 Subject: [PATCH 12/31] fix(aws): one deploy per retried stage, SSO session refresh failures A stage retry re-runs the deploy action under the same execution ID; keep only the latest attempt so link never sees a stale failure beside it. With an sso-session profile the SDK does not wrap token refresh errors in InvalidTokenError, so IsSSOExpired also matches a failed refresh and a missing cached token by message. Assisted-by: Claude Code:claude-opus-5-5 --- internal/aws/client.go | 7 ++++++- internal/aws/client_test.go | 3 +++ internal/aws/history.go | 25 +++++++++++++++++-------- internal/aws/history_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/internal/aws/client.go b/internal/aws/client.go index abd4036..1300a54 100644 --- a/internal/aws/client.go +++ b/internal/aws/client.go @@ -48,11 +48,16 @@ func New(ctx context.Context, account, profile, region string) (*Client, error) } // ssoExpiredMessages are the SDK's wordings for an SSO session that needs a -// new `aws sso login`, for errors that arrive without the typed error. +// new `aws sso login`, for errors that arrive without the typed error. With +// an sso-session profile the token provider's errors are not wrapped in +// ssocreds.InvalidTokenError, so a failed refresh or a missing cached token +// is only visible in the message. var ssoExpiredMessages = []string{ "the sso session has expired", "token has expired", "cached sso token is expired", + "refresh cached sso token failed", + "failed to read cached sso token file", } // IsSSOExpired reports whether err means the profile's SSO session has diff --git a/internal/aws/client_test.go b/internal/aws/client_test.go index af8b748..7af9c03 100644 --- a/internal/aws/client_test.go +++ b/internal/aws/client_test.go @@ -22,6 +22,9 @@ func TestIsSSOExpired(t *testing.T) { {"session message", errors.New("operation error: failed to refresh cached credentials, the SSO session has expired or is invalid"), true}, {"token expired message", errors.New("refresh cached SSO token failed, unable to refresh SSO token, token has expired"), true}, {"cached token", errors.New("cached SSO token is expired, or not present, and cannot be refreshed"), true}, + // sso-session profiles: the token provider's errors arrive unwrapped. + {"refresh rejected", fmt.Errorf("get identity: %w", fmt.Errorf("refresh cached SSO token failed, %w", fmt.Errorf("unable to refresh SSO token, %w", &smithy.GenericAPIError{Code: "InvalidGrantException"}))), true}, + {"never logged in", errors.New("failed to read cached SSO token file, open /home/u/.aws/sso/cache/x.json: no such file or directory"), true}, {"access denied", &smithy.GenericAPIError{Code: "AccessDeniedException", Message: "not authorized"}, false}, {"other", errors.New("pipeline not found"), false}, } diff --git a/internal/aws/history.go b/internal/aws/history.go index 1e0f30a..cc0fdb0 100644 --- a/internal/aws/history.go +++ b/internal/aws/history.go @@ -140,13 +140,13 @@ func oldestStart(summaries []types.PipelineExecutionSummary) time.Time { return oldest } -// stageDeploys returns spec's deploys from its deploy action's executions, -// and the approval action executions still waiting for a run that has not -// reached the deploy action. Runs whose execution is not in execs (outside +// stageDeploys returns spec's deploys from its deploy action's executions +// (one per execution, the latest attempt), and the approval action +// executions still waiting for a run that has not reached the deploy action. Runs whose execution is not in execs (outside // the window, superseded or canceled) are dropped. func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[string]map[string]string) ([]model.Deploy, []types.ActionExecutionDetail) { deploys := []model.Deploy{} - deployed := map[string]bool{} + deployed := map[string]int{} // execution ID → index in deploys var waiting []types.ActionExecutionDetail for _, a := range acts { id := awssdk.ToString(a.PipelineExecutionId) @@ -161,13 +161,22 @@ func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[ if !ok { continue } - deployed[id] = true - deploys = append(deploys, model.Deploy{ + d := model.Deploy{ ExecutionID: id, Status: status, Revisions: maps.Clone(revs), FinishedAt: awssdk.ToTime(a.LastUpdateTime), - }) + } + // A stage retry re-runs the action under the same execution: + // keep the latest attempt. + if i, seen := deployed[id]; seen { + if d.FinishedAt.After(deploys[i].FinishedAt) { + deploys[i] = d + } + continue + } + deployed[id] = len(deploys) + deploys = append(deploys, d) case spec.ApprovalAction != "" && stage == spec.ApprovalStage && action == spec.ApprovalAction && a.Status == types.ActionExecutionStatusInProgress: waiting = append(waiting, a) @@ -175,7 +184,7 @@ func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[ } var pending []types.ActionExecutionDetail for _, a := range waiting { - if !deployed[awssdk.ToString(a.PipelineExecutionId)] { + if _, ok := deployed[awssdk.ToString(a.PipelineExecutionId)]; !ok { pending = append(pending, a) } } diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go index 95847ba..093d623 100644 --- a/internal/aws/history_test.go +++ b/internal/aws/history_test.go @@ -347,3 +347,33 @@ func TestHistoryActionPagingStopsPastTheWindow(t *testing.T) { t.Errorf("ListActionExecutions calls = %d, want 1", len(f.actionInputs)) } } + +func TestHistoryRetriedStageIsOneDeploy(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec5", types.PipelineExecutionStatusSucceeded, allRevs("eee")...), + }}, + // A stage retry re-runs the action under the same execution ID. + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + { + PipelineExecutionId: awssdk.String("exec5"), ActionExecutionId: awssdk.String("exec5-deploy-2"), + StageName: awssdk.String("Production"), ActionName: awssdk.String("deploy"), + Status: types.ActionExecutionStatusSucceeded, StartTime: at(12), LastUpdateTime: at(15), + }, + { + PipelineExecutionId: awssdk.String("exec5"), ActionExecutionId: awssdk.String("exec5-deploy-1"), + StageName: awssdk.String("Production"), ActionName: awssdk.String("deploy"), + Status: types.ActionExecutionStatusFailed, StartTime: at(8), LastUpdateTime: at(10), + }, + }}}, + } + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, StageSpec{Stage: "Production", DeployAction: "deploy"}) + if err != nil { + t.Fatal(err) + } + prod := got["Production"] + assertStatuses(t, prod, []string{"exec5"}, []model.DeployStatus{model.DeploySucceeded}) + if !prod[0].FinishedAt.Equal(*at(15)) { + t.Errorf("FinishedAt = %v, want the retry's", prod[0].FinishedAt) + } +} From b09bc3dc3c725082eda612b09daa3b2a31d8bbb0 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 21:59:54 -0400 Subject: [PATCH 13/31] fix(aws): keep what superseded and stopped runs finished A run superseded while waiting at the approval still deployed to Test, so drop only the actions that never finished: abandoned ones, and ones an ended run left showing InProgress (including its approval). Finished successes and failures of superseded, stopped and canceled runs stay. Assisted-by: Claude Code:claude-opus-5-5 --- internal/aws/history.go | 40 ++++++++++++++++++++---------------- internal/aws/history_test.go | 17 ++++++++++++--- internal/model/model.go | 5 +++-- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/internal/aws/history.go b/internal/aws/history.go index cc0fdb0..6aac32a 100644 --- a/internal/aws/history.go +++ b/internal/aws/history.go @@ -71,7 +71,7 @@ func (c *Client) History(ctx context.Context, pipeline string, sources map[strin d := model.Deploy{ ExecutionID: awssdk.ToString(p.PipelineExecutionId), Status: model.DeployAwaitingApproval, - Revisions: maps.Clone(execs[awssdk.ToString(p.PipelineExecutionId)]), + Revisions: maps.Clone(execs[awssdk.ToString(p.PipelineExecutionId)].revs), FinishedAt: awssdk.ToTime(p.LastUpdateTime), ApprovalToken: approvalToken(state, p), } @@ -142,16 +142,18 @@ func oldestStart(summaries []types.PipelineExecutionSummary) time.Time { // stageDeploys returns spec's deploys from its deploy action's executions // (one per execution, the latest attempt), and the approval action -// executions still waiting for a run that has not reached the deploy action. Runs whose execution is not in execs (outside -// the window, superseded or canceled) are dropped. -func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[string]map[string]string) ([]model.Deploy, []types.ActionExecutionDetail) { +// executions still waiting for a run that has not reached the deploy action. +// Runs outside the window are dropped. An ended run (superseded, canceled, +// stopped) keeps the actions that finished and drops those still showing +// InProgress: they never will finish. +func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[string]execution) ([]model.Deploy, []types.ActionExecutionDetail) { deploys := []model.Deploy{} deployed := map[string]int{} // execution ID → index in deploys var waiting []types.ActionExecutionDetail for _, a := range acts { id := awssdk.ToString(a.PipelineExecutionId) - revs, known := execs[id] - if !known { + e, known := execs[id] + if !known || (!e.active && a.Status == types.ActionExecutionStatusInProgress) { continue } stage, action := awssdk.ToString(a.StageName), awssdk.ToString(a.ActionName) @@ -164,7 +166,7 @@ func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[ d := model.Deploy{ ExecutionID: id, Status: status, - Revisions: maps.Clone(revs), + Revisions: maps.Clone(e.revs), FinishedAt: awssdk.ToTime(a.LastUpdateTime), } // A stage retry re-runs the action under the same execution: @@ -220,21 +222,22 @@ func approvalToken(state *codepipeline.GetPipelineStateOutput, a types.ActionExe return "" } -// executions maps each execution ID to the commits it carries, repo → SHA, -// using sources (repo → source action) inverted. A source action no repo -// maps to is ignored, and a repo with no revision is left out. Superseded and -// canceled executions are left out (see model.Deploy), so their action -// executions are dropped like those outside the window. -func executions(summaries []types.PipelineExecutionSummary, sources map[string]string) map[string]map[string]string { +// execution is what History needs from one pipeline execution. +type execution struct { + revs map[string]string // repo → SHA + active bool // still running: its InProgress actions may finish +} + +// executions maps each execution ID to the commits it carries, using sources +// (repo → source action) inverted. A source action no repo maps to is +// ignored, and a repo with no revision is left out. +func executions(summaries []types.PipelineExecutionSummary, sources map[string]string) map[string]execution { repoOf := make(map[string]string, len(sources)) for repo, action := range sources { repoOf[action] = strings.ToLower(repo) } - out := make(map[string]map[string]string, len(summaries)) + out := make(map[string]execution, len(summaries)) for _, e := range summaries { - if e.Status == types.PipelineExecutionStatusSuperseded || e.Status == types.PipelineExecutionStatusCancelled { - continue - } revs := map[string]string{} for _, r := range e.SourceRevisions { repo, ok := repoOf[awssdk.ToString(r.ActionName)] @@ -242,7 +245,8 @@ func executions(summaries []types.PipelineExecutionSummary, sources map[string]s revs[repo] = sha } } - out[awssdk.ToString(e.PipelineExecutionId)] = revs + active := e.Status == types.PipelineExecutionStatusInProgress || e.Status == types.PipelineExecutionStatusStopping + out[awssdk.ToString(e.PipelineExecutionId)] = execution{revs: revs, active: active} } return out } diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go index 093d623..1b56905 100644 --- a/internal/aws/history_test.go +++ b/internal/aws/history_test.go @@ -140,9 +140,10 @@ func TestHistoryRevisions(t *testing.T) { } } -func TestHistoryDropsRunsThatNeverDeployed(t *testing.T) { +func TestHistoryKeepsFinishedActionsOfEndedRuns(t *testing.T) { f := &fakePipeline{ execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec6", types.PipelineExecutionStatusCancelled, allRevs("fff")...), execSummary("exec5", types.PipelineExecutionStatusStopped, allRevs("eee")...), execSummary("exec4", types.PipelineExecutionStatusSuperseded, allRevs("ddd")...), execSummary("exec3", types.PipelineExecutionStatusCancelled, allRevs("ccc")...), @@ -150,20 +151,30 @@ func TestHistoryDropsRunsThatNeverDeployed(t *testing.T) { execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), }}, actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + // The run ended while its deploy showed InProgress: it never finished. + actionExec("exec6", "Test", "Deploy", types.ActionExecutionStatusInProgress, at(60)), actionExec("exec5", "Test", "Deploy", types.ActionExecutionStatusAbandoned, at(50)), + // Superseded while waiting at the approval: its Test deploy + // really ran, and the approval it waited at never finished. + actionExec("exec4", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusInProgress, at(42)), actionExec("exec4", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(40)), + // A canceled run's finished failure is still a real failure. actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusFailed, at(30)), // Stopped after the deploy finished: the deploy still counts. actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), }}}, } - got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec, prodSpec) if err != nil { t.Fatal(err) } ok := model.DeploySucceeded - assertStatuses(t, got["Test"], []string{"exec2", "exec1"}, []model.DeployStatus{ok, ok}) + assertStatuses(t, got["Test"], []string{"exec4", "exec3", "exec2", "exec1"}, []model.DeployStatus{ok, model.DeployFailed, ok, ok}) + assertStatuses(t, got["Production"], []string{}, nil) + if f.stateCalls != 0 { + t.Errorf("GetPipelineState calls = %d, want 0", f.stateCalls) + } } // approvalFake is the plan's scenario: exec3 passed Test/Deploy and waits at diff --git a/internal/model/model.go b/internal/model/model.go index c2a75c5..6c8ea56 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -142,8 +142,9 @@ const ( // Deploy is one pipeline execution as seen from one Env's stage, as the // adapter builds it: Status is the stage's deploy action status, except that // a run paused at the stage's approval action is DeployAwaitingApproval with -// that action's ApprovalToken. Adapters drop Abandoned and Superseded -// executions; they never deployed anything. +// that action's ApprovalToken. Adapters drop actions that never finished +// (abandoned, or left in progress by a run that ended), but keep what a +// superseded, stopped or canceled run did finish: it really ran. type Deploy struct { ExecutionID string Status DeployStatus From 5939e01838395ff0145a23b2457633ca6aaa6cc2 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:01:54 -0400 Subject: [PATCH 14/31] feat: a rejected approval shows red on the env it gates Add model.DeployRejected. CodePipeline has no Rejected action status: a rejected (or timed out) manual approval is a Failed approval action, so aws.History turns a run whose latest approval attempt failed, and that never reached the gated stage's deploy action, into a Rejected deploy there. link treats it like a failed run (a newer run containing the PR still supersedes it), and rules names it " approval rejected" on the red pipeline-failed flag. Assisted-by: Claude Code:claude-opus-5-5 --- internal/aws/history.go | 36 +++++++++++++++++++----- internal/aws/history_test.go | 54 ++++++++++++++++++++++++++++++++++++ internal/e2e/e2e_test.go | 10 +++++++ internal/link/slots.go | 5 ++-- internal/link/slots_test.go | 32 +++++++++++++++++++++ internal/model/model.go | 6 +++- internal/rules/flags.go | 2 ++ internal/rules/flags_test.go | 23 +++++++++++++++ 8 files changed, 158 insertions(+), 10 deletions(-) diff --git a/internal/aws/history.go b/internal/aws/history.go index 6aac32a..88bbf6d 100644 --- a/internal/aws/history.go +++ b/internal/aws/history.go @@ -143,13 +143,16 @@ func oldestStart(summaries []types.PipelineExecutionSummary) time.Time { // stageDeploys returns spec's deploys from its deploy action's executions // (one per execution, the latest attempt), and the approval action // executions still waiting for a run that has not reached the deploy action. +// A run whose latest approval attempt failed (was rejected) and that has not +// reached the deploy action is a DeployRejected deploy. // Runs outside the window are dropped. An ended run (superseded, canceled, // stopped) keeps the actions that finished and drops those still showing // InProgress: they never will finish. func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[string]execution) ([]model.Deploy, []types.ActionExecutionDetail) { deploys := []model.Deploy{} - deployed := map[string]int{} // execution ID → index in deploys - var waiting []types.ActionExecutionDetail + deployed := map[string]int{} // execution ID → index in deploys + approvals := map[string]types.ActionExecutionDetail{} // execution ID → latest approval attempt + var approvalIDs []string // in listing order, for stable output for _, a := range acts { id := awssdk.ToString(a.PipelineExecutionId) e, known := execs[id] @@ -179,15 +182,34 @@ func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[ } deployed[id] = len(deploys) deploys = append(deploys, d) - case spec.ApprovalAction != "" && stage == spec.ApprovalStage && action == spec.ApprovalAction && - a.Status == types.ActionExecutionStatusInProgress: - waiting = append(waiting, a) + case spec.ApprovalAction != "" && stage == spec.ApprovalStage && action == spec.ApprovalAction: + prev, seen := approvals[id] + if !seen { + approvalIDs = append(approvalIDs, id) + } + if !seen || awssdk.ToTime(a.LastUpdateTime).After(awssdk.ToTime(prev.LastUpdateTime)) { + approvals[id] = a + } } } var pending []types.ActionExecutionDetail - for _, a := range waiting { - if _, ok := deployed[awssdk.ToString(a.PipelineExecutionId)]; !ok { + for _, id := range approvalIDs { + a := approvals[id] + if _, ok := deployed[id]; ok { + continue + } + switch a.Status { + case types.ActionExecutionStatusInProgress: pending = append(pending, a) + case types.ActionExecutionStatusFailed: + // CodePipeline has no Rejected status: a rejected (or timed + // out) manual approval is a Failed action execution. + deploys = append(deploys, model.Deploy{ + ExecutionID: id, + Status: model.DeployRejected, + Revisions: maps.Clone(execs[id].revs), + FinishedAt: awssdk.ToTime(a.LastUpdateTime), + }) } } return deploys, pending diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go index 1b56905..2645773 100644 --- a/internal/aws/history_test.go +++ b/internal/aws/history_test.go @@ -388,3 +388,57 @@ func TestHistoryRetriedStageIsOneDeploy(t *testing.T) { t.Errorf("FinishedAt = %v, want the retry's", prod[0].FinishedAt) } } + +// CodePipeline has no Rejected action status: a rejected manual approval +// (or one that timed out) is an approval action execution that is Failed. +func TestHistoryRejectedApproval(t *testing.T) { + f := &fakePipeline{ + execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ + execSummary("exec3", types.PipelineExecutionStatusFailed, allRevs("ccc")...), + execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...), + }}, + actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ + actionExec("exec3", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusFailed, at(36)), + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(34)), + actionExec("exec2", "Production", "deploy", types.ActionExecutionStatusSucceeded, at(25)), + actionExec("exec2", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusSucceeded, at(23)), + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + }}}, + } + got, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec, prodSpec) + if err != nil { + t.Fatal(err) + } + if len(warns) > 0 { + t.Errorf("warnings: %v", warns) + } + prod := got["Production"] + assertStatuses(t, prod, []string{"exec3", "exec2"}, []model.DeployStatus{model.DeployRejected, model.DeploySucceeded}) + if !prod[0].FinishedAt.Equal(*at(36)) || prod[0].Revisions["acme/app"] != "ccc" || prod[0].ApprovalToken != "" { + t.Errorf("rejected deploy = %+v", prod[0]) + } + ok := model.DeploySucceeded + assertStatuses(t, got["Test"], []string{"exec3", "exec2"}, []model.DeployStatus{ok, ok}) + if f.stateCalls != 0 { + t.Errorf("GetPipelineState calls = %d, want 0", f.stateCalls) + } +} + +func TestHistoryRetriedApprovalWaitsAgain(t *testing.T) { + f := approvalFake() + page := f.actionPages[0] + // exec3's approval failed once, then the stage was retried. + failed := actionExec("exec3", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusFailed, at(33)) + failed.ActionExecutionId = awssdk.String("exec3-approval-1") + page.ActionExecutionDetails = append(page.ActionExecutionDetails, failed) + got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, prodSpec) + if err != nil { + t.Fatal(err) + } + prod := got["Production"] + assertStatuses(t, prod, []string{"exec3", "exec2", "exec1"}, + []model.DeployStatus{model.DeployAwaitingApproval, model.DeploySucceeded, model.DeployFailed}) + if prod[0].ApprovalToken != "tok-3" { + t.Errorf("token = %q", prod[0].ApprovalToken) + } +} diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index b9e24c7..abbfeb1 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -255,3 +255,13 @@ func TestRepoCaseMismatchStillApplies(t *testing.T) { } } } + +func TestRejectedApprovalInOneAccountIsRed(t *testing.T) { + hs := everywhere() + hs[3].Deploys = []model.Deploy{deploy(model.DeployRejected, "a", now.Add(-30*time.Minute)), ok("z", now.Add(-45*time.Hour))} + c, _ := run(t, done, []model.PR{squash}, hs) + want(t, c, model.StageAwaitingProd, model.Red, model.FlagPipelineFailed) + if f := c.Flags[0]; f.Reason != "prod-acct Production approval rejected" || f.Slot == nil || f.Slot.Env.ID() != prodProd.ID() { + t.Errorf("flag %+v, want it on prod-acct Production saying the approval was rejected", f) + } +} diff --git a/internal/link/slots.go b/internal/link/slots.go index 48754ce..58e4e1f 100644 --- a/internal/link/slots.go +++ b/internal/link/slots.go @@ -136,7 +136,7 @@ func rank(s model.SlotState) int { // never older than the PR's own oldest exact-SHA success (see liveSince). An // older exact success does not count when N does not contain the PR (a // rollback). Otherwise the newest non-success run that contains the PR decides -// (failed, in progress, awaiting approval): exact SHA, or by compare for runs +// (failed or rejected, in progress, awaiting approval): exact SHA, or by compare for runs // newer than N. When N does not contain the PR but an older success did (its // exact SHA, or else, walking older successes newest to oldest, the first one // whose compare says Included: a batched deploy), the slot is RolledBack @@ -210,7 +210,8 @@ func slotFor(p model.PR, h model.EnvHistory, cmp model.CompareFunc) model.EnvSlo if pending != nil { s.Deploy, s.At, s.SHA = pending, pending.FinishedAt, revision(pending.Revisions, p.Repo) switch pending.Status { - case model.DeployFailed: + case model.DeployFailed, model.DeployRejected: + // A rejected approval stops the run like a failure; rules names it. s.State = model.SlotFailed case model.DeployAwaitingApproval: s.State = model.SlotAwaitingApproval diff --git a/internal/link/slots_test.go b/internal/link/slots_test.go index 17befa0..af7adbf 100644 --- a/internal/link/slots_test.go +++ b/internal/link/slots_test.go @@ -541,3 +541,35 @@ func TestSlotBatchedRollbackWalkStopsAtUnknown(t *testing.T) { t.Errorf("slot = %+v, want unknown", s) } } + +func TestSlotRejectedApprovalIsFailed(t *testing.T) { + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{ + dep(model.DeployRejected, "aaaa", t0), + dep(model.DeploySucceeded, "zzzz", t0.Add(-time.Hour)), + }} + cmp := func(_, _, _ string) model.Inclusion { return model.NotIncluded } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotFailed || s.Deploy == nil || s.Deploy.Status != model.DeployRejected || !s.At.Equal(t0) { + t.Errorf("slot = %+v, want failed on the rejected run", s) + } +} + +func TestSlotNewerRunSupersedesRejection(t *testing.T) { + // aaaa's run was rejected at the approval; a newer run of bbbb, which + // contains aaaa, now waits there. + h := model.EnvHistory{Env: prod, Deploys: []model.Deploy{ + dep(model.DeployAwaitingApproval, "bbbb", t0), + dep(model.DeployRejected, "aaaa", t0.Add(-2*time.Hour)), + dep(model.DeploySucceeded, "zzzz", t0.Add(-3*time.Hour)), + }} + cmp := func(_, base, head string) model.Inclusion { + if base == "aaaa" && head == "bbbb" { + return model.Included + } + return model.NotIncluded + } + s := link.Slot([]model.PR{merged("aaaa")}, h, cmp) + if s.State != model.SlotAwaitingApproval || s.Deploy == nil || s.Deploy.Revisions["acme/app"] != "bbbb" { + t.Errorf("slot = %+v, want awaiting approval on the bbbb run", s) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 6c8ea56..d1c7c6a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -137,12 +137,16 @@ const ( DeployFailed DeployStatus = "Failed" DeployInProgress DeployStatus = "InProgress" DeployAwaitingApproval DeployStatus = "AwaitingApproval" + // DeployRejected: the approval gating this Env was rejected (or timed + // out), so the run never reached the Env's deploy action. + DeployRejected DeployStatus = "Rejected" ) // Deploy is one pipeline execution as seen from one Env's stage, as the // adapter builds it: Status is the stage's deploy action status, except that // a run paused at the stage's approval action is DeployAwaitingApproval with -// that action's ApprovalToken. Adapters drop actions that never finished +// that action's ApprovalToken, and a run whose approval was rejected is +// DeployRejected. Adapters drop actions that never finished // (abandoned, or left in progress by a run that ended), but keep what a // superseded, stopped or canceled run did finish: it really ran. type Deploy struct { diff --git a/internal/rules/flags.go b/internal/rules/flags.go index 9a2bf44..e9f20aa 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -42,6 +42,8 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { continue // this Env deploys none of the chain's merged repos } switch { + case s.State == model.SlotFailed && s.Deploy != nil && s.Deploy.Status == model.DeployRejected: + add(model.Red, model.FlagPipelineFailed, fmt.Sprintf("%s %s approval rejected", s.Env.Account, s.Env.Stage), nil, s) case s.State == model.SlotFailed: add(model.Red, model.FlagPipelineFailed, fmt.Sprintf("%s %s failed", s.Env.Account, s.Env.Stage), nil, s) case s.State == model.SlotRolledBack: diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index 0fffb84..ca3d838 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -253,3 +253,26 @@ func TestCheckExpectedSortsAfterPartialProdBeforeReadyToMerge(t *testing.T) { t.Errorf("FlagCheckExpected = %d, want between FlagPartialProd and FlagReadyToMerge", model.FlagCheckExpected) } } + +func TestRejectedApprovalFlagIsRedPipelineFailed(t *testing.T) { + rejected := &model.Deploy{ExecutionID: "exec3", Status: model.DeployRejected} + failed := &model.Deploy{ExecutionID: "exec2", Status: model.DeployFailed} + cases := []struct { + deploy *model.Deploy + reason string + }{ + {rejected, "a Production approval rejected"}, + {failed, "a Production failed"}, + {nil, "a Production failed"}, + } + for _, tc := range cases { + c := model.Chain{Slots: []model.EnvSlot{{Env: prodEnv, Applies: true, State: model.SlotFailed, Deploy: tc.deploy}}} + fs := rules.Flags(c, now, rules.Thresholds{}) + if len(fs) != 1 { + t.Fatalf("flags %v, want one pipeline-failed flag", kinds(fs)) + } + if f := fs[0]; f.Level != model.Red || f.Kind != model.FlagPipelineFailed || f.Slot == nil || f.Reason != tc.reason { + t.Errorf("flag %+v, want red pipeline failed with reason %q", f, tc.reason) + } + } +} From a9aa96084435c0c9f72642ec87d70d7d135ff693 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:10:27 -0400 Subject: [PATCH 15/31] test(aws): neutral action names in fixtures Assisted-by: Claude Code:claude-opus-5-5 --- internal/aws/approve_test.go | 8 ++++---- internal/aws/discover_test.go | 8 ++++---- internal/aws/history_test.go | 32 ++++++++++++++++---------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/aws/approve_test.go b/internal/aws/approve_test.go index 1fa7165..d4f6cce 100644 --- a/internal/aws/approve_test.go +++ b/internal/aws/approve_test.go @@ -23,7 +23,7 @@ func TestApprove(t *testing.T) { } for _, tc := range cases { f := &fakePipeline{} - err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ManualApprovalOfTestEnvironment", "tok-3", tc.ok, tc.summary) + err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ApproveTest", "tok-3", tc.ok, tc.summary) if err != nil { t.Fatal(err) } @@ -32,7 +32,7 @@ func TestApprove(t *testing.T) { t.Fatal("PutApprovalResult not called") } if awssdk.ToString(in.PipelineName) != "app-pipeline" || awssdk.ToString(in.StageName) != "Test" || - awssdk.ToString(in.ActionName) != "ManualApprovalOfTestEnvironment" || awssdk.ToString(in.Token) != "tok-3" { + awssdk.ToString(in.ActionName) != "ApproveTest" || awssdk.ToString(in.Token) != "tok-3" { t.Errorf("input = %+v", in) } if in.Result == nil || in.Result.Status != tc.wantStatus || awssdk.ToString(in.Result.Summary) != tc.wantSummary { @@ -43,7 +43,7 @@ func TestApprove(t *testing.T) { func TestApproveNeedsToken(t *testing.T) { f := &fakePipeline{} - if err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ManualApprovalOfTestEnvironment", "", true, ""); err == nil { + if err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ApproveTest", "", true, ""); err == nil { t.Error("want an error for an empty token") } if f.approval != nil { @@ -64,7 +64,7 @@ func TestApproveErrors(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := &fakePipeline{err: tc.err} - err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ManualApprovalOfTestEnvironment", "tok-3", true, "") + err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ApproveTest", "tok-3", true, "") if !errors.Is(err, tc.want) { t.Errorf("err = %v, want %v", err, tc.want) } diff --git a/internal/aws/discover_test.go b/internal/aws/discover_test.go index 82882bc..9a9e060 100644 --- a/internal/aws/discover_test.go +++ b/internal/aws/discover_test.go @@ -29,9 +29,9 @@ func testPipeline() *codepipeline.GetPipelineOutput { Name: awssdk.String("app-pipeline"), Stages: []types.StageDeclaration{ {Name: awssdk.String("Source"), Actions: []types.ActionDeclaration{ - sourceAction("AppCode", "Acme/App"), - sourceAction("InfraCode", "acme/infra"), - sourceAction("ReportsCode", "acme/reports"), + sourceAction("AppSource", "Acme/App"), + sourceAction("InfraSource", "acme/infra"), + sourceAction("ReportsSource", "acme/reports"), }}, {Name: awssdk.String("Test"), Actions: []types.ActionDeclaration{{ Name: awssdk.String("Deploy"), @@ -48,7 +48,7 @@ func TestSources(t *testing.T) { if err != nil { t.Fatal(err) } - want := map[string]string{"acme/app": "AppCode", "acme/infra": "InfraCode", "acme/reports": "ReportsCode"} + want := map[string]string{"acme/app": "AppSource", "acme/infra": "InfraSource", "acme/reports": "ReportsSource"} if !maps.Equal(got, want) { t.Errorf("Sources = %v, want %v", got, want) } diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go index 2645773..6f98048 100644 --- a/internal/aws/history_test.go +++ b/internal/aws/history_test.go @@ -17,10 +17,10 @@ var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) func at(min int) *time.Time { t := t0.Add(time.Duration(min) * time.Minute); return &t } -var testSources = map[string]string{"acme/app": "AppCode", "acme/infra": "InfraCode", "acme/reports": "ReportsCode"} +var testSources = map[string]string{"acme/app": "AppSource", "acme/infra": "InfraSource", "acme/reports": "ReportsSource"} var ( - prodSpec = StageSpec{Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ManualApprovalOfTestEnvironment"} + prodSpec = StageSpec{Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ApproveTest"} testSpec = StageSpec{Stage: "Test", DeployAction: "Deploy"} ) @@ -45,7 +45,7 @@ func actionExec(exec, stage, action string, status types.ActionExecutionStatus, } func allRevs(sha string) []string { - return []string{"AppCode", sha, "InfraCode", sha + "i", "ReportsCode", sha + "r"} + return []string{"AppSource", sha, "InfraSource", sha + "i", "ReportsSource", sha + "r"} } func TestHistoryStatuses(t *testing.T) { @@ -112,9 +112,9 @@ func TestHistoryNewestFirst(t *testing.T) { func TestHistoryRevisions(t *testing.T) { f := &fakePipeline{ execs: &codepipeline.ListPipelineExecutionsOutput{PipelineExecutionSummaries: []types.PipelineExecutionSummary{ - // exec2 has no ReportsCode revision, plus a source action no + // exec2 has no ReportsSource revision, plus a source action no // configured repo maps to. - execSummary("exec2", types.PipelineExecutionStatusSucceeded, "AppCode", "bbb", "InfraCode", "bbbi", "OtherCode", "zzz"), + execSummary("exec2", types.PipelineExecutionStatusSucceeded, "AppSource", "bbb", "InfraSource", "bbbi", "OtherCode", "zzz"), execSummary("exec1", types.PipelineExecutionStatusSucceeded, allRevs("aaa")...), }}, actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ @@ -156,7 +156,7 @@ func TestHistoryKeepsFinishedActionsOfEndedRuns(t *testing.T) { actionExec("exec5", "Test", "Deploy", types.ActionExecutionStatusAbandoned, at(50)), // Superseded while waiting at the approval: its Test deploy // really ran, and the approval it waited at never finished. - actionExec("exec4", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusInProgress, at(42)), + actionExec("exec4", "Test", "ApproveTest", types.ActionExecutionStatusInProgress, at(42)), actionExec("exec4", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(40)), // A canceled run's finished failure is still a real failure. actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusFailed, at(30)), @@ -187,14 +187,14 @@ func approvalFake() *fakePipeline { execSummary("exec1", types.PipelineExecutionStatusFailed, allRevs("aaa")...), }}, actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ - actionExec("exec3", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusInProgress, at(36)), - actionExec("exec3", "Test", "SmokeTests", types.ActionExecutionStatusSucceeded, at(35)), + actionExec("exec3", "Test", "ApproveTest", types.ActionExecutionStatusInProgress, at(36)), + actionExec("exec3", "Test", "Smoke", types.ActionExecutionStatusSucceeded, at(35)), actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(34)), actionExec("exec2", "Production", "deploy", types.ActionExecutionStatusSucceeded, at(25)), - actionExec("exec2", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusSucceeded, at(23)), + actionExec("exec2", "Test", "ApproveTest", types.ActionExecutionStatusSucceeded, at(23)), actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), actionExec("exec1", "Production", "deploy", types.ActionExecutionStatusFailed, at(15)), - actionExec("exec1", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusSucceeded, at(12)), + actionExec("exec1", "Test", "ApproveTest", types.ActionExecutionStatusSucceeded, at(12)), actionExec("exec1", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(10)), }}}, state: &codepipeline.GetPipelineStateOutput{StageStates: []types.StageState{ @@ -203,8 +203,8 @@ func approvalFake() *fakePipeline { LatestExecution: &types.StageExecution{PipelineExecutionId: awssdk.String("exec3"), Status: types.StageExecutionStatusInProgress}, ActionStates: []types.ActionState{ {ActionName: awssdk.String("Deploy"), LatestExecution: &types.ActionExecution{Status: types.ActionExecutionStatusSucceeded}}, - {ActionName: awssdk.String("ManualApprovalOfTestEnvironment"), LatestExecution: &types.ActionExecution{ - ActionExecutionId: awssdk.String("exec3-ManualApprovalOfTestEnvironment"), + {ActionName: awssdk.String("ApproveTest"), LatestExecution: &types.ActionExecution{ + ActionExecutionId: awssdk.String("exec3-ApproveTest"), Status: types.ActionExecutionStatusInProgress, Token: awssdk.String("tok-3"), }}, @@ -326,7 +326,7 @@ func TestHistoryActionPagingIsBounded(t *testing.T) { }}, // Always another page. actionPages: []*codepipeline.ListActionExecutionsOutput{{NextToken: awssdk.String("more"), ActionExecutionDetails: []types.ActionExecutionDetail{ - actionExec("exec1", "Test", "SmokeTests", types.ActionExecutionStatusSucceeded, at(10)), + actionExec("exec1", "Test", "Smoke", types.ActionExecutionStatusSucceeded, at(10)), }}}, } if _, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec); err != nil { @@ -398,10 +398,10 @@ func TestHistoryRejectedApproval(t *testing.T) { execSummary("exec2", types.PipelineExecutionStatusSucceeded, allRevs("bbb")...), }}, actionPages: []*codepipeline.ListActionExecutionsOutput{{ActionExecutionDetails: []types.ActionExecutionDetail{ - actionExec("exec3", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusFailed, at(36)), + actionExec("exec3", "Test", "ApproveTest", types.ActionExecutionStatusFailed, at(36)), actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(34)), actionExec("exec2", "Production", "deploy", types.ActionExecutionStatusSucceeded, at(25)), - actionExec("exec2", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusSucceeded, at(23)), + actionExec("exec2", "Test", "ApproveTest", types.ActionExecutionStatusSucceeded, at(23)), actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), }}}, } @@ -428,7 +428,7 @@ func TestHistoryRetriedApprovalWaitsAgain(t *testing.T) { f := approvalFake() page := f.actionPages[0] // exec3's approval failed once, then the stage was retried. - failed := actionExec("exec3", "Test", "ManualApprovalOfTestEnvironment", types.ActionExecutionStatusFailed, at(33)) + failed := actionExec("exec3", "Test", "ApproveTest", types.ActionExecutionStatusFailed, at(33)) failed.ActionExecutionId = awssdk.String("exec3-approval-1") page.ActionExecutionDetails = append(page.ActionExecutionDetails, failed) got, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, prodSpec) From 10f481446733c26cf434b73eb86b19d203b047b0 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:11:49 -0400 Subject: [PATCH 16/31] feat(aws): state failure is a warning, IsThrottled and IsAccessDenied GetPipelineState only supplies the approval token, so its failure now keeps the history and shows the waiting approval without a token, with a warning. IsThrottled and IsAccessDenied give callers typed checks; approvalError uses IsAccessDenied. A rejected approval's flag reads "approval rejected or expired", since a timed-out approval looks the same to CodePipeline. Assisted-by: Claude Code:claude-opus-5-5 --- internal/aws/approve.go | 3 +-- internal/aws/client.go | 30 +++++++++++++++++++++ internal/aws/client_test.go | 52 ++++++++++++++++++++++++++++++++++++ internal/aws/fake_test.go | 4 +++ internal/aws/history.go | 5 +++- internal/aws/history_test.go | 23 ++++++++++++++++ internal/e2e/e2e_test.go | 2 +- internal/rules/flags.go | 2 +- internal/rules/flags_test.go | 2 +- 9 files changed, 117 insertions(+), 6 deletions(-) diff --git a/internal/aws/approve.go b/internal/aws/approve.go index 07b105e..f6f6b2d 100644 --- a/internal/aws/approve.go +++ b/internal/aws/approve.go @@ -64,8 +64,7 @@ func approvalError(err error) error { if errors.As(err, &invalid) || errors.As(err, &completed) { return fmt.Errorf("%w: %w", ErrApprovalAlreadyDecided, err) } - var apiErr interface{ ErrorCode() string } - if errors.As(err, &apiErr) && strings.HasPrefix(apiErr.ErrorCode(), "AccessDenied") { + if IsAccessDenied(err) { return fmt.Errorf("%w: %w", ErrApprovalNotPermitted, err) } return fmt.Errorf("aws: PutApprovalResult: %w", err) diff --git a/internal/aws/client.go b/internal/aws/client.go index 1300a54..cfa24cd 100644 --- a/internal/aws/client.go +++ b/internal/aws/client.go @@ -9,10 +9,12 @@ import ( "errors" "strings" + "github.com/aws/aws-sdk-go-v2/aws/retry" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" "github.com/aws/aws-sdk-go-v2/service/codepipeline" "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/aws/smithy-go" ) // pipelineAPI is the part of the CodePipeline client the adapter uses, so @@ -78,3 +80,31 @@ func IsSSOExpired(err error) bool { } return false } + +// extraThrottleCodes are throttling codes beyond the SDK's retry defaults. +var extraThrottleCodes = map[string]bool{ + "ThrottlingException": true, + "TooManyRequestsException": true, + "RequestLimitExceeded": true, +} + +// IsThrottled reports whether err is AWS rate limiting, so the caller can +// back off instead of reporting a failure. +func IsThrottled(err error) bool { + var apiErr smithy.APIError + if !errors.As(err, &apiErr) { + return false + } + code := apiErr.ErrorCode() + if _, ok := retry.DefaultThrottleErrorCodes[code]; ok { + return true + } + return extraThrottleCodes[code] +} + +// IsAccessDenied reports whether err means the profile lacks permission for +// the call (AccessDenied, AccessDeniedException). +func IsAccessDenied(err error) bool { + var apiErr smithy.APIError + return errors.As(err, &apiErr) && strings.HasPrefix(apiErr.ErrorCode(), "AccessDenied") +} diff --git a/internal/aws/client_test.go b/internal/aws/client_test.go index 7af9c03..26c41a0 100644 --- a/internal/aws/client_test.go +++ b/internal/aws/client_test.go @@ -54,3 +54,55 @@ func TestSSOExpiredThroughAdapterErrors(t *testing.T) { t.Errorf("Approve err = %v, want SSO expired", err) } } + +func TestIsThrottledAndIsAccessDenied(t *testing.T) { + op := func(code string) error { + return &smithy.OperationError{ServiceID: "CodePipeline", Err: &smithy.GenericAPIError{Code: code, Message: "m"}} + } + cases := []struct { + err error + throttled, denied bool + }{ + {op("ThrottlingException"), true, false}, + {op("TooManyRequestsException"), true, false}, + {op("RequestLimitExceeded"), true, false}, + {op("Throttling"), true, false}, + {op("AccessDeniedException"), false, true}, + {op("AccessDenied"), false, true}, + {op("PipelineNotFoundException"), false, false}, + {errors.New("ThrottlingException"), false, false}, + {nil, false, false}, + } + for _, tc := range cases { + if got := IsThrottled(tc.err); got != tc.throttled { + t.Errorf("IsThrottled(%v) = %v", tc.err, got) + } + if got := IsAccessDenied(tc.err); got != tc.denied { + t.Errorf("IsAccessDenied(%v) = %v", tc.err, got) + } + } +} + +func TestThrottledAndDeniedThroughAdapterErrors(t *testing.T) { + for _, code := range []string{"ThrottlingException", "AccessDeniedException"} { + e := &smithy.OperationError{ServiceID: "CodePipeline", Err: &smithy.GenericAPIError{Code: code}} + check := IsThrottled + if code == "AccessDeniedException" { + check = IsAccessDenied + } + c := &Client{cp: &fakePipeline{err: e}, ecs: &fakeECS{err: e}} + ctx := context.Background() + if _, err := c.Sources(ctx, "app-pipeline"); !check(err) { + t.Errorf("%s: Sources err = %v", code, err) + } + if _, _, err := c.History(ctx, "app-pipeline", testSources, testSpec); !check(err) { + t.Errorf("%s: History err = %v", code, err) + } + if _, _, err := c.Health(ctx, []Service{{Cluster: "c1", Name: "s1"}}); !check(err) { + t.Errorf("%s: Health err = %v", code, err) + } + if err := c.Approve(ctx, "app-pipeline", "Test", "a", "tok", true, ""); !check(err) { + t.Errorf("%s: Approve err = %v", code, err) + } + } +} diff --git a/internal/aws/fake_test.go b/internal/aws/fake_test.go index b47f89b..1f59e0c 100644 --- a/internal/aws/fake_test.go +++ b/internal/aws/fake_test.go @@ -17,6 +17,7 @@ type fakePipeline struct { actionPages []*codepipeline.ListActionExecutionsOutput state *codepipeline.GetPipelineStateOutput err error + stateErr error // GetPipelineState only actionInputs []*codepipeline.ListActionExecutionsInput execInputs []*codepipeline.ListPipelineExecutionsInput @@ -64,6 +65,9 @@ func (f *fakePipeline) GetPipelineState(_ context.Context, _ *codepipeline.GetPi if f.err != nil { return nil, f.err } + if f.stateErr != nil { + return nil, f.stateErr + } if f.state == nil { return &codepipeline.GetPipelineStateOutput{}, nil } diff --git a/internal/aws/history.go b/internal/aws/history.go index 88bbf6d..119339c 100644 --- a/internal/aws/history.go +++ b/internal/aws/history.go @@ -63,9 +63,12 @@ func (c *Client) History(ctx context.Context, pipeline string, sources map[strin deploys, pending := stageDeploys(spec, acts, execs) for _, p := range pending { if state == nil { + // Only the token comes from here: on failure keep the history + // and show the approval without one. state, err = c.cp.GetPipelineState(ctx, &codepipeline.GetPipelineStateInput{Name: awssdk.String(pipeline)}) if err != nil { - return nil, warns, fmt.Errorf("aws: GetPipelineState %s: %w", pipeline, err) + warns = append(warns, fmt.Sprintf("aws: GetPipelineState %s: %v", pipeline, err)) + state = &codepipeline.GetPipelineStateOutput{} } } d := model.Deploy{ diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go index 6f98048..c33f17a 100644 --- a/internal/aws/history_test.go +++ b/internal/aws/history_test.go @@ -2,7 +2,9 @@ package aws import ( "context" + "errors" "maps" + "strings" "testing" "time" @@ -442,3 +444,24 @@ func TestHistoryRetriedApprovalWaitsAgain(t *testing.T) { t.Errorf("token = %q", prod[0].ApprovalToken) } } + +func TestHistoryStateFailureIsAWarning(t *testing.T) { + f := approvalFake() + f.stateErr = errors.New("throttled") + got, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec, prodSpec) + if err != nil { + t.Fatal(err) + } + prod := got["Production"] + assertStatuses(t, prod, []string{"exec3", "exec2", "exec1"}, + []model.DeployStatus{model.DeployAwaitingApproval, model.DeploySucceeded, model.DeployFailed}) + if prod[0].ApprovalToken != "" { + t.Errorf("token = %q, want none", prod[0].ApprovalToken) + } + if len(got["Test"]) != 3 { + t.Errorf("Test history = %+v, want kept", got["Test"]) + } + if len(warns) == 0 || !strings.Contains(strings.Join(warns, "\n"), "throttled") { + t.Errorf("warnings = %v, want the GetPipelineState failure", warns) + } +} diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index abbfeb1..966f0a1 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -261,7 +261,7 @@ func TestRejectedApprovalInOneAccountIsRed(t *testing.T) { hs[3].Deploys = []model.Deploy{deploy(model.DeployRejected, "a", now.Add(-30*time.Minute)), ok("z", now.Add(-45*time.Hour))} c, _ := run(t, done, []model.PR{squash}, hs) want(t, c, model.StageAwaitingProd, model.Red, model.FlagPipelineFailed) - if f := c.Flags[0]; f.Reason != "prod-acct Production approval rejected" || f.Slot == nil || f.Slot.Env.ID() != prodProd.ID() { + if f := c.Flags[0]; f.Reason != "prod-acct Production approval rejected or expired" || f.Slot == nil || f.Slot.Env.ID() != prodProd.ID() { t.Errorf("flag %+v, want it on prod-acct Production saying the approval was rejected", f) } } diff --git a/internal/rules/flags.go b/internal/rules/flags.go index e9f20aa..74576ad 100644 --- a/internal/rules/flags.go +++ b/internal/rules/flags.go @@ -43,7 +43,7 @@ func Flags(c model.Chain, now time.Time, th Thresholds) []model.Flag { } switch { case s.State == model.SlotFailed && s.Deploy != nil && s.Deploy.Status == model.DeployRejected: - add(model.Red, model.FlagPipelineFailed, fmt.Sprintf("%s %s approval rejected", s.Env.Account, s.Env.Stage), nil, s) + add(model.Red, model.FlagPipelineFailed, fmt.Sprintf("%s %s approval rejected or expired", s.Env.Account, s.Env.Stage), nil, s) case s.State == model.SlotFailed: add(model.Red, model.FlagPipelineFailed, fmt.Sprintf("%s %s failed", s.Env.Account, s.Env.Stage), nil, s) case s.State == model.SlotRolledBack: diff --git a/internal/rules/flags_test.go b/internal/rules/flags_test.go index ca3d838..75cc58d 100644 --- a/internal/rules/flags_test.go +++ b/internal/rules/flags_test.go @@ -261,7 +261,7 @@ func TestRejectedApprovalFlagIsRedPipelineFailed(t *testing.T) { deploy *model.Deploy reason string }{ - {rejected, "a Production approval rejected"}, + {rejected, "a Production approval rejected or expired"}, {failed, "a Production failed"}, {nil, "a Production failed"}, } From 08ec751502f2b0bd3db02d8e3bc57867477b24ed Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:13:46 -0400 Subject: [PATCH 17/31] fix(aws): review fixes for truncation, ARNs, summaries, misnamed specs Warn when the action page cap (now 10) cuts the window short, and when a spec's deploy or approval action matches nothing. Health accepts service ARNs, approval summaries are cut to AWS's 512 characters, and the docs cover the approval-to-deploy gap, duplicate source repos, and the checked facts that make the early stop and superseded runs safe. Assisted-by: Claude Code:claude-opus-5-5 --- internal/aws/approve.go | 9 +++++- internal/aws/approve_test.go | 14 +++++++++ internal/aws/discover.go | 4 ++- internal/aws/health.go | 15 +++++++-- internal/aws/health_test.go | 20 ++++++++++++ internal/aws/history.go | 59 ++++++++++++++++++++++++++++-------- internal/aws/history_test.go | 32 +++++++++++++++++-- 7 files changed, 134 insertions(+), 19 deletions(-) diff --git a/internal/aws/approve.go b/internal/aws/approve.go index f6f6b2d..52cba77 100644 --- a/internal/aws/approve.go +++ b/internal/aws/approve.go @@ -23,8 +23,12 @@ var ErrApprovalAlreadyDecided = errors.New("aws: approval already decided") // decide it (no codepipeline:PutApprovalResult). var ErrApprovalNotPermitted = errors.New("aws: profile not permitted to decide approvals") +// maxSummary is PutApprovalResult's limit on the summary, in characters. +const maxSummary = 512 + // Approve approves (ok) or rejects the waiting approval action identified by -// token. An empty summary gets a default naming app-green. +// token. An empty summary gets a default naming app-green; a long one is cut +// to maxSummary. func (c *Client) Approve(ctx context.Context, pipeline, stage, action, token string, ok bool, summary string) error { if token == "" { return fmt.Errorf("aws: approve %s/%s/%s: no approval token", pipeline, stage, action) @@ -39,6 +43,9 @@ func (c *Client) Approve(ctx context.Context, pipeline, stage, action, token str summary = "Rejected via app-green" } } + if r := []rune(summary); len(r) > maxSummary { + summary = string(r[:maxSummary]) + } _, err := c.cp.PutApprovalResult(ctx, &codepipeline.PutApprovalResultInput{ PipelineName: awssdk.String(pipeline), StageName: awssdk.String(stage), diff --git a/internal/aws/approve_test.go b/internal/aws/approve_test.go index d4f6cce..9c0dd46 100644 --- a/internal/aws/approve_test.go +++ b/internal/aws/approve_test.go @@ -3,7 +3,9 @@ package aws import ( "context" "errors" + "strings" "testing" + "unicode/utf8" awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" @@ -76,3 +78,15 @@ func TestApproveErrors(t *testing.T) { t.Errorf("err = %v, want boom only", err) } } + +func TestApproveTruncatesSummary(t *testing.T) { + f := &fakePipeline{} + long := strings.Repeat("é", 600) + if err := (&Client{cp: f}).Approve(context.Background(), "app-pipeline", "Test", "ApproveTest", "tok-3", true, long); err != nil { + t.Fatal(err) + } + got := awssdk.ToString(f.approval.Result.Summary) + if n := utf8.RuneCountInString(got); n != 512 || !utf8.ValidString(got) { + t.Errorf("summary is %d runes (valid %v), want 512", n, utf8.ValidString(got)) + } +} diff --git a/internal/aws/discover.go b/internal/aws/discover.go index d84a557..345411f 100644 --- a/internal/aws/discover.go +++ b/internal/aws/discover.go @@ -14,7 +14,9 @@ import ( // Sources returns the repos a pipeline builds from: lowercased "owner/name" // → the source action's name. It reads every Source-category action with a -// FullRepositoryId (CodeStar connections to GitHub). +// FullRepositoryId (CodeStar connections to GitHub). When two source actions +// build the same repo (say, two branches), the last one declared wins, so +// revisions for that repo come from that action only. func (c *Client) Sources(ctx context.Context, pipeline string) (map[string]string, error) { out, err := c.cp.GetPipeline(ctx, &codepipeline.GetPipelineInput{Name: awssdk.String(pipeline)}) if err != nil { diff --git a/internal/aws/health.go b/internal/aws/health.go index 2f46b0a..bee1f51 100644 --- a/internal/aws/health.go +++ b/internal/aws/health.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "strings" awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ecs" @@ -34,8 +35,9 @@ func (c *Client) Health(ctx context.Context, services []Service) (model.Health, if _, ok := names[s.Cluster]; !ok { clusters = append(clusters, s.Cluster) } - if !slices.Contains(names[s.Cluster], s.Name) { - names[s.Cluster] = append(names[s.Cluster], s.Name) + name := serviceName(s.Name) + if !slices.Contains(names[s.Cluster], name) { + names[s.Cluster] = append(names[s.Cluster], name) } } @@ -71,3 +73,12 @@ func (c *Client) Health(ctx context.Context, services []Service) (model.Health, } return h, nil, nil } + +// serviceName reduces a service ARN (".../service/c1/s1") to its name, the +// form DescribeServices returns in ServiceName; a plain name is unchanged. +func serviceName(s string) string { + if i := strings.LastIndex(s, "/"); i >= 0 { + return s[i+1:] + } + return s +} diff --git a/internal/aws/health_test.go b/internal/aws/health_test.go index 0a1694d..b4caf3e 100644 --- a/internal/aws/health_test.go +++ b/internal/aws/health_test.go @@ -88,3 +88,23 @@ func TestHealthError(t *testing.T) { t.Errorf("Health = %+v, %v; want unknown, boom", got, err) } } + +func TestHealthServiceARNsMatchByName(t *testing.T) { + f := &fakeECS{byCluster: map[string]*ecs.DescribeServicesOutput{ + "c1": {Services: []ecstypes.Service{svc("s1", 2, 2), svc("s2", 1, 1)}}, + }} + got, warns, err := (&Client{ecs: f}).Health(context.Background(), []Service{ + {Cluster: "c1", Name: "arn:aws:ecs:us-east-1:111111111111:service/c1/s1"}, + {Cluster: "c1", Name: "s2"}, + {Cluster: "c1", Name: "s1"}, // the same service again + }) + if err != nil { + t.Fatal(err) + } + if want := (model.Health{Known: true, Desired: 3, Healthy: 3}); got != want || len(warns) > 0 { + t.Errorf("Health = %+v %v, want %+v", got, warns, want) + } + if !slices.Equal(f.inputs[0].Services, []string{"s1", "s2"}) { + t.Errorf("services = %v", f.inputs[0].Services) + } +} diff --git a/internal/aws/history.go b/internal/aws/history.go index 119339c..f581e1f 100644 --- a/internal/aws/history.go +++ b/internal/aws/history.go @@ -34,14 +34,19 @@ const maxExecutions = 50 // maxActionPages bounds ListActionExecutions paging. With no execution // filter it lists every action of every run (about nine per run in a // Source → Test → Production pipeline), so 100 per page covers about eleven -// runs; five pages cover the execution window. -const maxActionPages = 5 +// runs; ten pages cover the execution window with room to spare. +const maxActionPages = 10 // History returns each spec's deploys, keyed by StageSpec.Stage, newest // first. sources is the repo → source action map from Sources. An execution // that has not run the spec's deploy action but waits at its approval action // is a DeployAwaitingApproval deploy carrying the approval token. A warning // means the history is usable but incomplete. +// +// Between the approval succeeding and the gated deploy action starting, the +// run has neither a waiting approval nor a deploy in that stage, so for that +// moment (usually seconds) it is absent and the Env shows NotYet for it; the +// next poll sees the deploy InProgress. func (c *Client) History(ctx context.Context, pipeline string, sources map[string]string, specs ...StageSpec) (map[string][]model.Deploy, []string, error) { execList, err := c.cp.ListPipelineExecutions(ctx, &codepipeline.ListPipelineExecutionsInput{ PipelineName: awssdk.String(pipeline), @@ -50,17 +55,21 @@ func (c *Client) History(ctx context.Context, pipeline string, sources map[strin if err != nil { return nil, nil, fmt.Errorf("aws: ListPipelineExecutions %s: %w", pipeline, err) } - acts, err := c.actionExecutions(ctx, pipeline, oldestStart(execList.PipelineExecutionSummaries)) + acts, truncated, err := c.actionExecutions(ctx, pipeline, oldestStart(execList.PipelineExecutionSummaries)) if err != nil { return nil, nil, err } execs := executions(execList.PipelineExecutionSummaries, sources) var warns []string + if truncated { + warns = append(warns, fmt.Sprintf("aws: %s: action history truncated at %d pages; older runs omitted", pipeline, maxActionPages)) + } var state *codepipeline.GetPipelineStateOutput // fetched once, on the first pending approval out := map[string][]model.Deploy{} for _, spec := range specs { deploys, pending := stageDeploys(spec, acts, execs) + warns = append(warns, unmatched(pipeline, spec, acts)...) for _, p := range pending { if state == nil { // Only the token comes from here: on failure keep the history @@ -89,11 +98,35 @@ func (c *Client) History(ctx context.Context, pipeline string, sources map[strin return out, warns, nil } -// actionExecutions lists the pipeline's action executions, newest first, -// paging until AWS has no more, a page reaches actions that started before -// oldest (the oldest execution in the window; zero means unknown), or -// maxActionPages. -func (c *Client) actionExecutions(ctx context.Context, pipeline string, oldest time.Time) ([]types.ActionExecutionDetail, error) { +// unmatched warns about a spec action that no action in the window has: a +// misnamed action in config, since across a window of recent runs each +// action has normally run at least once. Names are case-sensitive. An empty window warns about nothing. +func unmatched(pipeline string, spec StageSpec, acts []types.ActionExecutionDetail) []string { + if len(acts) == 0 { + return nil + } + pairs := [][2]string{{spec.Stage, spec.DeployAction}} + if spec.ApprovalAction != "" { + pairs = append(pairs, [2]string{spec.ApprovalStage, spec.ApprovalAction}) + } + var warns []string + for _, p := range pairs { + if !slices.ContainsFunc(acts, func(a types.ActionExecutionDetail) bool { + return awssdk.ToString(a.StageName) == p[0] && awssdk.ToString(a.ActionName) == p[1] + }) { + warns = append(warns, fmt.Sprintf("aws: %s: spec %s/%s matched no actions", pipeline, p[0], p[1])) + } + } + return warns +} + +// actionExecutions lists the pipeline's action executions, paging until AWS +// has no more, a page reaches actions that started before oldest (the oldest +// execution in the window; zero means unknown), or maxActionPages. AWS lists +// them newest first by start time (checked against a real pipeline: no +// inversions in 100), which is what makes the early stop safe. truncated +// reports that the page cap ended the listing with more runs in the window. +func (c *Client) actionExecutions(ctx context.Context, pipeline string, oldest time.Time) (acts []types.ActionExecutionDetail, truncated bool, err error) { var all []types.ActionExecutionDetail var next *string for range maxActionPages { @@ -103,15 +136,15 @@ func (c *Client) actionExecutions(ctx context.Context, pipeline string, oldest t NextToken: next, }) if err != nil { - return nil, fmt.Errorf("aws: ListActionExecutions %s: %w", pipeline, err) + return nil, false, fmt.Errorf("aws: ListActionExecutions %s: %w", pipeline, err) } all = append(all, out.ActionExecutionDetails...) if out.NextToken == nil || pastWindow(out.ActionExecutionDetails, oldest) { - break + return all, false, nil } next = out.NextToken } - return all, nil + return all, true, nil } // pastWindow reports whether page holds an action that started before @@ -150,7 +183,9 @@ func oldestStart(summaries []types.PipelineExecutionSummary) time.Time { // reached the deploy action is a DeployRejected deploy. // Runs outside the window are dropped. An ended run (superseded, canceled, // stopped) keeps the actions that finished and drops those still showing -// InProgress: they never will finish. +// InProgress: they never will finish. A superseded run never reached the +// approval (checked against a real pipeline: superseded runs list no approval +// action), so supersession cannot produce a false Rejected. func stageDeploys(spec StageSpec, acts []types.ActionExecutionDetail, execs map[string]execution) ([]model.Deploy, []types.ActionExecutionDetail) { deploys := []model.Deploy{} deployed := map[string]int{} // execution ID → index in deploys diff --git a/internal/aws/history_test.go b/internal/aws/history_test.go index c33f17a..a24db82 100644 --- a/internal/aws/history_test.go +++ b/internal/aws/history_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "maps" + "slices" "strings" "testing" "time" @@ -331,11 +332,16 @@ func TestHistoryActionPagingIsBounded(t *testing.T) { actionExec("exec1", "Test", "Smoke", types.ActionExecutionStatusSucceeded, at(10)), }}}, } - if _, _, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec); err != nil { + _, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + if err != nil { t.Fatal(err) } - if len(f.actionInputs) != maxActionPages { - t.Errorf("ListActionExecutions calls = %d, want %d", len(f.actionInputs), maxActionPages) + if len(f.actionInputs) != 10 { + t.Errorf("ListActionExecutions calls = %d, want 10", len(f.actionInputs)) + } + want := "aws: app-pipeline: action history truncated at 10 pages; older runs omitted" + if !slices.Contains(warns, want) { + t.Errorf("warnings = %v, want %q", warns, want) } } @@ -465,3 +471,23 @@ func TestHistoryStateFailureIsAWarning(t *testing.T) { t.Errorf("warnings = %v, want the GetPipelineState failure", warns) } } + +func TestHistoryWarnsOnSpecThatMatchesNothing(t *testing.T) { + f := approvalFake() + typo := StageSpec{Stage: "Production", DeployAction: "Deploy", ApprovalStage: "Test", ApprovalAction: "ApproveTst"} + got, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, typo) + if err != nil { + t.Fatal(err) + } + if len(got["Production"]) != 0 { + t.Errorf("Production = %+v, want empty", got["Production"]) + } + for _, w := range []string{ + "aws: app-pipeline: spec Production/Deploy matched no actions", + "aws: app-pipeline: spec Test/ApproveTst matched no actions", + } { + if !slices.Contains(warns, w) { + t.Errorf("warnings = %v, want %q", warns, w) + } + } +} From 45e71728aa166ea06e9aaf4058637e88586f3a30 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:22:09 -0400 Subject: [PATCH 18/31] feat(config): config.toml with accounts, envs and thresholds Load validates jira (https site, project keys), github (author, owner/name repos), aws accounts and envs, rejects unknown keys, and resolves tokens from token_env or token_command. Envs, StageSpecs, Services and Thresholds give the resolver what the adapters need. An env with no repos loads with a warning: history fallback is a last resort. Assisted-by: Claude Code:claude-opus-5-5 --- go.mod | 1 + go.sum | 2 + internal/config/config.go | 500 ++++++++++++++++++ internal/config/config_test.go | 212 ++++++++ .../testdata/bad-account-no-region.toml | 46 ++ .../testdata/bad-approval-stage-only.toml | 46 ++ .../testdata/bad-duplicate-account.toml | 47 ++ .../config/testdata/bad-duplicate-env.toml | 47 ++ .../testdata/bad-duration-negative.toml | 47 ++ internal/config/testdata/bad-duration.toml | 47 ++ .../config/testdata/bad-ecs-no-cluster.toml | 47 ++ .../config/testdata/bad-ecs-no-services.toml | 47 ++ .../testdata/bad-env-no-deploy-action.toml | 46 ++ internal/config/testdata/bad-env-order.toml | 48 ++ .../config/testdata/bad-github-no-author.toml | 46 ++ .../config/testdata/bad-github-no-repos.toml | 47 ++ internal/config/testdata/bad-github-repo.toml | 47 ++ internal/config/testdata/bad-jira-http.toml | 47 ++ .../config/testdata/bad-jira-no-email.toml | 46 ++ .../config/testdata/bad-jira-userinfo.toml | 47 ++ internal/config/testdata/bad-no-envs.toml | 29 + internal/config/testdata/bad-no-projects.toml | 47 ++ internal/config/testdata/bad-project-key.toml | 47 ++ internal/config/testdata/bad-token-both.toml | 48 ++ internal/config/testdata/bad-token-none.toml | 46 ++ .../config/testdata/bad-unknown-account.toml | 47 ++ internal/config/testdata/bad-unknown-key.toml | 47 ++ internal/config/testdata/grouped.toml | 61 +++ internal/config/testdata/minimal.toml | 25 + internal/config/testdata/ok.toml | 47 ++ internal/config/testdata/token-fail.toml | 25 + 31 files changed, 1932 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/testdata/bad-account-no-region.toml create mode 100644 internal/config/testdata/bad-approval-stage-only.toml create mode 100644 internal/config/testdata/bad-duplicate-account.toml create mode 100644 internal/config/testdata/bad-duplicate-env.toml create mode 100644 internal/config/testdata/bad-duration-negative.toml create mode 100644 internal/config/testdata/bad-duration.toml create mode 100644 internal/config/testdata/bad-ecs-no-cluster.toml create mode 100644 internal/config/testdata/bad-ecs-no-services.toml create mode 100644 internal/config/testdata/bad-env-no-deploy-action.toml create mode 100644 internal/config/testdata/bad-env-order.toml create mode 100644 internal/config/testdata/bad-github-no-author.toml create mode 100644 internal/config/testdata/bad-github-no-repos.toml create mode 100644 internal/config/testdata/bad-github-repo.toml create mode 100644 internal/config/testdata/bad-jira-http.toml create mode 100644 internal/config/testdata/bad-jira-no-email.toml create mode 100644 internal/config/testdata/bad-jira-userinfo.toml create mode 100644 internal/config/testdata/bad-no-envs.toml create mode 100644 internal/config/testdata/bad-no-projects.toml create mode 100644 internal/config/testdata/bad-project-key.toml create mode 100644 internal/config/testdata/bad-token-both.toml create mode 100644 internal/config/testdata/bad-token-none.toml create mode 100644 internal/config/testdata/bad-unknown-account.toml create mode 100644 internal/config/testdata/bad-unknown-key.toml create mode 100644 internal/config/testdata/grouped.toml create mode 100644 internal/config/testdata/minimal.toml create mode 100644 internal/config/testdata/ok.toml create mode 100644 internal/config/testdata/token-fail.toml diff --git a/go.mod b/go.mod index 1d57c0b..8ea27d5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.25.10 require ( + github.com/BurntSushi/toml v1.6.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.29.14 github.com/aws/aws-sdk-go-v2/credentials v1.17.67 diff --git a/go.sum b/go.sum index e8f392e..5beb622 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3247610 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,500 @@ +// Package config loads and validates ~/.config/app-green/config.toml. +// Style follows ../jira-green/internal/config/config.go. +package config + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + "github.com/BurntSushi/toml" + "github.com/ericdahl-dev/app-green/internal/aws" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" +) + +// Settings is the optional [settings] table of durations written like "48h". +// Empty means "use the default"; read them through Thresholds. +type Settings struct { + StaleReviewAfter string `toml:"stale_review_after"` + DoneGrace string `toml:"done_grace"` + FadeAfter string `toml:"fade_after"` + PartialProdAfter string `toml:"partial_prod_after"` +} + +// Jira is the [jira] table. +type Jira struct { + Site string `toml:"site"` + Email string `toml:"email"` + TokenEnv string `toml:"token_env"` + TokenCommand string `toml:"token_command"` + Projects []string `toml:"projects"` +} + +// GitHub is the [github] table. +type GitHub struct { + Author string `toml:"author"` + TokenEnv string `toml:"token_env"` + TokenCommand string `toml:"token_command"` + Repos []string `toml:"repos"` // "owner/name", lowercased by Load +} + +// Account is one [[aws.accounts]] entry. +type Account struct { + Name string `toml:"name"` + Profile string `toml:"profile"` + Region string `toml:"region"` + ReadOnly bool `toml:"read_only"` +} + +// AWS is the [aws] table. +type AWS struct { + Accounts []Account `toml:"accounts"` +} + +// ECS is one entry of an env's ecs list: a cluster and services in it. +// Names or ARNs are accepted for both. +type ECS struct { + Cluster string `toml:"cluster"` + Services []string `toml:"services"` +} + +// EnvConfig is one [[envs]] entry as the user wrote it. +type EnvConfig struct { + Account string `toml:"account"` + Pipeline string `toml:"pipeline"` + Stage string `toml:"stage"` + DeployAction string `toml:"deploy_action"` + ApprovalStage string `toml:"approval_stage"` + ApprovalAction string `toml:"approval_action"` + Prod bool `toml:"prod"` + Repos []string `toml:"repos"` + ECS []ECS `toml:"ecs"` +} + +// Config is config.toml after Load. +type Config struct { + Settings Settings `toml:"settings"` + Jira Jira `toml:"jira"` + GitHub GitHub `toml:"github"` + AWS AWS `toml:"aws"` + EnvList []EnvConfig `toml:"envs"` + + thresholds rules.Thresholds + warnings []string +} + +// Default thresholds, used when [settings] leaves a key unset. +const ( + DefaultStaleReview = 48 * time.Hour + DefaultDoneGrace = 2 * time.Hour + DefaultFadeAfter = 24 * time.Hour + DefaultPartialProd = 4 * time.Hour +) + +// DefaultPath is $XDG_CONFIG_HOME/app-green/config.toml, or +// ~/.config/app-green/config.toml when XDG_CONFIG_HOME is unset. It returns +// an error when neither is available rather than guessing a relative path. +func DefaultPath() (string, error) { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find config path: %w", err) + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "app-green", "config.toml"), nil +} + +// Load reads and validates the config at path. Unknown keys are an error, so +// a typo never silently falls back to a default. +func Load(path string) (*Config, error) { + var c Config + md, err := toml.DecodeFile(path, &c) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if undecoded := md.Undecoded(); len(undecoded) > 0 { + keys := make([]string, len(undecoded)) + for i, k := range undecoded { + keys[i] = k.String() + } + return nil, fmt.Errorf("%s: unknown key(s): %s", path, strings.Join(keys, ", ")) + } + if err := c.validate(); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return &c, nil +} + +// validate checks the values the user wrote and fills the derived fields. +func (c *Config) validate() error { + durations := []struct { + key string + val string + def time.Duration + into *time.Duration + }{ + {"stale_review_after", c.Settings.StaleReviewAfter, DefaultStaleReview, &c.thresholds.StaleReview}, + {"done_grace", c.Settings.DoneGrace, DefaultDoneGrace, &c.thresholds.DoneGrace}, + {"fade_after", c.Settings.FadeAfter, DefaultFadeAfter, &c.thresholds.FadeAfter}, + {"partial_prod_after", c.Settings.PartialProdAfter, DefaultPartialProd, &c.thresholds.PartialProd}, + } + for _, d := range durations { + v, err := durationOr(d.val, d.def) + if err != nil { + return fmt.Errorf("settings.%s: %w", d.key, err) + } + if v <= 0 { + return fmt.Errorf("settings.%s must be positive, got %q", d.key, d.val) + } + *d.into = v + } + if err := c.validateJira(); err != nil { + return err + } + if err := c.validateGitHub(); err != nil { + return err + } + if err := c.validateAccounts(); err != nil { + return err + } + return c.validateEnvs() +} + +func (c *Config) validateAccounts() error { + seen := map[string]bool{} + for i, a := range c.AWS.Accounts { + field := fmt.Sprintf("aws.accounts[%d]", i) + if strings.TrimSpace(a.Name) == "" { + return fmt.Errorf("%s.name is required", field) + } + if seen[a.Name] { + return fmt.Errorf("%s.name: duplicate account %q", field, a.Name) + } + seen[a.Name] = true + if strings.TrimSpace(a.Region) == "" { + return fmt.Errorf("%s.region is required", field) + } + } + return nil +} + +func (c *Config) validateEnvs() error { + if len(c.EnvList) == 0 { + return errors.New("envs: no envs configured") + } + seen := map[string]bool{} + for i := range c.EnvList { + e := &c.EnvList[i] + field := fmt.Sprintf("envs[%d]", i) + for _, req := range []struct{ key, val string }{ + {"account", e.Account}, + {"pipeline", e.Pipeline}, + {"stage", e.Stage}, + {"deploy_action", e.DeployAction}, + } { + if strings.TrimSpace(req.val) == "" { + return fmt.Errorf("%s.%s is required", field, req.key) + } + } + if _, ok := c.Account(e.Account); !ok { + return fmt.Errorf("%s.account: unknown account %q (not in aws.accounts)", field, e.Account) + } + id := c.env(i).ID() + if seen[id] { + return fmt.Errorf("%s: duplicate env %s", field, id) + } + seen[id] = true + if e.ApprovalStage != "" && e.ApprovalAction == "" { + return fmt.Errorf("%s.approval_action is required when approval_stage is set", field) + } + for j, ecs := range e.ECS { + f := fmt.Sprintf("%s.ecs[%d]", field, j) + if strings.TrimSpace(ecs.Cluster) == "" { + return fmt.Errorf("%s.cluster is required", f) + } + if len(ecs.Services) == 0 { + return fmt.Errorf("%s.services: no services listed", f) + } + for _, svc := range ecs.Services { + if strings.TrimSpace(svc) == "" { + return fmt.Errorf("%s.services: empty service name", f) + } + } + } + repos, err := normalizeRepos(field+".repos", e.Repos) + if err != nil { + return err + } + e.Repos = repos + if len(repos) == 0 { + c.warnings = append(c.warnings, fmt.Sprintf("%s (%s) lists no repos: app-green will guess them from deploy history, a last resort that a missing or truncated history defeats - add repos = [\"owner/name\"]", field, c.env(i).ID())) + } + } + return nil +} + +// Warnings are problems Load found that do not stop the app, for main to +// print. +func (c *Config) Warnings() []string { return slices.Clone(c.warnings) } + +// Account returns the [[aws.accounts]] entry with this name. +func (c *Config) Account(name string) (Account, bool) { + for _, a := range c.AWS.Accounts { + if a.Name == name { + return a, true + } + } + return Account{}, false +} + +// Envs are the configured envs as model.Env, in file order: Order is the +// position in the file and ReadOnly comes from the account. +func (c *Config) Envs() []model.Env { + out := make([]model.Env, len(c.EnvList)) + for i := range c.EnvList { + out[i] = c.env(i) + } + return out +} + +// env is EnvList[i] as a model.Env. Repos is a copy. +func (c *Config) env(i int) model.Env { + e := c.EnvList[i] + a, _ := c.Account(e.Account) + return model.Env{ + Account: e.Account, + Pipeline: e.Pipeline, + Stage: e.Stage, + Order: i, + Prod: e.Prod, + ReadOnly: a.ReadOnly, + Repos: slices.Clone(e.Repos), + } +} + +func (c *Config) validateGitHub() error { + g := &c.GitHub + if strings.TrimSpace(g.Author) == "" { + return errors.New("github.author is required") + } + if err := checkTokenSource("github", g.TokenEnv, g.TokenCommand); err != nil { + return err + } + if len(g.Repos) == 0 { + return errors.New("github.repos: no repos configured") + } + repos, err := normalizeRepos("github.repos", g.Repos) + if err != nil { + return err + } + g.Repos = repos + return nil +} + +// repoName is a lowercased GitHub "owner/name". +var repoName = regexp.MustCompile(`^[a-z0-9-]+/[a-z0-9._-]+$`) + +// normalizeRepos lowercases each "owner/name" in repos (adapters compare +// repos lowercased) and checks its form. field names the key in errors. +func normalizeRepos(field string, repos []string) ([]string, error) { + out := make([]string, len(repos)) + for i, r := range repos { + out[i] = strings.ToLower(strings.TrimSpace(r)) + if !repoName.MatchString(out[i]) { + return nil, fmt.Errorf("%s: %q is not an owner/name repo", field, r) + } + } + return out, nil +} + +// projectKey is the Jira project key rule, copied from +// internal/jira/tickets.go so a bad key fails at Load, not at the first poll. +var projectKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]+$`) + +func (c *Config) validateJira() error { + j := &c.Jira + j.Site = strings.TrimRight(strings.TrimSpace(j.Site), "/") + if j.Site == "" { + return errors.New("jira.site is required") + } + u, err := url.Parse(j.Site) + if err == nil && u.User != nil { + // Not quoted back: it holds a password. + return errors.New("jira.site must not contain a user name or password") + } + if err != nil || u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("jira.site must be an https URL such as https://example.atlassian.net, got %q", j.Site) + } + if strings.TrimSpace(j.Email) == "" { + return errors.New("jira.email is required") + } + if err := checkTokenSource("jira", j.TokenEnv, j.TokenCommand); err != nil { + return err + } + if len(j.Projects) == 0 { + return errors.New("jira.projects: no projects configured") + } + for _, p := range j.Projects { + if !projectKey.MatchString(p) { + return fmt.Errorf("jira.projects: %q is not a Jira project key (uppercase letters, digits and _, starting with a letter)", p) + } + } + return nil +} + +// durationOr parses v like "48h" or "90m", or returns def when v is empty. +func durationOr(v string, def time.Duration) (time.Duration, error) { + if v == "" { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("invalid duration %q (use a form like 48h or 90m)", v) + } + return d, nil +} + +// Thresholds are the rules thresholds, defaults filled in. +func (c *Config) Thresholds() rules.Thresholds { return c.thresholds } + +// PipelineKey names one pipeline in one account: the unit of one AWS +// History call. +type PipelineKey struct { + Account string + Pipeline string +} + +// StageSpecs groups the envs' stage specs by pipeline, each group in file +// order, for aws.Client.History. +func (c *Config) StageSpecs() map[PipelineKey][]aws.StageSpec { + out := map[PipelineKey][]aws.StageSpec{} + for _, e := range c.EnvList { + k := PipelineKey{Account: e.Account, Pipeline: e.Pipeline} + out[k] = append(out[k], e.stageSpec()) + } + return out +} + +// StageSpec is env's stage spec, looked up by env.ID(). +func (c *Config) StageSpec(env model.Env) (aws.StageSpec, bool) { + i := c.index(env) + if i < 0 { + return aws.StageSpec{}, false + } + return c.EnvList[i].stageSpec(), true +} + +// stageSpec is e as an aws.StageSpec. An approval_action with no +// approval_stage is an approval inside the env's own stage. +func (e EnvConfig) stageSpec() aws.StageSpec { + s := aws.StageSpec{ + Stage: e.Stage, + DeployAction: e.DeployAction, + ApprovalStage: e.ApprovalStage, + ApprovalAction: e.ApprovalAction, + } + if s.ApprovalAction != "" && s.ApprovalStage == "" { + s.ApprovalStage = e.Stage + } + return s +} + +// index is the position in EnvList of the env with env.ID(), or -1. +func (c *Config) index(env model.Env) int { + for i := range c.EnvList { + if c.env(i).ID() == env.ID() { + return i + } + } + return -1 +} + +// Services are env's ECS services, looked up by env.ID(), for +// aws.Client.Health. Names and ARNs pass through as written. Nil when the +// env lists none or is unknown. +func (c *Config) Services(env model.Env) []aws.Service { + i := c.index(env) + if i < 0 { + return nil + } + var out []aws.Service + for _, ecs := range c.EnvList[i].ECS { + for _, name := range ecs.Services { + out = append(out, aws.Service{Cluster: ecs.Cluster, Name: name}) + } + } + return out +} + +// checkTokenSource requires exactly one token source in a table. The config +// file never holds a token itself. +func checkTokenSource(table, env, command string) error { + if (strings.TrimSpace(env) == "") == (strings.TrimSpace(command) == "") { + return fmt.Errorf("%s: set exactly one of token_env or token_command", table) + } + return nil +} + +// JiraToken resolves the Jira API token from jira.token_env or +// jira.token_command. +func (c *Config) JiraToken() (string, error) { + return resolveToken("jira", c.Jira.TokenEnv, c.Jira.TokenCommand) +} + +// GitHubToken resolves the GitHub token from github.token_env or +// github.token_command. +func (c *Config) GitHubToken() (string, error) { + return resolveToken("github", c.GitHub.TokenEnv, c.GitHub.TokenCommand) +} + +// tokenCommandTimeout bounds how long a token_command may run. +var tokenCommandTimeout = 10 * time.Second + +// maxStderr caps how much of a failed token_command's stderr an error quotes. +const maxStderr = 200 + +// resolveToken reads the variable env, or runs command with sh -c and +// trims its output. Errors never include stdout, where the token would be. +func resolveToken(table, env, command string) (string, error) { + if command != "" { + ctx, cancel := context.WithTimeout(context.Background(), tokenCommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.WaitDelay = time.Second // don't wait on a killed shell's children holding stdout + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if ctx.Err() != nil { + return "", fmt.Errorf("%s.token_command timed out after %v", table, tokenCommandTimeout) + } + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if len(msg) > maxStderr { + msg = msg[:maxStderr] + "..." + } + return "", fmt.Errorf("%s.token_command failed: %w: %s", table, err, msg) + } + tok := strings.TrimSpace(string(out)) + if tok == "" { + return "", fmt.Errorf("%s.token_command produced no output", table) + } + return tok, nil + } + tok := strings.TrimSpace(os.Getenv(env)) + if tok == "" { + return "", fmt.Errorf("%s.token_env %q is unset", table, env) + } + return tok, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..852e96f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,212 @@ +package config + +import ( + "reflect" + "time" + + "github.com/ericdahl-dev/app-green/internal/aws" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" + "path/filepath" + "strings" + "testing" +) + +func load(t *testing.T, name string) *Config { + t.Helper() + c, err := Load(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("Load(%s): %v", name, err) + } + return c +} + +func TestLoadOKJira(t *testing.T) { + c := load(t, "ok.toml") + if c.Jira.Site != "https://example.atlassian.net" { + t.Errorf("site = %q, want trailing slash trimmed", c.Jira.Site) + } + if c.Jira.Email != "me@example.com" { + t.Errorf("email = %q", c.Jira.Email) + } + if strings.Join(c.Jira.Projects, ",") != "ABC" { + t.Errorf("projects = %v", c.Jira.Projects) + } +} + +// TestLoadBad checks that each bad-*.toml fails with an error naming the +// problem. +func TestLoadBad(t *testing.T) { + tests := []struct{ file, want string }{ + {"bad-unknown-key.toml", "unknown key(s): settings.done_grase"}, + {"bad-env-order.toml", "unknown key(s): envs.order"}, // Order is file order + {"bad-duration.toml", `settings.fade_after: invalid duration "soon"`}, + {"bad-jira-http.toml", "jira.site must be an https URL"}, + {"bad-jira-userinfo.toml", "jira.site must not contain a user name or password"}, + {"bad-jira-no-email.toml", "jira.email is required"}, + {"bad-no-projects.toml", "jira.projects: no projects configured"}, + {"bad-project-key.toml", `jira.projects: "abc" is not a Jira project key`}, + {"bad-github-no-author.toml", "github.author is required"}, + {"bad-github-no-repos.toml", "github.repos: no repos configured"}, + {"bad-github-repo.toml", `github.repos: "acme" is not an owner/name repo`}, + {"bad-unknown-account.toml", `envs[1].account: unknown account "nope-acct"`}, + {"bad-no-envs.toml", "envs: no envs configured"}, + {"bad-duplicate-env.toml", "envs[1]: duplicate env stage-acct/app-pipeline/Test"}, + {"bad-duplicate-account.toml", `aws.accounts[1].name: duplicate account "stage-acct"`}, + {"bad-account-no-region.toml", "aws.accounts[1].region is required"}, + {"bad-env-no-deploy-action.toml", "envs[1].deploy_action is required"}, + {"bad-approval-stage-only.toml", "envs[1].approval_action is required when approval_stage is set"}, + {"bad-ecs-no-cluster.toml", "envs[0].ecs[0].cluster is required"}, + {"bad-ecs-no-services.toml", "envs[0].ecs[0].services: no services listed"}, + {"bad-token-both.toml", "github: set exactly one of token_env or token_command"}, + {"bad-token-none.toml", "jira: set exactly one of token_env or token_command"}, + {"bad-duration-negative.toml", `settings.stale_review_after must be positive, got "-1h"`}, + } + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + _, err := Load(filepath.Join("testdata", tt.file)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Load(%s) error = %v, want it to contain %q", tt.file, err, tt.want) + } + }) + } +} + +func TestThresholds(t *testing.T) { + got := load(t, "ok.toml").Thresholds() + want := rules.Thresholds{StaleReview: 48 * time.Hour, DoneGrace: 2 * time.Hour, FadeAfter: 24 * time.Hour, PartialProd: 4 * time.Hour} + if got != want { + t.Errorf("ok.toml Thresholds() = %+v, want %+v", got, want) + } + // minimal.toml sets only partial_prod_after; the rest default. + got = load(t, "minimal.toml").Thresholds() + want.PartialProd = 90 * time.Minute + if got != want { + t.Errorf("minimal.toml Thresholds() = %+v, want %+v", got, want) + } +} + +func TestGitHubReposLowercased(t *testing.T) { + c := load(t, "ok.toml") + if strings.Join(c.GitHub.Repos, ",") != "acme/app" { + t.Errorf("github.repos = %v, want [acme/app]", c.GitHub.Repos) + } + if c.GitHub.Author != "me" { + t.Errorf("github.author = %q", c.GitHub.Author) + } +} + +func TestEnvs(t *testing.T) { + envs := load(t, "ok.toml").Envs() + if len(envs) != 2 { + t.Fatalf("got %d envs, want 2", len(envs)) + } + want := []model.Env{ + {Account: "stage-acct", Pipeline: "app-pipeline", Stage: "Test", Order: 0, Repos: []string{"acme/app"}}, + {Account: "prod-acct", Pipeline: "app-pipeline", Stage: "Production", Order: 1, Prod: true, ReadOnly: true, Repos: []string{"acme/app"}}, + } + for i, w := range want { + if !reflect.DeepEqual(envs[i], w) { + t.Errorf("envs[%d] = %+v, want %+v", i, envs[i], w) + } + } +} + +func TestStageSpecs(t *testing.T) { + got := load(t, "grouped.toml").StageSpecs() + want := map[PipelineKey][]aws.StageSpec{ + {Account: "stage-acct", Pipeline: "app-pipeline"}: { + // approval_stage defaults to the env's own stage. + {Stage: "Test", DeployAction: "Deploy", ApprovalStage: "Test", ApprovalAction: "ApproveTest"}, + {Stage: "Staging", DeployAction: "Deploy"}, + }, + {Account: "prod-acct", Pipeline: "app-pipeline"}: { + {Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ApproveProd"}, + }, + {Account: "stage-acct", Pipeline: "other-pipeline"}: { + {Stage: "Test", DeployAction: "Deploy"}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("StageSpecs() = %+v\nwant %+v", got, want) + } +} + +func TestServicesAndStageSpec(t *testing.T) { + c := load(t, "ok.toml") + envs := c.Envs() + want := []aws.Service{ + {Cluster: "c1", Name: "s1"}, + {Cluster: "c1", Name: "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"}, // aws reduces ARNs + } + if got := c.Services(envs[0]); !reflect.DeepEqual(got, want) { + t.Errorf("Services(Test) = %+v, want %+v", got, want) + } + if got := c.Services(envs[1]); len(got) != 0 { + t.Errorf("Services(Production) = %+v, want none", got) + } + spec, ok := c.StageSpec(envs[1]) + if !ok || spec != (aws.StageSpec{Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ApproveProd"}) { + t.Errorf("StageSpec(Production) = %+v, %v", spec, ok) + } + if _, ok := c.StageSpec(model.Env{Account: "x", Pipeline: "y", Stage: "z"}); ok { + t.Error("StageSpec of an unknown env reported ok") + } +} + +func TestWarnings(t *testing.T) { + if w := load(t, "ok.toml").Warnings(); len(w) != 0 { + t.Errorf("ok.toml warnings = %q, want none", w) + } + w := load(t, "minimal.toml").Warnings() + if len(w) != 1 || !strings.Contains(w[0], "envs[0] (stage-acct/app-pipeline/Test) lists no repos") || !strings.Contains(w[0], "last resort") { + t.Errorf("minimal.toml warnings = %q, want one naming the env and the history fallback", w) + } +} + +func TestTokenEnv(t *testing.T) { + c := load(t, "ok.toml") + t.Setenv("JIRA_API_TOKEN", " jtok\n") + t.Setenv("GITHUB_TOKEN", "") + if tok, err := c.JiraToken(); err != nil || tok != "jtok" { + t.Errorf("JiraToken() = %q, %v; want jtok", tok, err) + } + if _, err := c.GitHubToken(); err == nil || !strings.Contains(err.Error(), `github.token_env "GITHUB_TOKEN" is unset`) { + t.Errorf("GitHubToken() error = %v, want unset error", err) + } +} + +func TestTokenCommand(t *testing.T) { + c := load(t, "minimal.toml") + for name, f := range map[string]func() (string, error){"jira": c.JiraToken, "github": c.GitHubToken} { + if tok, err := f(); err != nil || tok != "tok" { + t.Errorf("%s token = %q, %v; want tok", name, tok, err) + } + } +} + +func TestTokenCommandErrors(t *testing.T) { + c := load(t, "token-fail.toml") + _, err := c.JiraToken() + if err == nil || !strings.Contains(err.Error(), "jira.token_command failed") || !strings.Contains(err.Error(), "boom") { + t.Errorf("JiraToken() error = %v, want a failure with stderr", err) + } + if err != nil && strings.Contains(err.Error(), "sekrit") { + t.Errorf("JiraToken() error leaks stdout: %v", err) + } + if _, err := c.GitHubToken(); err == nil || !strings.Contains(err.Error(), "github.token_command produced no output") { + t.Errorf("GitHubToken() error = %v, want no-output error", err) + } +} + +func TestDefaultPath(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/xdg") + if p, err := DefaultPath(); err != nil || p != "/xdg/app-green/config.toml" { + t.Errorf("DefaultPath() with XDG_CONFIG_HOME = %q, %v", p, err) + } + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "/home/me") + if p, err := DefaultPath(); err != nil || p != "/home/me/.config/app-green/config.toml" { + t.Errorf("DefaultPath() without XDG_CONFIG_HOME = %q, %v", p, err) + } +} diff --git a/internal/config/testdata/bad-account-no-region.toml b/internal/config/testdata/bad-account-no-region.toml new file mode 100644 index 0000000..593668f --- /dev/null +++ b/internal/config/testdata/bad-account-no-region.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-approval-stage-only.toml b/internal/config/testdata/bad-approval-stage-only.toml new file mode 100644 index 0000000..a087d6f --- /dev/null +++ b/internal/config/testdata/bad-approval-stage-only.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-duplicate-account.toml b/internal/config/testdata/bad-duplicate-account.toml new file mode 100644 index 0000000..4fda7cc --- /dev/null +++ b/internal/config/testdata/bad-duplicate-account.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "stage-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-duplicate-env.toml b/internal/config/testdata/bad-duplicate-env.toml new file mode 100644 index 0000000..d3f1d36 --- /dev/null +++ b/internal/config/testdata/bad-duplicate-env.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-duration-negative.toml b/internal/config/testdata/bad-duration-negative.toml new file mode 100644 index 0000000..bbdc419 --- /dev/null +++ b/internal/config/testdata/bad-duration-negative.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "-1h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-duration.toml b/internal/config/testdata/bad-duration.toml new file mode 100644 index 0000000..839151e --- /dev/null +++ b/internal/config/testdata/bad-duration.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "soon" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-ecs-no-cluster.toml b/internal/config/testdata/bad-ecs-no-cluster.toml new file mode 100644 index 0000000..176c530 --- /dev/null +++ b/internal/config/testdata/bad-ecs-no-cluster.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-ecs-no-services.toml b/internal/config/testdata/bad-ecs-no-services.toml new file mode 100644 index 0000000..e1478cb --- /dev/null +++ b/internal/config/testdata/bad-ecs-no-services.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = [] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-env-no-deploy-action.toml b/internal/config/testdata/bad-env-no-deploy-action.toml new file mode 100644 index 0000000..455eb11 --- /dev/null +++ b/internal/config/testdata/bad-env-no-deploy-action.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-env-order.toml b/internal/config/testdata/bad-env-order.toml new file mode 100644 index 0000000..fbd98e2 --- /dev/null +++ b/internal/config/testdata/bad-env-order.toml @@ -0,0 +1,48 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + order = 5 + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-github-no-author.toml b/internal/config/testdata/bad-github-no-author.toml new file mode 100644 index 0000000..667ab4c --- /dev/null +++ b/internal/config/testdata/bad-github-no-author.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-github-no-repos.toml b/internal/config/testdata/bad-github-no-repos.toml new file mode 100644 index 0000000..3e86e52 --- /dev/null +++ b/internal/config/testdata/bad-github-no-repos.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = [] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-github-repo.toml b/internal/config/testdata/bad-github-repo.toml new file mode 100644 index 0000000..c20b122 --- /dev/null +++ b/internal/config/testdata/bad-github-repo.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["acme/app", "acme"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-jira-http.toml b/internal/config/testdata/bad-jira-http.toml new file mode 100644 index 0000000..75f32f8 --- /dev/null +++ b/internal/config/testdata/bad-jira-http.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "http://example.atlassian.net" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-jira-no-email.toml b/internal/config/testdata/bad-jira-no-email.toml new file mode 100644 index 0000000..9211d8d --- /dev/null +++ b/internal/config/testdata/bad-jira-no-email.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-jira-userinfo.toml b/internal/config/testdata/bad-jira-userinfo.toml new file mode 100644 index 0000000..1e84298 --- /dev/null +++ b/internal/config/testdata/bad-jira-userinfo.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://user:pw@example.atlassian.net" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-no-envs.toml b/internal/config/testdata/bad-no-envs.toml new file mode 100644 index 0000000..60c144e --- /dev/null +++ b/internal/config/testdata/bad-no-envs.toml @@ -0,0 +1,29 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + diff --git a/internal/config/testdata/bad-no-projects.toml b/internal/config/testdata/bad-no-projects.toml new file mode 100644 index 0000000..08b3ab3 --- /dev/null +++ b/internal/config/testdata/bad-no-projects.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = [] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-project-key.toml b/internal/config/testdata/bad-project-key.toml new file mode 100644 index 0000000..6731e00 --- /dev/null +++ b/internal/config/testdata/bad-project-key.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC", "abc"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-token-both.toml b/internal/config/testdata/bad-token-both.toml new file mode 100644 index 0000000..ed35fb3 --- /dev/null +++ b/internal/config/testdata/bad-token-both.toml @@ -0,0 +1,48 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" + token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-token-none.toml b/internal/config/testdata/bad-token-none.toml new file mode 100644 index 0000000..a47564b --- /dev/null +++ b/internal/config/testdata/bad-token-none.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-unknown-account.toml b/internal/config/testdata/bad-unknown-account.toml new file mode 100644 index 0000000..f69f13f --- /dev/null +++ b/internal/config/testdata/bad-unknown-account.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "nope-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-unknown-key.toml b/internal/config/testdata/bad-unknown-key.toml new file mode 100644 index 0000000..4117faa --- /dev/null +++ b/internal/config/testdata/bad-unknown-key.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grase = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/grouped.toml b/internal/config/testdata/grouped.toml new file mode 100644 index 0000000..b3d1d61 --- /dev/null +++ b/internal/config/testdata/grouped.toml @@ -0,0 +1,61 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Staging" + deploy_action = "Deploy" + repos = ["acme/app"] + +[[envs]] + account = "stage-acct" + pipeline = "other-pipeline" + stage = "Test" + deploy_action = "Deploy" + repos = ["acme/other"] diff --git a/internal/config/testdata/minimal.toml b/internal/config/testdata/minimal.toml new file mode 100644 index 0000000..f937248 --- /dev/null +++ b/internal/config/testdata/minimal.toml @@ -0,0 +1,25 @@ +# Only the required keys, one settings override, and an env with no repos. +[settings] + partial_prod_after = "90m" + +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_command = "echo tok" + projects = ["ABC", "XY_2"] + +[github] + author = "me" + token_command = "echo tok" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" diff --git a/internal/config/testdata/ok.toml b/internal/config/testdata/ok.toml new file mode 100644 index 0000000..9f58002 --- /dev/null +++ b/internal/config/testdata/ok.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/token-fail.toml b/internal/config/testdata/token-fail.toml new file mode 100644 index 0000000..5de9439 --- /dev/null +++ b/internal/config/testdata/token-fail.toml @@ -0,0 +1,25 @@ +# Token commands that fail: one exits non-zero, one prints only whitespace. +[settings] + partial_prod_after = "90m" + +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_command = "echo sekrit-token; echo boom >&2; exit 3" + projects = ["ABC", "XY_2"] + +[github] + author = "me" + token_command = "printf ' \\n'" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" From 025f88756ab81bc528d27cc842c39f1bb5183db1 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:25:59 -0400 Subject: [PATCH 19/31] feat(config): watched repos, day durations, poll interval WatchedRepos is github.repos plus every env's repos, so github.repos is now optional; Load fails only when there is nothing to watch. Durations accept a leading day count ("2d", "1d12h"). settings.poll_interval defaults to 60s and must be at least 15s. approval_stage's default is documented on the field. Assisted-by: Claude Code:claude-opus-5-5 --- internal/config/config.go | 108 ++++++++++++++---- internal/config/config_test.go | 37 +++++- ...b-no-repos.toml => bad-duration-unit.toml} | 4 +- .../testdata/bad-no-repos-to-watch.toml | 24 ++++ .../config/testdata/bad-poll-interval.toml | 48 ++++++++ internal/config/testdata/days.toml | 28 +++++ internal/config/testdata/env-repos-only.toml | 46 ++++++++ 7 files changed, 270 insertions(+), 25 deletions(-) rename internal/config/testdata/{bad-github-no-repos.toml => bad-duration-unit.toml} (96%) create mode 100644 internal/config/testdata/bad-no-repos-to-watch.toml create mode 100644 internal/config/testdata/bad-poll-interval.toml create mode 100644 internal/config/testdata/days.toml create mode 100644 internal/config/testdata/env-repos-only.toml diff --git a/internal/config/config.go b/internal/config/config.go index 3247610..23c29f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( "path/filepath" "regexp" "slices" + "strconv" "strings" "time" @@ -22,13 +23,16 @@ import ( "github.com/ericdahl-dev/app-green/internal/rules" ) -// Settings is the optional [settings] table of durations written like "48h". -// Empty means "use the default"; read them through Thresholds. +// Settings is the optional [settings] table of durations written like "2d", +// "48h" or "90m". +// Empty means "use the default"; read them through Thresholds and +// PollInterval. type Settings struct { StaleReviewAfter string `toml:"stale_review_after"` DoneGrace string `toml:"done_grace"` FadeAfter string `toml:"fade_after"` PartialProdAfter string `toml:"partial_prod_after"` + PollInterval string `toml:"poll_interval"` // read through PollInterval } // Jira is the [jira] table. @@ -42,10 +46,12 @@ type Jira struct { // GitHub is the [github] table. type GitHub struct { - Author string `toml:"author"` - TokenEnv string `toml:"token_env"` - TokenCommand string `toml:"token_command"` - Repos []string `toml:"repos"` // "owner/name", lowercased by Load + Author string `toml:"author"` + TokenEnv string `toml:"token_env"` + TokenCommand string `toml:"token_command"` + // Repos are extra "owner/name" repos to watch, lowercased by Load. + // Optional: WatchedRepos adds every env's repos. + Repos []string `toml:"repos"` } // Account is one [[aws.accounts]] entry. @@ -70,10 +76,15 @@ type ECS struct { // EnvConfig is one [[envs]] entry as the user wrote it. type EnvConfig struct { - Account string `toml:"account"` - Pipeline string `toml:"pipeline"` - Stage string `toml:"stage"` - DeployAction string `toml:"deploy_action"` + Account string `toml:"account"` + Pipeline string `toml:"pipeline"` + Stage string `toml:"stage"` + DeployAction string `toml:"deploy_action"` + // ApprovalStage and ApprovalAction name the manual approval that gates + // this env. ApprovalAction has no default. ApprovalStage defaults to + // Stage (an approval inside the env's own stage); set it when the + // approval sits in another stage, usually the one before. Setting + // ApprovalStage without ApprovalAction is an error. ApprovalStage string `toml:"approval_stage"` ApprovalAction string `toml:"approval_action"` Prod bool `toml:"prod"` @@ -89,8 +100,9 @@ type Config struct { AWS AWS `toml:"aws"` EnvList []EnvConfig `toml:"envs"` - thresholds rules.Thresholds - warnings []string + thresholds rules.Thresholds + pollInterval time.Duration + warnings []string } // Default thresholds, used when [settings] leaves a key unset. @@ -101,6 +113,13 @@ const ( DefaultPartialProd = 4 * time.Hour ) +// DefaultPollInterval is how often the resolver polls when +// settings.poll_interval is unset; MinPollInterval is the lowest allowed. +const ( + DefaultPollInterval = 60 * time.Second + MinPollInterval = 15 * time.Second +) + // DefaultPath is $XDG_CONFIG_HOME/app-green/config.toml, or // ~/.config/app-green/config.toml when XDG_CONFIG_HOME is unset. It returns // an error when neither is available rather than guessing a relative path. @@ -160,6 +179,14 @@ func (c *Config) validate() error { } *d.into = v } + poll, err := durationOr(c.Settings.PollInterval, DefaultPollInterval) + if err != nil { + return fmt.Errorf("settings.poll_interval: %w", err) + } + if poll < MinPollInterval { + return fmt.Errorf("settings.poll_interval must be at least %v, got %q", MinPollInterval, c.Settings.PollInterval) + } + c.pollInterval = poll if err := c.validateJira(); err != nil { return err } @@ -169,7 +196,24 @@ func (c *Config) validate() error { if err := c.validateAccounts(); err != nil { return err } - return c.validateEnvs() + if err := c.validateEnvs(); err != nil { + return err + } + if len(c.WatchedRepos()) == 0 { + return errors.New("no repos to watch: set github.repos or envs[].repos") + } + return nil +} + +// WatchedRepos are the repos whose PRs are fetched: github.repos and every +// env's repos, lowercased, deduplicated and sorted. +func (c *Config) WatchedRepos() []string { + all := slices.Clone(c.GitHub.Repos) + for _, e := range c.EnvList { + all = append(all, e.Repos...) + } + slices.Sort(all) + return slices.Compact(all) } func (c *Config) validateAccounts() error { @@ -292,9 +336,6 @@ func (c *Config) validateGitHub() error { if err := checkTokenSource("github", g.TokenEnv, g.TokenCommand); err != nil { return err } - if len(g.Repos) == 0 { - return errors.New("github.repos: no repos configured") - } repos, err := normalizeRepos("github.repos", g.Repos) if err != nil { return err @@ -354,18 +395,41 @@ func (c *Config) validateJira() error { return nil } -// durationOr parses v like "48h" or "90m", or returns def when v is empty. +// dayPart is a leading whole number of days, as in "2d" or "1d12h". +var dayPart = regexp.MustCompile(`^(\d+)d`) + +// durationOr parses v, or returns def when v is empty. v is a Go duration +// ("48h", "90m"), optionally led by whole days ("2d", "1d12h"). Adapted from +// ParseAge in ../jira-green/internal/model/threshold.go; the caller checks +// the sign. func durationOr(v string, def time.Duration) (time.Duration, error) { - if v == "" { + s := strings.TrimSpace(v) + if s == "" { return def, nil } - d, err := time.ParseDuration(v) - if err != nil { - return 0, fmt.Errorf("invalid duration %q (use a form like 48h or 90m)", v) + bad := fmt.Errorf("invalid duration %q (use a form like 2d, 48h or 90m)", v) + var total time.Duration + if m := dayPart.FindStringSubmatch(s); m != nil { + n, err := strconv.Atoi(m[1]) + if err != nil || n > 100000 { + return 0, bad + } + total = time.Duration(n) * 24 * time.Hour + s = s[len(m[0]):] + } + if s != "" { + d, err := time.ParseDuration(s) + if err != nil { + return 0, bad + } + total += d } - return d, nil + return total, nil } +// PollInterval is how often the resolver polls. Default 60s, at least 15s. +func (c *Config) PollInterval() time.Duration { return c.pollInterval } + // Thresholds are the rules thresholds, defaults filled in. func (c *Config) Thresholds() rules.Thresholds { return c.thresholds } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 852e96f..2e6f479 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,7 +47,7 @@ func TestLoadBad(t *testing.T) { {"bad-no-projects.toml", "jira.projects: no projects configured"}, {"bad-project-key.toml", `jira.projects: "abc" is not a Jira project key`}, {"bad-github-no-author.toml", "github.author is required"}, - {"bad-github-no-repos.toml", "github.repos: no repos configured"}, + {"bad-no-repos-to-watch.toml", "no repos to watch: set github.repos or envs[].repos"}, {"bad-github-repo.toml", `github.repos: "acme" is not an owner/name repo`}, {"bad-unknown-account.toml", `envs[1].account: unknown account "nope-acct"`}, {"bad-no-envs.toml", "envs: no envs configured"}, @@ -60,6 +60,8 @@ func TestLoadBad(t *testing.T) { {"bad-ecs-no-services.toml", "envs[0].ecs[0].services: no services listed"}, {"bad-token-both.toml", "github: set exactly one of token_env or token_command"}, {"bad-token-none.toml", "jira: set exactly one of token_env or token_command"}, + {"bad-duration-unit.toml", `settings.done_grace: invalid duration "2x"`}, + {"bad-poll-interval.toml", `settings.poll_interval must be at least 15s, got "10s"`}, {"bad-duration-negative.toml", `settings.stale_review_after must be positive, got "-1h"`}, } for _, tt := range tests { @@ -72,6 +74,14 @@ func TestLoadBad(t *testing.T) { } } +func TestThresholdsDays(t *testing.T) { + got := load(t, "days.toml").Thresholds() + want := rules.Thresholds{StaleReview: 48 * time.Hour, DoneGrace: DefaultDoneGrace, FadeAfter: 36 * time.Hour, PartialProd: 90 * time.Minute} + if got != want { + t.Errorf("days.toml Thresholds() = %+v, want %+v", got, want) + } +} + func TestThresholds(t *testing.T) { got := load(t, "ok.toml").Thresholds() want := rules.Thresholds{StaleReview: 48 * time.Hour, DoneGrace: 2 * time.Hour, FadeAfter: 24 * time.Hour, PartialProd: 4 * time.Hour} @@ -210,3 +220,28 @@ func TestDefaultPath(t *testing.T) { t.Errorf("DefaultPath() without XDG_CONFIG_HOME = %q, %v", p, err) } } + +func TestPollInterval(t *testing.T) { + if got := load(t, "ok.toml").PollInterval(); got != 60*time.Second { + t.Errorf("ok.toml PollInterval() = %v, want default 60s", got) + } + if got := load(t, "days.toml").PollInterval(); got != 30*time.Second { + t.Errorf("days.toml PollInterval() = %v, want 30s", got) + } +} + +func TestWatchedRepos(t *testing.T) { + tests := []struct { + file string + want []string + }{ + {"ok.toml", []string{"acme/app"}}, + {"grouped.toml", []string{"acme/app", "acme/other"}}, + {"env-repos-only.toml", []string{"acme/app", "acme/lib"}}, + } + for _, tt := range tests { + if got := load(t, tt.file).WatchedRepos(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s WatchedRepos() = %v, want %v", tt.file, got, tt.want) + } + } +} diff --git a/internal/config/testdata/bad-github-no-repos.toml b/internal/config/testdata/bad-duration-unit.toml similarity index 96% rename from internal/config/testdata/bad-github-no-repos.toml rename to internal/config/testdata/bad-duration-unit.toml index 3e86e52..a7ffa72 100644 --- a/internal/config/testdata/bad-github-no-repos.toml +++ b/internal/config/testdata/bad-duration-unit.toml @@ -1,6 +1,6 @@ [settings] stale_review_after = "48h" - done_grace = "2h" + done_grace = "2x" fade_after = "24h" partial_prod_after = "4h" @@ -13,7 +13,7 @@ [github] author = "me" token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" - repos = [] + repos = ["Acme/App"] [[aws.accounts]] name = "stage-acct" diff --git a/internal/config/testdata/bad-no-repos-to-watch.toml b/internal/config/testdata/bad-no-repos-to-watch.toml new file mode 100644 index 0000000..77b4f43 --- /dev/null +++ b/internal/config/testdata/bad-no-repos-to-watch.toml @@ -0,0 +1,24 @@ +# Nothing to watch: no github.repos and no env repos. +[settings] + partial_prod_after = "90m" + +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_command = "echo tok" + projects = ["ABC", "XY_2"] + +[github] + author = "me" + token_command = "echo tok" + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" diff --git a/internal/config/testdata/bad-poll-interval.toml b/internal/config/testdata/bad-poll-interval.toml new file mode 100644 index 0000000..a67c15a --- /dev/null +++ b/internal/config/testdata/bad-poll-interval.toml @@ -0,0 +1,48 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + poll_interval = "10s" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/days.toml b/internal/config/testdata/days.toml new file mode 100644 index 0000000..27eef35 --- /dev/null +++ b/internal/config/testdata/days.toml @@ -0,0 +1,28 @@ +# Durations with day units. +[settings] + stale_review_after = "2d" + fade_after = "1d12h" + partial_prod_after = "90m" + poll_interval = "30s" + +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_command = "echo tok" + projects = ["ABC", "XY_2"] + +[github] + author = "me" + token_command = "echo tok" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" diff --git a/internal/config/testdata/env-repos-only.toml b/internal/config/testdata/env-repos-only.toml new file mode 100644 index 0000000..7ae1046 --- /dev/null +++ b/internal/config/testdata/env-repos-only.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # no repos: the envs' repos are watched + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/lib", "Acme/App"] From 93f5437ed8f5640670c7fab6f57bfe60d2b4eed1 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:42:36 -0400 Subject: [PATCH 20/31] fix(config): review fixes for names, ECS, durations and tokens - jira.site may have a path but no query or fragment; duplicate projects are an error. - aws.accounts[].profile is required; account and env names are trimmed, and pipeline, stage and action names must use CodePipeline's characters. - Env errors name the env: envs[i] (account/pipeline/stage).field. - Repo names follow GitHub's rules, and "." and ".." are rejected. - An approval_action equal to deploy_action in the same stage is an error, as is a service listed twice in one env (ARNs reduced to names). - "1d-23h" is rejected instead of meaning 1h. - StageSpecs returns pipelines in file order. - A relative XDG_CONFIG_HOME is ignored, quoted stderr is cut on a rune boundary, and resolveToken documents its worst-case delay and that stderr is quoted. Assisted-by: Claude Code:claude-opus-5-5 --- internal/config/config.go | 151 ++++++++++++++---- internal/config/config_test.go | 125 ++++++++++++--- .../testdata/bad-account-blank-region.toml | 47 ++++++ .../testdata/bad-account-no-profile.toml | 46 ++++++ .../testdata/bad-approval-is-deploy.toml | 47 ++++++ .../testdata/bad-duration-day-minus.toml | 47 ++++++ .../testdata/bad-duration-day-plus.toml | 47 ++++++ .../bad-ecs-duplicate-same-entry.toml | 47 ++++++ .../config/testdata/bad-ecs-duplicate.toml | 47 ++++++ .../config/testdata/bad-env-action-chars.toml | 47 ++++++ .../bad-env-approval-stage-chars.toml | 47 ++++++ .../testdata/bad-env-pipeline-chars.toml | 47 ++++++ internal/config/testdata/bad-env-repo.toml | 47 ++++++ .../config/testdata/bad-jira-forcequery.toml | 47 ++++++ .../config/testdata/bad-jira-fragment.toml | 47 ++++++ internal/config/testdata/bad-jira-query.toml | 47 ++++++ .../testdata/bad-project-duplicate.toml | 47 ++++++ internal/config/testdata/bad-repo-dot.toml | 47 ++++++ internal/config/testdata/bad-repo-dotdot.toml | 47 ++++++ .../testdata/bad-repo-leading-dash.toml | 47 ++++++ .../config/testdata/bad-repo-two-slashes.toml | 47 ++++++ internal/config/testdata/site-path.toml | 25 +++ internal/config/testdata/token-slow.toml | 25 +++ internal/config/testdata/whitespace.toml | 48 ++++++ 24 files changed, 1217 insertions(+), 49 deletions(-) create mode 100644 internal/config/testdata/bad-account-blank-region.toml create mode 100644 internal/config/testdata/bad-account-no-profile.toml create mode 100644 internal/config/testdata/bad-approval-is-deploy.toml create mode 100644 internal/config/testdata/bad-duration-day-minus.toml create mode 100644 internal/config/testdata/bad-duration-day-plus.toml create mode 100644 internal/config/testdata/bad-ecs-duplicate-same-entry.toml create mode 100644 internal/config/testdata/bad-ecs-duplicate.toml create mode 100644 internal/config/testdata/bad-env-action-chars.toml create mode 100644 internal/config/testdata/bad-env-approval-stage-chars.toml create mode 100644 internal/config/testdata/bad-env-pipeline-chars.toml create mode 100644 internal/config/testdata/bad-env-repo.toml create mode 100644 internal/config/testdata/bad-jira-forcequery.toml create mode 100644 internal/config/testdata/bad-jira-fragment.toml create mode 100644 internal/config/testdata/bad-jira-query.toml create mode 100644 internal/config/testdata/bad-project-duplicate.toml create mode 100644 internal/config/testdata/bad-repo-dot.toml create mode 100644 internal/config/testdata/bad-repo-dotdot.toml create mode 100644 internal/config/testdata/bad-repo-leading-dash.toml create mode 100644 internal/config/testdata/bad-repo-two-slashes.toml create mode 100644 internal/config/testdata/site-path.toml create mode 100644 internal/config/testdata/token-slow.toml create mode 100644 internal/config/testdata/whitespace.toml diff --git a/internal/config/config.go b/internal/config/config.go index 23c29f0..b8e297c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/BurntSushi/toml" "github.com/ericdahl-dev/app-green/internal/aws" @@ -121,11 +122,12 @@ const ( ) // DefaultPath is $XDG_CONFIG_HOME/app-green/config.toml, or -// ~/.config/app-green/config.toml when XDG_CONFIG_HOME is unset. It returns -// an error when neither is available rather than guessing a relative path. +// ~/.config/app-green/config.toml when XDG_CONFIG_HOME is unset or relative +// (the XDG spec says to ignore a relative value). It returns an error when +// neither is available rather than guessing a relative path. func DefaultPath() (string, error) { base := os.Getenv("XDG_CONFIG_HOME") - if base == "" { + if !filepath.IsAbs(base) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("find config path: %w", err) @@ -218,22 +220,42 @@ func (c *Config) WatchedRepos() []string { func (c *Config) validateAccounts() error { seen := map[string]bool{} - for i, a := range c.AWS.Accounts { - field := fmt.Sprintf("aws.accounts[%d]", i) - if strings.TrimSpace(a.Name) == "" { - return fmt.Errorf("%s.name is required", field) + for i := range c.AWS.Accounts { + a := &c.AWS.Accounts[i] + a.Name = strings.TrimSpace(a.Name) + a.Profile = strings.TrimSpace(a.Profile) + a.Region = strings.TrimSpace(a.Region) + if a.Name == "" { + return fmt.Errorf("aws.accounts[%d].name is required", i) } + field := fmt.Sprintf("aws.accounts[%d] (%s)", i, a.Name) if seen[a.Name] { return fmt.Errorf("%s.name: duplicate account %q", field, a.Name) } seen[a.Name] = true - if strings.TrimSpace(a.Region) == "" { + if a.Profile == "" { + return fmt.Errorf("%s.profile is required", field) + } + if a.Region == "" { return fmt.Errorf("%s.region is required", field) } } return nil } +// arnName reduces an ECS cluster or service ARN to its name (the part after +// the last "/", as aws.serviceName does) and returns anything else as is. +func arnName(s string) string { + if !strings.HasPrefix(s, "arn:") { + return s + } + return s[strings.LastIndex(s, "/")+1:] +} + +// pipelineName is the character set CodePipeline allows in pipeline, stage +// and action names. +var pipelineName = regexp.MustCompile(`^[A-Za-z0-9.@_-]+$`) + func (c *Config) validateEnvs() error { if len(c.EnvList) == 0 { return errors.New("envs: no envs configured") @@ -241,7 +263,12 @@ func (c *Config) validateEnvs() error { seen := map[string]bool{} for i := range c.EnvList { e := &c.EnvList[i] - field := fmt.Sprintf("envs[%d]", i) + for _, f := range []*string{&e.Account, &e.Pipeline, &e.Stage, &e.DeployAction, &e.ApprovalStage, &e.ApprovalAction} { + *f = strings.TrimSpace(*f) + } + // field labels errors with the env's ID, e.g. + // "envs[1] (stage-acct/app-pipeline/Production)". + field := fmt.Sprintf("envs[%d] (%s/%s/%s)", i, e.Account, e.Pipeline, e.Stage) for _, req := range []struct{ key, val string }{ {"account", e.Account}, {"pipeline", e.Pipeline}, @@ -252,29 +279,53 @@ func (c *Config) validateEnvs() error { return fmt.Errorf("%s.%s is required", field, req.key) } } + for _, n := range []struct{ key, val string }{ + {"pipeline", e.Pipeline}, + {"stage", e.Stage}, + {"deploy_action", e.DeployAction}, + {"approval_stage", e.ApprovalStage}, + {"approval_action", e.ApprovalAction}, + } { + if n.val != "" && !pipelineName.MatchString(n.val) { + return fmt.Errorf("%s.%s: %q has characters outside A-Z a-z 0-9 . @ _ - (CodePipeline names allow only these)", field, n.key, n.val) + } + } if _, ok := c.Account(e.Account); !ok { return fmt.Errorf("%s.account: unknown account %q (not in aws.accounts)", field, e.Account) } id := c.env(i).ID() if seen[id] { - return fmt.Errorf("%s: duplicate env %s", field, id) + return fmt.Errorf("%s: duplicate env", field) } seen[id] = true if e.ApprovalStage != "" && e.ApprovalAction == "" { return fmt.Errorf("%s.approval_action is required when approval_stage is set", field) } - for j, ecs := range e.ECS { + if spec := e.stageSpec(); spec.ApprovalAction != "" && spec.ApprovalStage == spec.Stage && spec.ApprovalAction == spec.DeployAction { + return fmt.Errorf("%s.approval_action must differ from deploy_action when the approval is in the same stage", field) + } + services := map[string]bool{} // "cluster/service" names, ARNs reduced + for j := range e.ECS { + ecs := &e.ECS[j] f := fmt.Sprintf("%s.ecs[%d]", field, j) - if strings.TrimSpace(ecs.Cluster) == "" { + ecs.Cluster = strings.TrimSpace(ecs.Cluster) + if ecs.Cluster == "" { return fmt.Errorf("%s.cluster is required", f) } if len(ecs.Services) == 0 { return fmt.Errorf("%s.services: no services listed", f) } - for _, svc := range ecs.Services { - if strings.TrimSpace(svc) == "" { + for k := range ecs.Services { + ecs.Services[k] = strings.TrimSpace(ecs.Services[k]) + if ecs.Services[k] == "" { return fmt.Errorf("%s.services: empty service name", f) } + name := arnName(ecs.Services[k]) + key := arnName(ecs.Cluster) + "/" + name + if services[key] { + return fmt.Errorf("%s.services: duplicate service %q", f, name) + } + services[key] = true } } repos, err := normalizeRepos(field+".repos", e.Repos) @@ -283,7 +334,7 @@ func (c *Config) validateEnvs() error { } e.Repos = repos if len(repos) == 0 { - c.warnings = append(c.warnings, fmt.Sprintf("%s (%s) lists no repos: app-green will guess them from deploy history, a last resort that a missing or truncated history defeats - add repos = [\"owner/name\"]", field, c.env(i).ID())) + c.warnings = append(c.warnings, fmt.Sprintf("%s lists no repos: app-green will guess them from deploy history, a last resort that a missing or truncated history defeats - add repos = [\"owner/name\"]", field)) } } return nil @@ -345,7 +396,7 @@ func (c *Config) validateGitHub() error { } // repoName is a lowercased GitHub "owner/name". -var repoName = regexp.MustCompile(`^[a-z0-9-]+/[a-z0-9._-]+$`) +var repoName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*/[a-z0-9._-]+$`) // normalizeRepos lowercases each "owner/name" in repos (adapters compare // repos lowercased) and checks its form. field names the key in errors. @@ -353,7 +404,8 @@ func normalizeRepos(field string, repos []string) ([]string, error) { out := make([]string, len(repos)) for i, r := range repos { out[i] = strings.ToLower(strings.TrimSpace(r)) - if !repoName.MatchString(out[i]) { + _, name, _ := strings.Cut(out[i], "/") + if !repoName.MatchString(out[i]) || name == "." || name == ".." { return nil, fmt.Errorf("%s: %q is not an owner/name repo", field, r) } } @@ -375,6 +427,10 @@ func (c *Config) validateJira() error { // Not quoted back: it holds a password. return errors.New("jira.site must not contain a user name or password") } + if err == nil && (u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(j.Site, "#")) { + // Not quoted back: a query may hold a token. + return errors.New("jira.site must not have a query or fragment") + } if err != nil || u.Scheme != "https" || u.Host == "" { return fmt.Errorf("jira.site must be an https URL such as https://example.atlassian.net, got %q", j.Site) } @@ -387,10 +443,15 @@ func (c *Config) validateJira() error { if len(j.Projects) == 0 { return errors.New("jira.projects: no projects configured") } + seen := map[string]bool{} for _, p := range j.Projects { if !projectKey.MatchString(p) { return fmt.Errorf("jira.projects: %q is not a Jira project key (uppercase letters, digits and _, starting with a letter)", p) } + if seen[p] { + return fmt.Errorf("jira.projects: duplicate project %q", p) + } + seen[p] = true } return nil } @@ -416,6 +477,9 @@ func durationOr(v string, def time.Duration) (time.Duration, error) { } total = time.Duration(n) * 24 * time.Hour s = s[len(m[0]):] + if strings.HasPrefix(s, "-") || strings.HasPrefix(s, "+") { + return 0, bad // "1d-23h" would quietly mean 1h + } } if s != "" { d, err := time.ParseDuration(s) @@ -440,13 +504,27 @@ type PipelineKey struct { Pipeline string } -// StageSpecs groups the envs' stage specs by pipeline, each group in file -// order, for aws.Client.History. -func (c *Config) StageSpecs() map[PipelineKey][]aws.StageSpec { - out := map[PipelineKey][]aws.StageSpec{} +// PipelineSpecs is one pipeline and the stage specs of its envs. +type PipelineSpecs struct { + Key PipelineKey + Specs []aws.StageSpec +} + +// StageSpecs groups the envs' stage specs by pipeline, for +// aws.Client.History. Pipelines come in order of first appearance in the +// file and each group's specs in file order, so the result is stable. +func (c *Config) StageSpecs() []PipelineSpecs { + var out []PipelineSpecs + pos := map[PipelineKey]int{} for _, e := range c.EnvList { k := PipelineKey{Account: e.Account, Pipeline: e.Pipeline} - out[k] = append(out[k], e.stageSpec()) + i, ok := pos[k] + if !ok { + i = len(out) + pos[k] = i + out = append(out, PipelineSpecs{Key: k}) + } + out[i].Specs = append(out[i].Specs, e.stageSpec()) } return out } @@ -529,8 +607,29 @@ var tokenCommandTimeout = 10 * time.Second // maxStderr caps how much of a failed token_command's stderr an error quotes. const maxStderr = 200 +// quoteStderr trims s and cuts it to at most maxStderr bytes on a rune +// boundary, marking a cut with "...". +func quoteStderr(s string) string { + s = strings.TrimSpace(s) + if len(s) <= maxStderr { + return s + } + n := maxStderr + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] + "..." +} + // resolveToken reads the variable env, or runs command with sh -c and -// trims its output. Errors never include stdout, where the token would be. +// trims its output. +// +// A command runs at most tokenCommandTimeout (10s) plus a 1s WaitDelay, so +// with both tokens on commands startup can stall about 22s in the worst case. +// Errors never include stdout, where the token is, but a failed command's +// stderr is quoted (trimmed, at most maxStderr bytes) to explain the +// failure: a command that echoes the token to stderr and then fails would +// leak it into the error. func resolveToken(table, env, command string) (string, error) { if command != "" { ctx, cancel := context.WithTimeout(context.Background(), tokenCommandTimeout) @@ -544,11 +643,7 @@ func resolveToken(table, env, command string) (string, error) { return "", fmt.Errorf("%s.token_command timed out after %v", table, tokenCommandTimeout) } if err != nil { - msg := strings.TrimSpace(stderr.String()) - if len(msg) > maxStderr { - msg = msg[:maxStderr] + "..." - } - return "", fmt.Errorf("%s.token_command failed: %w: %s", table, err, msg) + return "", fmt.Errorf("%s.token_command failed: %w: %s", table, err, quoteStderr(stderr.String())) } tok := strings.TrimSpace(string(out)) if tok == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2e6f479..b8ac0f8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,15 +1,16 @@ package config import ( + "path/filepath" "reflect" + "strings" + "testing" "time" + "unicode/utf8" "github.com/ericdahl-dev/app-green/internal/aws" "github.com/ericdahl-dev/app-green/internal/model" "github.com/ericdahl-dev/app-green/internal/rules" - "path/filepath" - "strings" - "testing" ) func load(t *testing.T, name string) *Config { @@ -49,19 +50,38 @@ func TestLoadBad(t *testing.T) { {"bad-github-no-author.toml", "github.author is required"}, {"bad-no-repos-to-watch.toml", "no repos to watch: set github.repos or envs[].repos"}, {"bad-github-repo.toml", `github.repos: "acme" is not an owner/name repo`}, - {"bad-unknown-account.toml", `envs[1].account: unknown account "nope-acct"`}, + {"bad-unknown-account.toml", `envs[1] (nope-acct/app-pipeline/Production).account: unknown account "nope-acct"`}, {"bad-no-envs.toml", "envs: no envs configured"}, - {"bad-duplicate-env.toml", "envs[1]: duplicate env stage-acct/app-pipeline/Test"}, - {"bad-duplicate-account.toml", `aws.accounts[1].name: duplicate account "stage-acct"`}, - {"bad-account-no-region.toml", "aws.accounts[1].region is required"}, - {"bad-env-no-deploy-action.toml", "envs[1].deploy_action is required"}, - {"bad-approval-stage-only.toml", "envs[1].approval_action is required when approval_stage is set"}, - {"bad-ecs-no-cluster.toml", "envs[0].ecs[0].cluster is required"}, - {"bad-ecs-no-services.toml", "envs[0].ecs[0].services: no services listed"}, + {"bad-duplicate-env.toml", "envs[1] (stage-acct/app-pipeline/Test): duplicate env"}, + {"bad-duplicate-account.toml", `aws.accounts[1] (stage-acct).name: duplicate account "stage-acct"`}, + {"bad-account-no-region.toml", "aws.accounts[1] (prod-acct).region is required"}, + {"bad-env-no-deploy-action.toml", "envs[1] (prod-acct/app-pipeline/Production).deploy_action is required"}, + {"bad-approval-stage-only.toml", "envs[1] (prod-acct/app-pipeline/Production).approval_action is required when approval_stage is set"}, + {"bad-ecs-no-cluster.toml", "envs[0] (stage-acct/app-pipeline/Test).ecs[0].cluster is required"}, + {"bad-ecs-no-services.toml", "envs[0] (stage-acct/app-pipeline/Test).ecs[0].services: no services listed"}, {"bad-token-both.toml", "github: set exactly one of token_env or token_command"}, {"bad-token-none.toml", "jira: set exactly one of token_env or token_command"}, {"bad-duration-unit.toml", `settings.done_grace: invalid duration "2x"`}, {"bad-poll-interval.toml", `settings.poll_interval must be at least 15s, got "10s"`}, + {"bad-jira-query.toml", `jira.site must not have a query or fragment`}, + {"bad-jira-forcequery.toml", `jira.site must not have a query or fragment`}, + {"bad-jira-fragment.toml", `jira.site must not have a query or fragment`}, + {"bad-project-duplicate.toml", `jira.projects: duplicate project "ABC"`}, + {"bad-account-no-profile.toml", `aws.accounts[1] (prod-acct).profile is required`}, + {"bad-account-blank-region.toml", `aws.accounts[1] (prod-acct).region is required`}, + {"bad-env-repo.toml", `envs[1] (prod-acct/app-pipeline/Production).repos: "acme" is not an owner/name repo`}, + {"bad-env-pipeline-chars.toml", `envs[1] (prod-acct/app pipeline/Production).pipeline: "app pipeline" has characters outside A-Z a-z 0-9 . @ _ -`}, + {"bad-env-action-chars.toml", `envs[1] (prod-acct/app-pipeline/Production).deploy_action: "deploy/now" has characters outside`}, + {"bad-env-approval-stage-chars.toml", `envs[1] (prod-acct/app-pipeline/Production).approval_stage: "Test!" has characters outside`}, + {"bad-approval-is-deploy.toml", `envs[0] (stage-acct/app-pipeline/Test).approval_action must differ from deploy_action when the approval is in the same stage`}, + {"bad-ecs-duplicate.toml", `envs[0] (stage-acct/app-pipeline/Test).ecs[2].services: duplicate service "s1"`}, + {"bad-ecs-duplicate-same-entry.toml", `envs[0] (stage-acct/app-pipeline/Test).ecs[0].services: duplicate service "s1"`}, + {"bad-repo-leading-dash.toml", `github.repos: "-acme/app" is not an owner/name repo`}, + {"bad-repo-dot.toml", `github.repos: "acme/." is not an owner/name repo`}, + {"bad-repo-dotdot.toml", `github.repos: "acme/.." is not an owner/name repo`}, + {"bad-repo-two-slashes.toml", `github.repos: "acme/app/x" is not an owner/name repo`}, + {"bad-duration-day-minus.toml", `settings.fade_after: invalid duration "1d-23h"`}, + {"bad-duration-day-plus.toml", `settings.fade_after: invalid duration "1d+1h"`}, {"bad-duration-negative.toml", `settings.stale_review_after must be positive, got "-1h"`}, } for _, tt := range tests { @@ -124,18 +144,19 @@ func TestEnvs(t *testing.T) { func TestStageSpecs(t *testing.T) { got := load(t, "grouped.toml").StageSpecs() - want := map[PipelineKey][]aws.StageSpec{ - {Account: "stage-acct", Pipeline: "app-pipeline"}: { + // Pipelines in order of first appearance, specs in file order. + want := []PipelineSpecs{ + {Key: PipelineKey{Account: "stage-acct", Pipeline: "app-pipeline"}, Specs: []aws.StageSpec{ // approval_stage defaults to the env's own stage. {Stage: "Test", DeployAction: "Deploy", ApprovalStage: "Test", ApprovalAction: "ApproveTest"}, {Stage: "Staging", DeployAction: "Deploy"}, - }, - {Account: "prod-acct", Pipeline: "app-pipeline"}: { + }}, + {Key: PipelineKey{Account: "prod-acct", Pipeline: "app-pipeline"}, Specs: []aws.StageSpec{ {Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ApproveProd"}, - }, - {Account: "stage-acct", Pipeline: "other-pipeline"}: { + }}, + {Key: PipelineKey{Account: "stage-acct", Pipeline: "other-pipeline"}, Specs: []aws.StageSpec{ {Stage: "Test", DeployAction: "Deploy"}, - }, + }}, } if !reflect.DeepEqual(got, want) { t.Errorf("StageSpecs() = %+v\nwant %+v", got, want) @@ -214,10 +235,12 @@ func TestDefaultPath(t *testing.T) { if p, err := DefaultPath(); err != nil || p != "/xdg/app-green/config.toml" { t.Errorf("DefaultPath() with XDG_CONFIG_HOME = %q, %v", p, err) } - t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("HOME", "/home/me") - if p, err := DefaultPath(); err != nil || p != "/home/me/.config/app-green/config.toml" { - t.Errorf("DefaultPath() without XDG_CONFIG_HOME = %q, %v", p, err) + for _, xdg := range []string{"", "relative/dir"} { // the XDG spec says ignore a relative path + t.Setenv("XDG_CONFIG_HOME", xdg) + if p, err := DefaultPath(); err != nil || p != "/home/me/.config/app-green/config.toml" { + t.Errorf("DefaultPath() with XDG_CONFIG_HOME=%q = %q, %v", xdg, p, err) + } } } @@ -245,3 +268,63 @@ func TestWatchedRepos(t *testing.T) { } } } + +func TestJiraSitePath(t *testing.T) { + if got := load(t, "site-path.toml").Jira.Site; got != "https://example.atlassian.net/jira" { + t.Errorf("site = %q, want the path kept and the trailing slash trimmed", got) + } +} + +func TestWhitespaceTrimmed(t *testing.T) { + c := load(t, "whitespace.toml") + ref := load(t, "ok.toml") + if !reflect.DeepEqual(c.AWS.Accounts, ref.AWS.Accounts) { + t.Errorf("accounts = %+v, want %+v", c.AWS.Accounts, ref.AWS.Accounts) + } + if !reflect.DeepEqual(c.Envs(), ref.Envs()) { + t.Errorf("Envs() = %+v, want %+v", c.Envs(), ref.Envs()) + } + if !reflect.DeepEqual(c.StageSpecs(), ref.StageSpecs()) { + t.Errorf("StageSpecs() = %+v, want %+v", c.StageSpecs(), ref.StageSpecs()) + } +} + +func TestRepoNames(t *testing.T) { + for _, r := range []string{"acme/app", "acme_co/app.js", "a1-b/.github", "acme/my_app-2"} { + if _, err := normalizeRepos("github.repos", []string{r}); err != nil { + t.Errorf("normalizeRepos(%q) = %v, want ok", r, err) + } + } +} + +func TestTokenCommandTimeout(t *testing.T) { + old := tokenCommandTimeout + tokenCommandTimeout = 100 * time.Millisecond + t.Cleanup(func() { tokenCommandTimeout = old }) + start := time.Now() + _, err := load(t, "token-slow.toml").JiraToken() + if err == nil || !strings.Contains(err.Error(), "jira.token_command timed out after 100ms") { + t.Errorf("JiraToken() error = %v, want a timeout", err) + } + if d := time.Since(start); d > 3*time.Second { + t.Errorf("JiraToken() took %v, want it cut off near the timeout", d) + } +} + +func TestQuoteStderrRuneBoundary(t *testing.T) { + got := quoteStderr(strings.Repeat("\u00e9", maxStderr)) // 2 bytes each + if !utf8.ValidString(got) || !strings.HasSuffix(got, "...") || len(got) > maxStderr+len("...") { + t.Errorf("quoteStderr cut badly: %q (len %d)", got, len(got)) + } + if got := quoteStderr(" short\n"); got != "short" { + t.Errorf("quoteStderr(short) = %q", got) + } +} + +func TestEnvsReturnsCopies(t *testing.T) { + c := load(t, "ok.toml") + c.Envs()[0].Repos[0] = "changed/repo" + if got := c.Envs()[0].Repos[0]; got != "acme/app" { + t.Errorf("Envs() shares Repos with the config: got %q after editing a copy", got) + } +} diff --git a/internal/config/testdata/bad-account-blank-region.toml b/internal/config/testdata/bad-account-blank-region.toml new file mode 100644 index 0000000..2d7a8d0 --- /dev/null +++ b/internal/config/testdata/bad-account-blank-region.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = " " + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-account-no-profile.toml b/internal/config/testdata/bad-account-no-profile.toml new file mode 100644 index 0000000..cb55778 --- /dev/null +++ b/internal/config/testdata/bad-account-no-profile.toml @@ -0,0 +1,46 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-approval-is-deploy.toml b/internal/config/testdata/bad-approval-is-deploy.toml new file mode 100644 index 0000000..7648723 --- /dev/null +++ b/internal/config/testdata/bad-approval-is-deploy.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "Deploy" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-duration-day-minus.toml b/internal/config/testdata/bad-duration-day-minus.toml new file mode 100644 index 0000000..76c90c4 --- /dev/null +++ b/internal/config/testdata/bad-duration-day-minus.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "1d-23h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-duration-day-plus.toml b/internal/config/testdata/bad-duration-day-plus.toml new file mode 100644 index 0000000..10b7d4d --- /dev/null +++ b/internal/config/testdata/bad-duration-day-plus.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "1d+1h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-ecs-duplicate-same-entry.toml b/internal/config/testdata/bad-ecs-duplicate-same-entry.toml new file mode 100644 index 0000000..f55066b --- /dev/null +++ b/internal/config/testdata/bad-ecs-duplicate-same-entry.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "s1 "] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-ecs-duplicate.toml b/internal/config/testdata/bad-ecs-duplicate.toml new file mode 100644 index 0000000..37993e3 --- /dev/null +++ b/internal/config/testdata/bad-ecs-duplicate.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "s2"] }, { cluster = "c2", services = ["s1"] }, { cluster = "arn:aws:ecs:us-east-1:111111111111:cluster/c1", services = ["arn:aws:ecs:us-east-1:111111111111:service/c1/s1"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-env-action-chars.toml b/internal/config/testdata/bad-env-action-chars.toml new file mode 100644 index 0000000..528730b --- /dev/null +++ b/internal/config/testdata/bad-env-action-chars.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy/now" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-env-approval-stage-chars.toml b/internal/config/testdata/bad-env-approval-stage-chars.toml new file mode 100644 index 0000000..9501ba3 --- /dev/null +++ b/internal/config/testdata/bad-env-approval-stage-chars.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test!" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-env-pipeline-chars.toml b/internal/config/testdata/bad-env-pipeline-chars.toml new file mode 100644 index 0000000..c263dda --- /dev/null +++ b/internal/config/testdata/bad-env-pipeline-chars.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-env-repo.toml b/internal/config/testdata/bad-env-repo.toml new file mode 100644 index 0000000..d01c1b5 --- /dev/null +++ b/internal/config/testdata/bad-env-repo.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme"] diff --git a/internal/config/testdata/bad-jira-forcequery.toml b/internal/config/testdata/bad-jira-forcequery.toml new file mode 100644 index 0000000..40c66a7 --- /dev/null +++ b/internal/config/testdata/bad-jira-forcequery.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net?" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-jira-fragment.toml b/internal/config/testdata/bad-jira-fragment.toml new file mode 100644 index 0000000..5dddb13 --- /dev/null +++ b/internal/config/testdata/bad-jira-fragment.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/#top" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-jira-query.toml b/internal/config/testdata/bad-jira-query.toml new file mode 100644 index 0000000..eada421 --- /dev/null +++ b/internal/config/testdata/bad-jira-query.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/?token=x" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-project-duplicate.toml b/internal/config/testdata/bad-project-duplicate.toml new file mode 100644 index 0000000..697b59d --- /dev/null +++ b/internal/config/testdata/bad-project-duplicate.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC", "XY", "ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-repo-dot.toml b/internal/config/testdata/bad-repo-dot.toml new file mode 100644 index 0000000..b270880 --- /dev/null +++ b/internal/config/testdata/bad-repo-dot.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["acme/."] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-repo-dotdot.toml b/internal/config/testdata/bad-repo-dotdot.toml new file mode 100644 index 0000000..9ee6bdd --- /dev/null +++ b/internal/config/testdata/bad-repo-dotdot.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["acme/.."] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-repo-leading-dash.toml b/internal/config/testdata/bad-repo-leading-dash.toml new file mode 100644 index 0000000..21a3da0 --- /dev/null +++ b/internal/config/testdata/bad-repo-leading-dash.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["-acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/bad-repo-two-slashes.toml b/internal/config/testdata/bad-repo-two-slashes.toml new file mode 100644 index 0000000..80baa33 --- /dev/null +++ b/internal/config/testdata/bad-repo-two-slashes.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["acme/app/x"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = "Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] diff --git a/internal/config/testdata/site-path.toml b/internal/config/testdata/site-path.toml new file mode 100644 index 0000000..fc719f9 --- /dev/null +++ b/internal/config/testdata/site-path.toml @@ -0,0 +1,25 @@ +# A Jira site with a path. +[settings] + partial_prod_after = "90m" + +[jira] + site = "https://example.atlassian.net/jira/" + email = "me@example.com" + token_command = "echo tok" + projects = ["ABC", "XY_2"] + +[github] + author = "me" + token_command = "echo tok" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" diff --git a/internal/config/testdata/token-slow.toml b/internal/config/testdata/token-slow.toml new file mode 100644 index 0000000..804bbd9 --- /dev/null +++ b/internal/config/testdata/token-slow.toml @@ -0,0 +1,25 @@ +# A token command that outlives the timeout. +[settings] + partial_prod_after = "90m" + +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_command = "sleep 5" + projects = ["ABC", "XY_2"] + +[github] + author = "me" + token_command = "echo tok" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" diff --git a/internal/config/testdata/whitespace.toml b/internal/config/testdata/whitespace.toml new file mode 100644 index 0000000..b1da70f --- /dev/null +++ b/internal/config/testdata/whitespace.toml @@ -0,0 +1,48 @@ +# ok.toml with stray whitespace around names. +[settings] + stale_review_after = "48h" + done_grace = "2h" + fade_after = "24h" + partial_prod_after = "4h" + +[jira] + site = "https://example.atlassian.net/" + email = "me@example.com" + token_env = "JIRA_API_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "GITHUB_TOKEN" # or token_command = "gh auth token" + repos = ["Acme/App"] + +[[aws.accounts]] + name = " stage-acct " + profile = " stage " + region = "us-east-1" + read_only = false + +[[aws.accounts]] + name = "prod-acct" + profile = "prod-view" + region = "us-east-1" + read_only = true + +[[envs]] + account = "stage-acct " + pipeline = " app-pipeline" + stage = "Test " + deploy_action = " Deploy " + approval_action = "ApproveTest " + repos = ["Acme/App"] # repos this env deploys; decides EnvSlot.Applies + ecs = [{ cluster = "c1", services = ["s1", "arn:aws:ecs:us-east-1:111111111111:service/c1/s2"] }] + +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "deploy" + approval_stage = " Test" + approval_action = "ApproveProd" + prod = true + repos = ["acme/app"] From b5f0dfa4388b0ea8b13f030570053c70ecfe4d53 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:48:41 -0400 Subject: [PATCH 21/31] refactor(github): drop the unused ETag cache Every read now goes through GraphQL (a POST) or Compare, which skipped the cache already, so the transport cached nothing. The client keeps one plain http.Client; the resolver caches Compare results by SHA pair. Assisted-by: Claude Code:claude-opus-5-5 --- internal/github/client.go | 16 ++-- internal/github/compare.go | 2 +- internal/github/compare_test.go | 4 +- internal/github/etag.go | 138 -------------------------------- internal/github/etag_test.go | 47 ----------- 5 files changed, 8 insertions(+), 199 deletions(-) delete mode 100644 internal/github/etag.go delete mode 100644 internal/github/etag_test.go diff --git a/internal/github/client.go b/internal/github/client.go index 2d2196d..47f0e6e 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -18,19 +18,17 @@ import ( // Client talks to the GitHub API with one token. type Client struct { - http *http.Client // GETs cached by ETag - plain *http.Client // no cache: Compare, whose results the resolver caches by SHA pair + http *http.Client token string api string now func() time.Time // reads X-RateLimit-Reset } -// New returns a Client for token. GET responses other than Compare's are -// cached by ETag, so an unchanged one costs nothing against the rate limit. +// New returns a Client for token. Nothing is cached here: the resolver +// caches Compare results by SHA pair. func New(token string) *Client { return &Client{ - http: &http.Client{Timeout: 20 * time.Second, Transport: newETagTransport(http.DefaultTransport, 500)}, - plain: &http.Client{Timeout: 20 * time.Second}, + http: &http.Client{Timeout: 20 * time.Second}, token: token, api: "https://api.github.com", now: time.Now, @@ -42,10 +40,6 @@ func New(token string) *Client { func (c *Client) SetAPI(base string) { c.api = strings.TrimRight(base, "/") } func (c *Client) do(ctx context.Context, method, path string, body, out any) error { - return c.doWith(ctx, c.http, method, path, body, out) -} - -func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out any) error { var rdr io.Reader if body != nil { b, err := json.Marshal(body) @@ -64,7 +58,7 @@ func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path strin if body != nil { req.Header.Set("Content-Type", "application/json") } - resp, err := hc.Do(req) + resp, err := c.http.Do(req) if err != nil { return err } diff --git a/internal/github/compare.go b/internal/github/compare.go index fc834c8..af4a600 100644 --- a/internal/github/compare.go +++ b/internal/github/compare.go @@ -26,7 +26,7 @@ func (c *Client) Compare(ctx context.Context, repo, base, head string) (model.In var out struct { Status string `json:"status"` } - err := c.doWith(ctx, c.plain, http.MethodGet, "/repos/"+repo+"/compare/"+base+"..."+head+"?per_page=1", nil, &out) + err := c.do(ctx, http.MethodGet, "/repos/"+repo+"/compare/"+base+"..."+head+"?per_page=1", nil, &out) var ae *APIError if errors.As(err, &ae) && ae.Status == http.StatusNotFound { return model.InclusionUnknown, nil diff --git a/internal/github/compare_test.go b/internal/github/compare_test.go index bc01b4d..6d872b5 100644 --- a/internal/github/compare_test.go +++ b/internal/github/compare_test.go @@ -47,9 +47,9 @@ func TestCompare(t *testing.T) { } } -func TestCompareSkipsTheETagCache(t *testing.T) { +func TestCompareSendsNoConditionalRequest(t *testing.T) { // The resolver caches compare results by SHA pair, so Compare never sends - // If-None-Match. + // If-None-Match and every call reaches the server. hits := 0 c := serve(t, func(w http.ResponseWriter, r *http.Request) { hits++ diff --git a/internal/github/etag.go b/internal/github/etag.go deleted file mode 100644 index 0e255ea..0000000 --- a/internal/github/etag.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copied from git-green internal/github/etag.go (2026-10-06); Pool, fromCache and -// maxCachedResponses dropped, cache header renamed, bodies over -// maxCachedBody not cached. - -package github - -import ( - "bytes" - "io" - "net/http" - "strings" - "sync" -) - -// maxCachedBody is the largest body the cache keeps; a bigger response -// passes through uncached so a few large ones cannot pin megabytes. -const maxCachedBody = 256 << 10 - -// cacheHeader marks a response replayed from the ETag cache. -const cacheHeader = "X-App-Green-Cache" - -// etagTransport makes unchanged GET responses free. It sends the ETag from the -// last response for a URL as If-None-Match; when GitHub answers 304 Not -// Modified, it replays the stored body as a 200 so callers see ordinary data. -// -// One transport serves one token, so cached responses never cross tokens. -type etagTransport struct { - base http.RoundTripper - max int - - mu sync.Mutex - entries map[string]*cachedResponse - clock uint64 // bumped on every use, for least-recently-used eviction -} - -type cachedResponse struct { - etag string - header http.Header - body []byte - used uint64 -} - -func newETagTransport(base http.RoundTripper, max int) *etagTransport { - return &etagTransport{base: base, max: max, entries: make(map[string]*cachedResponse)} -} - -func (t *etagTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - return t.base.RoundTrip(req) - } - key := req.URL.String() - - t.mu.Lock() - cached := t.entries[key] - if cached != nil { - t.clock++ - cached.used = t.clock - } - t.mu.Unlock() - - if cached != nil { - req = req.Clone(req.Context()) - req.Header.Set("If-None-Match", cached.etag) - } - - resp, err := t.base.RoundTrip(req) - if err != nil { - return nil, err - } - - if resp.StatusCode == http.StatusNotModified && cached != nil { - _ = resp.Body.Close() - return replay(req, resp, cached), nil - } - - etag := resp.Header.Get("ETag") - if resp.StatusCode != http.StatusOK || etag == "" { - return resp, nil - } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxCachedBody+1)) - if err != nil { - _ = resp.Body.Close() - return nil, err - } - if len(body) > maxCachedBody { - // Too big to cache: hand back what was read followed by the rest. - resp.Body = struct { - io.Reader - io.Closer - }{io.MultiReader(bytes.NewReader(body), resp.Body), resp.Body} - return resp, nil - } - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(body)) - t.store(key, &cachedResponse{etag: etag, header: resp.Header.Clone(), body: body}) - return resp, nil -} - -// replay builds a 200 from the cached body, taking the rate-limit headers from -// the fresh 304 so they stay current. -func replay(req *http.Request, notModified *http.Response, cached *cachedResponse) *http.Response { - header := cached.header.Clone() - for k, v := range notModified.Header { - if strings.HasPrefix(strings.ToLower(k), "x-ratelimit-") { - header[k] = v - } - } - header.Set(cacheHeader, "1") - return &http.Response{ - Status: "200 OK", - StatusCode: http.StatusOK, - Proto: notModified.Proto, - ProtoMajor: notModified.ProtoMajor, - ProtoMinor: notModified.ProtoMinor, - Header: header, - Body: io.NopCloser(bytes.NewReader(cached.body)), - ContentLength: int64(len(cached.body)), - Request: req, - } -} - -func (t *etagTransport) store(key string, c *cachedResponse) { - t.mu.Lock() - defer t.mu.Unlock() - t.clock++ - c.used = t.clock - t.entries[key] = c - if len(t.entries) <= t.max { - return - } - oldest, oldestUse := "", ^uint64(0) - for k, e := range t.entries { - if e.used < oldestUse { - oldest, oldestUse = k, e.used - } - } - delete(t.entries, oldest) -} diff --git a/internal/github/etag_test.go b/internal/github/etag_test.go deleted file mode 100644 index 32a3414..0000000 --- a/internal/github/etag_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package github - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestETagTransportSkipsLargeBodies(t *testing.T) { - cases := []struct { - name string - size int - cached bool - }{ - {"a small body is cached", 1024, true}, - {"a body over 256KB is not", maxCachedBody + 1, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - body := strings.Repeat("x", tc.size) - var inm []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - inm = append(inm, r.Header.Get("If-None-Match")) - w.Header().Set("ETag", `"v1"`) - _, _ = io.WriteString(w, body) - })) - defer srv.Close() - hc := &http.Client{Transport: newETagTransport(http.DefaultTransport, 10)} - for i := range 2 { - resp, err := hc.Get(srv.URL) - if err != nil { - t.Fatal(err) - } - got, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if len(got) != tc.size { - t.Errorf("call %d: body %d bytes, want %d", i+1, len(got), tc.size) - } - } - if sent := inm[1] != ""; sent != tc.cached { - t.Errorf("second If-None-Match = %q, want cached %v", inm[1], tc.cached) - } - }) - } -} From fd6769b97aa01f7de752859ebd34e7ef6fed1688 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:56:59 -0400 Subject: [PATCH 22/31] feat(resolver): polling, compare cache, snapshots Poll fetches Jira, GitHub (my recent PRs plus their base-branch PRs as link context, per watched repo) and each AWS account (sources once per pipeline for the session, one History call per pipeline, health per env) concurrently, then looks up the tickets my open PRs name that MyTickets missed, and runs link and rules over the last good data. - Compare answers are cached by (repo, base, head); only Included and NotIncluded are kept. A compare error is Unknown plus a warning. - Each source, and each AWS account, keeps its previous result when it fails and reports it in Snapshot.Status: the error, SSO expiry, and a rate limit that skips the source until RetryAt. - An env with no repos in config takes them from its pipeline's sources. - Warnings are deduplicated per poll, put in the Snapshot and logged once. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/cache.go | 36 ++ internal/resolver/fakes_test.go | 313 ++++++++++++++++ internal/resolver/resolver.go | 528 +++++++++++++++++++++++++++ internal/resolver/resolver_test.go | 551 +++++++++++++++++++++++++++++ 4 files changed, 1428 insertions(+) create mode 100644 internal/resolver/cache.go create mode 100644 internal/resolver/fakes_test.go create mode 100644 internal/resolver/resolver.go create mode 100644 internal/resolver/resolver_test.go diff --git a/internal/resolver/cache.go b/internal/resolver/cache.go new file mode 100644 index 0000000..0bbc553 --- /dev/null +++ b/internal/resolver/cache.go @@ -0,0 +1,36 @@ +package resolver + +import ( + "sync" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// compareCache holds compare answers by (repo, base, head) for the session. +// Only Included and NotIncluded are stored: both are permanent for a SHA +// pair, and a new head is a new key. Unknown (a 404, an unexpected status) +// and errors are asked again next time. +type compareCache struct { + mu sync.Mutex + m map[[3]string]model.Inclusion +} + +func newCompareCache() *compareCache { + return &compareCache{m: map[[3]string]model.Inclusion{}} +} + +func (c *compareCache) get(repo, base, head string) (model.Inclusion, bool) { + c.mu.Lock() + defer c.mu.Unlock() + inc, ok := c.m[[3]string{repo, base, head}] + return inc, ok +} + +func (c *compareCache) put(repo, base, head string, inc model.Inclusion) { + if inc != model.Included && inc != model.NotIncluded { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.m[[3]string{repo, base, head}] = inc +} diff --git a/internal/resolver/fakes_test.go b/internal/resolver/fakes_test.go new file mode 100644 index 0000000..4cdeb54 --- /dev/null +++ b/internal/resolver/fakes_test.go @@ -0,0 +1,313 @@ +package resolver_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/aws" + "github.com/ericdahl-dev/app-green/internal/config" + "github.com/ericdahl-dev/app-green/internal/model" +) + +const repo = "acme/app" + +var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + +// clock is a settable clock for the resolver. +type clock struct { + mu sync.Mutex + now time.Time +} + +func newClock() *clock { return &clock{now: t0} } + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +// baseTOML is a config with two accounts; envs are appended per test. +const baseTOML = ` +[jira] + site = "https://example.atlassian.net" + email = "me@example.com" + token_env = "APP_GREEN_TEST_JIRA_TOKEN" + projects = ["ABC"] + +[github] + author = "me" + token_env = "APP_GREEN_TEST_GITHUB_TOKEN" + repos = ["acme/app"] + +[[aws.accounts]] + name = "stage-acct" + profile = "stage" + region = "us-east-1" + +[[aws.accounts]] + name = "prod-acct" + profile = "prod" + region = "us-east-1" +` + +// testEnv is the Test stage in stage-acct, deploying acme/app. +const testEnv = ` +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + repos = ["acme/app"] +` + +// loadConfig writes baseTOML plus envs to a temp file and loads it. +func loadConfig(t *testing.T, envs ...string) *config.Config { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(baseTOML+strings.Join(envs, "\n")), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + return cfg +} + +type fakeTracker struct { + mu sync.Mutex + my []model.Ticket + myWarn []string + myErr error + myCalls int + byKey map[string]model.Ticket + byKeyErr error + byKeyCalls [][]string + block chan struct{} // when set, MyTickets waits for it to close + inFlight int + maxFlight int +} + +func (f *fakeTracker) MyTickets(ctx context.Context, projects []string) ([]model.Ticket, []string, error) { + f.mu.Lock() + f.myCalls++ + f.inFlight++ + f.maxFlight = max(f.maxFlight, f.inFlight) + block := f.block + f.mu.Unlock() + if block != nil { + select { + case <-block: + case <-ctx.Done(): + } + } + f.mu.Lock() + defer f.mu.Unlock() + f.inFlight-- + return slices.Clone(f.my), slices.Clone(f.myWarn), f.myErr +} + +func (f *fakeTracker) TicketsByKey(_ context.Context, keys []string) ([]model.Ticket, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.byKeyCalls = append(f.byKeyCalls, slices.Clone(keys)) + if f.byKeyErr != nil { + return nil, nil, f.byKeyErr + } + var out []model.Ticket + for _, k := range keys { + if t, ok := f.byKey[k]; ok { + out = append(out, t) + } + } + return out, nil, nil +} + +func (f *fakeTracker) set(fn func(f *fakeTracker)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(f) +} + +func (f *fakeTracker) calls() (my int, byKey [][]string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.myCalls, slices.Clone(f.byKeyCalls) +} + +type compareCall struct{ repo, base, head string } + +type fakeHost struct { + mu sync.Mutex + prs map[string][]model.PR // by repo + prWarn []string + prErr error + prErrRepo map[string]error + prCalls int + base map[string][]model.PR // BasePRs result by repo + baseInputs map[string][]model.PR + compare func(repo, base, head string) (model.Inclusion, error) + compareCalls []compareCall +} + +func (f *fakeHost) RecentPRs(_ context.Context, owner, name, _ string, _ time.Time) ([]model.PR, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.prCalls++ + if f.prErr != nil { + return nil, nil, f.prErr + } + if err := f.prErrRepo[owner+"/"+name]; err != nil { + return nil, nil, err + } + return slices.Clone(f.prs[owner+"/"+name]), slices.Clone(f.prWarn), nil +} + +func (f *fakeHost) BasePRs(_ context.Context, owner, name string, prs []model.PR) ([]model.PR, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.baseInputs == nil { + f.baseInputs = map[string][]model.PR{} + } + f.baseInputs[owner+"/"+name] = slices.Clone(prs) + return slices.Clone(f.base[owner+"/"+name]), nil, nil +} + +func (f *fakeHost) Compare(_ context.Context, repo, base, head string) (model.Inclusion, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.compareCalls = append(f.compareCalls, compareCall{repo, base, head}) + if f.compare == nil { + return model.InclusionUnknown, nil + } + return f.compare(repo, base, head) +} + +func (f *fakeHost) set(fn func(f *fakeHost)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(f) +} + +func (f *fakeHost) compares() []compareCall { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.compareCalls) +} + +type fakeDeployer struct { + mu sync.Mutex + sources map[string]map[string]string // by pipeline + sourcesCalls int + hist map[string]map[string][]model.Deploy // by pipeline, then stage + histWarn []string + histErr error + histErrPipe map[string]error + histCalls int + histSpecs [][]aws.StageSpec + health map[string]model.Health // by first service name + healthErr error +} + +func (f *fakeDeployer) Sources(_ context.Context, pipeline string) (map[string]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sourcesCalls++ + if f.histErr != nil { + return nil, f.histErr + } + src, ok := f.sources[pipeline] + if !ok { + src = map[string]string{repo: "AppSource"} + } + return src, nil +} + +func (f *fakeDeployer) History(_ context.Context, pipeline string, _ map[string]string, specs ...aws.StageSpec) (map[string][]model.Deploy, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.histCalls++ + f.histSpecs = append(f.histSpecs, slices.Clone(specs)) + if f.histErr != nil { + return nil, nil, f.histErr + } + if err := f.histErrPipe[pipeline]; err != nil { + return nil, nil, err + } + return f.hist[pipeline], slices.Clone(f.histWarn), nil +} + +func (f *fakeDeployer) Health(_ context.Context, services []aws.Service) (model.Health, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.healthErr != nil { + return model.Health{}, nil, f.healthErr + } + if len(services) == 0 { + return model.Health{}, nil, nil + } + return f.health[services[0].Name], nil, nil +} + +func (f *fakeDeployer) set(fn func(f *fakeDeployer)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(f) +} + +func (f *fakeDeployer) calls() (sources, history int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.sourcesCalls, f.histCalls +} + +func ticket(key string) model.Ticket { + return model.Ticket{Key: key, Title: "x", Status: "In Progress", StatusCategory: model.StatusInProgress, + Updated: t0.Add(-time.Hour), StatusSince: t0.Add(-time.Hour)} +} + +func mergedPR(n int, title, sha string) model.PR { + return model.PR{Repo: repo, DefaultBranch: "main", Number: n, Title: title, HeadRef: "feature/x" + sha, BaseRef: "main", + State: model.PRMerged, MergeSHA: sha, MergedAt: t0.Add(-3 * time.Hour), OpenedAt: t0.Add(-5 * time.Hour)} +} + +func openPR(n int, title string) model.PR { + return model.PR{Repo: repo, DefaultBranch: "main", Number: n, Title: title, HeadRef: "feature/open" + title, BaseRef: "main", + State: model.PROpen, OpenedAt: t0.Add(-5 * time.Hour), Checks: model.ChecksPending} +} + +func succeeded(sha string, at time.Time) model.Deploy { + return model.Deploy{ExecutionID: "ex-" + sha, Status: model.DeploySucceeded, Revisions: map[string]string{repo: sha}, FinishedAt: at} +} + +// safeBuffer is a bytes.Buffer safe for concurrent writes. +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 0000000..728fd36 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,528 @@ +// Package resolver is the only package that orchestrates I/O: it polls the +// Jira, GitHub and AWS adapters, runs the pure core (link, rules) and emits +// Snapshots for the UI. +package resolver + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "slices" + "strings" + "sync" + "time" + + "github.com/ericdahl-dev/app-green/internal/aws" + "github.com/ericdahl-dev/app-green/internal/config" + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/link" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/rules" +) + +// Tracker is the part of jira.Client the resolver uses. +type Tracker interface { + MyTickets(ctx context.Context, projects []string) ([]model.Ticket, []string, error) + TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, []string, error) +} + +// CodeHost is the part of github.Client the resolver uses. +type CodeHost interface { + RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, []string, error) + BasePRs(ctx context.Context, owner, name string, prs []model.PR) ([]model.PR, []string, error) + Compare(ctx context.Context, repo, base, head string) (model.Inclusion, error) +} + +// Deployer is the part of aws.Client (one account) the resolver uses. +type Deployer interface { + Sources(ctx context.Context, pipeline string) (map[string]string, error) + History(ctx context.Context, pipeline string, sources map[string]string, specs ...aws.StageSpec) (map[string][]model.Deploy, []string, error) + Health(ctx context.Context, services []aws.Service) (model.Health, []string, error) +} + +// Adapters are the resolver's sources. AWS is keyed by config account name. +type Adapters struct { + Tracker Tracker + Code CodeHost + AWS map[string]Deployer +} + +// AdapterStatus is how one source fared, for the header. +type AdapterStatus struct { + Name string // "jira", "github", "aws " + OK bool // the last attempt succeeded + At time.Time // last success; zero if none yet + Err string // the last attempt's error, short + SSO bool // the error is an expired AWS SSO session + Throttled bool // rate limited: the source is skipped until RetryAt + RetryAt time.Time +} + +// Snapshot is one poll's result, for the UI. +type Snapshot struct { + Chains []model.Chain // evaluated and sorted + Unlinked []model.PR + Status []AdapterStatus // jira, github, then AWS accounts in config order + // Warnings are problems that left data usable but maybe incomplete + // (decode errors, truncation, a stage spec that matched nothing, failed + // compares), deduplicated, in first-seen order. They cover the data + // shown, so a source that failed this poll keeps its last warnings. + Warnings []string + At time.Time +} + +// prSince is how far back RecentPRs looks. +const prSince = 30 * 24 * time.Hour + +// AWSThrottleBackoff is how long an AWS account is skipped after AWS +// throttles it; AWS errors carry no Retry-After. +const AWSThrottleBackoff = time.Minute + +// maxErr caps AdapterStatus.Err, in runes. +const maxErr = 160 + +// repoData is the last good GitHub result for one repo. +type repoData struct { + prs []model.PR // mine + context []model.PR // base-branch PRs of mine, any author + warn []string +} + +// pipelineData is the last good AWS result for one pipeline. +type pipelineData struct { + deploys map[string][]model.Deploy // by stage + warn []string +} + +// healthData is the last good health of one Env. +type healthData struct { + health model.Health + warn []string +} + +// Resolver polls the adapters and builds Snapshots. Poll and Run are safe +// to call from several goroutines; polls never overlap. +type Resolver struct { + cfg *config.Config + ad Adapters + now func() time.Time + log *slog.Logger + + pollMu sync.Mutex // held for a whole poll; guards everything below + cache *compareCache + status map[string]*AdapterStatus + order []string // status names in display order + + tickets []model.Ticket + ticketWarn []string + extra []model.Ticket // named by my open PRs, not in tickets + extraWarn []string + repos map[string]repoData + sources map[config.PipelineKey]map[string]string // session cache + pipelines map[config.PipelineKey]pipelineData + health map[string]healthData // by Env.ID() +} + +// New returns a Resolver over ad. now is the clock; log receives each +// poll's warnings and source errors. +func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger) *Resolver { + r := &Resolver{ + cfg: cfg, ad: ad, now: now, log: log, + cache: newCompareCache(), + status: map[string]*AdapterStatus{}, + repos: map[string]repoData{}, + sources: map[config.PipelineKey]map[string]string{}, + pipelines: map[config.PipelineKey]pipelineData{}, + health: map[string]healthData{}, + } + r.order = append(r.order, "jira", "github") + for _, a := range r.accounts() { + r.order = append(r.order, "aws "+a) + } + for _, n := range r.order { + r.status[n] = &AdapterStatus{Name: n} + } + return r +} + +// accounts are the config accounts that have envs, in config order. +func (r *Resolver) accounts() []string { + var out []string + for _, a := range r.cfg.AWS.Accounts { + if slices.ContainsFunc(r.cfg.EnvList, func(e config.EnvConfig) bool { return e.Account == a.Name }) { + out = append(out, a.Name) + } + } + return out +} + +// jiraResult, githubResult and accountResult are one poll's fetches, +// merged into the Resolver after every fetch is done. +type jiraResult struct { + tickets []model.Ticket + warn []string + err error +} + +type githubResult struct { + repos map[string]repoData // repos fetched without error + err error // the first error, naming its repo +} + +type accountResult struct { + sources map[config.PipelineKey]map[string]string // newly discovered + pipelines map[config.PipelineKey]pipelineData + health map[string]healthData + err error +} + +// Poll fetches every source once, concurrently, and returns the resulting +// Snapshot. A source that fails keeps its previous result. +func (r *Resolver) Poll(ctx context.Context) Snapshot { + r.pollMu.Lock() + defer r.pollMu.Unlock() + now := r.now() + accts := r.accounts() + + var ( + wg sync.WaitGroup + jr jiraResult + gr githubResult + ars = make([]accountResult, len(accts)) + known = maps.Clone(r.sources) // read by the AWS goroutines + ) + run := func(name string, fetch func()) bool { + if r.backingOff(name, now) { + return false + } + wg.Add(1) + go func() { defer wg.Done(); fetch() }() + return true + } + jiraRan := run("jira", func() { jr = r.fetchJira(ctx) }) + githubRan := run("github", func() { gr = r.fetchGitHub(ctx, now) }) + acctRan := make([]bool, len(accts)) + for i, a := range accts { + acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, known) }) + } + wg.Wait() + + if githubRan { + for repo, d := range gr.repos { + r.repos[repo] = d + } + r.record("github", gr.err, now) + } + if jiraRan { + err := jr.err + if err == nil { + r.tickets, r.ticketWarn = jr.tickets, jr.warn + } + // Extra tickets need GitHub's PRs, so they come after the fetches. + // A rate-limited Jira is not asked again this poll. + if retryAfter(err) == 0 { + if xerr := r.fetchExtraTickets(ctx); err == nil { + err = xerr + } + } + r.record("jira", err, now) + } + for i, a := range accts { + if !acctRan[i] { + continue + } + ar := ars[i] + for k, v := range ar.sources { + r.sources[k] = v + } + for k, v := range ar.pipelines { + r.pipelines[k] = v + } + for k, v := range ar.health { + r.health[k] = v + } + r.record("aws "+a, ar.err, now) + } + snap := r.build(ctx, now) + for _, w := range snap.Warnings { + r.log.Warn("poll warning", "msg", w) + } + return snap +} + +func (r *Resolver) fetchJira(ctx context.Context) jiraResult { + t, w, err := r.ad.Tracker.MyTickets(ctx, r.cfg.Jira.Projects) + return jiraResult{tickets: t, warn: w, err: err} +} + +// fetchExtraTickets looks up the tickets my open PRs name that MyTickets +// did not return (assigned to someone else, say), so those PRs still get a +// row. On error the previous extras stay. +func (r *Resolver) fetchExtraTickets(ctx context.Context) error { + have := map[string]bool{} + for _, t := range r.tickets { + have[t.Key] = true + } + var keys []string + for _, d := range r.repos { + for _, p := range d.prs { + if p.State != model.PROpen { + continue + } + for _, k := range link.KeysIn(r.cfg.Jira.Projects, p.Title, p.HeadRef) { + if !have[k] && !slices.Contains(keys, k) { + keys = append(keys, k) + } + } + } + } + if len(keys) == 0 { + r.extra, r.extraWarn = nil, nil + return nil + } + slices.Sort(keys) + t, w, err := r.ad.Tracker.TicketsByKey(ctx, keys) + if err != nil { + return err + } + r.extra, r.extraWarn = t, w + return nil +} + +// fetchGitHub fetches my recent PRs and their base-branch PRs per watched +// repo. A repo that fails keeps its previous data and the rest go on, except +// that a rate limit or a bad token stops the loop: every later call would +// fail the same way. +func (r *Resolver) fetchGitHub(ctx context.Context, now time.Time) githubResult { + res := githubResult{repos: map[string]repoData{}} + for _, full := range r.cfg.WatchedRepos() { + d, err := r.fetchRepo(ctx, full, now) + if err != nil { + if res.err == nil { + res.err = fmt.Errorf("%s: %w", full, err) + } + if retryAfter(err) > 0 || github.IsAuth(err) { + break + } + continue + } + res.repos[full] = d + } + return res +} + +func (r *Resolver) fetchRepo(ctx context.Context, full string, now time.Time) (repoData, error) { + owner, name, _ := strings.Cut(full, "/") + prs, warn, err := r.ad.Code.RecentPRs(ctx, owner, name, r.cfg.GitHub.Author, now.Add(-prSince)) + if err != nil { + return repoData{}, err + } + bases, bwarn, err := r.ad.Code.BasePRs(ctx, owner, name, prs) + if err != nil { + return repoData{}, err + } + return repoData{prs: prs, context: bases, warn: slices.Concat(warn, bwarn)}, nil +} + +// fetchAccount reads one account: per pipeline, its sources (once per +// session) and one History call for all its envs' stages, then each env's +// health. A failing call keeps that pipeline's or env's previous data and +// the rest go on, except that an expired SSO session or throttling stops +// the account: every later call would fail the same way. +func (r *Resolver) fetchAccount(ctx context.Context, account string, known map[config.PipelineKey]map[string]string) accountResult { + res := accountResult{ + sources: map[config.PipelineKey]map[string]string{}, + pipelines: map[config.PipelineKey]pipelineData{}, + health: map[string]healthData{}, + } + d, ok := r.ad.AWS[account] + if !ok { + res.err = fmt.Errorf("no AWS client for account %s", account) + return res + } + // fail records err and reports whether the account should stop. + fail := func(err error) bool { + if res.err == nil { + res.err = err + } + return aws.IsSSOExpired(err) || aws.IsThrottled(err) + } + for _, ps := range r.cfg.StageSpecs() { + if ps.Key.Account != account { + continue + } + src, ok := known[ps.Key] + if !ok { + var err error + if src, err = d.Sources(ctx, ps.Key.Pipeline); err != nil { + if fail(err) { + return res + } + continue + } + res.sources[ps.Key] = src + } + deploys, warn, err := d.History(ctx, ps.Key.Pipeline, src, ps.Specs...) + if err != nil { + if fail(err) { + return res + } + continue + } + res.pipelines[ps.Key] = pipelineData{deploys: deploys, warn: warn} + } + for _, e := range r.cfg.Envs() { + if e.Account != account { + continue + } + h, warn, err := d.Health(ctx, r.cfg.Services(e)) + if err != nil { + if fail(err) { + return res + } + continue + } + res.health[e.ID()] = healthData{health: h, warn: warn} + } + return res +} + +// record updates a source's status after a poll's attempt. +func (r *Resolver) record(name string, err error, now time.Time) { + s := r.status[name] + if err == nil { + *s = AdapterStatus{Name: name, OK: true, At: now} + return + } + r.log.Warn("source failed", "source", name, "err", err) + *s = AdapterStatus{Name: name, At: s.At, Err: short(err.Error()), SSO: aws.IsSSOExpired(err)} + if d := retryAfter(err); d > 0 { + s.Throttled, s.RetryAt = true, now.Add(d) + } +} + +// backingOff reports whether a throttled source is still waiting out its +// RetryAt. +func (r *Resolver) backingOff(name string, now time.Time) bool { + s := r.status[name] + return s.Throttled && now.Before(s.RetryAt) +} + +// retryAfter is how long err asks the source to back off: Retry-After from +// a Jira or GitHub rate limit, AWSThrottleBackoff for AWS throttling, else 0. +func retryAfter(err error) time.Duration { + var je *jira.APIError + if errors.As(err, &je) && je.RetryAfter > 0 { + return je.RetryAfter + } + var ge *github.APIError + if errors.As(err, &ge) && ge.RetryAfter > 0 { + return ge.RetryAfter + } + if aws.IsThrottled(err) { + return AWSThrottleBackoff + } + return 0 +} + +// short cuts s to maxErr runes. +func short(s string) string { + if r := []rune(s); len(r) > maxErr { + return string(r[:maxErr-1]) + "…" + } + return s +} + +// build runs link and rules over the last good data. +func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { + warn := &warnings{} + warn.add(r.ticketWarn...) + warn.add(r.extraWarn...) + tickets := slices.Clone(r.tickets) + for _, x := range r.extra { + if !slices.ContainsFunc(tickets, func(t model.Ticket) bool { return t.Key == x.Key }) { + tickets = append(tickets, x) + } + } + var prs, bases []model.PR + for _, repo := range r.cfg.WatchedRepos() { + d := r.repos[repo] + prs = append(prs, d.prs...) + bases = append(bases, d.context...) + warn.add(d.warn...) + } + var histories []model.EnvHistory + for _, e := range r.cfg.Envs() { + k := config.PipelineKey{Account: e.Account, Pipeline: e.Pipeline} + if len(e.Repos) == 0 { + // Config repos win; without them, the pipeline's source actions + // say what it deploys (better than guessing from history). + e.Repos = aws.Repos(r.sources[k]) + } + p := r.pipelines[k] + h := r.health[e.ID()] + warn.add(p.warn...) + warn.add(h.warn...) + histories = append(histories, model.EnvHistory{Env: e, Deploys: p.deploys[e.Stage], Health: h.health}) + } + + chains, unlinked := link.Link(tickets, prs, bases, r.cfg.Jira.Projects) + cmp := func(repo, base, head string) model.Inclusion { + if inc, ok := r.cache.get(repo, base, head); ok { + return inc + } + inc, err := r.ad.Code.Compare(ctx, repo, base, head) + if err != nil { + warn.add(fmt.Sprintf("compare %s %s...%s: %v", repo, base, head, err)) + inc = model.InclusionUnknown + } + r.cache.put(repo, base, head, inc) + return inc + } + for i := range chains { + chains[i] = link.Slots(chains[i], histories, cmp) + } + + status := make([]AdapterStatus, len(r.order)) + for i, n := range r.order { + status[i] = *r.status[n] + } + return Snapshot{ + Chains: rules.Evaluate(chains, now, r.cfg.Thresholds()), + Unlinked: unlinked, + Status: status, + Warnings: warn.list(), + At: now, + } +} + +// warnings collects one poll's warnings, deduplicated, in first-seen order. +// Safe for concurrent use. +type warnings struct { + mu sync.Mutex + seen map[string]bool + all []string +} + +func (w *warnings) add(msgs ...string) { + w.mu.Lock() + defer w.mu.Unlock() + if w.seen == nil { + w.seen = map[string]bool{} + } + for _, m := range msgs { + if !w.seen[m] { + w.seen[m] = true + w.all = append(w.all, m) + } + } +} + +func (w *warnings) list() []string { + w.mu.Lock() + defer w.mu.Unlock() + return slices.Clone(w.all) +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go new file mode 100644 index 0000000..92b00e7 --- /dev/null +++ b/internal/resolver/resolver_test.go @@ -0,0 +1,551 @@ +package resolver_test + +import ( + "context" + "errors" + "io" + "log/slog" + "slices" + "strings" + "testing" + "time" + + "github.com/aws/smithy-go" + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +type harness struct { + r *resolver.Resolver + tracker *fakeTracker + host *fakeHost + stage *fakeDeployer + prod *fakeDeployer + clock *clock +} + +func newHarness(t *testing.T, log *slog.Logger, envs ...string) *harness { + t.Helper() + if len(envs) == 0 { + envs = []string{testEnv} + } + if log == nil { + log = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + h := &harness{tracker: &fakeTracker{}, host: &fakeHost{}, stage: &fakeDeployer{}, prod: &fakeDeployer{}, clock: newClock()} + h.r = resolver.New(loadConfig(t, envs...), resolver.Adapters{ + Tracker: h.tracker, + Code: h.host, + AWS: map[string]resolver.Deployer{"stage-acct": h.stage, "prod-acct": h.prod}, + }, h.clock.Now, log) + return h +} + +// withShippedChain sets up ABC-1 with one merged PR (sha aaaa111) whose exact +// SHA succeeded in Test. +func (h *harness) withShippedChain() { + h.tracker.set(func(f *fakeTracker) { f.my = []model.Ticket{ticket("ABC-1")} }) + h.host.set(func(f *fakeHost) { f.prs = map[string][]model.PR{repo: {mergedPR(1, "ABC-1: x", "aaaa111")}} }) + h.stage.set(func(f *fakeDeployer) { + f.hist = map[string]map[string][]model.Deploy{"app-pipeline": {"Test": {succeeded("aaaa111", t0.Add(-time.Hour))}}} + }) +} + +func TestPollBuildsChainsWithoutCompare(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + + snap := h.r.Poll(context.Background()) + + if len(snap.Chains) != 1 || snap.Chains[0].Ticket.Key != "ABC-1" { + t.Fatalf("chains = %+v, want one for ABC-1", snap.Chains) + } + if got := snap.Chains[0].Stage; got != model.StageInTest { + t.Errorf("stage = %v, want %v", got, model.StageInTest) + } + if n := len(h.host.compares()); n != 0 { + t.Errorf("compare calls = %d, want 0 on an exact SHA match", n) + } + if !snap.At.Equal(t0) { + t.Errorf("At = %v, want %v", snap.At, t0) + } +} + +// withCompareChain sets up ABC-1 whose merge sha aaaa111 reached Test only +// inside the newer cccc222, so the slot needs a compare. +func (h *harness) withCompareChain(inc model.Inclusion, err error) { + h.withShippedChain() + h.stage.set(func(f *fakeDeployer) { + f.hist = map[string]map[string][]model.Deploy{"app-pipeline": {"Test": {succeeded("cccc222", t0.Add(-time.Hour))}}} + }) + h.host.set(func(f *fakeHost) { + f.compare = func(string, string, string) (model.Inclusion, error) { return inc, err } + }) +} + +func TestCompareCachedAcrossPolls(t *testing.T) { + h := newHarness(t, nil) + h.withCompareChain(model.Included, nil) + + h.r.Poll(context.Background()) + snap := h.r.Poll(context.Background()) + + want := []compareCall{{repo, "aaaa111", "cccc222"}} + if got := h.host.compares(); len(got) != 1 || got[0] != want[0] { + t.Errorf("compare calls = %v, want %v", got, want) + } + if got := snap.Chains[0].Stage; got != model.StageInTest { + t.Errorf("stage = %v, want %v from the cached Included", got, model.StageInTest) + } +} + +func TestUnknownCompareNotCached(t *testing.T) { + h := newHarness(t, nil) + h.withCompareChain(model.InclusionUnknown, nil) + + h.r.Poll(context.Background()) + h.r.Poll(context.Background()) + + if n := len(h.host.compares()); n != 2 { + t.Errorf("compare calls = %d, want 2 (Unknown is asked again)", n) + } +} + +func TestCompareErrorIsUnknownWithOneWarning(t *testing.T) { + h := newHarness(t, nil) + h.withCompareChain(model.InclusionUnknown, errors.New("github: HTTP 502 Bad Gateway")) + // Two chains whose slots need the same failing compare: one warning. + h.tracker.set(func(f *fakeTracker) { f.my = []model.Ticket{ticket("ABC-1"), ticket("ABC-2")} }) + h.host.set(func(f *fakeHost) { + pr := mergedPR(1, "ABC-1 ABC-2: x", "aaaa111") + f.prs = map[string][]model.PR{repo: {pr}} + }) + + snap := h.r.Poll(context.Background()) + + if len(snap.Warnings) != 1 || !strings.Contains(snap.Warnings[0], "502") || !strings.Contains(snap.Warnings[0], "aaaa111") { + t.Errorf("warnings = %q, want one naming the compare and its error", snap.Warnings) + } + for _, c := range snap.Chains { + if got := c.Slots[0].State; got != model.SlotUnknown { + t.Errorf("%s slot = %v, want unknown", c.Ticket.Key, got) + } + } + h.r.Poll(context.Background()) + if n := len(h.host.compares()); n != 4 { + t.Errorf("compare calls = %d, want 4 (errors are not cached)", n) + } +} + +func statusOf(t *testing.T, snap resolver.Snapshot, name string) resolver.AdapterStatus { + t.Helper() + for _, s := range snap.Status { + if s.Name == name { + return s + } + } + t.Fatalf("no status %q in %+v", name, snap.Status) + return resolver.AdapterStatus{} +} + +func TestFailedSourceKeepsLastGoodWhileOthersUpdate(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.r.Poll(context.Background()) + + h.clock.Advance(time.Minute) + h.tracker.set(func(f *fakeTracker) { f.myErr = errors.New("jira: HTTP 502 Bad Gateway") }) + // GitHub has news: the PR now names ABC-1 and is joined by an unlinked one. + h.host.set(func(f *fakeHost) { + f.prs[repo] = append(f.prs[repo], openPR(2, "no key here")) + }) + snap := h.r.Poll(context.Background()) + + if len(snap.Chains) != 1 || snap.Chains[0].Ticket.Key != "ABC-1" { + t.Fatalf("chains = %+v, want ABC-1 kept from the last good poll", snap.Chains) + } + if len(snap.Unlinked) != 1 || snap.Unlinked[0].Number != 2 { + t.Errorf("unlinked = %+v, want the new PR #2 (GitHub still updates)", snap.Unlinked) + } + j := statusOf(t, snap, "jira") + if j.OK || !strings.Contains(j.Err, "502") || !j.At.Equal(t0) { + t.Errorf("jira status = %+v, want not OK, the error, At = first poll", j) + } + for _, name := range []string{"github", "aws stage-acct"} { + if s := statusOf(t, snap, name); !s.OK || s.Err != "" || !s.At.Equal(t0.Add(time.Minute)) { + t.Errorf("%s status = %+v, want OK at the second poll", name, s) + } + } + if got := []string{snap.Status[0].Name, snap.Status[1].Name, snap.Status[2].Name}; got[0] != "jira" || got[1] != "github" || got[2] != "aws stage-acct" || len(snap.Status) != 3 { + t.Errorf("status order = %v, want jira, github, then accounts with envs", snap.Status) + } +} + +// prodEnv is the Production stage in prod-acct, deploying acme/app. +const prodEnv = ` +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "Deploy" + prod = true + repos = ["acme/app"] +` + +func TestSSOExpiredMarksOnlyThatAccount(t *testing.T) { + h := newHarness(t, nil, testEnv, prodEnv) + h.withShippedChain() + h.prod.set(func(f *fakeDeployer) { + f.histErr = errors.New("operation error CodePipeline: ListPipelineExecutions, the SSO session has expired or is invalid") + }) + + snap := h.r.Poll(context.Background()) + + p := statusOf(t, snap, "aws prod-acct") + if p.OK || !p.SSO || p.Err == "" { + t.Errorf("prod-acct status = %+v, want not OK with SSO set", p) + } + if s := statusOf(t, snap, "aws stage-acct"); !s.OK || s.SSO { + t.Errorf("stage-acct status = %+v, want OK", s) + } + if got := snap.Chains[0].Stage; got != model.StageInTest { + t.Errorf("stage = %v, want %v from stage-acct's history", got, model.StageInTest) + } +} + +func TestThrottledSourceSkippedUntilRetryAt(t *testing.T) { + cases := []struct { + name string + fail func(h *harness, err bool) + calls func(h *harness) int + retry time.Duration // RetryAt - poll time + source string + }{ + { + name: "jira 429", source: "jira", retry: 2 * time.Minute, + fail: func(h *harness, on bool) { + h.tracker.set(func(f *fakeTracker) { + f.myErr = nil + if on { + f.myErr = &jira.APIError{Status: 429, RetryAfter: 2 * time.Minute} + } + }) + }, + calls: func(h *harness) int { n, _ := h.tracker.calls(); return n }, + }, + { + name: "github rate-limited 403", source: "github", retry: 2 * time.Minute, + fail: func(h *harness, on bool) { + h.host.set(func(f *fakeHost) { + f.prErr = nil + if on { + f.prErr = &github.APIError{Status: 403, Message: "API rate limit exceeded", RetryAfter: 2 * time.Minute} + } + }) + }, + calls: func(h *harness) int { h.host.mu.Lock(); defer h.host.mu.Unlock(); return h.host.prCalls }, + }, + { + name: "aws throttling", source: "aws stage-acct", retry: resolver.AWSThrottleBackoff, + fail: func(h *harness, on bool) { + h.stage.set(func(f *fakeDeployer) { + f.histErr = nil + if on { + f.histErr = &smithy.GenericAPIError{Code: "ThrottlingException", Message: "Rate exceeded"} + } + }) + }, + calls: func(h *harness) int { _, n := h.stage.calls(); return n }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.r.Poll(context.Background()) // last good data + h.clock.Advance(time.Minute) + polledAt := h.clock.Now() + c.fail(h, true) + + snap := h.r.Poll(context.Background()) + s := statusOf(t, snap, c.source) + if s.OK || !s.Throttled || !s.RetryAt.Equal(polledAt.Add(c.retry)) { + t.Fatalf("status = %+v, want throttled until %v", s, polledAt.Add(c.retry)) + } + before := c.calls(h) + c.fail(h, false) + + h.clock.Advance(c.retry - time.Second) + snap = h.r.Poll(context.Background()) + if got := c.calls(h); got != before { + t.Errorf("calls before RetryAt = %d, want %d (skipped)", got, before) + } + if s := statusOf(t, snap, c.source); !s.Throttled || s.OK { + t.Errorf("status while skipped = %+v, want still throttled", s) + } + if len(snap.Chains) != 1 || snap.Chains[0].Stage != model.StageInTest { + t.Errorf("chains while skipped = %+v, want last good kept", snap.Chains) + } + + h.clock.Advance(time.Second) + snap = h.r.Poll(context.Background()) + if got := c.calls(h); got <= before { + t.Errorf("calls at RetryAt = %d, want more than %d", got, before) + } + if s := statusOf(t, snap, c.source); !s.OK || s.Throttled { + t.Errorf("status after RetryAt = %+v, want OK", s) + } + }) + } +} + +func TestTicketsFromMyOpenPRs(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { + f.byKey = map[string]model.Ticket{"ABC-9": ticket("ABC-9"), "ABC-8": ticket("ABC-8")} + }) + h.host.set(func(f *fakeHost) { + f.prs[repo] = append(f.prs[repo], + openPR(9, "ABC-9: not assigned to me"), + openPR(10, "ABC-1: already mine"), + mergedPR(8, "ABC-8: merged, not looked up", "bbbb222")) + }) + + snap := h.r.Poll(context.Background()) + + if _, byKey := h.tracker.calls(); len(byKey) != 1 || len(byKey[0]) != 1 || byKey[0][0] != "ABC-9" { + t.Errorf("TicketsByKey calls = %v, want one for [ABC-9]", byKey) + } + var keys []string + for _, c := range snap.Chains { + keys = append(keys, c.Ticket.Key) + } + if !slices.Contains(keys, "ABC-9") || !slices.Contains(keys, "ABC-1") || slices.Contains(keys, "ABC-8") { + t.Errorf("chain keys = %v, want ABC-1 and ABC-9 only", keys) + } +} + +func TestNoTicketsByKeyCallWithoutExtraKeys(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.r.Poll(context.Background()) + if _, byKey := h.tracker.calls(); len(byKey) != 0 { + t.Errorf("TicketsByKey calls = %v, want none", byKey) + } +} + +func TestExtraTicketsFailureKeepsLastGood(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.byKey = map[string]model.Ticket{"ABC-9": ticket("ABC-9")} }) + h.host.set(func(f *fakeHost) { f.prs[repo] = append(f.prs[repo], openPR(9, "ABC-9: x")) }) + h.r.Poll(context.Background()) + + h.tracker.set(func(f *fakeTracker) { f.byKeyErr = errors.New("jira: HTTP 500") }) + snap := h.r.Poll(context.Background()) + + if len(snap.Chains) != 2 { + t.Errorf("chains = %d, want 2 (ABC-9 kept)", len(snap.Chains)) + } + if j := statusOf(t, snap, "jira"); j.OK || !strings.Contains(j.Err, "500") { + t.Errorf("jira status = %+v, want the TicketsByKey error", j) + } +} + +func TestBasePRsAreContextOnly(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + mine := mergedPR(3, "ABC-1: part 2", "dddd444") + mine.BaseRef = "colleague/base" + // A colleague's base PR with no ticket key: it carries mine onto main. + base := mergedPR(4, "refactor the thing", "aaaa111") + base.HeadRef = "colleague/base" + base.MergedAt = mine.MergedAt.Add(time.Hour) + h.host.set(func(f *fakeHost) { + f.prs = map[string][]model.PR{repo: {mine}} + f.base = map[string][]model.PR{repo: {base}} + }) + + snap := h.r.Poll(context.Background()) + + h.host.mu.Lock() + in := h.host.baseInputs[repo] + h.host.mu.Unlock() + if len(in) != 1 || in[0].Number != 3 { + t.Errorf("BasePRs got %+v, want my PR #3", in) + } + if len(snap.Unlinked) != 0 { + t.Errorf("unlinked = %+v, want none (a base PR is context only)", snap.Unlinked) + } + if len(snap.Chains) != 1 || len(snap.Chains[0].PRs) != 1 || snap.Chains[0].PRs[0].Number != 3 { + t.Fatalf("chains = %+v, want ABC-1 with only #3", snap.Chains) + } + pr := snap.Chains[0].PRs[0] + if pr.Stranded || pr.EffectiveSHA != "aaaa111" { + t.Errorf("#3 stranded=%v effective=%q, want carried by the base PR's aaaa111", pr.Stranded, pr.EffectiveSHA) + } + if got := snap.Chains[0].Stage; got != model.StageInTest { + t.Errorf("stage = %v, want %v", got, model.StageInTest) + } +} + +// apiEnv deploys a second repo, acme/api, from its own pipeline. +const apiEnv = ` +[[envs]] + account = "prod-acct" + pipeline = "api-pipeline" + stage = "Test" + deploy_action = "Deploy" + repos = ["acme/api"] +` + +func TestOneFailingRepoDoesNotStopTheOthers(t *testing.T) { + h := newHarness(t, nil, testEnv, apiEnv) + h.withShippedChain() + h.host.set(func(f *fakeHost) { + // acme/api sorts first and fails; acme/app must still be fetched. + f.prErrRepo = map[string]error{"acme/api": &github.APIError{Status: 404, Message: "Not Found"}} + }) + + snap := h.r.Poll(context.Background()) + + if len(snap.Chains) != 1 || len(snap.Chains[0].PRs) != 1 { + t.Errorf("chains = %+v, want ABC-1 with acme/app's PR", snap.Chains) + } + if g := statusOf(t, snap, "github"); g.OK || !strings.Contains(g.Err, "acme/api") { + t.Errorf("github status = %+v, want the error naming acme/api", g) + } +} + +// prodTestEnv is a Test stage in prod-acct's app-pipeline, before prodEnv. +const prodTestEnv = ` +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" + approval_action = "ApproveTest" + repos = ["acme/app"] +` + +func TestOneHistoryCallPerPipelineAndSourcesCached(t *testing.T) { + h := newHarness(t, nil, prodTestEnv, prodEnv, apiEnv) + h.withShippedChain() + + h.r.Poll(context.Background()) + h.r.Poll(context.Background()) + + src, hist := h.prod.calls() + if src != 2 { + t.Errorf("Sources calls = %d, want 2 (once per pipeline for the session)", src) + } + if hist != 4 { + t.Errorf("History calls = %d, want 4 (once per pipeline per poll)", hist) + } + h.prod.mu.Lock() + specs := h.prod.histSpecs[0] + h.prod.mu.Unlock() + if len(specs) != 2 || specs[0].Stage != "Test" || specs[0].ApprovalAction != "ApproveTest" || specs[1].Stage != "Production" { + t.Errorf("app-pipeline specs = %+v, want Test (with its approval) then Production", specs) + } +} + +func TestOneFailingPipelineDoesNotStopTheAccount(t *testing.T) { + h := newHarness(t, nil, testEnv, prodEnv, apiEnv) + h.withShippedChain() + h.prod.set(func(f *fakeDeployer) { + f.histErrPipe = map[string]error{"app-pipeline": &smithy.GenericAPIError{Code: "AccessDeniedException", Message: "no"}} + f.hist = map[string]map[string][]model.Deploy{"api-pipeline": {"Test": {{ExecutionID: "e1", Status: model.DeploySucceeded, + Revisions: map[string]string{"acme/api": "eeee555"}, FinishedAt: t0}}}} + }) + + snap := h.r.Poll(context.Background()) + + h.prod.mu.Lock() + n := len(h.prod.histSpecs) + h.prod.mu.Unlock() + if n != 2 { + t.Errorf("History calls = %d, want 2 (api-pipeline still asked)", n) + } + if p := statusOf(t, snap, "aws prod-acct"); p.OK || !strings.Contains(p.Err, "AccessDenied") { + t.Errorf("prod-acct status = %+v, want the AccessDenied error", p) + } +} + +// bareTestEnv is testEnv without repos: they come from the pipeline. +const bareTestEnv = ` +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Test" + deploy_action = "Deploy" +` + +func TestEnvReposFromSourcesOnlyWhenConfigHasNone(t *testing.T) { + cases := []struct { + name string + env string + want []string + }{ + {"config has none: discovered", bareTestEnv, []string{"acme/app", "acme/lib"}}, + {"config wins", testEnv, []string{"acme/app"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t, nil, c.env) + h.withShippedChain() + h.stage.set(func(f *fakeDeployer) { + f.sources = map[string]map[string]string{"app-pipeline": {"acme/lib": "LibSource", "acme/app": "AppSource"}} + }) + + snap := h.r.Poll(context.Background()) + + if got := snap.Chains[0].Slots[0].Env.Repos; !slices.Equal(got, c.want) { + t.Errorf("Env.Repos = %v, want %v", got, c.want) + } + }) + } +} + +func TestWarningsDedupedAndLoggedOncePerPoll(t *testing.T) { + var buf safeBuffer + log := slog.New(slog.NewTextHandler(&buf, nil)) + h := newHarness(t, log, testEnv, apiEnv) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.myWarn = []string{"jira: w1"} }) + // Both repos return the same warnings. + h.host.set(func(f *fakeHost) { f.prWarn = []string{"jira: w1", "github: w2"} }) + h.stage.set(func(f *fakeDeployer) { f.histWarn = []string{"aws: w3", "aws: w3"} }) + + snap := h.r.Poll(context.Background()) + + want := []string{"jira: w1", "github: w2", "aws: w3"} + if !slices.Equal(snap.Warnings, want) { + t.Errorf("warnings = %q, want %q", snap.Warnings, want) + } + for _, w := range want { + if n := strings.Count(buf.String(), w); n != 1 { + t.Errorf("%q logged %d times in one poll, want 1\n%s", w, n, buf.String()) + } + } + h.r.Poll(context.Background()) + if n := strings.Count(buf.String(), "github: w2"); n != 2 { + t.Errorf("w2 logged %d times over two polls, want 2", n) + } +} + +func TestSourceErrorIsLogged(t *testing.T) { + var buf safeBuffer + h := newHarness(t, slog.New(slog.NewTextHandler(&buf, nil))) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.myErr = errors.New("jira: HTTP 502 Bad Gateway") }) + + h.r.Poll(context.Background()) + + if out := buf.String(); !strings.Contains(out, "source=jira") || !strings.Contains(out, "502") { + t.Errorf("log = %q, want the jira error", out) + } +} From c6ed41f695c6e3c9cade1c120720d3f834d928e4 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:58:43 -0400 Subject: [PATCH 23/31] feat(resolver): run loop with refresh, adapters built from config Run polls now, then every poll interval and on each refresh, one poll at a time, and returns when ctx is canceled. FromConfig resolves both tokens once, concurrently, and builds the Jira, GitHub and per-account AWS clients. An account with no client is a status error, not a panic. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/export_test.go | 6 ++ internal/resolver/fromconfig.go | 49 ++++++++++++ internal/resolver/fromconfig_test.go | 46 +++++++++++ internal/resolver/resolver.go | 41 +++++++++- internal/resolver/resolver_test.go | 17 ++++ internal/resolver/run_test.go | 113 +++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 internal/resolver/export_test.go create mode 100644 internal/resolver/fromconfig.go create mode 100644 internal/resolver/fromconfig_test.go create mode 100644 internal/resolver/run_test.go diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go new file mode 100644 index 0000000..8e89324 --- /dev/null +++ b/internal/resolver/export_test.go @@ -0,0 +1,6 @@ +package resolver + +import "time" + +// SetInterval replaces the poll interval for a test. +func SetInterval(r *Resolver, d time.Duration) { r.interval = d } diff --git a/internal/resolver/fromconfig.go b/internal/resolver/fromconfig.go new file mode 100644 index 0000000..74c04b5 --- /dev/null +++ b/internal/resolver/fromconfig.go @@ -0,0 +1,49 @@ +package resolver + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/ericdahl-dev/app-green/internal/aws" + "github.com/ericdahl-dev/app-green/internal/config" + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/jira" +) + +// FromConfig builds the real adapters from cfg and returns a Resolver over +// them. It resolves both tokens once, concurrently (a token_command can take +// seconds), and reports every token error at once. Building the AWS clients +// calls nothing, so an expired SSO session shows up on the first poll as +// that account's status. A nil now is time.Now; a nil log discards. +func FromConfig(ctx context.Context, cfg *config.Config, now func() time.Time, log *slog.Logger) (*Resolver, error) { + var ( + wg sync.WaitGroup + jiraTok, githubTok string + jiraErr, githubErr error + ) + wg.Add(2) + go func() { defer wg.Done(); jiraTok, jiraErr = cfg.JiraToken() }() + go func() { defer wg.Done(); githubTok, githubErr = cfg.GitHubToken() }() + wg.Wait() + if err := errors.Join(jiraErr, githubErr); err != nil { + return nil, err + } + + deployers := map[string]Deployer{} + for _, a := range cfg.AWS.Accounts { + c, err := aws.New(ctx, a.Name, a.Profile, a.Region) + if err != nil { + return nil, fmt.Errorf("aws account %s: %w", a.Name, err) + } + deployers[a.Name] = c + } + return New(cfg, Adapters{ + Tracker: jira.New(cfg.Jira.Site, cfg.Jira.Email, jiraTok), + Code: github.New(githubTok), + AWS: deployers, + }, now, log), nil +} diff --git a/internal/resolver/fromconfig_test.go b/internal/resolver/fromconfig_test.go new file mode 100644 index 0000000..5a4e514 --- /dev/null +++ b/internal/resolver/fromconfig_test.go @@ -0,0 +1,46 @@ +package resolver_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +// fakeAWSConfig points the SDK at a shared config with the test profiles, +// so building clients reads no real AWS settings (and calls nothing). +func fakeAWSConfig(t *testing.T) { + t.Helper() + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + if err := os.WriteFile(cfg, []byte("[profile stage]\nregion = us-east-1\n[profile prod]\nregion = us-east-1\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("AWS_CONFIG_FILE", cfg) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) +} + +func TestFromConfigBuildsAdapters(t *testing.T) { + fakeAWSConfig(t) + t.Setenv("APP_GREEN_TEST_JIRA_TOKEN", "jira-tok") + t.Setenv("APP_GREEN_TEST_GITHUB_TOKEN", "gh-tok") + + r, err := resolver.FromConfig(context.Background(), loadConfig(t, testEnv, prodEnv), nil, nil) + if err != nil || r == nil { + t.Fatalf("FromConfig = %v, %v; want a resolver", r, err) + } +} + +func TestFromConfigReportsEveryTokenError(t *testing.T) { + fakeAWSConfig(t) + t.Setenv("APP_GREEN_TEST_JIRA_TOKEN", "") + t.Setenv("APP_GREEN_TEST_GITHUB_TOKEN", "") + + _, err := resolver.FromConfig(context.Background(), loadConfig(t, testEnv), nil, nil) + if err == nil || !strings.Contains(err.Error(), "jira.token_env") || !strings.Contains(err.Error(), "github.token_env") { + t.Errorf("err = %v, want both token errors", err) + } +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 728fd36..e088982 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -111,6 +111,8 @@ type Resolver struct { now func() time.Time log *slog.Logger + interval time.Duration + pollMu sync.Mutex // held for a whole poll; guards everything below cache *compareCache status map[string]*AdapterStatus @@ -126,11 +128,18 @@ type Resolver struct { health map[string]healthData // by Env.ID() } -// New returns a Resolver over ad. now is the clock; log receives each -// poll's warnings and source errors. +// New returns a Resolver over ad. now is the clock (nil is time.Now); log +// receives each poll's warnings and source errors (nil discards them). func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger) *Resolver { + if now == nil { + now = time.Now + } + if log == nil { + log = slog.New(slog.DiscardHandler) + } r := &Resolver{ cfg: cfg, ad: ad, now: now, log: log, + interval: cfg.PollInterval(), cache: newCompareCache(), status: map[string]*AdapterStatus{}, repos: map[string]repoData{}, @@ -148,6 +157,34 @@ func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger return r } +// Run polls now, then every poll interval, and on each receive from +// refresh, sending each Snapshot to out, until ctx is canceled. Polls run +// one at a time: a refresh that arrives during a poll starts the next one +// as soon as it ends, and the interval restarts after every poll. Send on +// refresh without blocking (a buffer of 1 coalesces repeated presses). +func (r *Resolver) Run(ctx context.Context, out chan<- Snapshot, refresh <-chan struct{}) { + timer := time.NewTimer(r.interval) + defer timer.Stop() + for { + snap := r.Poll(ctx) + if ctx.Err() != nil { + return + } + select { + case out <- snap: + case <-ctx.Done(): + return + } + timer.Reset(r.interval) + select { + case <-timer.C: + case <-refresh: + case <-ctx.Done(): + return + } + } +} + // accounts are the config accounts that have envs, in config order. func (r *Resolver) accounts() []string { var out []string diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 92b00e7..4ddd2f5 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -549,3 +549,20 @@ func TestSourceErrorIsLogged(t *testing.T) { t.Errorf("log = %q, want the jira error", out) } } + +func TestAccountWithoutClientIsAStatusError(t *testing.T) { + h := newHarness(t, nil, testEnv, prodEnv) + h.withShippedChain() + r := resolver.New(loadConfig(t, testEnv, prodEnv), resolver.Adapters{ + Tracker: h.tracker, Code: h.host, AWS: map[string]resolver.Deployer{"stage-acct": h.stage}, + }, h.clock.Now, nil) + + snap := r.Poll(context.Background()) + + if p := statusOf(t, snap, "aws prod-acct"); p.OK || !strings.Contains(p.Err, "no AWS client") { + t.Errorf("prod-acct status = %+v, want a missing-client error", p) + } + if s := statusOf(t, snap, "aws stage-acct"); !s.OK { + t.Errorf("stage-acct status = %+v, want OK", s) + } +} diff --git a/internal/resolver/run_test.go b/internal/resolver/run_test.go new file mode 100644 index 0000000..867ee3f --- /dev/null +++ b/internal/resolver/run_test.go @@ -0,0 +1,113 @@ +package resolver_test + +import ( + "context" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +// next waits for a snapshot or fails. +func next(t *testing.T, out <-chan resolver.Snapshot) resolver.Snapshot { + t.Helper() + select { + case s := <-out: + return s + case <-time.After(5 * time.Second): + t.Fatal("no snapshot within 5s") + return resolver.Snapshot{} + } +} + +// start runs h.r in the background and returns its channels and a stop +// function that waits for Run to return. +func start(t *testing.T, h *harness) (chan resolver.Snapshot, chan struct{}, func()) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + out := make(chan resolver.Snapshot) + refresh := make(chan struct{}, 1) + done := make(chan struct{}) + go func() { defer close(done); h.r.Run(ctx, out, refresh) }() + stop := func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after cancel") + } + } + t.Cleanup(stop) + return out, refresh, stop +} + +func TestRunPollsNowThenOnRefresh(t *testing.T) { + h := newHarness(t, nil) // default 60s interval: only refresh can poll again + h.withShippedChain() + out, refresh, _ := start(t, h) + + if s := next(t, out); len(s.Chains) != 1 { + t.Fatalf("first snapshot chains = %d, want 1", len(s.Chains)) + } + refresh <- struct{}{} + next(t, out) + if n, _ := h.tracker.calls(); n != 2 { + t.Errorf("MyTickets calls = %d, want 2", n) + } +} + +func TestRunPollsEveryInterval(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + resolver.SetInterval(h.r, 10*time.Millisecond) + out, _, _ := start(t, h) + + for range 3 { + next(t, out) + } +} + +func TestRunStopsOnCancel(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + _, _, stop := start(t, h) + // Nobody reads out: Run must still return when ctx is canceled. + time.Sleep(20 * time.Millisecond) + stop() +} + +func TestPollsNeverOverlap(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + release := make(chan struct{}) + h.tracker.set(func(f *fakeTracker) { f.block = release }) + out, refresh, _ := start(t, h) + + waitFor(t, func() bool { n, _ := h.tracker.calls(); return n == 1 }) + refresh <- struct{}{} + polled := make(chan struct{}) + go func() { defer close(polled); h.r.Poll(context.Background()) }() + time.Sleep(50 * time.Millisecond) + if n, _ := h.tracker.calls(); n != 1 { + t.Errorf("MyTickets calls while a poll runs = %d, want 1", n) + } + close(release) + next(t, out) + <-polled + h.tracker.set(func(f *fakeTracker) { + if f.maxFlight != 1 { + t.Errorf("polls in flight at once = %d, want 1", f.maxFlight) + } + }) +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("condition not met within 5s") + } + time.Sleep(time.Millisecond) + } +} From 00de3858922ae8115d3a587957862b43ea6c8bf0 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 22:59:33 -0400 Subject: [PATCH 24/31] fix(resolver): a canceled poll records no failures Fetches cut short by shutdown fail with the context's error; Poll now returns the last good data without marking sources failed, logging, or asking GitHub for compares. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/resolver.go | 8 ++++++++ internal/resolver/resolver_test.go | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index e088982..894fc72 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -246,6 +246,11 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, known) }) } wg.Wait() + if ctx.Err() != nil { + // Shutting down: what failed failed because of ctx, so record + // nothing and ask nothing more. + return r.build(ctx, now) + } if githubRan { for repo, d := range gr.repos { @@ -511,6 +516,9 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { if inc, ok := r.cache.get(repo, base, head); ok { return inc } + if ctx.Err() != nil { + return model.InclusionUnknown + } inc, err := r.ad.Code.Compare(ctx, repo, base, head) if err != nil { warn.add(fmt.Sprintf("compare %s %s...%s: %v", repo, base, head, err)) diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 4ddd2f5..12e7a29 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -566,3 +566,26 @@ func TestAccountWithoutClientIsAStatusError(t *testing.T) { t.Errorf("stage-acct status = %+v, want OK", s) } } + +func TestCanceledPollRecordsNoFailures(t *testing.T) { + var buf safeBuffer + h := newHarness(t, slog.New(slog.NewTextHandler(&buf, nil))) + h.withShippedChain() + h.r.Poll(context.Background()) + // Fetches cut short by shutdown fail with the context's error. + h.tracker.set(func(f *fakeTracker) { f.myErr = context.Canceled }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + snap := h.r.Poll(ctx) + + if j := statusOf(t, snap, "jira"); !j.OK { + t.Errorf("jira status = %+v, want still OK: a canceled poll is not a failure", j) + } + if len(snap.Chains) != 1 { + t.Errorf("chains = %d, want the last good 1", len(snap.Chains)) + } + if strings.Contains(buf.String(), "source failed") { + t.Errorf("log = %q, want no source failure", buf.String()) + } +} From e8daba82724b3d3a3c31de04acfa6d898d204e1e Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:01:42 -0400 Subject: [PATCH 25/31] fix(resolver): SSO only on AWS statuses; test env health A Jira or GitHub error worded like an expired token no longer sets SSO. Env health reaches the slots and keeps its last good value when DescribeServices fails. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/resolver.go | 3 ++- internal/resolver/resolver_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 894fc72..eb32182 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -440,7 +440,8 @@ func (r *Resolver) record(name string, err error, now time.Time) { return } r.log.Warn("source failed", "source", name, "err", err) - *s = AdapterStatus{Name: name, At: s.At, Err: short(err.Error()), SSO: aws.IsSSOExpired(err)} + *s = AdapterStatus{Name: name, At: s.At, Err: short(err.Error()), + SSO: strings.HasPrefix(name, "aws ") && aws.IsSSOExpired(err)} if d := retryAfter(err); d > 0 { s.Throttled, s.RetryAt = true, now.Add(d) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 12e7a29..3b11f2d 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -589,3 +589,40 @@ func TestCanceledPollRecordsNoFailures(t *testing.T) { t.Errorf("log = %q, want no source failure", buf.String()) } } + +// ecsTestEnv is testEnv running ECS service s1. +const ecsTestEnv = testEnv + ` ecs = [{ cluster = "c1", services = ["s1"] }] +` + +func TestHealthPerEnvKeepsLastGoodOnError(t *testing.T) { + h := newHarness(t, nil, ecsTestEnv) + h.withShippedChain() + sick := model.Health{Known: true, Desired: 2, Healthy: 1} + h.stage.set(func(f *fakeDeployer) { f.health = map[string]model.Health{"s1": sick} }) + + snap := h.r.Poll(context.Background()) + if got := snap.Chains[0].Slots[0].Health; got != sick { + t.Fatalf("slot health = %+v, want %+v", got, sick) + } + + h.stage.set(func(f *fakeDeployer) { f.healthErr = errors.New("aws: DescribeServices c1: boom") }) + snap = h.r.Poll(context.Background()) + if got := snap.Chains[0].Slots[0].Health; got != sick { + t.Errorf("slot health after error = %+v, want last good %+v", got, sick) + } + if s := statusOf(t, snap, "aws stage-acct"); s.OK || !strings.Contains(s.Err, "boom") { + t.Errorf("stage-acct status = %+v, want the health error", s) + } +} + +func TestSSOFlagOnlyForAWS(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.myErr = errors.New("jira: HTTP 401: token has expired") }) + + snap := h.r.Poll(context.Background()) + + if j := statusOf(t, snap, "jira"); j.SSO { + t.Errorf("jira status = %+v, want SSO false (SSO is an AWS state)", j) + } +} From 745acc969af0bba24dcd0e0a32f063873ad42e62 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:06:51 -0400 Subject: [PATCH 26/31] feat(resolver): watch discovered repos, stop on auth, compare backoff - GitHub watches the configured repos plus every repo the known pipeline sources name, as of the start of the poll, so a repo discovered only from Sources has its PRs fetched from the next poll. - A Jira or GitHub 401 sets AdapterStatus.Auth with "token rejected" and stops calling that source until a refresh, which retries it once. - A rate-limited Compare throttles GitHub until its RetryAfter: the rest of the poll's compare misses are Unknown without a call or a further warning, and PR fetches and compares wait for RetryAt. - AWS throttling backs off the longer of one minute and the poll interval. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/decisions_test.go | 191 ++++++++++++++++++++++++++++ internal/resolver/fakes_test.go | 2 + internal/resolver/resolver.go | 98 ++++++++++---- 3 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 internal/resolver/decisions_test.go diff --git a/internal/resolver/decisions_test.go b/internal/resolver/decisions_test.go new file mode 100644 index 0000000..c295a11 --- /dev/null +++ b/internal/resolver/decisions_test.go @@ -0,0 +1,191 @@ +package resolver_test + +import ( + "context" + "slices" + "strings" + "testing" + "time" + + "github.com/aws/smithy-go" + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +const slowPoll = ` +[settings] + poll_interval = "90s" +` + +func TestAWSThrottleBackoffIsAtLeastPollInterval(t *testing.T) { + h := newHarness(t, nil, testEnv, slowPoll) + h.withShippedChain() + h.stage.set(func(f *fakeDeployer) { + f.histErr = &smithy.GenericAPIError{Code: "ThrottlingException", Message: "Rate exceeded"} + }) + + snap := h.r.Poll(context.Background()) + + if s := statusOf(t, snap, "aws stage-acct"); !s.Throttled || !s.RetryAt.Equal(t0.Add(90*time.Second)) { + t.Errorf("status = %+v, want throttled until poll time + 90s", s) + } +} + +func TestDiscoveredReposWatchedFromTheNextPoll(t *testing.T) { + h := newHarness(t, nil, bareTestEnv) + h.withShippedChain() + infra := mergedPR(5, "ABC-1: infra", "ffff666") + infra.Repo = "acme/infra" + h.stage.set(func(f *fakeDeployer) { + f.sources = map[string]map[string]string{"app-pipeline": {"acme/app": "AppSource", "acme/infra": "InfraSource"}} + }) + h.host.set(func(f *fakeHost) { f.prs["acme/infra"] = []model.PR{infra} }) + + h.r.Poll(context.Background()) + h.host.set(func(f *fakeHost) { + if !slices.Equal(f.prRepos, []string{"acme/app"}) { + t.Errorf("poll 1 RecentPRs repos = %v, want [acme/app]", f.prRepos) + } + f.prRepos = nil + }) + + snap := h.r.Poll(context.Background()) + h.host.set(func(f *fakeHost) { + if !slices.Equal(f.prRepos, []string{"acme/app", "acme/infra"}) { + t.Errorf("poll 2 RecentPRs repos = %v, want acme/app and the discovered acme/infra", f.prRepos) + } + }) + if len(snap.Chains) != 1 || len(snap.Chains[0].PRs) != 2 { + t.Errorf("chains = %+v, want ABC-1 with both PRs", snap.Chains) + } +} + +func TestRejectedTokenStopsTheSource(t *testing.T) { + cases := []struct { + name, source string + fail func(h *harness) + calls func(h *harness) int + }{ + {"jira", "jira", + func(h *harness) { h.tracker.set(func(f *fakeTracker) { f.myErr = &jira.APIError{Status: 401} }) }, + func(h *harness) int { n, _ := h.tracker.calls(); return n }}, + {"github", "github", + func(h *harness) { + h.host.set(func(f *fakeHost) { f.prErr = &github.APIError{Status: 401, Message: "Bad credentials"} }) + }, + func(h *harness) int { h.host.mu.Lock(); defer h.host.mu.Unlock(); return h.host.prCalls }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + c.fail(h) + + snap := h.r.Poll(context.Background()) + if s := statusOf(t, snap, c.source); s.OK || !s.Auth || s.Err != "token rejected" { + t.Fatalf("status = %+v, want Auth with Err %q", s, "token rejected") + } + if _, byKey := h.tracker.calls(); c.source == "jira" && len(byKey) != 0 { + t.Errorf("TicketsByKey called %v after a rejected token", byKey) + } + before := c.calls(h) + h.clock.Advance(time.Hour) + snap = h.r.Poll(context.Background()) + if got := c.calls(h); got != before { + t.Errorf("calls after auth stop = %d, want %d (not called until refresh)", got, before) + } + if s := statusOf(t, snap, c.source); !s.Auth { + t.Errorf("status = %+v, want still Auth", s) + } + if s := statusOf(t, snap, "aws stage-acct"); !s.OK { + t.Errorf("aws status = %+v, want other sources still polled", s) + } + }) + } +} + +func TestRefreshRetriesARejectedTokenOnce(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.myErr = &jira.APIError{Status: 401} }) + resolver.SetInterval(h.r, 10*time.Millisecond) + out, refresh, _ := start(t, h) + + if s := statusOf(t, next(t, out), "jira"); !s.Auth { + t.Fatalf("jira status = %+v, want Auth", s) + } + next(t, out) // interval polls skip jira + if n, _ := h.tracker.calls(); n != 1 { + t.Fatalf("MyTickets calls = %d, want 1 before refresh", n) + } + + // Still rejected: refresh retries exactly once, then it stays stopped. + refresh <- struct{}{} + waitFor(t, func() bool { n, _ := h.tracker.calls(); return n == 2 }) + for range 3 { + next(t, out) + } + if n, _ := h.tracker.calls(); n != 2 { + t.Errorf("MyTickets calls = %d, want 2 (one retry per refresh)", n) + } + + // Fixed token: the next refresh brings jira back. + h.tracker.set(func(f *fakeTracker) { f.myErr = nil }) + refresh <- struct{}{} + waitFor(t, func() bool { n, _ := h.tracker.calls(); return n == 3 }) + var s resolver.AdapterStatus + waitFor(t, func() bool { s = statusOf(t, next(t, out), "jira"); return s.OK }) + if s.Auth { + t.Errorf("jira status = %+v, want Auth cleared", s) + } +} + +func TestRateLimitedCompareBacksOffGitHub(t *testing.T) { + h := newHarness(t, nil) + h.withCompareChain(model.InclusionUnknown, &github.APIError{Status: 429, RetryAfter: 2 * time.Minute}) + h.tracker.set(func(f *fakeTracker) { f.my = []model.Ticket{ticket("ABC-1"), ticket("ABC-2")} }) + h.host.set(func(f *fakeHost) { + f.prs = map[string][]model.PR{repo: {mergedPR(1, "ABC-1: x", "aaaa111"), mergedPR(2, "ABC-2: y", "bbbb222")}} + }) + + snap := h.r.Poll(context.Background()) + + if n := len(h.host.compares()); n != 1 { + t.Errorf("compare calls = %d, want 1 (the rest of the poll asks nothing)", n) + } + var cmpWarn int + for _, w := range snap.Warnings { + if strings.HasPrefix(w, "compare ") { + cmpWarn++ + } + } + if cmpWarn != 1 { + t.Errorf("compare warnings = %q, want exactly one", snap.Warnings) + } + g := statusOf(t, snap, "github") + if !g.Throttled || !g.RetryAt.Equal(t0.Add(2*time.Minute)) { + t.Fatalf("github status = %+v, want throttled until +2m", g) + } + + h.host.set(func(f *fakeHost) { + f.compare = func(string, string, string) (model.Inclusion, error) { return model.Included, nil } + }) + prCalls := func() int { h.host.mu.Lock(); defer h.host.mu.Unlock(); return h.host.prCalls } + before := prCalls() + h.clock.Advance(time.Minute) + h.r.Poll(context.Background()) + if prCalls() != before || len(h.host.compares()) != 1 { + t.Errorf("before RetryAt: RecentPRs calls %d -> %d, compares %d; want GitHub left alone", before, prCalls(), len(h.host.compares())) + } + + h.clock.Advance(time.Minute) + snap = h.r.Poll(context.Background()) + if prCalls() == before || len(h.host.compares()) != 3 { + t.Errorf("at RetryAt: RecentPRs calls %d, compares %d; want both resumed", prCalls(), len(h.host.compares())) + } + if g := statusOf(t, snap, "github"); !g.OK || g.Throttled { + t.Errorf("github status = %+v, want OK", g) + } +} diff --git a/internal/resolver/fakes_test.go b/internal/resolver/fakes_test.go index 4cdeb54..f4156e3 100644 --- a/internal/resolver/fakes_test.go +++ b/internal/resolver/fakes_test.go @@ -158,6 +158,7 @@ type fakeHost struct { prErr error prErrRepo map[string]error prCalls int + prRepos []string base map[string][]model.PR // BasePRs result by repo baseInputs map[string][]model.PR compare func(repo, base, head string) (model.Inclusion, error) @@ -168,6 +169,7 @@ func (f *fakeHost) RecentPRs(_ context.Context, owner, name, _ string, _ time.Ti f.mu.Lock() defer f.mu.Unlock() f.prCalls++ + f.prRepos = append(f.prRepos, owner+"/"+name) if f.prErr != nil { return nil, nil, f.prErr } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index eb32182..2b607dd 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -52,12 +52,15 @@ type Adapters struct { // AdapterStatus is how one source fared, for the header. type AdapterStatus struct { - Name string // "jira", "github", "aws " - OK bool // the last attempt succeeded - At time.Time // last success; zero if none yet - Err string // the last attempt's error, short - SSO bool // the error is an expired AWS SSO session - Throttled bool // rate limited: the source is skipped until RetryAt + Name string // "jira", "github", "aws " + OK bool // the last attempt succeeded + At time.Time // last success; zero if none yet + Err string // the last attempt's error, short + SSO bool // the error is an expired AWS SSO session + // Auth: Jira or GitHub rejected the token (401). The source is not + // called again until a refresh, which retries it once, or a restart. + Auth bool + Throttled bool // rate limited: the source is skipped until RetryAt RetryAt time.Time } @@ -77,8 +80,9 @@ type Snapshot struct { // prSince is how far back RecentPRs looks. const prSince = 30 * 24 * time.Hour -// AWSThrottleBackoff is how long an AWS account is skipped after AWS -// throttles it; AWS errors carry no Retry-After. +// AWSThrottleBackoff is the least time an AWS account is skipped after AWS +// throttles it (a longer poll interval wins); AWS errors carry no +// Retry-After. const AWSThrottleBackoff = time.Minute // maxErr caps AdapterStatus.Err, in runes. @@ -160,7 +164,8 @@ func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger // Run polls now, then every poll interval, and on each receive from // refresh, sending each Snapshot to out, until ctx is canceled. Polls run // one at a time: a refresh that arrives during a poll starts the next one -// as soon as it ends, and the interval restarts after every poll. Send on +// as soon as it ends, and the interval restarts after every poll. A refresh +// also retries, once, a source stopped by a rejected token. Send on // refresh without blocking (a buffer of 1 coalesces repeated presses). func (r *Resolver) Run(ctx context.Context, out chan<- Snapshot, refresh <-chan struct{}) { timer := time.NewTimer(r.interval) @@ -179,6 +184,7 @@ func (r *Resolver) Run(ctx context.Context, out chan<- Snapshot, refresh <-chan select { case <-timer.C: case <-refresh: + r.clearAuth() case <-ctx.Done(): return } @@ -240,7 +246,8 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { return true } jiraRan := run("jira", func() { jr = r.fetchJira(ctx) }) - githubRan := run("github", func() { gr = r.fetchGitHub(ctx, now) }) + watched := r.watchedRepos(known) + githubRan := run("github", func() { gr = r.fetchGitHub(ctx, watched, now) }) acctRan := make([]bool, len(accts)) for i, a := range accts { acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, known) }) @@ -264,8 +271,9 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { r.tickets, r.ticketWarn = jr.tickets, jr.warn } // Extra tickets need GitHub's PRs, so they come after the fetches. - // A rate-limited Jira is not asked again this poll. - if retryAfter(err) == 0 { + // A rate-limited Jira, or one that rejected the token, is not asked + // again this poll. + if r.retryAfter(err) == 0 && !isAuth(err) { if xerr := r.fetchExtraTickets(ctx); err == nil { err = xerr } @@ -334,19 +342,32 @@ func (r *Resolver) fetchExtraTickets(ctx context.Context) error { return nil } +// watchedRepos are the config's watched repos plus every repo a pipeline's +// sources name, so an env without config repos still has its PRs fetched. +// It reads the sources known when the poll starts: a repo first discovered +// in a poll is fetched from the next one. +func (r *Resolver) watchedRepos(sources map[config.PipelineKey]map[string]string) []string { + all := r.cfg.WatchedRepos() + for _, src := range sources { + all = append(all, aws.Repos(src)...) + } + slices.Sort(all) + return slices.Compact(all) +} + // fetchGitHub fetches my recent PRs and their base-branch PRs per watched // repo. A repo that fails keeps its previous data and the rest go on, except // that a rate limit or a bad token stops the loop: every later call would // fail the same way. -func (r *Resolver) fetchGitHub(ctx context.Context, now time.Time) githubResult { +func (r *Resolver) fetchGitHub(ctx context.Context, repos []string, now time.Time) githubResult { res := githubResult{repos: map[string]repoData{}} - for _, full := range r.cfg.WatchedRepos() { + for _, full := range repos { d, err := r.fetchRepo(ctx, full, now) if err != nil { if res.err == nil { res.err = fmt.Errorf("%s: %w", full, err) } - if retryAfter(err) > 0 || github.IsAuth(err) { + if r.retryAfter(err) > 0 || isAuth(err) { break } continue @@ -442,21 +463,38 @@ func (r *Resolver) record(name string, err error, now time.Time) { r.log.Warn("source failed", "source", name, "err", err) *s = AdapterStatus{Name: name, At: s.At, Err: short(err.Error()), SSO: strings.HasPrefix(name, "aws ") && aws.IsSSOExpired(err)} - if d := retryAfter(err); d > 0 { + if !strings.HasPrefix(name, "aws ") && isAuth(err) { + s.Auth, s.Err = true, "token rejected" + } + if d := r.retryAfter(err); d > 0 { s.Throttled, s.RetryAt = true, now.Add(d) } } -// backingOff reports whether a throttled source is still waiting out its -// RetryAt. +// isAuth reports whether err is a Jira or GitHub 401: the token is bad. +func isAuth(err error) bool { return jira.IsAuth(err) || github.IsAuth(err) } + +// backingOff reports whether a source is stopped: its token was rejected +// (until a refresh), or it is throttled and RetryAt has not come. func (r *Resolver) backingOff(name string, now time.Time) bool { s := r.status[name] - return s.Throttled && now.Before(s.RetryAt) + return s.Auth || s.Throttled && now.Before(s.RetryAt) +} + +// clearAuth lets every source stopped by a rejected token try once more on +// the next poll. +func (r *Resolver) clearAuth() { + r.pollMu.Lock() + defer r.pollMu.Unlock() + for _, s := range r.status { + s.Auth = false + } } // retryAfter is how long err asks the source to back off: Retry-After from -// a Jira or GitHub rate limit, AWSThrottleBackoff for AWS throttling, else 0. -func retryAfter(err error) time.Duration { +// a Jira or GitHub rate limit, the longer of AWSThrottleBackoff and the poll +// interval for AWS throttling (so at least one poll is skipped), else 0. +func (r *Resolver) retryAfter(err error) time.Duration { var je *jira.APIError if errors.As(err, &je) && je.RetryAfter > 0 { return je.RetryAfter @@ -466,7 +504,7 @@ func retryAfter(err error) time.Duration { return ge.RetryAfter } if aws.IsThrottled(err) { - return AWSThrottleBackoff + return max(AWSThrottleBackoff, r.cfg.PollInterval()) } return 0 } @@ -491,7 +529,7 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } } var prs, bases []model.PR - for _, repo := range r.cfg.WatchedRepos() { + for _, repo := range r.watchedRepos(r.sources) { d := r.repos[repo] prs = append(prs, d.prs...) bases = append(bases, d.context...) @@ -513,17 +551,29 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } chains, unlinked := link.Link(tickets, prs, bases, r.cfg.Jira.Projects) + // A rate-limited GitHub is not asked for compares; misses are Unknown. + // link calls cmp from this goroutine only, so stop needs no lock. + stop := r.backingOff("github", now) cmp := func(repo, base, head string) model.Inclusion { if inc, ok := r.cache.get(repo, base, head); ok { return inc } - if ctx.Err() != nil { + if stop || ctx.Err() != nil { return model.InclusionUnknown } inc, err := r.ad.Code.Compare(ctx, repo, base, head) if err != nil { warn.add(fmt.Sprintf("compare %s %s...%s: %v", repo, base, head, err)) inc = model.InclusionUnknown + if d := r.retryAfter(err); d > 0 { + // Back off GitHub as a whole, from the rest of this poll's + // compares to the PR fetches until RetryAt. + stop = true + r.log.Warn("source failed", "source", "github", "err", err) + st := r.status["github"] + st.OK, st.Err = false, short(err.Error()) + st.Throttled, st.RetryAt = true, now.Add(d) + } } r.cache.put(repo, base, head, inc) return inc From 8cc2d0c1615d39d54ce44d1f51018cde7a34f45e Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:29:18 -0400 Subject: [PATCH 27/31] fix(resolver): unknown until sources are known; backoff skips a poll An env with no config repos whose pipeline sources were never discovered (an expired SSO session at startup) dropped out of every chain, so the other prod env alone could show "in prod". It now counts as applying with SlotUnknown for any chain with a merged PR on its way to the default branch, which rules shows as a yellow "deploy unknown". Env.Repos stays empty. AWS throttling backed off max(AWSThrottleBackoff, interval) from the poll's start, which expires just before the next poll (polls land one interval after the last one ends). It now backs off that plus one interval, from when the error is recorded, so at least one poll is skipped. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/decisions_test.go | 48 +++++++++++++++++++- internal/resolver/fakes_test.go | 7 +++ internal/resolver/resolver.go | 58 +++++++++++++++++++----- internal/resolver/resolver_test.go | 2 +- internal/resolver/undiscovered_test.go | 62 ++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 internal/resolver/undiscovered_test.go diff --git a/internal/resolver/decisions_test.go b/internal/resolver/decisions_test.go index c295a11..2ec0e1b 100644 --- a/internal/resolver/decisions_test.go +++ b/internal/resolver/decisions_test.go @@ -28,8 +28,8 @@ func TestAWSThrottleBackoffIsAtLeastPollInterval(t *testing.T) { snap := h.r.Poll(context.Background()) - if s := statusOf(t, snap, "aws stage-acct"); !s.Throttled || !s.RetryAt.Equal(t0.Add(90*time.Second)) { - t.Errorf("status = %+v, want throttled until poll time + 90s", s) + if s := statusOf(t, snap, "aws stage-acct"); !s.Throttled || !s.RetryAt.Equal(t0.Add(180*time.Second)) { + t.Errorf("status = %+v, want throttled until poll time + 90s + 90s", s) } } @@ -189,3 +189,47 @@ func TestRateLimitedCompareBacksOffGitHub(t *testing.T) { t.Errorf("github status = %+v, want OK", g) } } + +// Polls land one interval after the previous poll ends, so a backoff of +// exactly one interval would expire just before the next poll. AWS +// throttling must skip that poll. +func TestAWSThrottleSkipsTheNextPoll(t *testing.T) { + h := newHarness(t, nil, testEnv) + h.withShippedChain() + h.r.Poll(context.Background()) // sources known: the next poll goes straight to History + h.clock.Advance(time.Minute) + h.stage.set(func(f *fakeDeployer) { + f.histErr = &smithy.GenericAPIError{Code: "ThrottlingException", Message: "Rate exceeded"} + }) + h.r.Poll(context.Background()) + _, before := h.stage.calls() + + h.clock.Advance(time.Minute + 2*time.Second) // the interval plus the poll's own time + snap := h.r.Poll(context.Background()) + + if _, got := h.stage.calls(); got != before { + t.Errorf("History calls = %d, want %d (the next poll skips the account)", got, before) + } + if s := statusOf(t, snap, "aws stage-acct"); !s.Throttled { + t.Errorf("status = %+v, want still throttled", s) + } +} + +// Backoff runs from when the error is recorded, not from when the poll +// started: a slow poll must not eat into it. +func TestAWSThrottleBackoffFromRecordTime(t *testing.T) { + h := newHarness(t, nil, testEnv) + h.withShippedChain() + h.r.Poll(context.Background()) // sources known: the next poll goes straight to History + h.stage.set(func(f *fakeDeployer) { + f.histErr = &smithy.GenericAPIError{Code: "ThrottlingException", Message: "Rate exceeded"} + f.during = func() { h.clock.Advance(20 * time.Second) } + }) + + snap := h.r.Poll(context.Background()) + + want := t0.Add(20*time.Second + resolver.AWSThrottleBackoff + time.Minute) + if s := statusOf(t, snap, "aws stage-acct"); !s.RetryAt.Equal(want) { + t.Errorf("RetryAt = %v, want %v (from when the error came back)", s.RetryAt, want) + } +} diff --git a/internal/resolver/fakes_test.go b/internal/resolver/fakes_test.go index f4156e3..92f8695 100644 --- a/internal/resolver/fakes_test.go +++ b/internal/resolver/fakes_test.go @@ -223,6 +223,7 @@ type fakeDeployer struct { histSpecs [][]aws.StageSpec health map[string]model.Health // by first service name healthErr error + during func() // when set, History calls it first (to move the clock mid-poll) } func (f *fakeDeployer) Sources(_ context.Context, pipeline string) (map[string]string, error) { @@ -240,6 +241,12 @@ func (f *fakeDeployer) Sources(_ context.Context, pipeline string) (map[string]s } func (f *fakeDeployer) History(_ context.Context, pipeline string, _ map[string]string, specs ...aws.StageSpec) (map[string][]model.Deploy, []string, error) { + f.mu.Lock() + during := f.during + f.mu.Unlock() + if during != nil { + during() + } f.mu.Lock() defer f.mu.Unlock() f.histCalls++ diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 2b607dd..0e7ff29 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -80,9 +80,10 @@ type Snapshot struct { // prSince is how far back RecentPRs looks. const prSince = 30 * 24 * time.Hour -// AWSThrottleBackoff is the least time an AWS account is skipped after AWS -// throttles it (a longer poll interval wins); AWS errors carry no -// Retry-After. +// AWSThrottleBackoff is the floor of the backoff after AWS throttles an +// account (AWS errors carry no Retry-After): the account is skipped until +// max(AWSThrottleBackoff, poll interval) + poll interval after the error is +// recorded. See retryAfter for why. const AWSThrottleBackoff = time.Minute // maxErr caps AdapterStatus.Err, in runes. @@ -453,7 +454,9 @@ func (r *Resolver) fetchAccount(ctx context.Context, account string, known map[c return res } -// record updates a source's status after a poll's attempt. +// record updates a source's status after a poll's attempt. At is the poll's +// time; RetryAt runs from now, when the error is recorded, so a slow poll +// does not eat into a backoff. func (r *Resolver) record(name string, err error, now time.Time) { s := r.status[name] if err == nil { @@ -467,7 +470,7 @@ func (r *Resolver) record(name string, err error, now time.Time) { s.Auth, s.Err = true, "token rejected" } if d := r.retryAfter(err); d > 0 { - s.Throttled, s.RetryAt = true, now.Add(d) + s.Throttled, s.RetryAt = true, r.now().Add(d) } } @@ -491,9 +494,13 @@ func (r *Resolver) clearAuth() { } } -// retryAfter is how long err asks the source to back off: Retry-After from -// a Jira or GitHub rate limit, the longer of AWSThrottleBackoff and the poll -// interval for AWS throttling (so at least one poll is skipped), else 0. +// retryAfter is how long err asks the source to back off, from when the +// error is recorded: Retry-After from a Jira or GitHub rate limit, else +// max(AWSThrottleBackoff, interval) + interval for AWS throttling, else 0. +// The extra interval makes sure at least one poll is skipped: backoff is +// checked when a poll starts, and the next poll starts one interval after +// this one ends, so a backoff of exactly one interval would already have +// expired by then. func (r *Resolver) retryAfter(err error) time.Duration { var je *jira.APIError if errors.As(err, &je) && je.RetryAfter > 0 { @@ -504,7 +511,7 @@ func (r *Resolver) retryAfter(err error) time.Duration { return ge.RetryAfter } if aws.IsThrottled(err) { - return max(AWSThrottleBackoff, r.cfg.PollInterval()) + return max(AWSThrottleBackoff, r.interval) + r.interval } return 0 } @@ -535,13 +542,20 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { bases = append(bases, d.context...) warn.add(d.warn...) } - var histories []model.EnvHistory - for _, e := range r.cfg.Envs() { + var ( + histories []model.EnvHistory + undiscovered []int // indexes of envs with no config repos and no sources yet + ) + for i, e := range r.cfg.Envs() { k := config.PipelineKey{Account: e.Account, Pipeline: e.Pipeline} if len(e.Repos) == 0 { // Config repos win; without them, the pipeline's source actions // say what it deploys (better than guessing from history). - e.Repos = aws.Repos(r.sources[k]) + src, ok := r.sources[k] + if !ok { + undiscovered = append(undiscovered, i) + } + e.Repos = aws.Repos(src) } p := r.pipelines[k] h := r.health[e.ID()] @@ -580,6 +594,7 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } for i := range chains { chains[i] = link.Slots(chains[i], histories, cmp) + markUndiscovered(&chains[i], undiscovered) } status := make([]AdapterStatus, len(r.order)) @@ -595,6 +610,25 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } } +// markUndiscovered makes each env in envs (indexes into c.Slots) an +// applying SlotUnknown when c has a merged PR on its way to the default +// branch. Such an env has no config repos and its pipeline's sources were +// never discovered (an expired SSO session at startup, say), so nobody knows +// what it deploys; leaving its slot out would let the other envs alone show +// a false green. Env.Repos stays empty. Such an env has no history either +// (History is only asked once Sources answered), so nothing better is lost. +func markUndiscovered(c *model.Chain, envs []int) { + if len(envs) == 0 || !slices.ContainsFunc(c.PRs, func(p model.PR) bool { + return p.State == model.PRMerged && p.EffectiveSHA != "" + }) { + return + } + for _, i := range envs { + s := &c.Slots[i] + *s = model.EnvSlot{Env: s.Env, Health: s.Health, State: model.SlotUnknown, Applies: true} + } +} + // warnings collects one poll's warnings, deduplicated, in first-seen order. // Safe for concurrent use. type warnings struct { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 3b11f2d..262a4a6 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -248,7 +248,7 @@ func TestThrottledSourceSkippedUntilRetryAt(t *testing.T) { calls: func(h *harness) int { h.host.mu.Lock(); defer h.host.mu.Unlock(); return h.host.prCalls }, }, { - name: "aws throttling", source: "aws stage-acct", retry: resolver.AWSThrottleBackoff, + name: "aws throttling", source: "aws stage-acct", retry: resolver.AWSThrottleBackoff + time.Minute, // + the default interval fail: func(h *harness, on bool) { h.stage.set(func(f *fakeDeployer) { f.histErr = nil diff --git a/internal/resolver/undiscovered_test.go b/internal/resolver/undiscovered_test.go new file mode 100644 index 0000000..375e223 --- /dev/null +++ b/internal/resolver/undiscovered_test.go @@ -0,0 +1,62 @@ +package resolver_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// bareStageProd and bareProdProd are two prod envs with no config repos: +// what they deploy comes only from their pipelines' sources. +const bareStageProd = ` +[[envs]] + account = "stage-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "Deploy" + prod = true +` + +const bareProdProd = ` +[[envs]] + account = "prod-acct" + pipeline = "app-pipeline" + stage = "Production" + deploy_action = "Deploy" + prod = true +` + +// An env whose sources were never discovered (SSO expired at startup) must +// not drop out of the chain: that would show "in prod" from the other prod +// env alone. +func TestUndiscoveredEnvIsUnknownNotSkipped(t *testing.T) { + h := newHarness(t, nil, bareStageProd, bareProdProd) + h.withShippedChain() + h.stage.set(func(f *fakeDeployer) { + f.hist = map[string]map[string][]model.Deploy{"app-pipeline": {"Production": {succeeded("aaaa111", t0.Add(-time.Hour))}}} + }) + h.prod.set(func(f *fakeDeployer) { f.histErr = errors.New("the SSO session has expired or is invalid") }) + + c := h.r.Poll(context.Background()).Chains[0] + + if c.Stage == model.StageInProd { + t.Errorf("stage = %v, want not in prod while prod-acct is unknown", c.Stage) + } + p := c.Slots[1] + if p.Env.Account != "prod-acct" || !p.Applies || p.State != model.SlotUnknown || len(p.Env.Repos) != 0 { + t.Errorf("prod-acct slot = %+v, want applying, unknown, no repos", p) + } + var found bool + for _, f := range c.Flags { + if f.Kind == model.FlagDeployUnknown && f.Level == model.Yellow && strings.Contains(f.Reason, "prod-acct") { + found = true + } + } + if !found { + t.Errorf("flags = %+v, want a yellow deploy unknown naming prod-acct", c.Flags) + } +} From 5cbc9349c694f5b14d68752887d18707060cf480 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:31:13 -0400 Subject: [PATCH 28/31] feat(resolver): per-row staleness A failed source keeps its last good data, so a row can be behind while the header shows only that some source failed. model.Chain now carries Stale and StaleReason: set when a repo one of the chain's PRs comes from, or an env whose slot applies, did not load this poll (failed, skipped or never loaded). An env counts as loaded when both its pipeline history and its health did. Snapshot exposes the same freshness as RepoAt (by watched repo) and EnvAt (by Env.ID()): each input's last success, zero if never. Assisted-by: Claude Code:claude-opus-5-5 --- internal/model/model.go | 4 + internal/resolver/resolver.go | 72 +++++++++++++++++ internal/resolver/stale_test.go | 135 ++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 internal/resolver/stale_test.go diff --git a/internal/model/model.go b/internal/model/model.go index d1c7c6a..8ffc1ac 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -300,6 +300,10 @@ type Chain struct { Slots []EnvSlot // one per configured Env, in Env.Order Stage Stage Flags []Flag // ordered; Flags[0] is the row's flag + // Stale: some input repo or env for this chain failed this poll or never + // loaded, so the row may be behind. StaleReason names the first one. + Stale bool + StaleReason string } // Level is the chain's worst flag level, the max over all Flags. Flags[0] is diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 0e7ff29..a4f94cb 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -75,6 +75,12 @@ type Snapshot struct { // shown, so a source that failed this poll keeps its last warnings. Warnings []string At time.Time + // RepoAt is when each watched repo last loaded, and EnvAt when each env + // (by Env.ID()) last loaded both its history and its health; zero if + // never. A time before At means that input failed or was skipped this + // poll. + RepoAt map[string]time.Time + EnvAt map[string]time.Time } // prSince is how far back RecentPRs looks. @@ -94,18 +100,21 @@ type repoData struct { prs []model.PR // mine context []model.PR // base-branch PRs of mine, any author warn []string + at time.Time // the poll that fetched it } // pipelineData is the last good AWS result for one pipeline. type pipelineData struct { deploys map[string][]model.Deploy // by stage warn []string + at time.Time // the poll that fetched it } // healthData is the last good health of one Env. type healthData struct { health model.Health warn []string + at time.Time // the poll that fetched it } // Resolver polls the adapters and builds Snapshots. Poll and Run are safe @@ -262,6 +271,7 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { if githubRan { for repo, d := range gr.repos { + d.at = now r.repos[repo] = d } r.record("github", gr.err, now) @@ -290,9 +300,11 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { r.sources[k] = v } for k, v := range ar.pipelines { + v.at = now r.pipelines[k] = v } for k, v := range ar.health { + v.at = now r.health[k] = v } r.record("aws "+a, ar.err, now) @@ -536,15 +548,18 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } } var prs, bases []model.PR + repoAt := map[string]time.Time{} for _, repo := range r.watchedRepos(r.sources) { d := r.repos[repo] prs = append(prs, d.prs...) bases = append(bases, d.context...) warn.add(d.warn...) + repoAt[repo] = d.at } var ( histories []model.EnvHistory undiscovered []int // indexes of envs with no config repos and no sources yet + envAt = map[string]time.Time{} ) for i, e := range r.cfg.Envs() { k := config.PipelineKey{Account: e.Account, Pipeline: e.Pipeline} @@ -559,6 +574,7 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } p := r.pipelines[k] h := r.health[e.ID()] + envAt[e.ID()] = earliest(p.at, h.at) warn.add(p.warn...) warn.add(h.warn...) histories = append(histories, model.EnvHistory{Env: e, Deploys: p.deploys[e.Stage], Health: h.health}) @@ -595,6 +611,7 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { for i := range chains { chains[i] = link.Slots(chains[i], histories, cmp) markUndiscovered(&chains[i], undiscovered) + markStale(&chains[i], repoAt, envAt, now) } status := make([]AdapterStatus, len(r.order)) @@ -607,9 +624,64 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { Status: status, Warnings: warn.list(), At: now, + RepoAt: repoAt, + EnvAt: envAt, } } +// markStale sets c.Stale when an input of c did not load this poll (it +// failed, was skipped, or never loaded): a repo one of c's PRs comes from, +// or an env whose slot applies. repoAt is keyed by watched repo, and PR.Repo +// matches it ignoring case; envAt is keyed by Env.ID(). +func markStale(c *model.Chain, repoAt, envAt map[string]time.Time, now time.Time) { + for _, p := range c.PRs { + at, ok := lookupFold(repoAt, p.Repo) + if !ok || !at.Equal(now) { + c.Stale, c.StaleReason = true, staleReason(p.Repo, at) + return + } + } + for _, s := range c.Slots { + if at := envAt[s.Env.ID()]; s.Applies && !at.Equal(now) { + c.Stale, c.StaleReason = true, staleReason(s.Env.ID(), at) + return + } + } +} + +// earliest is the earlier of a and b; zero if either is. +func earliest(a, b time.Time) time.Time { + if a.IsZero() || b.IsZero() { + return time.Time{} + } + if a.Before(b) { + return a + } + return b +} + +// lookupFold is m[key], matching key ignoring case when there is no exact +// match. +func lookupFold(m map[string]time.Time, key string) (time.Time, bool) { + if v, ok := m[key]; ok { + return v, true + } + for k, v := range m { + if strings.EqualFold(k, key) { + return v, true + } + } + return time.Time{}, false +} + +// staleReason says why what (a repo or Env.ID()) is stale. +func staleReason(what string, at time.Time) string { + if at.IsZero() { + return what + " never loaded" + } + return what + " not refreshed since " + at.Format("15:04:05") +} + // markUndiscovered makes each env in envs (indexes into c.Slots) an // applying SlotUnknown when c has a merged PR on its way to the default // branch. Such an env has no config repos and its pipeline's sources were diff --git a/internal/resolver/stale_test.go b/internal/resolver/stale_test.go new file mode 100644 index 0000000..ca90eeb --- /dev/null +++ b/internal/resolver/stale_test.go @@ -0,0 +1,135 @@ +package resolver_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +func chainOf(t *testing.T, snap resolver.Snapshot, key string) model.Chain { + t.Helper() + for _, c := range snap.Chains { + if c.Ticket.Key == key { + return c + } + } + t.Fatalf("no chain %s in %+v", key, snap.Chains) + return model.Chain{} +} + +// withAPIChain adds ABC-2 with a merged PR in acme/api (apiEnv's repo). +func (h *harness) withAPIChain() { + api := mergedPR(2, "ABC-2: api", "eeee555") + api.Repo = "acme/api" + h.tracker.set(func(f *fakeTracker) { f.my = append(f.my, ticket("ABC-2")) }) + h.host.set(func(f *fakeHost) { f.prs["acme/api"] = []model.PR{api} }) +} + +func TestFailingRepoMarksItsChainsStale(t *testing.T) { + h := newHarness(t, nil, testEnv, apiEnv) + h.withShippedChain() + h.withAPIChain() + h.r.Poll(context.Background()) + + h.clock.Advance(time.Minute) + h.host.set(func(f *fakeHost) { + f.prErrRepo = map[string]error{"acme/api": &github.APIError{Status: 502, Message: "Bad Gateway"}} + }) + snap := h.r.Poll(context.Background()) + + if c := chainOf(t, snap, "ABC-2"); !c.Stale || c.StaleReason == "" { + t.Errorf("ABC-2 stale=%v reason=%q, want stale: acme/api failed", c.Stale, c.StaleReason) + } + if c := chainOf(t, snap, "ABC-1"); c.Stale { + t.Errorf("ABC-1 stale (%q), want fresh: acme/app loaded", c.StaleReason) + } + if got := snap.RepoAt["acme/api"]; !got.Equal(t0) { + t.Errorf("RepoAt[acme/api] = %v, want the last success %v", got, t0) + } + if got := snap.RepoAt["acme/app"]; !got.Equal(t0.Add(time.Minute)) { + t.Errorf("RepoAt[acme/app] = %v, want this poll", got) + } +} + +func TestFailingEnvHistoryMarksChainsWhereItAppliesStale(t *testing.T) { + h := newHarness(t, nil, testEnv, prodEnv, apiEnv) + h.withShippedChain() + h.withAPIChain() + h.r.Poll(context.Background()) + + h.clock.Advance(time.Minute) + h.prod.set(func(f *fakeDeployer) { + f.histErrPipe = map[string]error{"app-pipeline": errors.New("aws: HTTP 500")} + }) + snap := h.r.Poll(context.Background()) + + if c := chainOf(t, snap, "ABC-1"); !c.Stale || c.StaleReason == "" { + t.Errorf("ABC-1 stale=%v reason=%q, want stale: prod-acct app-pipeline failed", c.Stale, c.StaleReason) + } + if c := chainOf(t, snap, "ABC-2"); c.Stale { + t.Errorf("ABC-2 stale (%q), want fresh: its envs loaded", c.StaleReason) + } + prodID := "prod-acct/app-pipeline/Production" + if got := snap.EnvAt[prodID]; !got.Equal(t0) { + t.Errorf("EnvAt[%s] = %v, want the last success %v", prodID, got, t0) + } + if got := snap.EnvAt["stage-acct/app-pipeline/Test"]; !got.Equal(t0.Add(time.Minute)) { + t.Errorf("EnvAt[Test] = %v, want this poll", got) + } +} + +func TestHealthyPollIsNotStale(t *testing.T) { + h := newHarness(t, nil, ecsTestEnv, prodEnv, apiEnv) + h.withShippedChain() + h.withAPIChain() + h.r.Poll(context.Background()) + h.clock.Advance(time.Minute) + + snap := h.r.Poll(context.Background()) + + for _, c := range snap.Chains { + if c.Stale { + t.Errorf("%s stale (%q), want fresh after a clean poll", c.Ticket.Key, c.StaleReason) + } + } + for k, at := range snap.EnvAt { + if !at.Equal(t0.Add(time.Minute)) { + t.Errorf("EnvAt[%s] = %v, want this poll", k, at) + } + } +} + +func TestFailingEnvHealthMarksChainsStale(t *testing.T) { + h := newHarness(t, nil, ecsTestEnv) + h.withShippedChain() + h.r.Poll(context.Background()) + h.clock.Advance(time.Minute) + h.stage.set(func(f *fakeDeployer) { f.healthErr = errors.New("aws: DescribeServices c1: boom") }) + + snap := h.r.Poll(context.Background()) + + if c := chainOf(t, snap, "ABC-1"); !c.Stale { + t.Errorf("ABC-1 not stale, want stale: Test's health failed") + } +} + +func TestNeverLoadedEnvMarksChainsStale(t *testing.T) { + h := newHarness(t, nil, bareStageProd, bareProdProd) + h.withShippedChain() + h.prod.set(func(f *fakeDeployer) { f.histErr = errors.New("the SSO session has expired or is invalid") }) + + snap := h.r.Poll(context.Background()) + + c := chainOf(t, snap, "ABC-1") + if want := "prod-acct/app-pipeline/Production never loaded"; !c.Stale || c.StaleReason != want { + t.Errorf("stale=%v reason=%q, want %q", c.Stale, c.StaleReason, want) + } + if at, ok := snap.EnvAt["prod-acct/app-pipeline/Production"]; !ok || !at.IsZero() { + t.Errorf("EnvAt[prod] = %v, %v; want present and zero", at, ok) + } +} From 3302027a7179926ef3fca35ac2bc05d9ccd01396 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:34:32 -0400 Subject: [PATCH 29/31] feat(resolver): actions for approve, rerun, transition The resolver now owns the user's actions, so the UI talks to one thing: Approve (refuses read-only envs, empty tokens, envs with no approval configured and accounts with no client), Rerun (failed jobs of an Actions run), Transitions and Transition (Jira workflow moves). Each successful action asks Run for a poll through an internal channel, without the token retry a user's refresh does. PipelineURL builds an env's AWS console link. model.Env gains Region, ApprovalStage and ApprovalAction, filled by config.Envs() (approval_stage defaults to the env's own stage). Deployer, CodeHost and Tracker widen to the adapter methods the actions call. Assisted-by: Claude Code:claude-opus-5-5 --- internal/config/config.go | 22 +++-- internal/config/config_test.go | 7 +- internal/model/model.go | 6 ++ internal/resolver/actions.go | 71 ++++++++++++++ internal/resolver/actions_test.go | 149 ++++++++++++++++++++++++++++++ internal/resolver/fakes_test.go | 53 +++++++++++ internal/resolver/resolver.go | 11 ++- 7 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 internal/resolver/actions.go create mode 100644 internal/resolver/actions_test.go diff --git a/internal/config/config.go b/internal/config/config.go index b8e297c..565b14b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -355,7 +355,9 @@ func (c *Config) Account(name string) (Account, bool) { } // Envs are the configured envs as model.Env, in file order: Order is the -// position in the file and ReadOnly comes from the account. +// position in the file, Region and ReadOnly come from the account, and the +// approval fields are the stage spec's (approval_stage defaults to the env's +// own stage). func (c *Config) Envs() []model.Env { out := make([]model.Env, len(c.EnvList)) for i := range c.EnvList { @@ -368,14 +370,18 @@ func (c *Config) Envs() []model.Env { func (c *Config) env(i int) model.Env { e := c.EnvList[i] a, _ := c.Account(e.Account) + spec := e.stageSpec() return model.Env{ - Account: e.Account, - Pipeline: e.Pipeline, - Stage: e.Stage, - Order: i, - Prod: e.Prod, - ReadOnly: a.ReadOnly, - Repos: slices.Clone(e.Repos), + Account: e.Account, + Region: a.Region, + Pipeline: e.Pipeline, + Stage: e.Stage, + Order: i, + Prod: e.Prod, + ReadOnly: a.ReadOnly, + ApprovalStage: spec.ApprovalStage, + ApprovalAction: spec.ApprovalAction, + Repos: slices.Clone(e.Repos), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b8ac0f8..3b0459d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -132,8 +132,11 @@ func TestEnvs(t *testing.T) { t.Fatalf("got %d envs, want 2", len(envs)) } want := []model.Env{ - {Account: "stage-acct", Pipeline: "app-pipeline", Stage: "Test", Order: 0, Repos: []string{"acme/app"}}, - {Account: "prod-acct", Pipeline: "app-pipeline", Stage: "Production", Order: 1, Prod: true, ReadOnly: true, Repos: []string{"acme/app"}}, + // approval_stage defaults to the env's own stage. + {Account: "stage-acct", Region: "us-east-1", Pipeline: "app-pipeline", Stage: "Test", Order: 0, Repos: []string{"acme/app"}, + ApprovalStage: "Test", ApprovalAction: "ApproveTest"}, + {Account: "prod-acct", Region: "us-east-1", Pipeline: "app-pipeline", Stage: "Production", Order: 1, Prod: true, ReadOnly: true, Repos: []string{"acme/app"}, + ApprovalStage: "Test", ApprovalAction: "ApproveProd"}, } for i, w := range want { if !reflect.DeepEqual(envs[i], w) { diff --git a/internal/model/model.go b/internal/model/model.go index 8ffc1ac..56cb022 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -113,11 +113,17 @@ type PR struct { // Env is one deploy environment: a stage of a pipeline in an account. type Env struct { Account string // config name, e.g. "prod-acct" + Region string // the account's AWS region, e.g. "us-east-1" Pipeline string Stage string // "Test" | "Production" Order int // position in config; higher is further along Prod bool ReadOnly bool // profile cannot approve + // ApprovalStage and ApprovalAction name the manual approval that gates + // this Env; both empty when none is configured. ApprovalStage defaults + // to Stage when only ApprovalAction is set. + ApprovalStage string + ApprovalAction string // Repos are the "owner/name" repos this Env deploys, compared ignoring // case. Required in practice: phase 2 fills it from the pipeline's source // actions. link trusts it over what history shows, and rules uses it to diff --git a/internal/resolver/actions.go b/internal/resolver/actions.go new file mode 100644 index 0000000..f1bd253 --- /dev/null +++ b/internal/resolver/actions.go @@ -0,0 +1,71 @@ +package resolver + +import ( + "context" + "fmt" + "net/url" + + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/model" +) + +// Actions change the world on the user's behalf. They call the adapters +// directly and never take the poll lock, so they do not wait for a poll. +// Each successful action asks Run for a poll. + +// Approve approves (ok) or rejects the approval gating env, identified by +// token from the slot's AwaitingApproval deploy, using env's ApprovalStage +// and ApprovalAction. It refuses, calling nothing, for a read-only env, an +// empty token, an env with no approval configured, or an account with no +// AWS client. +func (r *Resolver) Approve(ctx context.Context, env model.Env, token string, ok bool, summary string) error { + switch { + case env.ReadOnly: + return fmt.Errorf("approve %s: the account is read-only", env.ID()) + case token == "": + return fmt.Errorf("approve %s: no approval token (nothing is waiting)", env.ID()) + case env.ApprovalAction == "": + return fmt.Errorf("approve %s: no approval_action configured", env.ID()) + } + d, found := r.ad.AWS[env.Account] + if !found { + return fmt.Errorf("approve %s: no AWS client for account %s", env.ID(), env.Account) + } + return r.refreshed(d.Approve(ctx, env.Pipeline, env.ApprovalStage, env.ApprovalAction, token, ok, summary)) +} + +// Rerun re-runs the failed jobs of GitHub Actions run runID in repo +// ("owner/name"), from a failing check's Check.RunID. +func (r *Resolver) Rerun(ctx context.Context, repo string, runID int64) error { + return r.refreshed(r.ad.Code.RerunFailedJobs(ctx, repo, runID)) +} + +// Transitions lists the workflow moves Jira allows from ticket key's +// current status. It only reads, so it asks for no poll. +func (r *Resolver) Transitions(ctx context.Context, key string) ([]jira.Transition, error) { + return r.ad.Tracker.Transitions(ctx, key) +} + +// Transition moves ticket key through transition id, one of Transitions'. +func (r *Resolver) Transition(ctx context.Context, key, id string) error { + return r.refreshed(r.ad.Tracker.DoTransition(ctx, key, id)) +} + +// refreshed asks Run for a poll when err is nil, so the action shows up +// without waiting for the interval, and returns err. It never blocks: a +// poll already asked for covers this one. +func (r *Resolver) refreshed(err error) error { + if err == nil { + select { + case r.kick <- struct{}{}: + default: + } + } + return err +} + +// PipelineURL is the AWS console page for env's pipeline, in env's region. +func PipelineURL(env model.Env) string { + return fmt.Sprintf("https://%s.console.aws.amazon.com/codesuite/codepipeline/pipelines/%s/view?region=%s", + env.Region, url.PathEscape(env.Pipeline), url.QueryEscape(env.Region)) +} diff --git a/internal/resolver/actions_test.go b/internal/resolver/actions_test.go new file mode 100644 index 0000000..00b2d14 --- /dev/null +++ b/internal/resolver/actions_test.go @@ -0,0 +1,149 @@ +package resolver_test + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/model" + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +type approveCall struct { + pipeline, stage, action, token string + ok bool + summary string +} + +// envFor returns the configured env with this ID, as the UI sees it in a +// snapshot slot. +func envFor(t *testing.T, h *harness, id string) model.Env { + t.Helper() + for _, c := range h.r.Poll(context.Background()).Chains { + for _, s := range c.Slots { + if s.Env.ID() == id { + return s.Env + } + } + } + t.Fatalf("no slot for env %s", id) + return model.Env{} +} + +func TestApproveUsesTheEnvsApproval(t *testing.T) { + h := newHarness(t, nil, prodTestEnv, prodEnv) + h.withShippedChain() + env := envFor(t, h, "prod-acct/app-pipeline/Test") + + if err := h.r.Approve(context.Background(), env, "tok-1", true, "ship it"); err != nil { + t.Fatalf("Approve = %v", err) + } + + want := approveCall{"app-pipeline", "Test", "ApproveTest", "tok-1", true, "ship it"} + if got := h.prod.approvals(); len(got) != 1 || got[0] != want { + t.Errorf("Approve calls = %+v, want [%+v]", got, want) + } +} + +func TestApproveRefuses(t *testing.T) { + h := newHarness(t, nil, prodTestEnv, prodEnv) + h.withShippedChain() + env := envFor(t, h, "prod-acct/app-pipeline/Test") + readOnly := env + readOnly.ReadOnly = true + noApproval := envFor(t, h, "prod-acct/app-pipeline/Production") + noClient := env + noClient.Account = "other-acct" + + cases := []struct { + name string + env model.Env + token string + want string + }{ + {"read-only env", readOnly, "tok-1", "read-only"}, + {"empty token", env, "", "no approval token"}, + {"no approval configured", noApproval, "tok-1", "no approval_action"}, + {"no AWS client", noClient, "tok-1", "no AWS client"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := h.r.Approve(context.Background(), c.env, c.token, true, "") + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("Approve = %v, want an error containing %q", err, c.want) + } + }) + } + if got := h.prod.approvals(); len(got) != 0 { + t.Errorf("Approve calls = %+v, want none", got) + } +} + +func TestSuccessfulApproveRefreshes(t *testing.T) { + h := newHarness(t, nil, prodTestEnv, prodEnv) // default 60s interval: only a refresh polls again + h.withShippedChain() + env := envFor(t, h, "prod-acct/app-pipeline/Test") + out, _, _ := start(t, h) + next(t, out) + before, _ := h.tracker.calls() + + if err := h.r.Approve(context.Background(), env, "tok-1", true, ""); err != nil { + t.Fatalf("Approve = %v", err) + } + + next(t, out) + if n, _ := h.tracker.calls(); n != before+1 { + t.Errorf("MyTickets calls = %d, want %d (one poll after the action)", n, before+1) + } +} + +func TestRerunReRunsTheFailedJobsAndRefreshes(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + out, _, _ := start(t, h) + next(t, out) + + if err := h.r.Rerun(context.Background(), "acme/app", 42); err != nil { + t.Fatalf("Rerun = %v", err) + } + + if got := h.host.reruns(); len(got) != 1 || got[0] != (rerunCall{"acme/app", 42}) { + t.Errorf("RerunFailedJobs calls = %+v, want one for acme/app run 42", got) + } + next(t, out) +} + +func TestTransitionsListsAndTransitionMovesAndRefreshes(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { + f.transitions = map[string][]jira.Transition{"ABC-1": {{ID: "31", Name: "Done", ToName: "Done"}}} + }) + out, _, _ := start(t, h) + next(t, out) + + ts, err := h.r.Transitions(context.Background(), "ABC-1") + if err != nil || len(ts) != 1 || ts[0].ID != "31" { + t.Fatalf("Transitions = %+v, %v; want Done (31)", ts, err) + } + if err := h.r.Transition(context.Background(), "ABC-1", "31"); err != nil { + t.Fatalf("Transition = %v", err) + } + + h.tracker.set(func(f *fakeTracker) { + if want := [][2]string{{"ABC-1", "31"}}; !slices.Equal(f.moves, want) { + t.Errorf("DoTransition calls = %v, want %v", f.moves, want) + } + }) + next(t, out) +} + +func TestPipelineURL(t *testing.T) { + env := model.Env{Account: "prod-acct", Region: "us-east-1", Pipeline: "app-pipeline", Stage: "Production"} + want := "https://us-east-1.console.aws.amazon.com/codesuite/codepipeline/pipelines/app-pipeline/view?region=us-east-1" + if got := resolver.PipelineURL(env); got != want { + t.Errorf("PipelineURL = %q, want %q", got, want) + } +} diff --git a/internal/resolver/fakes_test.go b/internal/resolver/fakes_test.go index 92f8695..512b420 100644 --- a/internal/resolver/fakes_test.go +++ b/internal/resolver/fakes_test.go @@ -13,6 +13,7 @@ import ( "github.com/ericdahl-dev/app-green/internal/aws" "github.com/ericdahl-dev/app-green/internal/config" + "github.com/ericdahl-dev/app-green/internal/jira" "github.com/ericdahl-dev/app-green/internal/model" ) @@ -100,6 +101,23 @@ type fakeTracker struct { block chan struct{} // when set, MyTickets waits for it to close inFlight int maxFlight int + + transitions map[string][]jira.Transition // by key + moves [][2]string // DoTransition calls: key, transition ID + moveErr error +} + +func (f *fakeTracker) Transitions(_ context.Context, key string) ([]jira.Transition, error) { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.transitions[key]), nil +} + +func (f *fakeTracker) DoTransition(_ context.Context, key, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.moves = append(f.moves, [2]string{key, id}) + return f.moveErr } func (f *fakeTracker) MyTickets(ctx context.Context, projects []string) ([]model.Ticket, []string, error) { @@ -163,6 +181,26 @@ type fakeHost struct { baseInputs map[string][]model.PR compare func(repo, base, head string) (model.Inclusion, error) compareCalls []compareCall + rerunCalls []rerunCall + rerunErr error +} + +type rerunCall struct { + repo string + runID int64 +} + +func (f *fakeHost) RerunFailedJobs(_ context.Context, repo string, runID int64) error { + f.mu.Lock() + defer f.mu.Unlock() + f.rerunCalls = append(f.rerunCalls, rerunCall{repo, runID}) + return f.rerunErr +} + +func (f *fakeHost) reruns() []rerunCall { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.rerunCalls) } func (f *fakeHost) RecentPRs(_ context.Context, owner, name, _ string, _ time.Time) ([]model.PR, []string, error) { @@ -224,6 +262,21 @@ type fakeDeployer struct { health map[string]model.Health // by first service name healthErr error during func() // when set, History calls it first (to move the clock mid-poll) + approveCalls []approveCall + approveErr error +} + +func (f *fakeDeployer) Approve(_ context.Context, pipeline, stage, action, token string, ok bool, summary string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.approveCalls = append(f.approveCalls, approveCall{pipeline, stage, action, token, ok, summary}) + return f.approveErr +} + +func (f *fakeDeployer) approvals() []approveCall { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.approveCalls) } func (f *fakeDeployer) Sources(_ context.Context, pipeline string) (map[string]string, error) { diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index a4f94cb..d6e5532 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -27,6 +27,8 @@ import ( type Tracker interface { MyTickets(ctx context.Context, projects []string) ([]model.Ticket, []string, error) TicketsByKey(ctx context.Context, keys []string) ([]model.Ticket, []string, error) + Transitions(ctx context.Context, key string) ([]jira.Transition, error) + DoTransition(ctx context.Context, key, transitionID string) error } // CodeHost is the part of github.Client the resolver uses. @@ -34,6 +36,7 @@ type CodeHost interface { RecentPRs(ctx context.Context, owner, name, author string, since time.Time) ([]model.PR, []string, error) BasePRs(ctx context.Context, owner, name string, prs []model.PR) ([]model.PR, []string, error) Compare(ctx context.Context, repo, base, head string) (model.Inclusion, error) + RerunFailedJobs(ctx context.Context, repo string, runID int64) error } // Deployer is the part of aws.Client (one account) the resolver uses. @@ -41,6 +44,7 @@ type Deployer interface { Sources(ctx context.Context, pipeline string) (map[string]string, error) History(ctx context.Context, pipeline string, sources map[string]string, specs ...aws.StageSpec) (map[string][]model.Deploy, []string, error) Health(ctx context.Context, services []aws.Service) (model.Health, []string, error) + Approve(ctx context.Context, pipeline, stage, action, token string, ok bool, summary string) error } // Adapters are the resolver's sources. AWS is keyed by config account name. @@ -126,6 +130,7 @@ type Resolver struct { log *slog.Logger interval time.Duration + kick chan struct{} // an action asks Run for a poll; buffer of 1 pollMu sync.Mutex // held for a whole poll; guards everything below cache *compareCache @@ -154,6 +159,7 @@ func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger r := &Resolver{ cfg: cfg, ad: ad, now: now, log: log, interval: cfg.PollInterval(), + kick: make(chan struct{}, 1), cache: newCompareCache(), status: map[string]*AdapterStatus{}, repos: map[string]repoData{}, @@ -176,7 +182,9 @@ func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger // one at a time: a refresh that arrives during a poll starts the next one // as soon as it ends, and the interval restarts after every poll. A refresh // also retries, once, a source stopped by a rejected token. Send on -// refresh without blocking (a buffer of 1 coalesces repeated presses). +// refresh without blocking (a buffer of 1 coalesces repeated presses). A +// successful action (Approve, Rerun, Transition) also starts a poll, but +// not the token retry: it is a re-read after a change, not the user asking. func (r *Resolver) Run(ctx context.Context, out chan<- Snapshot, refresh <-chan struct{}) { timer := time.NewTimer(r.interval) defer timer.Stop() @@ -195,6 +203,7 @@ func (r *Resolver) Run(ctx context.Context, out chan<- Snapshot, refresh <-chan case <-timer.C: case <-refresh: r.clearAuth() + case <-r.kick: case <-ctx.Done(): return } From c8715fcc2abe798fbeb567242b79800dee061f73 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:38:24 -0400 Subject: [PATCH 30/31] fix: phase 2 review fixes - A refresh asks every pipeline for its sources again, so a changed pipeline is picked up; until one answers, its previous sources stay in use, so a failed rediscovery loses nothing. - Extra tickets are not looked up when MyTickets failed this poll, and a failed lookup is a warning rather than a Jira failure (a rejected token or a rate limit still stops or backs off Jira). - A GitHub 401 during compares stops GitHub like any other 401 and skips the poll's remaining compares. - The compare cache drops answers nobody asked for in 50 polls. - A rate-limited compare backs GitHub off from when the error came back, like the other sources. - warnings drops a mutex nothing needed; resolveToken's doc gives the concurrent worst case (~11s); Snapshot's doc says the UI must treat it as read-only; TestCancelledContext is TestCanceledContext; a comment notes the first poll's compare latency, left as is. Assisted-by: Claude Code:claude-opus-5-5 --- internal/config/config.go | 5 +- internal/jira/tickets_test.go | 2 +- internal/resolver/cache.go | 54 +++++++++-- internal/resolver/resolver.go | 96 +++++++++++------- internal/resolver/resolver_test.go | 12 ++- internal/resolver/review_test.go | 150 +++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 48 deletions(-) create mode 100644 internal/resolver/review_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 565b14b..2c6b1bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -630,8 +630,9 @@ func quoteStderr(s string) string { // resolveToken reads the variable env, or runs command with sh -c and // trims its output. // -// A command runs at most tokenCommandTimeout (10s) plus a 1s WaitDelay, so -// with both tokens on commands startup can stall about 22s in the worst case. +// A command runs at most tokenCommandTimeout (10s) plus a 1s WaitDelay. +// resolver.FromConfig resolves both tokens concurrently, so with both on +// commands startup can stall about 11s in the worst case. // Errors never include stdout, where the token is, but a failed command's // stderr is quoted (trimmed, at most maxStderr bytes) to explain the // failure: a command that echoes the token to stderr and then fails would diff --git a/internal/jira/tickets_test.go b/internal/jira/tickets_test.go index ed4fc2e..34375ad 100644 --- a/internal/jira/tickets_test.go +++ b/internal/jira/tickets_test.go @@ -349,7 +349,7 @@ func TestSearchPageCapIsError(t *testing.T) { } } -func TestCancelledContext(t *testing.T) { +func TestCanceledContext(t *testing.T) { calls := 0 c := countingServer(t, &calls) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/resolver/cache.go b/internal/resolver/cache.go index 0bbc553..e5a8d17 100644 --- a/internal/resolver/cache.go +++ b/internal/resolver/cache.go @@ -6,24 +6,47 @@ import ( "github.com/ericdahl-dev/app-green/internal/model" ) -// compareCache holds compare answers by (repo, base, head) for the session. -// Only Included and NotIncluded are stored: both are permanent for a SHA -// pair, and a new head is a new key. Unknown (a 404, an unexpected status) -// and errors are asked again next time. +// compareKeep is how many polls a compare answer is kept without being +// asked for. +const compareKeep = 50 + +// compareCache holds compare answers by (repo, base, head). Only Included +// and NotIncluded are stored: both are permanent for a SHA pair, and a new +// head is a new key. Unknown (a 404, an unexpected status) and errors are +// asked again next time. An answer nobody asked for in compareKeep polls is +// dropped (see sweep), so the cache does not grow for the whole session. type compareCache struct { - mu sync.Mutex - m map[[3]string]model.Inclusion + mu sync.Mutex + gen int // the current poll + m map[[3]string]cacheEntry +} + +type cacheEntry struct { + inc model.Inclusion + used int // the last poll that asked for it } func newCompareCache() *compareCache { - return &compareCache{m: map[[3]string]model.Inclusion{}} + return &compareCache{m: map[[3]string]cacheEntry{}} +} + +// next starts a new poll's generation. +func (c *compareCache) next() { + c.mu.Lock() + defer c.mu.Unlock() + c.gen++ } func (c *compareCache) get(repo, base, head string) (model.Inclusion, bool) { c.mu.Lock() defer c.mu.Unlock() - inc, ok := c.m[[3]string{repo, base, head}] - return inc, ok + k := [3]string{repo, base, head} + e, ok := c.m[k] + if ok { + e.used = c.gen + c.m[k] = e + } + return e.inc, ok } func (c *compareCache) put(repo, base, head string, inc model.Inclusion) { @@ -32,5 +55,16 @@ func (c *compareCache) put(repo, base, head string, inc model.Inclusion) { } c.mu.Lock() defer c.mu.Unlock() - c.m[[3]string{repo, base, head}] = inc + c.m[[3]string{repo, base, head}] = cacheEntry{inc: inc, used: c.gen} +} + +// sweep drops the answers not asked for in the last compareKeep polls. +func (c *compareCache) sweep() { + c.mu.Lock() + defer c.mu.Unlock() + for k, e := range c.m { + if c.gen-e.used >= compareKeep { + delete(c.m, k) + } + } } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index d6e5532..6e4280e 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -68,7 +68,11 @@ type AdapterStatus struct { RetryAt time.Time } -// Snapshot is one poll's result, for the UI. +// Snapshot is one poll's result, for the UI, which must treat it as +// read-only: its slices and pointers (EnvSlot.Deploy, PR.Failing) may share +// backing data with other Snapshots and with the Resolver's cached results. +// The Resolver replaces that data on each poll and never mutates it, so +// reading a Snapshot from another goroutine is safe. type Snapshot struct { Chains []model.Chain // evaluated and sorted Unlinked []model.PR @@ -141,8 +145,10 @@ type Resolver struct { ticketWarn []string extra []model.Ticket // named by my open PRs, not in tickets extraWarn []string + extraErr string // the last extras lookup's failure, as a warning repos map[string]repoData - sources map[config.PipelineKey]map[string]string // session cache + sources map[config.PipelineKey]map[string]string // cached until a refresh + recheck map[config.PipelineKey]bool // cached sources a refresh wants asked again pipelines map[config.PipelineKey]pipelineData health map[string]healthData // by Env.ID() } @@ -164,6 +170,7 @@ func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger status: map[string]*AdapterStatus{}, repos: map[string]repoData{}, sources: map[config.PipelineKey]map[string]string{}, + recheck: map[config.PipelineKey]bool{}, pipelines: map[config.PipelineKey]pipelineData{}, health: map[string]healthData{}, } @@ -181,7 +188,8 @@ func New(cfg *config.Config, ad Adapters, now func() time.Time, log *slog.Logger // refresh, sending each Snapshot to out, until ctx is canceled. Polls run // one at a time: a refresh that arrives during a poll starts the next one // as soon as it ends, and the interval restarts after every poll. A refresh -// also retries, once, a source stopped by a rejected token. Send on +// also retries, once, a source stopped by a rejected token and asks every +// pipeline for its sources again. Send on // refresh without blocking (a buffer of 1 coalesces repeated presses). A // successful action (Approve, Rerun, Transition) also starts a poll, but // not the token retry: it is a re-read after a change, not the user asking. @@ -202,7 +210,7 @@ func (r *Resolver) Run(ctx context.Context, out chan<- Snapshot, refresh <-chan select { case <-timer.C: case <-refresh: - r.clearAuth() + r.onRefresh() case <-r.kick: case <-ctx.Done(): return @@ -255,7 +263,11 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { gr githubResult ars = make([]accountResult, len(accts)) known = maps.Clone(r.sources) // read by the AWS goroutines + ask = maps.Clone(known) // sources the AWS goroutines need not ask for ) + for k := range r.recheck { + delete(ask, k) + } run := func(name string, fetch func()) bool { if r.backingOff(name, now) { return false @@ -269,7 +281,7 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { githubRan := run("github", func() { gr = r.fetchGitHub(ctx, watched, now) }) acctRan := make([]bool, len(accts)) for i, a := range accts { - acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, known) }) + acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, ask) }) } wg.Wait() if ctx.Err() != nil { @@ -291,12 +303,10 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { r.tickets, r.ticketWarn = jr.tickets, jr.warn } // Extra tickets need GitHub's PRs, so they come after the fetches. - // A rate-limited Jira, or one that rejected the token, is not asked - // again this poll. - if r.retryAfter(err) == 0 && !isAuth(err) { - if xerr := r.fetchExtraTickets(ctx); err == nil { - err = xerr - } + // A Jira that just failed is not asked again this poll; the + // previous extras stay. + if err == nil { + err = r.fetchExtraTickets(ctx) } r.record("jira", err, now) } @@ -307,6 +317,7 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { ar := ars[i] for k, v := range ar.sources { r.sources[k] = v + delete(r.recheck, k) } for k, v := range ar.pipelines { v.at = now @@ -332,7 +343,9 @@ func (r *Resolver) fetchJira(ctx context.Context) jiraResult { // fetchExtraTickets looks up the tickets my open PRs name that MyTickets // did not return (assigned to someone else, say), so those PRs still get a -// row. On error the previous extras stay. +// row. On error the previous extras stay. A failed lookup is a warning, not +// a Jira failure (my tickets loaded), except a rejected token or a rate +// limit, which it returns so Jira stops or backs off. func (r *Resolver) fetchExtraTickets(ctx context.Context) error { have := map[string]bool{} for _, t := range r.tickets { @@ -352,15 +365,19 @@ func (r *Resolver) fetchExtraTickets(ctx context.Context) error { } } if len(keys) == 0 { - r.extra, r.extraWarn = nil, nil + r.extra, r.extraWarn, r.extraErr = nil, nil, "" return nil } slices.Sort(keys) t, w, err := r.ad.Tracker.TicketsByKey(ctx, keys) if err != nil { - return err + if isAuth(err) || r.retryAfter(err) > 0 { + return err + } + r.extraErr = short(fmt.Sprintf("jira: looking up %s: %v", strings.Join(keys, ", "), err)) + return nil } - r.extra, r.extraWarn = t, w + r.extra, r.extraWarn, r.extraErr = t, w, "" return nil } @@ -505,14 +522,21 @@ func (r *Resolver) backingOff(name string, now time.Time) bool { return s.Auth || s.Throttled && now.Before(s.RetryAt) } -// clearAuth lets every source stopped by a rejected token try once more on -// the next poll. -func (r *Resolver) clearAuth() { +// onRefresh is a user's refresh: every source stopped by a rejected token +// tries once more on the next poll, and every pipeline is asked for its +// sources again, so a changed pipeline is picked up. Until a pipeline +// answers (a skipped or failing account asks again next poll), its previous +// sources stay in use for watched repos and env repos, so a failed +// rediscovery loses nothing. +func (r *Resolver) onRefresh() { r.pollMu.Lock() defer r.pollMu.Unlock() for _, s := range r.status { s.Auth = false } + for k := range r.sources { + r.recheck[k] = true + } } // retryAfter is how long err asks the source to back off, from when the @@ -550,6 +574,9 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { warn := &warnings{} warn.add(r.ticketWarn...) warn.add(r.extraWarn...) + if r.extraErr != "" { + warn.add(r.extraErr) + } tickets := slices.Clone(r.tickets) for _, x := range r.extra { if !slices.ContainsFunc(tickets, func(t model.Ticket) bool { return t.Key == x.Key }) { @@ -590,8 +617,13 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } chains, unlinked := link.Link(tickets, prs, bases, r.cfg.Jira.Projects) - // A rate-limited GitHub is not asked for compares; misses are Unknown. - // link calls cmp from this goroutine only, so stop needs no lock. + r.cache.next() + defer r.cache.sweep() + // Compares run one at a time, here, after the fetches: the first poll + // of a session, with an empty cache, can take a while (left as is on + // purpose). A rate-limited GitHub, or one that rejected the token, is + // not asked for compares; misses are Unknown. link calls cmp from this goroutine + // only, so stop needs no lock. stop := r.backingOff("github", now) cmp := func(repo, base, head string) model.Inclusion { if inc, ok := r.cache.get(repo, base, head); ok { @@ -604,14 +636,19 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { if err != nil { warn.add(fmt.Sprintf("compare %s %s...%s: %v", repo, base, head, err)) inc = model.InclusionUnknown - if d := r.retryAfter(err); d > 0 { - // Back off GitHub as a whole, from the rest of this poll's - // compares to the PR fetches until RetryAt. + if d := r.retryAfter(err); d > 0 || isAuth(err) { + // Stop GitHub as a whole, from the rest of this poll's + // compares to the PR fetches: until RetryAt, or for a + // rejected token until a refresh. stop = true r.log.Warn("source failed", "source", "github", "err", err) st := r.status["github"] st.OK, st.Err = false, short(err.Error()) - st.Throttled, st.RetryAt = true, now.Add(d) + if isAuth(err) { + st.Auth, st.Err = true, "token rejected" + } else { + st.Throttled, st.RetryAt = true, r.now().Add(d) + } } } r.cache.put(repo, base, head, inc) @@ -711,16 +748,13 @@ func markUndiscovered(c *model.Chain, envs []int) { } // warnings collects one poll's warnings, deduplicated, in first-seen order. -// Safe for concurrent use. +// Only build's goroutine uses it. type warnings struct { - mu sync.Mutex seen map[string]bool all []string } func (w *warnings) add(msgs ...string) { - w.mu.Lock() - defer w.mu.Unlock() if w.seen == nil { w.seen = map[string]bool{} } @@ -732,8 +766,4 @@ func (w *warnings) add(msgs ...string) { } } -func (w *warnings) list() []string { - w.mu.Lock() - defer w.mu.Unlock() - return slices.Clone(w.all) -} +func (w *warnings) list() []string { return slices.Clone(w.all) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 262a4a6..a668d7c 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -350,8 +350,16 @@ func TestExtraTicketsFailureKeepsLastGood(t *testing.T) { if len(snap.Chains) != 2 { t.Errorf("chains = %d, want 2 (ABC-9 kept)", len(snap.Chains)) } - if j := statusOf(t, snap, "jira"); j.OK || !strings.Contains(j.Err, "500") { - t.Errorf("jira status = %+v, want the TicketsByKey error", j) + // My tickets loaded, so Jira is OK; the lookup's failure is a warning. + if j := statusOf(t, snap, "jira"); !j.OK { + t.Errorf("jira status = %+v, want OK", j) + } + if !slices.ContainsFunc(snap.Warnings, func(w string) bool { return strings.Contains(w, "500") }) { + t.Errorf("warnings = %q, want the TicketsByKey error", snap.Warnings) + } + h.tracker.set(func(f *fakeTracker) { f.byKeyErr = nil }) + if snap = h.r.Poll(context.Background()); slices.ContainsFunc(snap.Warnings, func(w string) bool { return strings.Contains(w, "500") }) { + t.Errorf("warnings after recovery = %q, want the error gone", snap.Warnings) } } diff --git a/internal/resolver/review_test.go b/internal/resolver/review_test.go new file mode 100644 index 0000000..f2c9392 --- /dev/null +++ b/internal/resolver/review_test.go @@ -0,0 +1,150 @@ +package resolver_test + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/model" +) + +func TestRefreshRediscoversSources(t *testing.T) { + h := newHarness(t, nil, bareTestEnv) // default 60s interval: only refresh polls again + h.withShippedChain() + out, refresh, _ := start(t, h) + next(t, out) + + // The pipeline gains a source while the app runs. + h.stage.set(func(f *fakeDeployer) { + f.sources = map[string]map[string]string{"app-pipeline": {"acme/app": "AppSource", "acme/infra": "InfraSource"}} + }) + refresh <- struct{}{} + snap := next(t, out) + + if n, _ := h.stage.calls(); n != 2 { + t.Errorf("Sources calls = %d, want 2 (asked again after refresh)", n) + } + if got := snap.Chains[0].Slots[0].Env.Repos; !slices.Equal(got, []string{"acme/app", "acme/infra"}) { + t.Errorf("Env.Repos = %v, want the new source too", got) + } + h.host.set(func(f *fakeHost) { f.prRepos = nil; f.prs["acme/infra"] = []model.PR{} }) + refresh <- struct{}{} + next(t, out) + h.host.set(func(f *fakeHost) { + if !slices.Contains(f.prRepos, "acme/infra") { + t.Errorf("RecentPRs repos = %v, want acme/infra watched", f.prRepos) + } + }) +} + +func TestNoExtraTicketsWhenMyTicketsFailed(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { + f.byKey = map[string]model.Ticket{"ABC-9": ticket("ABC-9")} + f.myErr = errors.New("jira: HTTP 502 Bad Gateway") + }) + h.host.set(func(f *fakeHost) { f.prs[repo] = append(f.prs[repo], openPR(9, "ABC-9: x")) }) + + h.r.Poll(context.Background()) + + if _, byKey := h.tracker.calls(); len(byKey) != 0 { + t.Errorf("TicketsByKey calls = %v, want none after MyTickets failed", byKey) + } +} + +func TestRejectedTokenDuringCompareStopsGitHub(t *testing.T) { + h := newHarness(t, nil) + h.withCompareChain(model.InclusionUnknown, &github.APIError{Status: 401, Message: "Bad credentials"}) + h.tracker.set(func(f *fakeTracker) { f.my = []model.Ticket{ticket("ABC-1"), ticket("ABC-2")} }) + h.host.set(func(f *fakeHost) { + f.prs = map[string][]model.PR{repo: {mergedPR(1, "ABC-1: x", "aaaa111"), mergedPR(2, "ABC-2: y", "bbbb222")}} + }) + + snap := h.r.Poll(context.Background()) + + if n := len(h.host.compares()); n != 1 { + t.Errorf("compare calls = %d, want 1 (the rest of the poll asks nothing)", n) + } + if g := statusOf(t, snap, "github"); g.OK || !g.Auth || g.Err != "token rejected" { + t.Fatalf("github status = %+v, want Auth with Err %q", g, "token rejected") + } + prCalls := func() int { h.host.mu.Lock(); defer h.host.mu.Unlock(); return h.host.prCalls } + before := prCalls() + h.clock.Advance(time.Hour) + h.r.Poll(context.Background()) + if prCalls() != before || len(h.host.compares()) != 1 { + t.Errorf("after the 401: RecentPRs calls %d -> %d, compares %d; want GitHub stopped until refresh", before, prCalls(), len(h.host.compares())) + } +} + +// Retry-After counts from when the error came back, not from the poll's +// start: a slow poll must not eat into it. +func TestRetryAfterFromRecordTime(t *testing.T) { + const slow = 20 * time.Second + rateLimited := func() error { return &github.APIError{Status: 429, RetryAfter: 2 * time.Minute} } + cases := []struct { + name, source string + fail func(h *harness) + }{ + {"jira", "jira", func(h *harness) { + h.tracker.set(func(f *fakeTracker) { f.myErr = &jira.APIError{Status: 429, RetryAfter: 2 * time.Minute} }) + h.stage.set(func(f *fakeDeployer) { f.during = func() { h.clock.Advance(slow) } }) + }}, + {"github", "github", func(h *harness) { + h.host.set(func(f *fakeHost) { f.prErr = rateLimited() }) + h.stage.set(func(f *fakeDeployer) { f.during = func() { h.clock.Advance(slow) } }) + }}, + {"compare", "github", func(h *harness) { + h.withCompareChain(model.InclusionUnknown, nil) + h.host.set(func(f *fakeHost) { + f.compare = func(string, string, string) (model.Inclusion, error) { + h.clock.Advance(slow) + return model.InclusionUnknown, rateLimited() + } + }) + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + c.fail(h) + + snap := h.r.Poll(context.Background()) + + if s, want := statusOf(t, snap, c.source), t0.Add(slow+2*time.Minute); !s.RetryAt.Equal(want) { + t.Errorf("RetryAt = %v, want %v", s.RetryAt, want) + } + }) + } +} + +// A compare answer nobody asked for in 50 polls is dropped, so the cache +// does not grow for the whole session. +func TestCompareCacheDropsKeysUnusedFor50Polls(t *testing.T) { + h := newHarness(t, nil) + h.withCompareChain(model.Included, nil) + h.r.Poll(context.Background()) + idle := func(polls int) { + h.tracker.set(func(f *fakeTracker) { f.my = nil }) // no chain, no compare + for range polls { + h.r.Poll(context.Background()) + } + h.tracker.set(func(f *fakeTracker) { f.my = []model.Ticket{ticket("ABC-1")} }) + h.r.Poll(context.Background()) + } + + idle(49) + if n := len(h.host.compares()); n != 1 { + t.Errorf("compare calls after 49 idle polls = %d, want 1 (still cached)", n) + } + idle(50) + if n := len(h.host.compares()); n != 2 { + t.Errorf("compare calls after 50 idle polls = %d, want 2 (dropped, asked again)", n) + } +} From 61d0e46cfd87bd78ad5223d0f24082184a6194f7 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 6 Oct 2026 23:43:38 -0400 Subject: [PATCH 31/31] fix(resolver): staleness during backoff, no-PR rows, rerun guard, cached sources - A chain with no PRs is stale ("GitHub not refreshed") when GitHub failed or was skipped this poll: nobody can tell whether a PR exists. - Rerun refuses a run ID of 0 or less (a check that is not an Actions run) without calling GitHub. - After a refresh, a pipeline whose Sources call fails (other than an expired SSO session or throttling) reads its history with its cached sources, with a warning, instead of skipping the poll. - Tests pin that a skipped source keeps dependent rows stale for the whole backoff window, and that a 401 or rate limit from TicketsByKey still stops or backs off Jira. Assisted-by: Claude Code:claude-opus-5-5 --- internal/resolver/actions.go | 6 +- internal/resolver/fakes_test.go | 4 + internal/resolver/followup_test.go | 118 +++++++++++++++++++++++++++++ internal/resolver/resolver.go | 46 ++++++++--- 4 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 internal/resolver/followup_test.go diff --git a/internal/resolver/actions.go b/internal/resolver/actions.go index f1bd253..3cc62f6 100644 --- a/internal/resolver/actions.go +++ b/internal/resolver/actions.go @@ -35,8 +35,12 @@ func (r *Resolver) Approve(ctx context.Context, env model.Env, token string, ok } // Rerun re-runs the failed jobs of GitHub Actions run runID in repo -// ("owner/name"), from a failing check's Check.RunID. +// ("owner/name"), from a failing check's Check.RunID. A runID of 0 or less +// (a check that is not an Actions run) is refused without calling GitHub. func (r *Resolver) Rerun(ctx context.Context, repo string, runID int64) error { + if runID <= 0 { + return fmt.Errorf("rerun in %s: check has no GitHub Actions run to re-run", repo) + } return r.refreshed(r.ad.Code.RerunFailedJobs(ctx, repo, runID)) } diff --git a/internal/resolver/fakes_test.go b/internal/resolver/fakes_test.go index 512b420..acb61ce 100644 --- a/internal/resolver/fakes_test.go +++ b/internal/resolver/fakes_test.go @@ -253,6 +253,7 @@ type fakeDeployer struct { mu sync.Mutex sources map[string]map[string]string // by pipeline sourcesCalls int + sourcesErr error // when set, Sources fails with it (histErr also fails it) hist map[string]map[string][]model.Deploy // by pipeline, then stage histWarn []string histErr error @@ -286,6 +287,9 @@ func (f *fakeDeployer) Sources(_ context.Context, pipeline string) (map[string]s if f.histErr != nil { return nil, f.histErr } + if f.sourcesErr != nil { + return nil, f.sourcesErr + } src, ok := f.sources[pipeline] if !ok { src = map[string]string{repo: "AppSource"} diff --git a/internal/resolver/followup_test.go b/internal/resolver/followup_test.go new file mode 100644 index 0000000..5ad486a --- /dev/null +++ b/internal/resolver/followup_test.go @@ -0,0 +1,118 @@ +package resolver_test + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/github" + "github.com/ericdahl-dev/app-green/internal/jira" + "github.com/ericdahl-dev/app-green/internal/resolver" +) + +// A source skipped for backoff loads nothing, so rows that depend on it stay +// stale for the whole window. +func TestSkippedSourceKeepsRowsStaleForTheWindow(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.r.Poll(context.Background()) + h.clock.Advance(time.Minute) + h.host.set(func(f *fakeHost) { f.prErr = &github.APIError{Status: 429, RetryAfter: 2 * time.Minute} }) + h.r.Poll(context.Background()) + h.host.set(func(f *fakeHost) { f.prErr = nil }) + + h.clock.Advance(time.Minute) // inside the window: GitHub is skipped + snap := h.r.Poll(context.Background()) + + if c := chainOf(t, snap, "ABC-1"); !c.Stale { + t.Errorf("ABC-1 not stale while GitHub is skipped, want stale") + } +} + +// A chain with no PRs cannot tell whether a PR exists when GitHub did not +// load, so it is stale then, and fresh after a clean poll. +func TestNoPRChainStaleWhenGitHubDidNotLoad(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.my = append(f.my, ticket("ABC-7")) }) + + snap := h.r.Poll(context.Background()) + if c := chainOf(t, snap, "ABC-7"); c.Stale { + t.Errorf("ABC-7 stale (%q) after a clean poll, want fresh", c.StaleReason) + } + + h.clock.Advance(time.Minute) + h.host.set(func(f *fakeHost) { f.prErr = &github.APIError{Status: 502, Message: "Bad Gateway"} }) + snap = h.r.Poll(context.Background()) + if c := chainOf(t, snap, "ABC-7"); !c.Stale || c.StaleReason != "GitHub not refreshed" { + t.Errorf("ABC-7 stale=%v reason=%q, want stale: GitHub not refreshed", c.Stale, c.StaleReason) + } +} + +func TestRerunRefusesACheckWithoutARun(t *testing.T) { + h := newHarness(t, nil) + for _, id := range []int64{0, -1} { + err := h.r.Rerun(context.Background(), "acme/app", id) + if err == nil || !strings.Contains(err.Error(), "check has no GitHub Actions run to re-run") { + t.Errorf("Rerun(%d) = %v, want the no-run refusal", id, err) + } + } + if got := h.host.reruns(); len(got) != 0 { + t.Errorf("RerunFailedJobs calls = %+v, want none", got) + } +} + +// Only ordinary lookup failures are warnings: a rejected token or a rate +// limit from TicketsByKey still stops or backs off Jira. +func TestTicketsByKeyAuthAndRateLimitStillStopJira(t *testing.T) { + cases := []struct { + name string + err error + check func(s resolver.AdapterStatus) bool + }{ + {"401", &jira.APIError{Status: 401}, func(s resolver.AdapterStatus) bool { return s.Auth }}, + {"429", &jira.APIError{Status: 429, RetryAfter: time.Minute}, func(s resolver.AdapterStatus) bool { + return s.Throttled && s.RetryAt.Equal(t0.Add(time.Minute)) + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t, nil) + h.withShippedChain() + h.tracker.set(func(f *fakeTracker) { f.byKeyErr = c.err }) + h.host.set(func(f *fakeHost) { f.prs[repo] = append(f.prs[repo], openPR(9, "ABC-9: x")) }) + + snap := h.r.Poll(context.Background()) + + if s := statusOf(t, snap, "jira"); s.OK || !c.check(s) { + t.Errorf("jira status = %+v, want stopped or backed off", s) + } + }) + } +} + +// After a refresh, a pipeline whose Sources call fails still has its +// history read, with the sources it had, and says so in a warning. +func TestFailedRediscoveryFallsBackToCachedSources(t *testing.T) { + h := newHarness(t, nil) // default 60s interval: only refresh polls again + h.withShippedChain() + out, refresh, _ := start(t, h) + next(t, out) + h.stage.set(func(f *fakeDeployer) { f.sourcesErr = errors.New("aws: HTTP 500") }) + + refresh <- struct{}{} + snap := next(t, out) + + if src, hist := h.stage.calls(); src != 2 || hist != 2 { + t.Errorf("Sources/History calls = %d/%d, want 2/2 (history read with the cached sources)", src, hist) + } + if !slices.ContainsFunc(snap.Warnings, func(w string) bool { return strings.Contains(w, "cached sources") && strings.Contains(w, "500") }) { + t.Errorf("warnings = %q, want one saying the cached sources were used", snap.Warnings) + } + if c := chainOf(t, snap, "ABC-1"); c.Stale { + t.Errorf("ABC-1 stale (%q), want fresh: its history loaded", c.StaleReason) + } +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 6e4280e..1520b60 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -281,7 +281,7 @@ func (r *Resolver) Poll(ctx context.Context) Snapshot { githubRan := run("github", func() { gr = r.fetchGitHub(ctx, watched, now) }) acctRan := make([]bool, len(accts)) for i, a := range accts { - acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, ask) }) + acctRan[i] = run("aws "+a, func() { ars[i] = r.fetchAccount(ctx, a, ask, known) }) } wg.Wait() if ctx.Err() != nil { @@ -431,10 +431,13 @@ func (r *Resolver) fetchRepo(ctx context.Context, full string, now time.Time) (r // fetchAccount reads one account: per pipeline, its sources (once per // session) and one History call for all its envs' stages, then each env's -// health. A failing call keeps that pipeline's or env's previous data and -// the rest go on, except that an expired SSO session or throttling stops -// the account: every later call would fail the same way. -func (r *Resolver) fetchAccount(ctx context.Context, account string, known map[config.PipelineKey]map[string]string) accountResult { +// health. Sources is asked for pipelines missing from ask; cached holds +// every pipeline's previous sources. A failing call keeps that pipeline's or +// env's previous data and the rest go on, except that an expired SSO session +// or throttling stops the account: every later call would fail the same +// way. A pipeline asked again after a refresh whose Sources call fails +// otherwise reads its history with its cached sources, with a warning. +func (r *Resolver) fetchAccount(ctx context.Context, account string, ask, cached map[config.PipelineKey]map[string]string) accountResult { res := accountResult{ sources: map[config.PipelineKey]map[string]string{}, pipelines: map[config.PipelineKey]pipelineData{}, @@ -456,16 +459,24 @@ func (r *Resolver) fetchAccount(ctx context.Context, account string, known map[c if ps.Key.Account != account { continue } - src, ok := known[ps.Key] + var fallback string // a warning when the cached sources stand in + src, ok := ask[ps.Key] if !ok { - var err error - if src, err = d.Sources(ctx, ps.Key.Pipeline); err != nil { + fresh, err := d.Sources(ctx, ps.Key.Pipeline) + old, hadOld := cached[ps.Key] + switch { + case err == nil: + src = fresh + res.sources[ps.Key] = src + case aws.IsSSOExpired(err) || aws.IsThrottled(err) || !hadOld: if fail(err) { return res } continue + default: + src = old + fallback = short(fmt.Sprintf("aws %s %s: using cached sources: %v", account, ps.Key.Pipeline, err)) } - res.sources[ps.Key] = src } deploys, warn, err := d.History(ctx, ps.Key.Pipeline, src, ps.Specs...) if err != nil { @@ -474,6 +485,9 @@ func (r *Resolver) fetchAccount(ctx context.Context, account string, known map[c } continue } + if fallback != "" { + warn = append(slices.Clone(warn), fallback) + } res.pipelines[ps.Key] = pipelineData{deploys: deploys, warn: warn} } for _, e := range r.cfg.Envs() { @@ -617,6 +631,8 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { } chains, unlinked := link.Link(tickets, prs, bases, r.cfg.Jira.Projects) + // Read before compares, which can stop GitHub mid-build. + githubFresh := r.status["github"].OK && r.status["github"].At.Equal(now) r.cache.next() defer r.cache.sweep() // Compares run one at a time, here, after the fetches: the first poll @@ -657,7 +673,7 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { for i := range chains { chains[i] = link.Slots(chains[i], histories, cmp) markUndiscovered(&chains[i], undiscovered) - markStale(&chains[i], repoAt, envAt, now) + markStale(&chains[i], repoAt, envAt, githubFresh, now) } status := make([]AdapterStatus, len(r.order)) @@ -677,9 +693,15 @@ func (r *Resolver) build(ctx context.Context, now time.Time) Snapshot { // markStale sets c.Stale when an input of c did not load this poll (it // failed, was skipped, or never loaded): a repo one of c's PRs comes from, -// or an env whose slot applies. repoAt is keyed by watched repo, and PR.Repo +// or an env whose slot applies. A chain with no PRs is stale when GitHub +// failed or was skipped this poll (githubFresh false): nobody can tell +// whether a PR for it exists. repoAt is keyed by watched repo, and PR.Repo // matches it ignoring case; envAt is keyed by Env.ID(). -func markStale(c *model.Chain, repoAt, envAt map[string]time.Time, now time.Time) { +func markStale(c *model.Chain, repoAt, envAt map[string]time.Time, githubFresh bool, now time.Time) { + if len(c.PRs) == 0 && !githubFresh { + c.Stale, c.StaleReason = true, "GitHub not refreshed" + return + } for _, p := range c.PRs { at, ok := lookupFold(repoAt, p.Repo) if !ok || !at.Equal(now) {