Skip to content

Commit 58581b6

Browse files
Danatharclaudehivecommons-hive[bot]
authored
fix(dashboard): the metrics collector reads each repo's metadata at most hourly (#17175)
collectOutreach (stars/forks) and collectIssuesDisabled (has_issues) read GET /repos/{owner}/{repo} for the primary repo and every watched repo on every five-minute collect: 189 calls an hour on the 20-repo projectbluefin hive, 177 charged. Hold each successful read for an hour and share it between both uses; a failed read is retried on the next collect. The advisory create path keeps its fresh ProbeIssuesDisabled check. Claude-Session: https://claude.ai/code/session_01BQYnPGFJx81c8JXR3o1sg1 Signed-off-by: Danathar <Danathar@users.noreply.github.com> Co-authored-by: Danathar <Danathar@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: hivecommons-hive[bot] <280983584+hivecommons-hive[bot]@users.noreply.github.com>
1 parent 38ac8ca commit 58581b6

6 files changed

Lines changed: 225 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- The dashboard metrics collector reads each repo's GitHub metadata at most once an hour instead of on every five-minute collect. It read the primary repo for the outreach card's stars and forks, and every watched repo for the Issues-disabled banner, every collect: 189 `GET /repos/{owner}/{repo}` calls in an hour on a hosted hive with 20 repos (2026-11-08), 177 of them charged, for numbers that change a few times a day. One read per repo now serves both, a failed read is retried on the next collect, and the banner reflects a changed Issues setting within the hour. The advisory issue's create path still checks `has_issues` fresh before it creates.

‎src/docs/fleet-health.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ shadowed by a shallower one:
145145
7. **Repo Issues disabled** — the repo's Issues tab is off (the GitHub
146146
default on forks), so the advisory digest and every agent-filed issue
147147
have nowhere to go. Nothing about the App, key, or agents is wrong; the
148-
fix is the repo setting.
148+
fix is the repo setting. The dashboard re-reads each watched repo's
149+
setting at most once an hour, so the banner clears within the hour of the
150+
fix (or at the next restart).
149151
7. **Banded output freshness** — only now, with every precondition green,
150152
can "no write in 30h (7 queued)" or "advisory stale" appear.
151153

‎src/pkg/dashboard/metrics_collector.go‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"sync"
1515
"time"
1616

17+
gogithub "github.com/google/go-github/v72/github"
1718
"github.com/hivecommons/hive/pkg/forgestate"
1819
ghpkg "github.com/hivecommons/hive/pkg/github"
1920
)
@@ -42,8 +43,24 @@ const (
4243
// forgeCountErrorLogInterval bounds the WARN for a failing count refresh;
4344
// repeats inside the window log at DEBUG.
4445
forgeCountErrorLogInterval = 15 * time.Minute
46+
47+
// repoMetadataRefreshInterval is how long one GET /repos/{owner}/{repo}
48+
// answer serves the collector: the outreach card's stars and forks and
49+
// the Issues-disabled banner's has_issues/fork. Reading every watched
50+
// repo on every collect cost 189 calls an hour on the 20-repo
51+
// projectbluefin hive (2026-11-08), 177 of them charged, for numbers
52+
// that move a few times a day. A failed read is not held, so the next
53+
// collect asks again. The advisory issue's create path keeps its own
54+
// fresh has_issues check (ProbeIssuesDisabled).
55+
repoMetadataRefreshInterval = time.Hour
4556
)
4657

58+
// repoMetadataEntry is one repo's metadata as last read, and when.
59+
type repoMetadataEntry struct {
60+
repo *gogithub.Repository
61+
at time.Time
62+
}
63+
4764
// forgeCountCacheTTL is the configured reuse window for Search-API counts.
4865
func forgeCountCacheTTL() time.Duration {
4966
return durationFromEnv(forgeCountCacheTTLEnv, forgeCountCacheTTLDefault)
@@ -111,6 +128,11 @@ type MetricsCollector struct {
111128
// watched repo (#9972); the dashboard banner renders it.
112129
issuesDisabledMu sync.RWMutex
113130
issuesDisabled []IssuesDisabledRepo
131+
132+
// repoMeta holds each repo's metadata for repoMetadataRefreshInterval,
133+
// keyed by lower-cased owner/name; see repoMetadata.
134+
repoMetaMu sync.Mutex
135+
repoMeta map[string]repoMetadataEntry
114136
}
115137

116138
func NewMetricsCollector(ghClient *ghpkg.Client, org, primaryRepo, badgeURL, aiAuthor, projectName string, logger *slog.Logger) *MetricsCollector {
@@ -690,8 +712,8 @@ func (mc *MetricsCollector) collectOutreach(ctx context.Context) map[string]any
690712
return result
691713
}
692714

693-
repo, _, err := gh.GetRepo(ctx, parts[0], parts[1])
694-
if err == nil && repo != nil {
715+
repo, err := mc.repoMetadata(ctx, gh, parts[0], parts[1])
716+
if err == nil {
695717
result["stars"] = repo.GetStargazersCount()
696718
result["forks"] = repo.GetForksCount()
697719
}
@@ -720,6 +742,38 @@ func (mc *MetricsCollector) collectOutreach(ctx context.Context) map[string]any
720742
return result
721743
}
722744

745+
// repoMetadata returns owner/name's GET /repos/{owner}/{repo} answer, reading
746+
// it from GitHub at most once per repoMetadataRefreshInterval. Only a
747+
// successful read is held.
748+
func (mc *MetricsCollector) repoMetadata(ctx context.Context, client *ghpkg.Client, owner, name string) (*gogithub.Repository, error) {
749+
key := strings.ToLower(owner + "/" + name)
750+
now := mc.clock()
751+
mc.repoMetaMu.Lock()
752+
if e, ok := mc.repoMeta[key]; ok && now.Sub(e.at) < repoMetadataRefreshInterval {
753+
mc.repoMetaMu.Unlock()
754+
return e.repo, nil
755+
}
756+
mc.repoMetaMu.Unlock()
757+
758+
if client == nil {
759+
return nil, ghpkg.ErrNoGitHubClient
760+
}
761+
repo, _, err := client.GetRepo(ctx, owner, name)
762+
if err != nil {
763+
return nil, err
764+
}
765+
if repo == nil {
766+
return nil, fmt.Errorf("no metadata returned for %s/%s", owner, name)
767+
}
768+
mc.repoMetaMu.Lock()
769+
if mc.repoMeta == nil {
770+
mc.repoMeta = make(map[string]repoMetadataEntry)
771+
}
772+
mc.repoMeta[key] = repoMetadataEntry{repo: repo, at: now}
773+
mc.repoMetaMu.Unlock()
774+
return repo, nil
775+
}
776+
723777
// collectCoverage reads the primary repo's test-coverage percentage for the
724778
// ci-maintainer card (and the ACMM advisor's coverage floors). The source is
725779
// badgeURL, which is HIVE_COVERAGE_BADGE_URL: either an http(s) URL to a

‎src/pkg/dashboard/metrics_issues_disabled.go‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package dashboard
22

3-
import "context"
3+
import (
4+
"context"
5+
6+
ghpkg "github.com/hivecommons/hive/pkg/github"
7+
)
48

59
// IssuesDisabledRepo is a watched repo whose Issues feature is turned off
610
// (has_issues=false). Agents cannot file issues there and the advisory issue
@@ -14,11 +18,13 @@ type IssuesDisabledRepo struct {
1418
Message string `json:"message"`
1519
}
1620

17-
// collectIssuesDisabled probes has_issues for every repo the GitHub client
18-
// watches (project.repos). It runs on the metrics cadence: once at startup and
19-
// every metricsCollectInterval after. A failed probe fails OPEN for that repo:
20-
// the previous verdict is kept, so a transient API error neither invents a
21-
// warning nor silently clears a real one.
21+
// collectIssuesDisabled checks has_issues for every repo the GitHub client
22+
// watches (project.repos). It runs on the metrics cadence (once at startup and
23+
// every metricsCollectInterval after), but each repo's metadata is read from
24+
// GitHub at most once per repoMetadataRefreshInterval (repoMetadata), so a
25+
// change to a repo's Issues setting shows within the hour. A failed read fails
26+
// OPEN for that repo: the previous verdict is kept, so a transient API error
27+
// neither invents a warning nor silently clears a real one.
2228
func (mc *MetricsCollector) collectIssuesDisabled(ctx context.Context) {
2329
gh := mc.client()
2430
if gh == nil {
@@ -43,15 +49,15 @@ func (mc *MetricsCollector) collectIssuesDisabled(ctx context.Context) {
4349
}
4450
seen[full] = true
4551

46-
disabled, err := gh.ProbeIssuesDisabled(ctx, owner, name)
52+
meta, err := mc.repoMetadata(ctx, gh, owner, name)
4753
if err != nil {
4854
mc.logger.Warn("could not check watched repo has_issues", "repo", full, "error", err)
4955
if p, ok := prev[full]; ok {
5056
found = append(found, p)
5157
}
5258
continue
5359
}
54-
if disabled != nil {
60+
if disabled := ghpkg.IssuesDisabledFor(owner, name, meta); disabled != nil {
5561
found = append(found, IssuesDisabledRepo{Repo: disabled.Repo, Fork: disabled.Fork, Message: disabled.Error()})
5662
}
5763
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package dashboard
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"net/http/httptest"
10+
"sync"
11+
"sync/atomic"
12+
"testing"
13+
"time"
14+
15+
ghpkg "github.com/hivecommons/hive/pkg/github"
16+
)
17+
18+
// repoMetaServer serves GET /repos/{owner}/{repo} for a fixed set of repos and
19+
// counts how often each is read. When fail is set every read returns 500.
20+
type repoMetaServer struct {
21+
mu sync.Mutex
22+
reads map[string]int
23+
meta map[string]map[string]any
24+
fail atomic.Bool
25+
}
26+
27+
func newRepoMetaServer(t *testing.T) (*repoMetaServer, *httptest.Server) {
28+
t.Helper()
29+
s := &repoMetaServer{
30+
reads: map[string]int{},
31+
meta: map[string]map[string]any{
32+
"/repos/myorg/open": {"has_issues": true, "fork": false, "stargazers_count": 32, "forks_count": 4},
33+
"/repos/myorg/fork-off": {"has_issues": false, "fork": true},
34+
"/repos/other/off": {"has_issues": false, "fork": false},
35+
},
36+
}
37+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
meta, ok := s.meta[r.URL.Path]
39+
if !ok {
40+
w.WriteHeader(http.StatusNotFound)
41+
return
42+
}
43+
s.mu.Lock()
44+
s.reads[r.URL.Path]++
45+
s.mu.Unlock()
46+
if s.fail.Load() {
47+
w.WriteHeader(http.StatusInternalServerError)
48+
return
49+
}
50+
_ = json.NewEncoder(w).Encode(meta)
51+
}))
52+
t.Cleanup(srv.Close)
53+
return s, srv
54+
}
55+
56+
func (s *repoMetaServer) count(path string) int {
57+
s.mu.Lock()
58+
defer s.mu.Unlock()
59+
return s.reads[path]
60+
}
61+
62+
// The live projectbluefin hive (2026-11-08) sent GET /repos/{owner}/{repo}
63+
// 189 times an hour from the metrics collector, 177 of them charged: the
64+
// primary repo for stars/forks plus every watched repo for has_issues, on
65+
// every five-minute collect. Stars, forks and the Issues-disabled banner do
66+
// not need that: each repo's metadata is read at most once an hour, and the
67+
// primary repo's read serves both the outreach card and the banner.
68+
func TestMetricsCollector_RepoMetadataReadHourly(t *testing.T) {
69+
s, srv := newRepoMetaServer(t)
70+
gh := ghpkg.NewClientForTest(srv.URL, "myorg", []string{"open", "fork-off", "other/off"}, slog.New(slog.NewTextHandler(io.Discard, nil)))
71+
mc := issuesDisabledTestCollector(gh)
72+
now := time.Date(2026, 11, 8, 2, 0, 0, 0, time.UTC)
73+
mc.now = func() time.Time { return now }
74+
75+
collect := func() map[string]any {
76+
out := mc.collectOutreach(context.Background())
77+
mc.collectIssuesDisabled(context.Background())
78+
return out
79+
}
80+
81+
// Twelve collects, five minutes apart: one hour of the old cadence.
82+
var outreach map[string]any
83+
for i := 0; i < 12; i++ {
84+
outreach = collect()
85+
if i < 11 {
86+
now = now.Add(metricsCollectInterval)
87+
}
88+
}
89+
for _, path := range []string{"/repos/myorg/open", "/repos/myorg/fork-off", "/repos/other/off"} {
90+
if got := s.count(path); got != 1 {
91+
t.Errorf("GET %s = %d reads in 55 minutes, want 1", path, got)
92+
}
93+
}
94+
if outreach["stars"] != 32 || outreach["forks"] != 4 {
95+
t.Errorf("outreach = %v, want stars 32 and forks 4 from the held metadata", outreach)
96+
}
97+
if got := mc.GetIssuesDisabledRepos(); len(got) != 2 || got[0].Repo != "myorg/fork-off" || got[1].Repo != "other/off" {
98+
t.Errorf("issues-disabled repos = %+v, want myorg/fork-off and other/off from the held metadata", got)
99+
}
100+
101+
// Past the hour, each repo is read once more and a change is seen.
102+
s.meta["/repos/myorg/fork-off"] = map[string]any{"has_issues": true, "fork": true}
103+
now = now.Add(repoMetadataRefreshInterval)
104+
collect()
105+
for _, path := range []string{"/repos/myorg/open", "/repos/myorg/fork-off", "/repos/other/off"} {
106+
if got := s.count(path); got != 2 {
107+
t.Errorf("GET %s = %d reads after the hour, want 2", path, got)
108+
}
109+
}
110+
if got := mc.GetIssuesDisabledRepos(); len(got) != 1 || got[0].Repo != "other/off" {
111+
t.Errorf("issues-disabled repos after refresh = %+v, want only other/off", got)
112+
}
113+
}
114+
115+
// A failed read is not held: the next collect asks again instead of waiting
116+
// an hour, and the banner keeps its previous verdict meanwhile.
117+
func TestMetricsCollector_RepoMetadataFailureRetriesNextCollect(t *testing.T) {
118+
s, srv := newRepoMetaServer(t)
119+
gh := ghpkg.NewClientForTest(srv.URL, "myorg", []string{"fork-off"}, slog.New(slog.NewTextHandler(io.Discard, nil)))
120+
mc := issuesDisabledTestCollector(gh)
121+
now := time.Date(2026, 11, 8, 2, 0, 0, 0, time.UTC)
122+
mc.now = func() time.Time { return now }
123+
124+
mc.collectIssuesDisabled(context.Background())
125+
if got := mc.GetIssuesDisabledRepos(); len(got) != 1 {
126+
t.Fatalf("initial verdict = %+v, want myorg/fork-off", got)
127+
}
128+
129+
now = now.Add(repoMetadataRefreshInterval + time.Minute)
130+
s.fail.Store(true)
131+
mc.collectIssuesDisabled(context.Background())
132+
if got := mc.GetIssuesDisabledRepos(); len(got) != 1 || got[0].Repo != "myorg/fork-off" {
133+
t.Fatalf("after a failed read = %+v, want the previous verdict kept", got)
134+
}
135+
s.fail.Store(false)
136+
now = now.Add(metricsCollectInterval)
137+
mc.collectIssuesDisabled(context.Background())
138+
if got := s.count("/repos/myorg/fork-off"); got != 3 {
139+
t.Fatalf("reads = %d, want 3 (initial, failed, retried on the next collect)", got)
140+
}
141+
}

‎src/pkg/github/advisory.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,17 @@ func (c *Client) ProbeIssuesDisabled(ctx context.Context, owner, repo string) (*
7272
if err != nil {
7373
return nil, err
7474
}
75-
if ghRepo != nil && !ghRepo.GetHasIssues() {
76-
return &IssuesDisabledError{Repo: owner + "/" + repo, Fork: ghRepo.GetFork()}, nil
75+
return IssuesDisabledFor(owner, repo, ghRepo), nil
76+
}
77+
78+
// IssuesDisabledFor classifies metadata already read for owner/repo the way
79+
// ProbeIssuesDisabled does: a non-nil *IssuesDisabledError when has_issues is
80+
// false, nil otherwise (including nil metadata).
81+
func IssuesDisabledFor(owner, repo string, meta *gh.Repository) *IssuesDisabledError {
82+
if meta != nil && !meta.GetHasIssues() {
83+
return &IssuesDisabledError{Repo: owner + "/" + repo, Fork: meta.GetFork()}
7784
}
78-
return nil, nil
85+
return nil
7986
}
8087

8188
// writeForbiddenMessage is GitHub's 403 body when an installation token may

0 commit comments

Comments
 (0)