diff --git a/go.mod b/go.mod index 1e75e3c..8ea27d5 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,25 @@ module github.com/ericdahl-dev/app-green 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 + 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..5beb622 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +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= +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..52cba77 --- /dev/null +++ b/internal/aws/approve.go @@ -0,0 +1,78 @@ +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") + +// 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; 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) + } + status := types.ApprovalStatusApproved + if !ok { + status = types.ApprovalStatusRejected + } + if strings.TrimSpace(summary) == "" { + summary = "Approved via app-green" + if !ok { + 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), + 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) + } + if IsAccessDenied(err) { + 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..9c0dd46 --- /dev/null +++ b/internal/aws/approve_test.go @@ -0,0 +1,92 @@ +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" + "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", "ApproveTest", "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) != "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 { + 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", "ApproveTest", "", 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", "ApproveTest", "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) + } +} + +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/client.go b/internal/aws/client.go new file mode 100644 index 0000000..cfa24cd --- /dev/null +++ b/internal/aws/client.go @@ -0,0 +1,110 @@ +// 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/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 +// 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. 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 +// 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 +} + +// 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 new file mode 100644 index 0000000..26c41a0 --- /dev/null +++ b/internal/aws/client_test.go @@ -0,0 +1,108 @@ +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}, + // 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}, + } + 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) + } +} + +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/discover.go b/internal/aws/discover.go new file mode 100644 index 0000000..345411f --- /dev/null +++ b/internal/aws/discover.go @@ -0,0 +1,47 @@ +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). 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 { + 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..9a9e060 --- /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("AppSource", "Acme/App"), + sourceAction("InfraSource", "acme/infra"), + sourceAction("ReportsSource", "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": "AppSource", "acme/infra": "InfraSource", "acme/reports": "ReportsSource"} + 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..1f59e0c --- /dev/null +++ b/internal/aws/fake_test.go @@ -0,0 +1,102 @@ +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 + stateErr error // GetPipelineState only + + 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.stateErr != nil { + return nil, f.stateErr + } + 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..bee1f51 --- /dev/null +++ b/internal/aws/health.go @@ -0,0 +1,84 @@ +package aws + +import ( + "context" + "fmt" + "slices" + "strings" + + 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) + } + name := serviceName(s.Name) + if !slices.Contains(names[s.Cluster], name) { + names[s.Cluster] = append(names[s.Cluster], 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 +} + +// 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 new file mode 100644 index 0000000..b4caf3e --- /dev/null +++ b/internal/aws/health_test.go @@ -0,0 +1,110 @@ +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) + } +} + +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 new file mode 100644 index 0000000..f581e1f --- /dev/null +++ b/internal/aws/history.go @@ -0,0 +1,337 @@ +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; 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), + MaxResults: awssdk.Int32(maxExecutions), + }) + if err != nil { + return nil, nil, fmt.Errorf("aws: ListPipelineExecutions %s: %w", pipeline, err) + } + 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 + // and show the approval without one. + state, err = c.cp.GetPipelineState(ctx, &codepipeline.GetPipelineStateInput{Name: awssdk.String(pipeline)}) + if err != nil { + warns = append(warns, fmt.Sprintf("aws: GetPipelineState %s: %v", pipeline, err)) + state = &codepipeline.GetPipelineStateOutput{} + } + } + d := model.Deploy{ + ExecutionID: awssdk.ToString(p.PipelineExecutionId), + Status: model.DeployAwaitingApproval, + Revisions: maps.Clone(execs[awssdk.ToString(p.PipelineExecutionId)].revs), + 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 +} + +// 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 { + out, err := c.cp.ListActionExecutions(ctx, &codepipeline.ListActionExecutionsInput{ + PipelineName: awssdk.String(pipeline), + MaxResults: awssdk.Int32(100), + NextToken: next, + }) + if err != nil { + return nil, false, fmt.Errorf("aws: ListActionExecutions %s: %w", pipeline, err) + } + all = append(all, out.ActionExecutionDetails...) + if out.NextToken == nil || pastWindow(out.ActionExecutionDetails, oldest) { + return all, false, nil + } + next = out.NextToken + } + return all, true, 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 +// (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. 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 + 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] + if !known || (!e.active && a.Status == types.ActionExecutionStatusInProgress) { + 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 + } + d := model.Deploy{ + ExecutionID: id, + Status: status, + Revisions: maps.Clone(e.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: + 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 _, 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 +} + +// 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 "" +} + +// 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]execution, len(summaries)) + for _, e := range summaries { + 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 + } + } + active := e.Status == types.PipelineExecutionStatusInProgress || e.Status == types.PipelineExecutionStatusStopping + out[awssdk.ToString(e.PipelineExecutionId)] = execution{revs: revs, active: active} + } + 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..a24db82 --- /dev/null +++ b/internal/aws/history_test.go @@ -0,0 +1,493 @@ +package aws + +import ( + "context" + "errors" + "maps" + "slices" + "strings" + "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": "AppSource", "acme/infra": "InfraSource", "acme/reports": "ReportsSource"} + +var ( + prodSpec = StageSpec{Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ApproveTest"} + 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{"AppSource", sha, "InfraSource", sha + "i", "ReportsSource", 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 ReportsSource revision, plus a source action no + // configured repo maps to. + execSummary("exec2", types.PipelineExecutionStatusSucceeded, "AppSource", "bbb", "InfraSource", "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 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")...), + execSummary("exec2", types.PipelineExecutionStatusStopped, allRevs("bbb")...), + 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", "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)), + // 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, prodSpec) + if err != nil { + t.Fatal(err) + } + ok := model.DeploySucceeded + 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 +// 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", "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", "ApproveTest", types.ActionExecutionStatusSucceeded, at(23)), + actionExec("exec2", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(20)), + actionExec("exec1", "Production", "deploy", types.ActionExecutionStatusFailed, at(15)), + actionExec("exec1", "Test", "ApproveTest", 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("ApproveTest"), LatestExecution: &types.ActionExecution{ + ActionExecutionId: awssdk.String("exec3-ApproveTest"), + 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", "Smoke", types.ActionExecutionStatusSucceeded, at(10)), + }}}, + } + _, warns, err := (&Client{cp: f}).History(context.Background(), "app-pipeline", testSources, testSpec) + if err != nil { + t.Fatal(err) + } + 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) + } +} + +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)) + } +} + +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) + } +} + +// 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", "ApproveTest", types.ActionExecutionStatusFailed, at(36)), + actionExec("exec3", "Test", "Deploy", types.ActionExecutionStatusSucceeded, at(34)), + actionExec("exec2", "Production", "deploy", types.ActionExecutionStatusSucceeded, at(25)), + actionExec("exec2", "Test", "ApproveTest", 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", "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) + 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) + } +} + +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) + } +} + +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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2c6b1bc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,666 @@ +// 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" + "strconv" + "strings" + "time" + "unicode/utf8" + + "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 "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. +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 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. +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 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"` + 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 + pollInterval time.Duration + 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 +) + +// 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 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 !filepath.IsAbs(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 + } + 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 + } + if err := c.validateGitHub(); err != nil { + return err + } + if err := c.validateAccounts(); err != nil { + return err + } + 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 { + seen := map[string]bool{} + 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 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") + } + seen := map[string]bool{} + for i := range c.EnvList { + e := &c.EnvList[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}, + {"stage", e.Stage}, + {"deploy_action", e.DeployAction}, + } { + if strings.TrimSpace(req.val) == "" { + 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", field) + } + seen[id] = true + if e.ApprovalStage != "" && e.ApprovalAction == "" { + return fmt.Errorf("%s.approval_action is required when approval_stage is set", field) + } + 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) + 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 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) + if err != nil { + return err + } + e.Repos = repos + if len(repos) == 0 { + 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 +} + +// 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, 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 { + 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) + spec := e.stageSpec() + return model.Env{ + 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), + } +} + +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 + } + 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_-]*/[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)) + _, 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) + } + } + 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.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) + } + 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") + } + 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 +} + +// 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) { + s := strings.TrimSpace(v) + if s == "" { + return def, nil + } + 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 strings.HasPrefix(s, "-") || strings.HasPrefix(s, "+") { + return 0, bad // "1d-23h" would quietly mean 1h + } + } + if s != "" { + d, err := time.ParseDuration(s) + if err != nil { + return 0, bad + } + total += d + } + 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 } + +// PipelineKey names one pipeline in one account: the unit of one AWS +// History call. +type PipelineKey struct { + Account string + Pipeline string +} + +// 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} + 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 +} + +// 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 + +// 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. +// +// 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 +// leak it into the error. +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 { + return "", fmt.Errorf("%s.token_command failed: %w: %s", table, err, quoteStderr(stderr.String())) + } + 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..3b0459d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,333 @@ +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" +) + +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-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] (nope-acct/app-pipeline/Production).account: unknown account "nope-acct"`}, + {"bad-no-envs.toml", "envs: no envs configured"}, + {"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 { + 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 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} + 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{ + // 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) { + t.Errorf("envs[%d] = %+v, want %+v", i, envs[i], w) + } + } +} + +func TestStageSpecs(t *testing.T) { + got := load(t, "grouped.toml").StageSpecs() + // 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"}, + }}, + {Key: PipelineKey{Account: "prod-acct", Pipeline: "app-pipeline"}, Specs: []aws.StageSpec{ + {Stage: "Production", DeployAction: "deploy", ApprovalStage: "Test", ApprovalAction: "ApproveProd"}, + }}, + {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) + } +} + +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("HOME", "/home/me") + 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) + } + } +} + +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) + } + } +} + +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-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-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-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-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-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-unit.toml b/internal/config/testdata/bad-duration-unit.toml new file mode 100644 index 0000000..a7ffa72 --- /dev/null +++ b/internal/config/testdata/bad-duration-unit.toml @@ -0,0 +1,47 @@ +[settings] + stale_review_after = "48h" + done_grace = "2x" + 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-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-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-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-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-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-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-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-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-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-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-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-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/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-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-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/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/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"] 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/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-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" 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"] diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 83ce0d3..966f0a1 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. @@ -229,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 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/github/base.go b/internal/github/base.go new file mode 100644 index 0000000..d619c1b --- /dev/null +++ b/internal/github/base.go @@ -0,0 +1,144 @@ +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 rateLimit{resetAt}\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. 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{} + 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 +} + +// 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 + +// 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 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 round := 0; ; round++ { + var branches []string + for _, b := range missingBases(repo, all) { + if !asked[b] { + branches = append(branches, b) + } + } + if len(branches) == 0 { + break + } + 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...) + } + } + return out, dedupe(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..cdab294 --- /dev/null +++ b/internal/github/base_test.go @@ -0,0 +1,178 @@ +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"}}, 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) + } +} + +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 new file mode 100644 index 0000000..47f0e6e --- /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. 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}, + 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..ce1a878 --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,67 @@ +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 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 { + 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", "aaaa111", "bbbb222") + 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..af4a600 --- /dev/null +++ b/internal/github/compare.go @@ -0,0 +1,44 @@ +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, 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) + 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..6d872b5 --- /dev/null +++ b/internal/github/compare_test.go @@ -0,0 +1,91 @@ +package github_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "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/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" { + 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", "aaaa111", "bbbb222") + if err != nil || got != tc.want { + t.Errorf("Compare = %v, %v; want %v, nil", got, err, tc.want) + } + }) + } +} + +func TestCompareSendsNoConditionalRequest(t *testing.T) { + // The resolver caches compare results by SHA pair, so Compare never sends + // If-None-Match and every call reaches the server. + hits := 0 + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + hits++ + 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"}`) + }) + for i := range 2 { + 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) + } + } + if hits != 2 { + 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 new file mode 100644 index 0000000..e173944 --- /dev/null +++ b/internal/github/errors.go @@ -0,0 +1,91 @@ +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, 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 + 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) + } + } + 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(d, 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/graphql.go b/internal/github/graphql.go new file mode 100644 index 0000000..9595fe0 --- /dev/null +++ b/internal/github/graphql.go @@ -0,0 +1,61 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "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. 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"` + } + 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 { + 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" { + 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 +} + +// graphqlRateLimit builds the 429 for a RATE_LIMITED GraphQL error. +func (c *Client) graphqlRateLimit(data json.RawMessage, msg string) *APIError { + 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() { + d = rl.RateLimit.ResetAt.Sub(c.now()) + } + return &APIError{Status: http.StatusTooManyRequests, Message: msg, RetryAfter: backoff(true, d)} +} diff --git a/internal/github/prs.go b/internal/github/prs.go new file mode 100644 index 0000000..c763a14 --- /dev/null +++ b/internal/github/prs.go @@ -0,0 +1,299 @@ +package github + +import ( + "context" + "fmt" + "strings" + "time" + + "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 = ` +fragment pr on PullRequest { + number title url headRefName baseRefName state isDraft createdAt updatedAt mergedAt + isCrossRepository + author{login} + mergeCommit{oid} + reviewDecision + reviewRequests{totalCount} + reviews{totalCount} + commits(last:1){nodes{commit{statusCheckRollup{state + contexts(first:50){pageInfo{hasNextPage} nodes{ + __typename + ... on CheckRun{name conclusion detailsUrl checkSuite{app{slug} workflowRun{databaseId}}} + ... on StatusContext{context state targetUrl} + }}}}}} +}` + +const recentPRsQuery = `query($owner:String!,$name:String!,$after:String){ + rateLimit{resetAt} + 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"` + UpdatedAt time.Time `json:"updatedAt"` + 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 { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + 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 { + App *struct { + Slug string `json:"slug"` + } `json:"app"` + WorkflowRun *struct { + DatabaseID int64 `json:"databaseId"` + } `json:"workflowRun"` + } `json:"checkSuite"` + 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 +} + +// 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{ + 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 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 { + 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, 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": + if c.Conclusion == nil { + return model.Check{}, false + } + switch *c.Conclusion { + case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE": + default: + return model.Check{}, false + } + chk := model.Check{Name: c.Name, URL: deref(c.DetailsURL)} + 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. 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" { + 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). 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 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...) + 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 != "" && !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, 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 new file mode 100644 index 0000000..a6891a1 --- /dev/null +++ b/internal/github/prs_test.go @@ -0,0 +1,342 @@ +package github_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/ericdahl-dev/app-green/internal/github" + "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: "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 TestRecentPRsPagesUntilUpdatesPredateSince(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: + // 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": + // #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, "") + } + _ = 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[1].Number != 9 || 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", "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) + } +} + +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) } + +// 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}, + {"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) { + 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) + } + } + }) + } +} + +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) + } +} 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..a8e5f78 --- /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": "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": []} + } + ] + } + } + } +} diff --git a/internal/jira/client.go b/internal/jira/client.go new file mode 100644 index 0000000..a436b33 --- /dev/null +++ b/internal/jira/client.go @@ -0,0 +1,114 @@ +// 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 { + _, _ = 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 +} + +// 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, []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 +} + +var _ API = (*Client)(nil) 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/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/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 new file mode 100644 index 0000000..1e9db37 --- /dev/null +++ b/internal/jira/search.go @@ -0,0 +1,116 @@ +// Copied from jira-green internal/jira/search.go (2026-10-06). + +package jira + +import ( + "context" + "encoding/json" + "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 map[string]json.RawMessage `json:"fields"` +} + +type apiStatus struct { + Name string `json:"name"` + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` +} + +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. 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 { + 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, nil, err + } + for _, ai := range r.Issues { + is, w := convert(ai) + issues = append(issues, is) + warnings = append(warnings, w...) + } + if r.IsLast || r.NextPageToken == "" { + return issues, warnings, nil + } + if seen[r.NextPageToken] { + return nil, nil, fmt.Errorf("jira: search: nextPageToken %q repeated", r.NextPageToken) + } + seen[r.NextPageToken] = true + token = r.NextPageToken + } + return nil, nil, fmt.Errorf("jira: search: more than %d pages", maxPages) +} + +// 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 := d.fields[name] + if !ok || string(raw) == "null" { + return v + } + 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, []string) { + d := &fieldDecoder{key: ai.Key, fields: ai.Fields} + st := decodeField[apiStatus](d, "status") + is := issue{ + Key: ai.Key, + Summary: decodeField[string](d, "summary"), + StatusName: st.Name, + StatusCategory: st.StatusCategory.Key, + Updated: decodeField[Time](d, "updated"), + StatusCategoryChangedDate: decodeField[Time](d, "statuscategorychangedate"), + } + return is, d.errs +} 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_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/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..40a5fef --- /dev/null +++ b/internal/jira/tickets.go @@ -0,0 +1,81 @@ +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, []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, ",")) + 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). 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, 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, []string, error) { + issues, warnings, err := c.search(ctx, jql) + if err != nil { + 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: key, + Title: is.Summary, + Status: is.StatusName, + StatusCategory: model.StatusCategory(is.StatusCategory), + URL: c.BrowseURL(key), + Updated: is.Updated.Time, + StatusSince: is.StatusCategoryChangedDate.Time, + } + } + return out, warnings, nil +} diff --git a/internal/jira/tickets_test.go b/internal/jira/tickets_test.go new file mode 100644 index 0000000..34375ad --- /dev/null +++ b/internal/jira/tickets_test.go @@ -0,0 +1,390 @@ +package jira_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "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 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"}) + if err == nil || jira.IsAuth(err) { + t.Fatalf("err = %v, want a non-auth error", err) + } +} + +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"}) + 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) + } +} + +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, 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) + } + 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]) + } + } +} + +// 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 TestCanceledContext(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/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) +} 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]) + } +} 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 da2e351..56cb022 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 @@ -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 @@ -86,6 +88,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 @@ -110,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 @@ -134,13 +143,18 @@ 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 Abandoned and Superseded -// executions; they never deployed anything. +// 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 { ExecutionID string Status DeployStatus @@ -245,7 +259,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 @@ -261,6 +276,7 @@ var flagKindNames = [...]string{ "changes requested", "awaiting approval", "partial prod", + "check expected", "ready to merge", "stale review", "status mismatch", @@ -290,6 +306,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/actions.go b/internal/resolver/actions.go new file mode 100644 index 0000000..3cc62f6 --- /dev/null +++ b/internal/resolver/actions.go @@ -0,0 +1,75 @@ +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. 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)) +} + +// 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/cache.go b/internal/resolver/cache.go new file mode 100644 index 0000000..e5a8d17 --- /dev/null +++ b/internal/resolver/cache.go @@ -0,0 +1,70 @@ +package resolver + +import ( + "sync" + + "github.com/ericdahl-dev/app-green/internal/model" +) + +// 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 + 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]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() + 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) { + if inc != model.Included && inc != model.NotIncluded { + return + } + c.mu.Lock() + defer c.mu.Unlock() + 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/decisions_test.go b/internal/resolver/decisions_test.go new file mode 100644 index 0000000..2ec0e1b --- /dev/null +++ b/internal/resolver/decisions_test.go @@ -0,0 +1,235 @@ +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(180*time.Second)) { + t.Errorf("status = %+v, want throttled until poll time + 90s + 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) + } +} + +// 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/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/fakes_test.go b/internal/resolver/fakes_test.go new file mode 100644 index 0000000..acb61ce --- /dev/null +++ b/internal/resolver/fakes_test.go @@ -0,0 +1,379 @@ +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/jira" + "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 + + 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) { + 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 + 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) + 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) { + 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 + } + 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 + 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 + histErrPipe map[string]error + histCalls int + 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) + 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) { + f.mu.Lock() + defer f.mu.Unlock() + f.sourcesCalls++ + 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"} + } + 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() + during := f.during + f.mu.Unlock() + if during != nil { + during() + } + 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/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/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 new file mode 100644 index 0000000..1520b60 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,791 @@ +// 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) + 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. +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. +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. +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 + // 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 +} + +// 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 + 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 + // 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. +const prSince = 30 * 24 * time.Hour + +// 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. +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 + 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 +// to call from several goroutines; polls never overlap. +type Resolver struct { + cfg *config.Config + ad Adapters + now func() time.Time + 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 + 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 + extraErr string // the last extras lookup's failure, as a warning + repos map[string]repoData + 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() +} + +// 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(), + kick: make(chan struct{}, 1), + cache: newCompareCache(), + 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{}, + } + 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 +} + +// 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. A refresh +// 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. +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: + r.onRefresh() + case <-r.kick: + case <-ctx.Done(): + return + } + } +} + +// 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 + 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 + } + wg.Add(1) + go func() { defer wg.Done(); fetch() }() + return true + } + jiraRan := run("jira", func() { jr = r.fetchJira(ctx) }) + 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, ask, 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 { + d.at = now + 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 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) + } + for i, a := range accts { + if !acctRan[i] { + continue + } + 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 + r.pipelines[k] = v + } + for k, v := range ar.health { + v.at = now + 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. 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 { + 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, r.extraErr = nil, nil, "" + return nil + } + slices.Sort(keys) + t, w, err := r.ad.Tracker.TicketsByKey(ctx, keys) + if err != nil { + 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, r.extraErr = t, w, "" + 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, repos []string, now time.Time) githubResult { + res := githubResult{repos: map[string]repoData{}} + 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 r.retryAfter(err) > 0 || 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. 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{}, + 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 + } + var fallback string // a warning when the cached sources stand in + src, ok := ask[ps.Key] + if !ok { + 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)) + } + } + deploys, warn, err := d.History(ctx, ps.Key.Pipeline, src, ps.Specs...) + if err != nil { + if fail(err) { + return res + } + continue + } + if fallback != "" { + warn = append(slices.Clone(warn), fallback) + } + 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. 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 { + *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: strings.HasPrefix(name, "aws ") && aws.IsSSOExpired(err)} + 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, r.now().Add(d) + } +} + +// 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.Auth || s.Throttled && now.Before(s.RetryAt) +} + +// 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 +// 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 { + return je.RetryAfter + } + var ge *github.APIError + if errors.As(err, &ge) && ge.RetryAfter > 0 { + return ge.RetryAfter + } + if aws.IsThrottled(err) { + return max(AWSThrottleBackoff, r.interval) + r.interval + } + 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...) + 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 }) { + tickets = append(tickets, x) + } + } + 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} + if len(e.Repos) == 0 { + // Config repos win; without them, the pipeline's source actions + // say what it deploys (better than guessing from history). + 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()] + 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}) + } + + 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 + // 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 { + return inc + } + 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 || 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()) + 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) + return inc + } + for i := range chains { + chains[i] = link.Slots(chains[i], histories, cmp) + markUndiscovered(&chains[i], undiscovered) + markStale(&chains[i], repoAt, envAt, githubFresh, now) + } + + 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, + 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. 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, 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) { + 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 +// 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. +// Only build's goroutine uses it. +type warnings struct { + seen map[string]bool + all []string +} + +func (w *warnings) add(msgs ...string) { + 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 { 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..a668d7c --- /dev/null +++ b/internal/resolver/resolver_test.go @@ -0,0 +1,636 @@ +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 + time.Minute, // + the default interval + 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)) + } + // 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) + } +} + +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) + } +} + +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) + } +} + +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()) + } +} + +// 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) + } +} 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) + } +} 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) + } +} 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) + } +} 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) + } +} diff --git a/internal/rules/flags.go b/internal/rules/flags.go index d23371a..74576ad 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. @@ -41,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 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: @@ -66,6 +69,11 @@ 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. 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: @@ -205,11 +213,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 04f7179..75cc58d 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}}}, @@ -208,3 +210,69 @@ 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) + } + }) + } +} + +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) + } +} + +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 or expired"}, + {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) + } + } +}