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 := "