mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-14 00:58:36 +00:00
feat: refresh a single repository from its own button (#2)
* feat(refresh): refresh a single repository with POST /refresh?repo= The endpoint refreshes one configured repository and returns the full state plus the repo name. Each repository has a 60-second cooldown that is separate from the full-refresh limit. A running full refresh or a refresh of the same repository returns 409. An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same. The collector reuses its fetch path and replaces only that repository. * feat(ui): add a refresh button beside each repository The button appears in the project list and in each attention group header. It stays hidden when the server disables manual refresh. The button shows a spinner while its repository refreshes and reports refusals in the notice line. The store swaps in the returned state, so no extra fetch follows.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -16,6 +17,13 @@ import (
|
||||
|
||||
const repoFetchTimeout = 5 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrUnknownRepo means the name is not in the configured repository list.
|
||||
ErrUnknownRepo = errors.New("repository is not configured")
|
||||
// ErrRefreshBusy means a full refresh or a refresh of the same repository is running.
|
||||
ErrRefreshBusy = errors.New("a refresh is already running")
|
||||
)
|
||||
|
||||
// Fetcher loads the data for one "owner/name" repository.
|
||||
type Fetcher interface {
|
||||
FetchRepo(ctx context.Context, full string) (model.Repo, error)
|
||||
@@ -52,6 +60,7 @@ type Collector struct {
|
||||
lastRefresh time.Time
|
||||
fetch Fetcher
|
||||
repos map[string]model.Repo
|
||||
inflight map[string]struct{}
|
||||
now func() time.Time
|
||||
log *slog.Logger
|
||||
webBase string
|
||||
@@ -83,6 +92,7 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
|
||||
log: log,
|
||||
kick: make(chan struct{}, 1),
|
||||
repos: make(map[string]model.Repo, len(cfg.Repos)),
|
||||
inflight: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +146,7 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
results[i] = c.refreshOne(ctx, name, start)
|
||||
results[i] = c.refreshOne(ctx, name, start, true)
|
||||
c.scanDone.Add(1)
|
||||
}()
|
||||
}
|
||||
@@ -154,18 +164,28 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
c.log.Info("refresh complete", "repos", len(c.names), "duration", c.lastDur.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) model.Repo {
|
||||
ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
|
||||
defer cancel()
|
||||
// refreshOne fetches one repo. With respectLimit set, it skips the fetch once the scan hit a rate limit.
|
||||
func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time, respectLimit bool) model.Repo {
|
||||
var r model.Repo
|
||||
err := model.ErrRateLimited
|
||||
// Once any repo hits the limit, remaining repos are skipped so no further requests go out.
|
||||
if !c.limited.Load() {
|
||||
r, err = c.fetch.FetchRepo(ctx, name)
|
||||
if !respectLimit || !c.limited.Load() {
|
||||
r, err = c.fetchRepo(ctx, name)
|
||||
}
|
||||
if errors.Is(err, model.ErrRateLimited) {
|
||||
if respectLimit && errors.Is(err, model.ErrRateLimited) {
|
||||
c.limited.Store(true)
|
||||
}
|
||||
return c.settle(name, at, r, err)
|
||||
}
|
||||
|
||||
func (c *Collector) fetchRepo(ctx context.Context, name string) (model.Repo, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
|
||||
defer cancel()
|
||||
return c.fetch.FetchRepo(ctx, name)
|
||||
}
|
||||
|
||||
// settle turns a fetch result into the repo to store; a failure keeps the last good data.
|
||||
func (c *Collector) settle(name string, at time.Time, r model.Repo, err error) model.Repo {
|
||||
if err == nil {
|
||||
r.FullName, r.Up, r.RefreshedAt = name, true, at
|
||||
return r
|
||||
@@ -182,6 +202,56 @@ func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) m
|
||||
return prev
|
||||
}
|
||||
|
||||
// RefreshRepo fetches one configured repo now and replaces only its state, leaving the schedule,
|
||||
// last scan time and other repos untouched. It fails with ErrRefreshBusy while a full refresh or a
|
||||
// refresh of the same repo runs. The GitHub client still enforces the rate-limit reserve.
|
||||
func (c *Collector) RefreshRepo(ctx context.Context, name string) (View, error) {
|
||||
canon, ok := c.configured(name)
|
||||
if !ok {
|
||||
return View{}, ErrUnknownRepo
|
||||
}
|
||||
if c.refreshing.Load() || !c.claim(canon) {
|
||||
return View{}, ErrRefreshBusy
|
||||
}
|
||||
defer c.release(canon)
|
||||
|
||||
at := c.now()
|
||||
r, err := c.fetchRepo(ctx, canon)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return View{}, ctx.Err()
|
||||
}
|
||||
r = c.settle(canon, at, r, err)
|
||||
c.mu.Lock()
|
||||
c.repos[canon] = r
|
||||
c.mu.Unlock()
|
||||
return c.View(), nil
|
||||
}
|
||||
|
||||
func (c *Collector) configured(name string) (string, bool) {
|
||||
for _, n := range c.names {
|
||||
if strings.EqualFold(n, name) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *Collector) claim(name string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, busy := c.inflight[name]; busy {
|
||||
return false
|
||||
}
|
||||
c.inflight[name] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Collector) release(name string) {
|
||||
c.mu.Lock()
|
||||
delete(c.inflight, name)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stats returns refresh-loop counters.
|
||||
func (c *Collector) Stats() Stats {
|
||||
c.mu.RLock()
|
||||
|
||||
@@ -351,3 +351,109 @@ func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
|
||||
t.Fatalf("repo link = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoReplacesOnlyThatRepo(t *testing.T) {
|
||||
gen := 0
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, gen)}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a", "o/b")
|
||||
c.Refresh(context.Background())
|
||||
before := c.View()
|
||||
gen = 2
|
||||
v, err := c.RefreshRepo(context.Background(), "O/B")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issues := map[string]int{}
|
||||
for _, r := range v.Repos {
|
||||
issues[r.FullName] = len(r.Issues)
|
||||
}
|
||||
if issues["o/a"] != 0 || issues["o/b"] != 2 {
|
||||
t.Fatalf("only o/b must change: %v", issues)
|
||||
}
|
||||
if !v.LastRefresh.Equal(before.LastRefresh) || c.Stats().RefreshTotal != 1 {
|
||||
t.Fatalf("full-scan bookkeeping must not move: %v %d", v.LastRefresh, c.Stats().RefreshTotal)
|
||||
}
|
||||
if len(f.calls) != 3 || f.calls[2] != "o/b" {
|
||||
t.Fatalf("calls %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoFailureKeepsLastGoodDataAndAlerts(t *testing.T) {
|
||||
fail := false
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
if fail {
|
||||
return model.Repo{}, errors.New("boom")
|
||||
}
|
||||
return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a")
|
||||
c.Refresh(context.Background())
|
||||
fail = true
|
||||
v, err := c.RefreshRepo(context.Background(), "o/a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := v.Repos[0]; r.Up || r.Err != "boom" || len(r.Issues) != 1 || v.Crit != 1 {
|
||||
t.Fatalf("%+v crit=%d", r, v.Crit)
|
||||
}
|
||||
if c.Stats().RepoErrorsTotal != 1 {
|
||||
t.Fatalf("stats %+v", c.Stats())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoIgnoresScanRateLimitFlag(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
|
||||
c.limited.Store(true)
|
||||
v, err := c.RefreshRepo(context.Background(), "o/a")
|
||||
if err != nil || !v.Repos[0].Up {
|
||||
t.Fatalf("err=%v %+v", err, v.Repos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoRejections(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
|
||||
if _, err := c.RefreshRepo(context.Background(), "x/y"); !errors.Is(err, ErrUnknownRepo) {
|
||||
t.Fatalf("unknown: %v", err)
|
||||
}
|
||||
c.refreshing.Store(true)
|
||||
if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
|
||||
t.Fatalf("full refresh running: %v", err)
|
||||
}
|
||||
c.refreshing.Store(false)
|
||||
if !c.claim("o/a") {
|
||||
t.Fatal("claim")
|
||||
}
|
||||
if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
|
||||
t.Fatalf("same repo running: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoCancelledKeepsState(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := &fakeFetcher{fn: func(string) (model.Repo, error) { cancel(); return model.Repo{}, context.Canceled }}
|
||||
c := newCollector(f, "o/a")
|
||||
if _, err := c.RefreshRepo(ctx, "o/a"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(c.View().Repos) != 0 || c.Stats().RepoErrorsTotal != 0 {
|
||||
t.Fatal("a cancelled request must not mark the repo down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}
|
||||
c := newCollector(f, "o/a", "o/b")
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); c.Refresh(context.Background()) }()
|
||||
go func() { defer wg.Done(); _, _ = c.RefreshRepo(context.Background(), "o/a") }()
|
||||
go func() { defer wg.Done(); _ = c.View(); _ = c.Stats() }()
|
||||
}
|
||||
wg.Wait()
|
||||
if len(c.View().Repos) != 2 {
|
||||
t.Fatalf("%+v", c.View().Repos)
|
||||
}
|
||||
}
|
||||
|
||||
+69
-1
@@ -2,11 +2,14 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -16,6 +19,8 @@ import (
|
||||
|
||||
const (
|
||||
minManualRefreshGap = 5 * time.Minute
|
||||
// repoRefreshCooldown limits each repository separately from the full-refresh gap.
|
||||
repoRefreshCooldown = 60 * time.Second
|
||||
indexFile = "index.html"
|
||||
assetsPrefix = "/assets/"
|
||||
immutableCache = "public, max-age=31536000, immutable"
|
||||
@@ -28,10 +33,12 @@ type Source interface {
|
||||
View() collector.View
|
||||
Stats() collector.Stats
|
||||
RequestRefresh() bool
|
||||
RefreshRepo(ctx context.Context, name string) (collector.View, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
lastManual time.Time
|
||||
lastRepo map[string]time.Time
|
||||
now func() time.Time
|
||||
src Source
|
||||
ui fs.FS
|
||||
@@ -43,7 +50,8 @@ type Server struct {
|
||||
|
||||
// New builds the server; ui is the built frontend (index.html at its root).
|
||||
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
|
||||
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now}
|
||||
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now,
|
||||
lastRepo: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -114,6 +122,10 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if repo := r.URL.Query().Get("repo"); repo != "" {
|
||||
s.refreshRepo(w, r, repo)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
|
||||
if !tooSoon {
|
||||
@@ -127,3 +139,59 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
s.src.RequestRefresh()
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name.
|
||||
type repoRefreshResponse struct {
|
||||
Repo string `json:"repo"`
|
||||
apiState
|
||||
}
|
||||
|
||||
// refreshRepo refreshes one repository and answers when it finishes. It has its own cooldown and
|
||||
// never touches the full-refresh gap. A running full refresh or same-repo refresh gives 409.
|
||||
func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string) {
|
||||
key := strings.ToLower(repo)
|
||||
s.mu.Lock()
|
||||
prev, had := s.lastRepo[key]
|
||||
wait := repoRefreshCooldown - s.now().Sub(prev)
|
||||
if had && wait > 0 {
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
|
||||
writeJSONError(w, http.StatusTooManyRequests, "repository refresh allowed once per "+repoRefreshCooldown.String())
|
||||
return
|
||||
}
|
||||
s.lastRepo[key] = s.now()
|
||||
s.mu.Unlock()
|
||||
|
||||
v, err := s.src.RefreshRepo(r.Context(), repo)
|
||||
if err != nil {
|
||||
// A refused or cancelled attempt spent no GitHub budget, so it must not start a cooldown.
|
||||
s.mu.Lock()
|
||||
if had {
|
||||
s.lastRepo[key] = prev
|
||||
} else {
|
||||
delete(s.lastRepo, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
switch {
|
||||
case errors.Is(err, collector.ErrUnknownRepo):
|
||||
writeJSONError(w, http.StatusNotFound, "unknown repository")
|
||||
case errors.Is(err, collector.ErrRefreshBusy):
|
||||
writeJSONError(w, http.StatusConflict, "a refresh is already running; try again shortly")
|
||||
default:
|
||||
s.log.Error("refresh repo", "repo", repo, "err", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "refresh failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil {
|
||||
s.log.Error("encode repo refresh", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@@ -19,15 +22,34 @@ import (
|
||||
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
type fakeSource struct {
|
||||
repoFn func(name string) (collector.View, error)
|
||||
repoCalls []string
|
||||
st collector.Stats
|
||||
v collector.View
|
||||
refreshed int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (f *fakeSource) View() collector.View { return f.v }
|
||||
func (f *fakeSource) Stats() collector.Stats { return f.st }
|
||||
func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true }
|
||||
|
||||
func (f *fakeSource) RefreshRepo(_ context.Context, name string) (collector.View, error) {
|
||||
f.mu.Lock()
|
||||
f.repoCalls = append(f.repoCalls, name)
|
||||
f.mu.Unlock()
|
||||
if f.repoFn != nil {
|
||||
return f.repoFn(name)
|
||||
}
|
||||
return f.v, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) calls() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.repoCalls)
|
||||
}
|
||||
|
||||
func testView() collector.View {
|
||||
repo := model.Repo{
|
||||
FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true,
|
||||
@@ -241,3 +263,187 @@ func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) {
|
||||
t.Fatalf("same-origin: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func post(h http.Handler, target string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", target, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func repoServer(src *fakeSource, manual bool) (*Server, *time.Time) {
|
||||
srv := newServer(src, testUI(), manual)
|
||||
clock := now
|
||||
srv.now = func() time.Time { return clock }
|
||||
return srv, &clock
|
||||
}
|
||||
|
||||
func TestRepoRefreshReturnsStateAndRepoName(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("%d %v", rec.Code, rec.Header())
|
||||
}
|
||||
var got struct {
|
||||
Repo string `json:"repo"`
|
||||
Repos []struct {
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"repos"`
|
||||
Counts struct{ Crit int } `json:"counts"`
|
||||
Loaded bool `json:"loaded"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Repo != "o/r" || !got.Loaded || len(got.Repos) != 2 || got.Counts.Crit != 1 {
|
||||
t.Fatalf("body: %s", rec.Body.String())
|
||||
}
|
||||
if len(src.repoCalls) != 1 || src.repoCalls[0] != "o/r" || src.refreshed != 0 {
|
||||
t.Fatalf("calls=%v full=%d", src.repoCalls, src.refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshUnknownRepoIs404JSON(t *testing.T) {
|
||||
src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, collector.ErrUnknownRepo }}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
rec := post(h, "/refresh?repo=x/y", nil)
|
||||
var body map[string]string
|
||||
if rec.Code != http.StatusNotFound || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body["error"] == "" {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=x/y", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("a rejected name must not start a cooldown: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshCooldownPerRepo(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, clock := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first: %d", rec.Code)
|
||||
}
|
||||
*clock = clock.Add(20 * time.Second)
|
||||
rec := post(h, "/refresh?repo=O/R", nil)
|
||||
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "40" {
|
||||
t.Fatalf("cooldown: %d retry-after=%q", rec.Code, rec.Header().Get("Retry-After"))
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("other repo must be independent: %d", rec.Code)
|
||||
}
|
||||
*clock = clock.Add(repoRefreshCooldown)
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK || src.calls() != 3 {
|
||||
t.Fatalf("after cooldown: %d calls=%d", rec.Code, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshDoesNotUseFullRefreshBudget(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("repo: %d", rec.Code)
|
||||
}
|
||||
if rec := post(h, "/refresh", nil); rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("full refresh after repo refresh: %d", rec.Code)
|
||||
}
|
||||
if rec := post(h, "/refresh", nil); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("full refresh gap must still apply: %d", rec.Code)
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("repo refresh must not be blocked by the full gap: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshEmptyParamKeepsFullRefresh(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
rec := post(srv.Handler(), "/refresh?repo=", nil)
|
||||
if rec.Code != http.StatusAccepted || rec.Body.Len() != 0 || src.refreshed != 1 || src.calls() != 0 {
|
||||
t.Fatalf("%d body=%q full=%d repo=%d", rec.Code, rec.Body.String(), src.refreshed, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshBusyIs409AndKeepsCooldownFree(t *testing.T) {
|
||||
busy := true
|
||||
src := &fakeSource{v: testView(), repoFn: func(string) (collector.View, error) {
|
||||
if busy {
|
||||
return collector.View{}, collector.ErrRefreshBusy
|
||||
}
|
||||
return testView(), nil
|
||||
}}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("busy: %d", rec.Code)
|
||||
}
|
||||
busy = false
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("retry after busy: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshUnexpectedErrorIs500(t *testing.T) {
|
||||
src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, errors.New("x") }}
|
||||
srv, _ := repoServer(src, true)
|
||||
if rec := post(srv.Handler(), "/refresh?repo=o/r", nil); rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshRefusesCrossSite(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
for _, site := range []string{"cross-site", "same-site"} {
|
||||
if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": site}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: %d", site, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": "same-origin"}); rec.Code != http.StatusOK || src.calls() != 1 {
|
||||
t.Fatalf("same-origin: %d calls=%d", rec.Code, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshDisabledByConfig(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, false)
|
||||
rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
|
||||
if rec.Code == http.StatusOK || src.calls() != 0 {
|
||||
t.Fatalf("%d calls=%d", rec.Code, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshConcurrentRequests(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan int, 16)
|
||||
for range 16 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
codes <- post(h, "/refresh?repo=o/r", nil).Code
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
ok, limited := 0, 0
|
||||
for c := range codes {
|
||||
switch c {
|
||||
case http.StatusOK:
|
||||
ok++
|
||||
case http.StatusTooManyRequests:
|
||||
limited++
|
||||
}
|
||||
}
|
||||
if ok != 1 || limited != 15 || src.calls() != 1 {
|
||||
t.Fatalf("ok=%d limited=%d calls=%d", ok, limited, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user