Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
79 changes: 57 additions & 22 deletions git.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,54 +101,89 @@ 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)
if err != nil {
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
}
}
}
Expand Down
197 changes: 196 additions & 1 deletion git_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
}
}
Loading
Loading