Compare commits

..
2 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 4c2c9d3341 fix(gh): confirm a failing workflow with a different query string (#1)
GitHub can answer one exact run-listing query from an old cached result
for weeks. The same query on a new connection returned the same old
failure, so a workflow whose latest runs passed still showed as failing.

The confirmation request now adds page=1. The changed query skips the
cached result and returns current runs. The test models a cache that
serves the plain query only.
2026-10-02 23:16:09 +01:00
lukaszraczylo b74396f005 fix(collector): link a failed repository to the configured host
When the first scan of a repository failed, the repository link pointed at github.com. It now follows github.api_url: Enterprise Server hosts drop the /api/v3 suffix and Enterprise Cloud hosts drop the api. prefix.
2026-10-02 10:15:48 +01:00
6 changed files with 66 additions and 6 deletions
+3 -1
View File
@@ -54,6 +54,7 @@ type Collector struct {
repos map[string]model.Repo repos map[string]model.Repo
now func() time.Time now func() time.Time
log *slog.Logger log *slog.Logger
webBase string
kick chan struct{} kick chan struct{}
names []string names []string
th config.Thresholds th config.Thresholds
@@ -78,6 +79,7 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
interval: cfg.RefreshInterval, interval: cfg.RefreshInterval,
concurrency: cfg.Concurrency, concurrency: cfg.Concurrency,
now: time.Now, now: time.Now,
webBase: config.WebBase(cfg.GitHub.APIURL),
log: log, log: log,
kick: make(chan struct{}, 1), kick: make(chan struct{}, 1),
repos: make(map[string]model.Repo, len(cfg.Repos)), repos: make(map[string]model.Repo, len(cfg.Repos)),
@@ -174,7 +176,7 @@ func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) m
prev, ok := c.repos[name] prev, ok := c.repos[name]
c.mu.RUnlock() c.mu.RUnlock()
if !ok { if !ok {
prev = model.Repo{FullName: name, URL: "https://github.com/" + name} prev = model.Repo{FullName: name, URL: c.webBase + "/" + name}
} }
prev.Up, prev.Err = false, err.Error() prev.Up, prev.Err = false, err.Error()
return prev return prev
+14
View File
@@ -337,3 +337,17 @@ func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal) t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
} }
} }
func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
fail := &fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}
cfg := &config.Config{
Repos: []string{"o/a"}, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th(),
GitHub: config.GitHub{APIURL: "https://ghe.example.com/api/v3"},
}
c := New(fail, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
c.Refresh(context.Background())
if got := c.View().Repos[0].URL; got != "https://ghe.example.com/o/a" {
t.Fatalf("repo link = %q", got)
}
}
+22
View File
@@ -4,6 +4,7 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
@@ -161,3 +162,24 @@ func normalizeRepos(in []string) ([]string, error) {
} }
return out, nil return out, nil
} }
const (
defaultWebURL = "https://github.com"
apiV3Suffix = "/api/v3"
apiHostPrefix = "api."
)
// WebBase returns the address of the web interface that belongs to a GitHub API address:
// github.com for the public API, the host without /api/v3 for Enterprise Server, and the host
// without its "api." prefix for Enterprise Cloud with data residency.
func WebBase(apiURL string) string {
u, err := url.Parse(strings.TrimRight(apiURL, "/"))
if err != nil || u.Host == "" || strings.EqualFold(u.Host, "api.github.com") {
return defaultWebURL
}
u.Path = strings.TrimSuffix(u.Path, apiV3Suffix)
if u.Path == "" {
u.Host = strings.TrimPrefix(u.Host, apiHostPrefix)
}
return strings.TrimRight(u.String(), "/")
}
+19
View File
@@ -126,3 +126,22 @@ func TestManualRefreshCanBeDisabled(t *testing.T) {
t.Fatal("explicit false must stay off") t.Fatal("explicit false must stay off")
} }
} }
func TestWebBase(t *testing.T) {
tests := map[string]string{
"": "https://github.com",
"https://api.github.com": "https://github.com",
"https://api.github.com/": "https://github.com",
"https://ghe.example.com/api/v3": "https://ghe.example.com",
"https://ghe.example.com/api/v3/": "https://ghe.example.com",
"http://ghe.internal:8080/api/v3": "http://ghe.internal:8080",
"https://api.acme.ghe.com": "https://acme.ghe.com",
"https://github.example.com/sub/api/v3": "https://github.example.com/sub",
"not a url": "https://github.com",
}
for in, want := range tests {
if got := WebBase(in); got != want {
t.Errorf("WebBase(%q) = %q, want %q", in, got, want)
}
}
}
+3
View File
@@ -360,6 +360,9 @@ func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id) path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
get := c.getJSON get := c.getJSON
if fresh { if fresh {
// GitHub can keep answering one exact query from an old cached result, for weeks, whatever
// the connection. A different query string skips that entry and reads current runs.
q.Set("page", "1")
get = c.getJSONFresh get = c.getJSONFresh
} }
if err := get(ctx, full, path, q, &out); err != nil { if err := get(ctx, full, path, q, &out); err != nil {
+5 -5
View File
@@ -460,13 +460,13 @@ func TestAPIErrorMessageParsing(t *testing.T) {
} }
} }
// staleThenFresh serves an old failing run on the first request and the current runs afterwards, // staleThenFresh models GitHub answering one exact query from an old cached result: the plain
// like a lagging GitHub replica followed by a healthy one. // query returns an old failing run every time, and the same query with page=1 returns current runs.
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 { func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
var calls atomic.Int64 var calls atomic.Int64
f.routes[path] = func(w http.ResponseWriter, r *http.Request) { f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1) calls.Add(1)
if n == 1 { if r.URL.Query().Get("page") == "" {
_, _ = w.Write([]byte(`{"workflow_runs":[ _, _ = 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"}]}`)) {"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
return return
@@ -482,7 +482,7 @@ func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
return &calls return &calls
} }
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) { func TestStaleFailureIsConfirmedWithADifferentQuery(t *testing.T) {
f := newFake(t) f := newFake(t)
c := f.client(t, 10) c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`) f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)