Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: GitHub Job Checker #95

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
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
69 changes: 69 additions & 0 deletions tools/github-job-checker/Earthfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
VERSION 0.8

deps:
FROM golang:1.23.2-alpine3.19

WORKDIR /work

RUN apk add git file

RUN mkdir -p /go/cache && mkdir -p /go/modcache
ENV GOCACHE=/go/cache
ENV GOMODCACHE=/go/modcache
CACHE --persist --sharing shared /go

COPY go.mod go.sum .
RUN go mod download

src:
FROM +deps

CACHE --persist --sharing shared /go

COPY . .
RUN go generate ./...

check:
FROM +src

RUN gofmt -l . | grep . && exit 1 || exit 0
RUN go vet ./...

build:
FROM +src

ARG GOOS
ARG GOARCH
ARG version="0.0.0"

ENV CGO_ENABLED=0

RUN go build -ldflags="-extldflags=-static -X main.version=$version" -o bin/github-job-checker main.go
RUN file bin/github-job-checker

SAVE ARTIFACT bin/github-job-checker github-job-checker

test:
FROM +build

RUN go test ./...

docker:
FROM alpine:3

ARG version="dev"

ARG TARGETOS
ARG TARGETARCH
ARG TARGETPLATFORM

COPY \
(+build/github-job-checker \
--GOOS=$TARGETOS \
--GOARCH=$TARGETARCH \
--PLATFORM=$TARGETPLATFORM \
--version=$version) bin/github-job-checker

ENTRYPOINT ["bin/github-job-checker"]

SAVE IMAGE --push 332405224602.dkr.ecr.eu-central-1.amazonaws.com/github-job-checker:$version
31 changes: 31 additions & 0 deletions tools/github-job-checker/blueprint.cue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
version: "1.0"
project: {
name: "gh-job-checker"
ci: targets: {
docker: {
args: {
version: string | *"dev" @forge(name="GIT_TAG")
}
platforms: [
"linux/amd64",
"linux/arm64",
"darwin/amd64",
"darwin/arm64",
]
}
test: retries: 3
}
release: {
github: {
on: tag: {}
config: {
name: string | *"dev" @forge(name="GIT_TAG")
prefix: project.name
token: {
provider: "env"
path: "GITHUB_TOKEN"
}
}
}
}
}
74 changes: 74 additions & 0 deletions tools/github-job-checker/cmd/checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"context"
"errors"
"fmt"
"log"
"time"
)

// Checker holds configuration for checking GitHub actions.
type Checker struct {
Owner string
Repo string
Ref string
CheckInterval time.Duration
Timeout time.Duration
Client GitHubClient
}

// Run executes the check process.
func (c *Checker) Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

ticker := time.NewTicker(c.CheckInterval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout of %v reached. GitHub jobs did not finish in time", c.Timeout)
case <-ticker.C:
results, err := c.Client.FetchCheckRunsForRef(ctx, c.Owner, c.Repo, c.Ref)
if err != nil {
return err
}

if results.GetTotal() == 0 {
log.Print("No GitHub jobs configured for this commit.")
return nil
}

var anyFailure, anyPending int

for _, checkRun := range results.CheckRuns {
status := checkRun.GetStatus()
conclusion := checkRun.GetConclusion()

if status == "completed" && conclusion != "success" {
anyFailure++
}

if status == "in_progress" || status == "queued" {
anyPending++
}
}

log.Printf("Number of failed check runs: %d", anyFailure)
log.Printf("Number of pending check runs: %d", anyPending)

if anyFailure > 0 {
return errors.New("one or more GitHub jobs failed")
}

if anyPending == 0 {
log.Print("All GitHub jobs succeeded.")
return nil
}

log.Printf("GitHub jobs are still running. Waiting for %v before rechecking.", c.CheckInterval)
}
}
}
27 changes: 27 additions & 0 deletions tools/github-job-checker/cmd/github_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cmd

import (
"context"

"github.com/google/go-github/v66/github"
)

// GitHubClient defines the methods needed from the GitHub API.
type GitHubClient interface {
FetchCheckRunsForRef(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error)
}

// GitHubAPIClient implements GitHubClient using the actual GitHub API.
type GitHubAPIClient struct {
client *github.Client
}

func NewGitHubAPIClient(token string) *GitHubAPIClient {
return &GitHubAPIClient{github.NewClient(nil).WithAuthToken(token)}
}

// FetchCheckRunsForRef fetches check runs for a specific reference.
func (c *GitHubAPIClient) FetchCheckRunsForRef(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error) {
results, _, err := c.client.Checks.ListCheckRunsForRef(ctx, owner, repo, ref, nil)
return results, err
}
28 changes: 28 additions & 0 deletions tools/github-job-checker/cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cmd

import (
"context"
"fmt"

"github.com/input-output-hk/catalyst-forge/tools/github-job-checker/internal/config"
)

// Run initializes the checker and starts the process.
func Run() error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}

checker := &Checker{
Owner: cfg.Owner,
Repo: cfg.Repo,
Ref: cfg.Ref,
CheckInterval: cfg.CheckInterval,
Timeout: cfg.Timeout,
Client: NewGitHubAPIClient(cfg.Token),
}

ctx := context.Background()
return checker.Run(ctx)
}
163 changes: 163 additions & 0 deletions tools/github-job-checker/cmd/tests/checker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package cmd_test

import (
"context"
"errors"
"testing"
"time"

"github.com/google/go-github/v66/github"
"github.com/input-output-hk/catalyst-forge/tools/github-job-checker/cmd"
)

// MockGitHubClient mocks the GitHubClient interface for testing.
type MockGitHubClient struct {
FetchFunc func(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error)
}

func (m *MockGitHubClient) FetchCheckRunsForRef(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error) {
if m.FetchFunc != nil {
return m.FetchFunc(ctx, owner, repo, ref)
}
return nil, errors.New("FetchFunc not implemented")
}

func TestChecker_Run_Success(t *testing.T) {
client := &MockGitHubClient{
FetchFunc: func(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error) {
return &github.ListCheckRunsResults{
Total: github.Int(1),
CheckRuns: []*github.CheckRun{
{
Status: github.String("completed"),
Conclusion: github.String("success"),
},
},
}, nil
},
}

checker := &cmd.Checker{
Owner: "owner",
Repo: "repo",
Ref: "ref",
CheckInterval: 1 * time.Second,
Timeout: 5 * time.Second,
Client: client,
}

ctx := context.Background()
err := checker.Run(ctx)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}

func TestChecker_Run_Failure(t *testing.T) {
client := &MockGitHubClient{
FetchFunc: func(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error) {
return &github.ListCheckRunsResults{
Total: github.Int(1),
CheckRuns: []*github.CheckRun{
{
Status: github.String("completed"),
Conclusion: github.String("failure"),
},
},
}, nil
},
}

checker := &cmd.Checker{
Owner: "owner",
Repo: "repo",
Ref: "ref",
CheckInterval: 1 * time.Second,
Timeout: 5 * time.Second,
Client: client,
}

ctx := context.Background()
err := checker.Run(ctx)
if err == nil || err.Error() != "one or more GitHub jobs failed" {
t.Fatalf("expected failure error, got %v", err)
}
}

func TestChecker_Run_PendingToSuccess(t *testing.T) {
callCount := 0
client := &MockGitHubClient{
FetchFunc: func(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error) {
defer func() { callCount++ }()
if callCount == 0 {
// First call: return pending
return &github.ListCheckRunsResults{
Total: github.Int(1),
CheckRuns: []*github.CheckRun{
{
Status: github.String("queued"),
Conclusion: nil,
},
},
}, nil
}
// Subsequent calls: return success
return &github.ListCheckRunsResults{
Total: github.Int(1),
CheckRuns: []*github.CheckRun{
{
Status: github.String("completed"),
Conclusion: github.String("success"),
},
},
}, nil
},
}

checker := &cmd.Checker{
Owner: "owner",
Repo: "repo",
Ref: "ref",
CheckInterval: 500 * time.Millisecond,
Timeout: 2 * time.Second,
Client: client,
}

ctx := context.Background()
err := checker.Run(ctx)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}

func TestChecker_Run_Timeout(t *testing.T) {
client := &MockGitHubClient{
FetchFunc: func(ctx context.Context, owner, repo, ref string) (*github.ListCheckRunsResults, error) {
// Always return in-progress status
return &github.ListCheckRunsResults{
Total: github.Int(1),
CheckRuns: []*github.CheckRun{
{
Status: github.String("in_progress"),
Conclusion: nil,
},
},
}, nil
},
}

checker := &cmd.Checker{
Owner: "owner",
Repo: "repo",
Ref: "ref",
CheckInterval: 500 * time.Millisecond,
Timeout: 1 * time.Second,
Client: client,
}

ctx := context.Background()
err := checker.Run(ctx)
if err == nil || !errors.Is(err, context.DeadlineExceeded) && err.Error() != "timeout of 1s reached. GitHub jobs did not finish in time" {
t.Fatalf("expected timeout error, got %v", err)
}
}
Loading
Loading