diff --git a/README.md b/README.md index c44959c..d153b29 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ Alerts are evaluated when you read them, so PR ages keep advancing between refre - `/api/state` full state as JSON. - `/healthz` returns 503 until the first refresh completes. - `POST /refresh` runs an extra refresh and backs the Refresh button. It is on by default, limited to once per 5 minutes, and refuses cross-site requests. Set `manual_refresh: false` to disable it. +- `POST /refresh?repo=owner/name` refreshes only that repository and answers when it finishes. An empty `repo` value runs the full refresh above. + - The repository must be in the configured list, or the answer is 404 with a JSON `{"error": ...}` body. The match ignores case. + - On success the answer is 200 with the same JSON as `/api/state`, plus a `repo` field. Only that repository changes. The last scan time and the schedule do not move. + - Each repository has its own cooldown of 60 seconds. Inside it, the answer is 429 with a `Retry-After` header. A repository refresh does not use the 5-minute budget of the full refresh, and the reverse also holds. + - While a full refresh or a refresh of the same repository runs, the answer is 409. The cooldown does not start, so you can retry at once. + - The same cross-site refusal and `manual_refresh: false` switch apply. The GitHub rate-limit protections below apply too. + +The dashboard shows a refresh button beside each project when `manual_refresh` is on. ## Metrics diff --git a/internal/collector/collector.go b/internal/collector/collector.go index d7e678b..059f50f 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -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() diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 0c2d98b..af7010b 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index fb95125..900e89b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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}) +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 24db35d..da174f7 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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()) + } +} diff --git a/src/App.test.ts b/src/App.test.ts index f14e7e5..4476029 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -89,4 +89,52 @@ describe('App', () => { await flushPromises() expect(off.find('[data-test=rescan]').exists()).toBe(false) }) + + it('refreshes one repo from its button, shows the new state and announces it', async () => { + const first = state({ + manualRefresh: true, + repos: [repo({ refreshedAt: '2026-10-02T10:00:00Z' })], + }) + const after = { ...first, repo: 'o/r', repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z' })] } + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + Promise.resolve( + new Response(JSON.stringify(init?.method === 'POST' ? after : first), { status: 200 }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + const w = mount(App, { global: { plugins: [createPinia()] } }) + await flushPromises() + await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click') + await flushPromises() + expect(fetchMock).toHaveBeenCalledWith('/refresh?repo=o%2Fr', { method: 'POST' }) + expect(w.get('[data-test=announcement]').text()).toBe('Refreshed o/r') + }) + + it('shows the refusal message when a repo is refreshed too soon', async () => { + const body = state({ manualRefresh: true, repos: [repo()] }) + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init?: RequestInit) => + Promise.resolve( + init?.method === 'POST' + ? new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }) + : new Response(JSON.stringify(body), { status: 200 }), + ), + ), + ) + const w = mount(App, { global: { plugins: [createPinia()] } }) + await flushPromises() + await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click') + await flushPromises() + expect(w.get('[data-test=notice]').text()).toBe('o/r was refreshed recently. Try again in 30s') + expect( + w.get('[data-test=project] [data-test=repo-refresh]').attributes('disabled'), + ).toBeUndefined() + }) + + it('hides the repo buttons when the server disables manual refresh', async () => { + const w = mountWith(state({ manualRefresh: false, repos: [repo()] })) + await flushPromises() + expect(w.find('[data-test=repo-refresh]').exists()).toBe(false) + }) }) diff --git a/src/App.vue b/src/App.vue index 24ad9f0..9ac7cef 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,6 +17,8 @@ const { state, error, notice, + announcement, + refreshingRepos, scanning, blocks, visibleBlocks, @@ -91,6 +93,8 @@ onBeforeUnmount(() => { {{ notice }}
+{{ announcement }}
+