mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
GitHub answered the confirming run query with a weeks-old cached result even with page=1, so alerts for fixed workflows stayed. A created filter changes the query each day and reads current runs.
582 lines
21 KiB
Go
582 lines
21 KiB
Go
package gh
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/ghtracker/internal/model"
|
|
)
|
|
|
|
func testKey(t *testing.T) (*rsa.PrivateKey, string) {
|
|
t.Helper()
|
|
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})
|
|
return k, string(p)
|
|
}
|
|
|
|
// fakeGitHub serves canned JSON by path and counts hits per path.
|
|
type fakeGitHub struct {
|
|
srv *httptest.Server
|
|
routes map[string]func(w http.ResponseWriter, r *http.Request)
|
|
hits map[string]*atomic.Int64
|
|
appKeyPub *rsa.PublicKey
|
|
t *testing.T
|
|
installs atomic.Int64
|
|
tokens atomic.Int64
|
|
}
|
|
|
|
func newFake(t *testing.T) *fakeGitHub {
|
|
t.Helper()
|
|
f := &fakeGitHub{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}, hits: map[string]*atomic.Int64{}}
|
|
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/installation"):
|
|
f.installs.Add(1)
|
|
f.checkJWT(r)
|
|
_, _ = w.Write([]byte(`{"id":42}`))
|
|
return
|
|
case strings.HasPrefix(r.URL.Path, "/app/installations/"):
|
|
f.tokens.Add(1)
|
|
f.checkJWT(r)
|
|
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
_, _ = fmt.Fprintf(w, `{"token":"inst-token","expires_at":%q}`, exp)
|
|
return
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer inst-token" {
|
|
http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
h, ok := f.routes[r.URL.Path]
|
|
if !ok {
|
|
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
c := f.hits[r.URL.Path]
|
|
if c == nil {
|
|
c = &atomic.Int64{}
|
|
f.hits[r.URL.Path] = c
|
|
}
|
|
c.Add(1)
|
|
h(w, r)
|
|
}
|
|
|
|
func (f *fakeGitHub) checkJWT(r *http.Request) {
|
|
if f.appKeyPub == nil {
|
|
return
|
|
}
|
|
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
parts := strings.Split(tok, ".")
|
|
if len(parts) != 3 {
|
|
f.t.Errorf("jwt has %d parts", len(parts))
|
|
return
|
|
}
|
|
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
|
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
|
if err := rsa.VerifyPKCS1v15(f.appKeyPub, crypto.SHA256, sum[:], sig); err != nil {
|
|
f.t.Errorf("jwt signature invalid: %v", err)
|
|
}
|
|
}
|
|
|
|
func (f *fakeGitHub) json(path, body string) {
|
|
f.routes[path] = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }
|
|
}
|
|
|
|
func (f *fakeGitHub) client(t *testing.T, maxItems int) *Client {
|
|
t.Helper()
|
|
key, pemStr := testKey(t)
|
|
f.appKeyPub = &key.PublicKey
|
|
auth, err := NewAppAuth(7, pemStr, f.srv.URL, 0, f.srv.Client())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := NewClient(f.srv.URL, auth, f.srv.Client(), maxItems)
|
|
c.sleep = func(context.Context, time.Duration) error { return nil }
|
|
return c
|
|
}
|
|
|
|
func TestJWTClaimsAndKeyFormats(t *testing.T) {
|
|
key, pemStr := testKey(t)
|
|
a, err := NewAppAuth(99, pemStr, "http://x", 0, http.DefaultClient)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixed := time.Unix(1_700_000_000, 0)
|
|
a.now = func() time.Time { return fixed }
|
|
jwt, err := a.appJWT()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parts := strings.Split(jwt, ".")
|
|
claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(claimsRaw, &claims); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if claims["iss"] != "99" || int64(claims["iat"].(float64)) != fixed.Unix()-60 || int64(claims["exp"].(float64)) != fixed.Unix()+540 {
|
|
t.Fatalf("claims = %v", claims)
|
|
}
|
|
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
|
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
der, _ := x509.MarshalPKCS8PrivateKey(key)
|
|
if _, err := ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})); err != nil {
|
|
t.Fatalf("pkcs8: %v", err)
|
|
}
|
|
if _, err := ParsePrivateKey([]byte("garbage")); err == nil {
|
|
t.Fatal("garbage key must fail")
|
|
}
|
|
}
|
|
|
|
func TestTokenCachedAndInstallationDiscoveredOnce(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","default_branch":"main"}`)
|
|
for range 3 {
|
|
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if f.installs.Load() != 1 || f.tokens.Load() != 1 {
|
|
t.Fatalf("installs=%d tokens=%d, want 1 each", f.installs.Load(), f.tokens.Load())
|
|
}
|
|
}
|
|
|
|
func TestPaginationStopsAtLimit(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 3)
|
|
f.routes["/repos/o/r/issues"] = func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("page") == "2" {
|
|
_, _ = w.Write([]byte(`[{"number":3,"title":"c"},{"number":4,"title":"d"}]`))
|
|
return
|
|
}
|
|
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?page=2>; rel="next", <x>; rel="last"`, f.srv.URL))
|
|
_, _ = w.Write([]byte(`[{"number":1,"title":"a"},{"number":2,"title":"b"}]`))
|
|
}
|
|
got, err := c.openIssues(context.Background(), "o/r")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("got %d issues, want 3 (limit)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestNextLink(t *testing.T) {
|
|
h := `<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"`
|
|
if got := nextLink(h); got != "https://api/x?page=2" {
|
|
t.Fatal(got)
|
|
}
|
|
if nextLink(`<https://api/x?page=1>; rel="prev"`) != "" || nextLink("") != "" {
|
|
t.Fatal("no next link expected")
|
|
}
|
|
}
|
|
|
|
func TestETagConditionalRequestServedFromCache(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
var conditional atomic.Int64
|
|
f.routes["/repos/o/r"] = func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("If-None-Match") == `"v1"` {
|
|
conditional.Add(1)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.Header().Set("ETag", `"v1"`)
|
|
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
|
|
}
|
|
for range 2 {
|
|
var out struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &out); err != nil || out.DefaultBranch != "main" {
|
|
t.Fatalf("out=%+v err=%v", out, err)
|
|
}
|
|
}
|
|
if conditional.Load() != 1 {
|
|
t.Fatalf("conditional requests = %d, want 1", conditional.Load())
|
|
}
|
|
}
|
|
|
|
func TestRetriesServerErrorsThenSucceeds(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
var n atomic.Int64
|
|
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
if n.Add(1) < 3 {
|
|
http.Error(w, `{"message":"boom"}`, http.StatusBadGateway)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n.Load() != 3 {
|
|
t.Fatalf("attempts = %d", n.Load())
|
|
}
|
|
}
|
|
|
|
func TestNoRetryOnClientError(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
err := c.getJSON(context.Background(), "o/r", "/missing", nil, &struct{}{})
|
|
if !isNotFound(err) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrimaryRateLimitBlocksFurtherRequests(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
reset := time.Now().Add(30 * time.Minute)
|
|
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(reset.Unix()))
|
|
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden)
|
|
}
|
|
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
|
|
if !errors.Is(err, model.ErrRateLimited) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
hits := f.hits["/repos/o/r"].Load()
|
|
err = c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
|
|
if !errors.Is(err, model.ErrRateLimited) || f.hits["/repos/o/r"].Load() != hits {
|
|
t.Fatalf("second call must be refused locally; err=%v hits=%d->%d", err, hits, f.hits["/repos/o/r"].Load())
|
|
}
|
|
}
|
|
|
|
func TestSecondaryRateLimitHonoursRetryAfter(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
now := time.Now()
|
|
c.now = func() time.Time { return now }
|
|
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Retry-After", "120")
|
|
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusTooManyRequests)
|
|
}
|
|
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
c.now = func() time.Time { return now.Add(119 * time.Second) }
|
|
if err := c.guard(); !errors.Is(err, model.ErrRateLimited) {
|
|
t.Fatalf("still blocked at 119s, got %v", err)
|
|
}
|
|
c.now = func() time.Time { return now.Add(121 * time.Second) }
|
|
if err := c.guard(); err != nil {
|
|
t.Fatalf("unblocked at 121s, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLowBudgetStopsBeforeQuotaExhausted(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("X-RateLimit-Remaining", "50")
|
|
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(time.Now().Add(time.Hour).Unix()))
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
|
|
t.Fatalf("budget below reserve must refuse, got %v", err)
|
|
}
|
|
if rem, ok := c.RateLimitRemaining(); !ok || rem != 50 {
|
|
t.Fatalf("remaining = %d %v", rem, ok)
|
|
}
|
|
}
|
|
|
|
func TestPlain403IsNotTreatedAsRateLimit(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
|
|
}
|
|
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
|
|
var ae *APIError
|
|
if errors.Is(err, model.ErrRateLimited) || !errors.As(err, &ae) || ae.Status != 403 {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if err := c.guard(); err != nil {
|
|
t.Fatalf("plain 403 must not block: %v", err)
|
|
}
|
|
}
|
|
|
|
func registerRepo(f *fakeGitHub) {
|
|
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","description":"d","default_branch":"main"}`)
|
|
f.json("/repos/o/r/issues", `[
|
|
{"number":1,"title":"real issue","html_url":"u1","user":{"login":"a"},"labels":[{"name":"bug"}],"comments":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"},
|
|
{"number":2,"title":"a PR","html_url":"u2","pull_request":{"url":"x"}}]`)
|
|
f.json("/repos/o/r/pulls", `[{"number":2,"title":"a PR","html_url":"u2","user":{"login":"b"},"draft":false,"head":{"sha":"abc"},"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}]`)
|
|
f.json("/repos/o/r/commits/abc/check-runs", `{"check_runs":[{"name":"build","status":"completed","conclusion":"failure"},{"name":"lint","status":"completed","conclusion":"success"},{"name":"slow","status":"in_progress"}]}`)
|
|
f.json("/repos/o/r/commits/abc/status", `{"total_count":1,"statuses":[{"context":"ci/ext","state":"error"}]}`)
|
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[
|
|
{"id":1,"name":"CI","html_url":"w1","state":"active"},
|
|
{"id":2,"name":"Nightly","html_url":"w2","state":"active"},
|
|
{"id":3,"name":"Old","html_url":"w3","state":"disabled_manually"},
|
|
{"id":4,"name":"Never ran","html_url":"w4","state":"active"}]}`)
|
|
f.json("/repos/o/r/actions/workflows/1/runs", `{"workflow_runs":[
|
|
{"id":11,"html_url":"r11","event":"push","head_branch":"main","status":"in_progress","created_at":"2026-01-05T00:00:00Z"},
|
|
{"id":10,"html_url":"r10","event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-01-04T00:00:00Z"}]}`)
|
|
f.json("/repos/o/r/actions/workflows/2/runs", `{"workflow_runs":[
|
|
{"id":20,"html_url":"r20","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-01-05T00:00:00Z"}]}`)
|
|
f.json("/repos/o/r/actions/workflows/4/runs", `{"workflow_runs":[]}`)
|
|
f.json("/repos/o/r/releases/latest", `{"tag_name":"v1.2.3","name":"x","html_url":"rel","published_at":"2026-01-01T00:00:00Z"}`)
|
|
}
|
|
|
|
func TestFetchRepoEndToEnd(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 50)
|
|
registerRepo(f)
|
|
r, err := c.FetchRepo(context.Background(), "o/r")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(r.Warnings) != 0 {
|
|
t.Fatalf("warnings: %v", r.Warnings)
|
|
}
|
|
if len(r.Issues) != 1 || r.Issues[0].Number != 1 || r.Issues[0].Labels[0] != "bug" {
|
|
t.Fatalf("issues must exclude PRs: %+v", r.Issues)
|
|
}
|
|
if len(r.PRs) != 1 || r.PRs[0].Checks.State != model.ChecksFail || r.PRs[0].Checks.Failed != 2 || r.PRs[0].Checks.Pending != 1 {
|
|
t.Fatalf("pr checks: %+v", r.PRs)
|
|
}
|
|
if got := r.PRs[0].Checks.Failing; len(got) != 2 || got[0] != "build" || got[1] != "ci/ext" {
|
|
t.Fatalf("failing names: %v", got)
|
|
}
|
|
if len(r.Workflows) != 2 {
|
|
t.Fatalf("want CI and Nightly only, got %+v", r.Workflows)
|
|
}
|
|
ci, nightly := r.Workflows[0], r.Workflows[1]
|
|
if !ci.Latest.Running() || ci.LastDone == nil || ci.LastDone.ID != 10 || ci.Background {
|
|
t.Fatalf("ci: %+v", ci)
|
|
}
|
|
if !nightly.Background || nightly.LastDone == nil || !nightly.LastDone.Failed() {
|
|
t.Fatalf("nightly: %+v", nightly)
|
|
}
|
|
if r.Release == nil || r.Release.Tag != "v1.2.3" || r.Release.URL != "rel" || r.ReleasesURL != "https://github.com/o/r/releases" {
|
|
t.Fatalf("release: %+v %s", r.Release, r.ReleasesURL)
|
|
}
|
|
}
|
|
|
|
func TestReleaseFallsBackToTag(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.json("/repos/o/r/tags", `[{"name":"v0.1.0"}]`)
|
|
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !rel.FromTag || rel.Tag != "v0.1.0" || rel.URL != "https://github.com/o/r/releases/tag/v0.1.0" {
|
|
t.Fatalf("%+v", rel)
|
|
}
|
|
}
|
|
|
|
func TestReleaseNoneAtAll(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.json("/repos/o/r/tags", `[]`)
|
|
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
|
|
if err != nil || rel != nil {
|
|
t.Fatalf("rel=%+v err=%v", rel, err)
|
|
}
|
|
}
|
|
|
|
func TestSectionFailureDegradesToWarning(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
registerRepo(f)
|
|
f.routes["/repos/o/r/actions/workflows"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
|
|
}
|
|
r, err := c.FetchRepo(context.Background(), "o/r")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "workflows") || len(r.Issues) != 1 {
|
|
t.Fatalf("warnings=%v issues=%d", r.Warnings, len(r.Issues))
|
|
}
|
|
}
|
|
|
|
func TestRateLimitInSectionFailsWholeRepo(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
registerRepo(f)
|
|
f.routes["/repos/o/r/pulls"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Retry-After", "60")
|
|
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusForbidden)
|
|
}
|
|
if _, err := c.FetchRepo(context.Background(), "o/r"); !errors.Is(err, model.ErrRateLimited) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildWorkflowIgnoresPullRequestRuns(t *testing.T) {
|
|
runs := []rawRun{
|
|
{ID: 2, Event: "pull_request", Status: "completed", Conclusion: "failure", CreatedAt: time.Unix(200, 0)},
|
|
{ID: 1, Event: "push", Status: "completed", Conclusion: "success", CreatedAt: time.Unix(100, 0)},
|
|
}
|
|
wf, ok := buildWorkflow("CI", "u", runs)
|
|
if !ok || wf.Latest.ID != 1 || wf.LastDone.ID != 1 {
|
|
t.Fatalf("%+v", wf)
|
|
}
|
|
if _, ok := buildWorkflow("CI", "u", runs[:1]); ok {
|
|
t.Fatal("only PR runs must yield no workflow")
|
|
}
|
|
}
|
|
|
|
func TestAPIErrorMessageParsing(t *testing.T) {
|
|
if got := apiMessage([]byte(`{"message":"nope"}`)); got != "nope" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := apiMessage([]byte(strings.Repeat("x", 500))); len(got) != 200 {
|
|
t.Fatalf("len=%d", len(got))
|
|
}
|
|
}
|
|
|
|
// staleThenFresh models GitHub answering one exact query from an old cached result: the plain
|
|
// query returns an old failing run every time, and the same query with a created filter returns current runs.
|
|
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
|
|
var calls atomic.Int64
|
|
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Query().Get("created") == "" {
|
|
_, _ = w.Write([]byte(`{"workflow_runs":[
|
|
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
|
|
return
|
|
}
|
|
if r.Header.Get("If-None-Match") != "" {
|
|
http.Error(w, "fresh requests must not be conditional", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"workflow_runs":[
|
|
{"id":3,"html_url":"new","event":"schedule","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"},
|
|
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
|
|
}
|
|
return &calls
|
|
}
|
|
|
|
func TestStaleFailureIsConfirmedWithADifferentQuery(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)
|
|
calls := staleThenFresh(f, "/repos/o/r/actions/workflows/7/runs")
|
|
wfs, err := c.workflows(context.Background(), "o/r", "main")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(wfs) != 1 || wfs[0].Latest.ID != 3 || wfs[0].LastDone == nil || wfs[0].LastDone.Failed() {
|
|
t.Fatalf("the newer success must supersede the stale failure: %+v", wfs)
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Fatalf("runs requested %d times, want 2", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestHealthyWorkflowIsNotRequestedTwice(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
|
|
var calls atomic.Int64
|
|
f.routes["/repos/o/r/actions/workflows/7/runs"] = func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"}]}`))
|
|
}
|
|
if _, err := c.workflows(context.Background(), "o/r", "main"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("a passing workflow needs one request, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestConfirmedFailureStaysFailing(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 10)
|
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
|
|
f.json("/repos/o/r/actions/workflows/7/runs", `{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-02T03:03:00Z"}]}`)
|
|
wfs, err := c.workflows(context.Background(), "o/r", "main")
|
|
if err != nil || len(wfs) != 1 || wfs[0].LastDone == nil || !wfs[0].LastDone.Failed() {
|
|
t.Fatalf("a failure that both answers agree on must stay: %+v %v", wfs, err)
|
|
}
|
|
}
|
|
|
|
func TestMergeRunsKeepsEachRunOnce(t *testing.T) {
|
|
got := mergeRuns([]rawRun{{ID: 1}, {ID: 2}}, []rawRun{{ID: 2}, {ID: 3}})
|
|
if len(got) != 3 || got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestFreshClientDisablesConnectionReuse(t *testing.T) {
|
|
base := &http.Client{Transport: &http.Transport{}, Timeout: 5 * time.Second}
|
|
fc := freshClient(base)
|
|
tr, ok := fc.Transport.(*http.Transport)
|
|
if !ok || !tr.DisableKeepAlives || fc.Timeout != 5*time.Second {
|
|
t.Fatalf("fresh client: %+v", fc)
|
|
}
|
|
if base.Transport.(*http.Transport).DisableKeepAlives {
|
|
t.Fatal("the original client must keep its connections")
|
|
}
|
|
custom := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("x") })}
|
|
if freshClient(custom) != custom {
|
|
t.Fatal("non-standard transports are used as they are")
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
func TestOpenPRsHeadSHAAndFork(t *testing.T) {
|
|
tests := map[string]struct {
|
|
pr string
|
|
wantFork bool
|
|
}{
|
|
"same repo": {`"head":{"sha":"abc","repo":{"full_name":"o/r"}},"base":{"repo":{"full_name":"o/r"}}`, false},
|
|
"same repo casing": {`"head":{"sha":"abc","repo":{"full_name":"O/R"}},"base":{"repo":{"full_name":"o/r"}}`, false},
|
|
"other repo": {`"head":{"sha":"abc","repo":{"full_name":"x/r"}},"base":{"repo":{"full_name":"o/r"}}`, true},
|
|
"deleted head": {`"head":{"sha":"abc","repo":null},"base":{"repo":{"full_name":"o/r"}}`, true},
|
|
}
|
|
for name, tc := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newFake(t)
|
|
c := f.client(t, 50)
|
|
f.json("/repos/o/r/pulls", `[{"number":2,"title":"t","html_url":"u2","user":{"login":"b"},`+tc.pr+`}]`)
|
|
prs, err := c.openPRs(context.Background(), "o/r")
|
|
if err != nil || len(prs) != 1 {
|
|
t.Fatalf("prs = %+v, err = %v", prs, err)
|
|
}
|
|
if prs[0].HeadSHA != "abc" || prs[0].Fork != tc.wantFork {
|
|
t.Fatalf("HeadSHA = %q, Fork = %v, want abc, %v", prs[0].HeadSHA, prs[0].Fork, tc.wantFork)
|
|
}
|
|
})
|
|
}
|
|
}
|