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:
2026-10-02 23:16:22 +01:00
committed by GitHub
parent 4c2c9d3341
commit 8cbc495dc7
16 changed files with 760 additions and 19 deletions
+69 -1
View File
@@ -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})
}
+206
View File
@@ -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())
}
}