Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7484c81
feat(jira): adapter copied from jira-green, trimmed to tickets and tr…
Skeyelab Oct 6, 2026
137a05d
fix(jira): a malformed field blanks only that field
Skeyelab Oct 6, 2026
6612a4a
fix(jira): validate keys, report decode errors, review fixes
Skeyelab Oct 6, 2026
aafda4f
feat(rules): draft PRs skip review flags
Skeyelab Oct 6, 2026
db9a51e
feat(github): recent PRs via GraphQL, base-branch PRs, compare, rerun
Skeyelab Oct 6, 2026
5ae03ad
fix(github): read a check run's app from its check suite
Skeyelab Oct 6, 2026
fe30574
fix(github): paging by update time, code-scanning slug, startup failu…
Skeyelab Oct 6, 2026
8d0235b
feat(link): context PRs feed stack walking only
Skeyelab Oct 6, 2026
a4254d4
feat(rules): a required check that has not reported is a yellow wait
Skeyelab Oct 6, 2026
31538c0
fix(github): review fixes for forks, caching, caps, backoff, SHAs
Skeyelab Oct 6, 2026
1c22cd7
feat(aws): pipeline sources, stage history with approvals, ECS health…
Skeyelab Oct 6, 2026
eaaee91
fix(aws): one deploy per retried stage, SSO session refresh failures
Skeyelab Oct 6, 2026
b09bc3d
fix(aws): keep what superseded and stopped runs finished
Skeyelab Oct 7, 2026
5939e01
feat: a rejected approval shows red on the env it gates
Skeyelab Oct 7, 2026
a9aa960
test(aws): neutral action names in fixtures
Skeyelab Oct 7, 2026
10f4814
feat(aws): state failure is a warning, IsThrottled and IsAccessDenied
Skeyelab Oct 7, 2026
08ec751
fix(aws): review fixes for truncation, ARNs, summaries, misnamed specs
Skeyelab Oct 7, 2026
45e7172
feat(config): config.toml with accounts, envs and thresholds
Skeyelab Oct 7, 2026
025f887
feat(config): watched repos, day durations, poll interval
Skeyelab Oct 7, 2026
93f5437
fix(config): review fixes for names, ECS, durations and tokens
Skeyelab Oct 7, 2026
b5f0dfa
refactor(github): drop the unused ETag cache
Skeyelab Oct 7, 2026
fd6769b
feat(resolver): polling, compare cache, snapshots
Skeyelab Oct 7, 2026
c6ed41f
feat(resolver): run loop with refresh, adapters built from config
Skeyelab Oct 7, 2026
00de385
fix(resolver): a canceled poll records no failures
Skeyelab Oct 7, 2026
e8daba8
fix(resolver): SSO only on AWS statuses; test env health
Skeyelab Oct 7, 2026
745acc9
feat(resolver): watch discovered repos, stop on auth, compare backoff
Skeyelab Oct 7, 2026
8cc2d0c
fix(resolver): unknown until sources are known; backoff skips a poll
Skeyelab Oct 7, 2026
5cbc934
feat(resolver): per-row staleness
Skeyelab Oct 7, 2026
3302027
feat(resolver): actions for approve, rerun, transition
Skeyelab Oct 7, 2026
c8715fc
fix: phase 2 review fixes
Skeyelab Oct 7, 2026
61d0e46
fix(resolver): staleness during backoff, no-PR rows, rerun guard, cac…
Skeyelab Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
32 changes: 32 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
78 changes: 78 additions & 0 deletions internal/aws/approve.go
Original file line number Diff line number Diff line change
@@ -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)
}
92 changes: 92 additions & 0 deletions internal/aws/approve_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
110 changes: 110 additions & 0 deletions internal/aws/client.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading