diff --git a/Dockerfile b/Dockerfile index a9c58c2..b8f9c64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Builder image -FROM golang:1.24-alpine AS builder +FROM golang:1.26-alpine AS builder # Copy source code to container COPY . /go/src diff --git a/apps.go b/apps.go index 6a0d2a6..9ef297c 100644 --- a/apps.go +++ b/apps.go @@ -23,12 +23,13 @@ type GitHubApp struct { // Configuration for the GitHub App interaction type GitHubAppConfig struct { - repoName string - RepoURL string - ApplicationID int64 - InstallationID int64 - LocalPath string - PrivateKey []byte + repoName string + RepoURL string + ApplicationID int64 + InstallationID int64 + LocalPath string + PrivateKey []byte + WaitForChecksToPass bool } var githubAPIURL string = "https://api.github.com" diff --git a/git.go b/git.go index abbd8ad..df6f18b 100644 --- a/git.go +++ b/git.go @@ -101,17 +101,24 @@ func (ghApp *GitHubApp) NewBranch(name string, checkout bool) error { } +// Polling configuration for waiting on a PR's CI checks. Declared as vars +// (rather than consts) so tests can shrink them to keep polling loops fast. +var ( + checksPollInterval = 10 * time.Second + checksWaitTimeout = 10 * time.Minute +) + // Create new pull request func (ghApp *GitHubApp) NewPullRequest(source, target, title, body string) error { ctx := context.Background() // Create PR newPR := &github.NewPullRequest{ - Title: github.String(title), - Head: github.String(source), // source branch - Base: github.String(target), // target branch - Body: github.String(body), - MaintainerCanModify: github.Bool(true), + Title: github.Ptr(title), + Head: github.Ptr(source), // source branch + Base: github.Ptr(target), // target branch + Body: github.Ptr(body), + MaintainerCanModify: github.Ptr(true), } pr, _, err := ghApp.githubClient.PullRequests.Create(ctx, "kununu", ghApp.Config.repoName, newPR) @@ -119,36 +126,64 @@ func (ghApp *GitHubApp) NewPullRequest(source, target, title, body string) error return err } - // Wait for checks to pass - err = waitForChecksToPass(ctx, ghApp.githubClient, "kununu", ghApp.Config.repoName, pr.GetNumber()) - if err != nil { - return err + // Optionally wait for checks to pass on the PR's head commit before merging. + if ghApp.Config.WaitForChecksToPass { + err = waitForChecksToPass(ctx, ghApp.githubClient, "kununu", ghApp.Config.repoName, pr.GetHead().GetSHA()) + if err != nil { + return err + } } // Merge PR return mergePullRequest(ctx, ghApp.githubClient, "kununu", ghApp.Config.repoName, pr.GetNumber()) } -func waitForChecksToPass(ctx context.Context, client *github.Client, owner, repo string, number int) error { - // Polling interval and timeout can be adjusted based on your requirements - ticker := time.NewTicker(10 * time.Second) +// waitForChecksToPass polls the GitHub Actions check runs for the given ref +// until they all complete successfully, one of them fails, or the timeout is +// reached. It relies on the Check Runs API (what Actions report to) rather than +// the legacy commit-status API, which does not reflect Actions results. +func waitForChecksToPass(ctx context.Context, client *github.Client, owner, repo, ref string) error { + // Bound the whole wait — and every API call made within it — so a stalled + // request can never hang indefinitely. + ctx, cancel := context.WithTimeout(ctx, checksWaitTimeout) + defer cancel() + + ticker := time.NewTicker(checksPollInterval) defer ticker.Stop() - timeout := time.After(10 * time.Minute) - for { + // Wait one interval before polling so GitHub has time to register the + // check runs for a freshly created PR. select { - case <-timeout: - return fmt.Errorf("timeout while waiting for checks to pass") + case <-ctx.Done(): + return fmt.Errorf("timeout while waiting for checks to pass: %w", ctx.Err()) case <-ticker.C: - combined, _, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, fmt.Sprintf("pull/%d/head", number), nil) - if err != nil { - return err - } + } + + result, _, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, ref, nil) + if err != nil { + return fmt.Errorf("listing check runs for %s: %w", ref, err) + } - if combined.GetState() == "success" { - return nil + pending := false + for _, run := range result.CheckRuns { + if run.GetStatus() != "completed" { + pending = true + continue } + switch run.GetConclusion() { + case "success", "skipped", "neutral": + // Passed (or intentionally not blocking). + default: + // failure, cancelled, timed_out, action_required, stale... + return fmt.Errorf("check %q did not pass: %s", run.GetName(), run.GetConclusion()) + } + } + + // Require at least one completed check so we never merge before CI has + // started. Once every registered check has completed successfully, done. + if !pending && result.GetTotal() > 0 { + return nil } } } diff --git a/git_test.go b/git_test.go index 4e2398f..865324d 100644 --- a/git_test.go +++ b/git_test.go @@ -1,6 +1,17 @@ package github -import "testing" +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/google/go-github/v70/github" +) func TestClone(t *testing.T) { // Test Case 1: Invalid repository URL @@ -16,3 +27,187 @@ func TestClone(t *testing.T) { t.Errorf("Expected error for invalid repository URL") } } + +// newTestClient returns a *github.Client whose requests are directed at the +// given handler instead of the real GitHub API. +func newTestClient(t *testing.T, handler http.Handler) *github.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + client := github.NewClient(nil) + baseURL, err := url.Parse(server.URL + "/") + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + client.BaseURL = baseURL + return client +} + +// fastChecksPolling shrinks the polling interval/timeout for the duration of a +// test so wait loops complete in milliseconds, restoring the originals after. +func fastChecksPolling(t *testing.T, interval, timeout time.Duration) { + t.Helper() + origInterval, origTimeout := checksPollInterval, checksWaitTimeout + checksPollInterval, checksWaitTimeout = interval, timeout + t.Cleanup(func() { + checksPollInterval, checksWaitTimeout = origInterval, origTimeout + }) +} + +func TestWaitForChecksToPass(t *testing.T) { + tests := []struct { + name string + body string + status int + timeout time.Duration + wantErr bool + wantTimeout bool // error must wrap context.DeadlineExceeded + errContains string + }{ + { + name: "all checks succeed", + body: `{"total_count":2,"check_runs":[{"name":"build","status":"completed","conclusion":"success"},{"name":"lint","status":"completed","conclusion":"success"}]}`, + status: http.StatusOK, + }, + { + name: "skipped and neutral count as passed", + body: `{"total_count":2,"check_runs":[{"name":"opt","status":"completed","conclusion":"skipped"},{"name":"info","status":"completed","conclusion":"neutral"}]}`, + status: http.StatusOK, + }, + { + name: "a failed check returns an error", + body: `{"total_count":1,"check_runs":[{"name":"build","status":"completed","conclusion":"failure"}]}`, + status: http.StatusOK, + wantErr: true, + errContains: `check "build" did not pass`, + }, + { + // A timeout can surface either from the select branch or mid-flight + // in the HTTP call, depending on where the deadline lands; both wrap + // context.DeadlineExceeded. + name: "still-pending checks time out", + body: `{"total_count":1,"check_runs":[{"name":"build","status":"in_progress"}]}`, + status: http.StatusOK, + timeout: 30 * time.Millisecond, + wantErr: true, + wantTimeout: true, + }, + { + name: "no checks registered times out", + body: `{"total_count":0,"check_runs":[]}`, + status: http.StatusOK, + timeout: 30 * time.Millisecond, + wantErr: true, + wantTimeout: true, + }, + { + name: "API error is propagated", + body: `{"message":"boom"}`, + status: http.StatusInternalServerError, + wantErr: true, + errContains: "listing check runs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timeout := tt.timeout + if timeout == 0 { + timeout = time.Second + } + fastChecksPolling(t, time.Millisecond, timeout) + + handler := http.NewServeMux() + handler.HandleFunc("GET /repos/kununu/test-repo/commits/{ref}/check-runs", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + w.Write([]byte(tt.body)) + }) + + client := newTestClient(t, handler) + err := waitForChecksToPass(context.Background(), client, "kununu", "test-repo", "deadbeef") + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tt.wantTimeout && !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("error %q does not wrap context.DeadlineExceeded", err.Error()) + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestNewPullRequestSkipsChecksWhenDisabled(t *testing.T) { + var checksCalled, mergeCalled bool + + handler := http.NewServeMux() + handler.HandleFunc("POST /repos/kununu/test-repo/pulls", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"number":7,"head":{"sha":"abc123"}}`)) + }) + handler.HandleFunc("GET /repos/kununu/test-repo/commits/{ref}/check-runs", func(w http.ResponseWriter, r *http.Request) { + checksCalled = true + w.Write([]byte(`{"total_count":1,"check_runs":[{"name":"build","status":"completed","conclusion":"success"}]}`)) + }) + handler.HandleFunc("PUT /repos/kununu/test-repo/pulls/7/merge", func(w http.ResponseWriter, r *http.Request) { + mergeCalled = true + w.Write([]byte(`{"merged":true}`)) + }) + + ghApp := &GitHubApp{ + Config: &GitHubAppConfig{repoName: "test-repo", WaitForChecksToPass: false}, + githubClient: newTestClient(t, handler), + } + + if err := ghApp.NewPullRequest("feature", "main", "title", "body"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if checksCalled { + t.Errorf("check runs were polled even though WaitForChecksToPass is false") + } + if !mergeCalled { + t.Errorf("PR was not merged") + } +} + +func TestNewPullRequestWaitsForChecksWhenEnabled(t *testing.T) { + var checksCalled, mergeCalled bool + + handler := http.NewServeMux() + handler.HandleFunc("POST /repos/kununu/test-repo/pulls", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"number":7,"head":{"sha":"abc123"}}`)) + }) + handler.HandleFunc("GET /repos/kununu/test-repo/commits/{ref}/check-runs", func(w http.ResponseWriter, r *http.Request) { + checksCalled = true + w.Write([]byte(`{"total_count":1,"check_runs":[{"name":"build","status":"completed","conclusion":"success"}]}`)) + }) + handler.HandleFunc("PUT /repos/kununu/test-repo/pulls/7/merge", func(w http.ResponseWriter, r *http.Request) { + mergeCalled = true + w.Write([]byte(`{"merged":true}`)) + }) + + fastChecksPolling(t, time.Millisecond, time.Second) + + ghApp := &GitHubApp{ + Config: &GitHubAppConfig{repoName: "test-repo", WaitForChecksToPass: true}, + githubClient: newTestClient(t, handler), + } + + if err := ghApp.NewPullRequest("feature", "main", "title", "body"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !checksCalled { + t.Errorf("check runs were not polled even though WaitForChecksToPass is true") + } + if !mergeCalled { + t.Errorf("PR was not merged") + } +} diff --git a/go.mod b/go.mod index 4bd397a..e7e5a56 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,37 @@ module github.com/kununu/go-github -go 1.24 +go 1.26 require ( - github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 - github.com/go-git/go-git/v5 v5.14.0 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 + github.com/go-git/go-git/v5 v5.19.1 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v70 v70.0.0 ) require ( - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect - github.com/cloudflare/circl v1.6.0 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/cloudflare/circl v1.6.4 // indirect + github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-github/v69 v69.2.0 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-github/v88 v88.0.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index fb45435..8bad045 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,30 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 h1:0D4vKCHOvYrDU8u61TnE2JfNT4VRrBLphmxtqazTO+M= github.com/bradleyfalzon/ghinstallation/v2 v2.14.0/go.mod h1:LOVmdZYVZ8jqdr4n9wWm1ocDiMz9IfMGfRkaYC1a52A= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,29 +38,44 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o= github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -62,6 +87,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -70,24 +97,34 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -96,12 +133,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/parse.go b/parse.go index c9a81ad..30388c2 100644 --- a/parse.go +++ b/parse.go @@ -12,6 +12,7 @@ var ( appId int64 instId int64 path string + waitForChecks bool noKeyError = "you need to pass the private key either with '-k' parameter or by setting 'GITHUB_KEY_PATH' or 'GITHUB_KEY_VALUE' or by passing it via STDIN\n" noAppIdError = "You need to define the App ID via '-a' parameter or 'GITHUB_APP_ID' environment variable\n" noInstIdError = "You need to define the Installation ID via '-i' parameter or 'GITHUB_INST_ID' environment variable\n" @@ -24,6 +25,7 @@ func ParseParameters() (*GitHubAppConfig, error) { flag.StringVar(&path, "k", "", "Path to key file for authentication") flag.Int64Var(&appId, "a", 0, "App ID to use for authentication") flag.Int64Var(&instId, "i", 0, "Installation ID that identifies the APP installation ID on GitHub") + flag.BoolVar(&waitForChecks, "w", true, "Wait for the PR's CI checks to pass before merging") flag.Parse() // Get the values from the environment variables if they are set with parameters @@ -64,9 +66,23 @@ func ParseParameters() (*GitHubAppConfig, error) { return nil, errors.New(noKeyError) } + // Defaults to true. Fall back to the environment variable only when the + // flag was not explicitly passed, so an explicit -w always wins. + wFlagSet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "w" { + wFlagSet = true + } + }) + if !wFlagSet { + if v, err := strconv.ParseBool(os.Getenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS")); err == nil { + waitForChecks = v + } + } + cfg.ApplicationID = appId cfg.InstallationID = instId - cfg.PrivateKey = cfg.PrivateKey + cfg.WaitForChecksToPass = waitForChecks return cfg, nil } diff --git a/parse_test.go b/parse_test.go index 04e39d0..c2572b6 100644 --- a/parse_test.go +++ b/parse_test.go @@ -199,3 +199,104 @@ func TestKeyValueEnvVar(t *testing.T) { } }) } + +// setValidCredentialsEnv sets the App ID, Installation ID and key via +// environment variables so ParseParameters gets past its validation and we can +// assert on the resolved WaitForChecksToPass value. +func setValidCredentialsEnv(t *testing.T) { + t.Setenv("GITHUB_APP_ID", strconv.FormatInt(appId, 10)) + t.Setenv("GITHUB_INST_ID", strconv.FormatInt(instId, 10)) + t.Setenv("GITHUB_KEY_VALUE", string(testPrivateKey)) +} + +func TestWaitForChecksDefaultsTrue(t *testing.T) { + runTestWithSetup(t, func(t *testing.T) { + setArgs([]string{"binary"}) + setValidCredentialsEnv(t) + t.Setenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS", "") + cfg, err := ParseParameters() + if err != nil { + t.Fatalf("Uncaught error with environment variables: %v", err) + } + if !cfg.WaitForChecksToPass { + t.Errorf("WaitForChecksToPass: expect: true, got: false") + } + }) +} + +func TestWaitForChecksEnvVarFalse(t *testing.T) { + runTestWithSetup(t, func(t *testing.T) { + setArgs([]string{"binary"}) + setValidCredentialsEnv(t) + t.Setenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS", "false") + cfg, err := ParseParameters() + if err != nil { + t.Fatalf("Uncaught error with environment variables: %v", err) + } + if cfg.WaitForChecksToPass { + t.Errorf("WaitForChecksToPass: expect: false, got: true") + } + }) +} + +func TestWaitForChecksEnvVarTrue(t *testing.T) { + runTestWithSetup(t, func(t *testing.T) { + setArgs([]string{"binary"}) + setValidCredentialsEnv(t) + t.Setenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS", "true") + cfg, err := ParseParameters() + if err != nil { + t.Fatalf("Uncaught error with environment variables: %v", err) + } + if !cfg.WaitForChecksToPass { + t.Errorf("WaitForChecksToPass: expect: true, got: false") + } + }) +} + +func TestWaitForChecksInvalidEnvVar(t *testing.T) { + runTestWithSetup(t, func(t *testing.T) { + setArgs([]string{"binary"}) + setValidCredentialsEnv(t) + t.Setenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS", "notabool") + cfg, err := ParseParameters() + if err != nil { + t.Fatalf("Uncaught error with environment variables: %v", err) + } + // An unparseable value is ignored and the default (true) is kept. + if !cfg.WaitForChecksToPass { + t.Errorf("WaitForChecksToPass: expect: true, got: false") + } + }) +} + +func TestWaitForChecksFlagFalse(t *testing.T) { + runTestWithSetup(t, func(t *testing.T) { + setArgs([]string{"binary", "-w=false"}) + setValidCredentialsEnv(t) + t.Setenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS", "") + cfg, err := ParseParameters() + if err != nil { + t.Fatalf("Uncaught error with parameters: %v", err) + } + if cfg.WaitForChecksToPass { + t.Errorf("WaitForChecksToPass: expect: false, got: true") + } + }) +} + +func TestWaitForChecksFlagOverridesEnv(t *testing.T) { + runTestWithSetup(t, func(t *testing.T) { + // An explicit -w=false must win over GITHUB_WAIT_FOR_CHECKS_TO_PASS=true. + setArgs([]string{"binary", "-w=false"}) + setValidCredentialsEnv(t) + t.Setenv("GITHUB_WAIT_FOR_CHECKS_TO_PASS", "true") + cfg, err := ParseParameters() + if err != nil { + t.Fatalf("Uncaught error with parameters: %v", err) + } + if cfg.WaitForChecksToPass { + t.Errorf("WaitForChecksToPass: expect: false, got: true") + } + }) +}