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
+77 -7
View File
@@ -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()
+106
View File
@@ -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)
}
}