Skip to content

Commit 0892678

Browse files
committed
feat!: support repository discovery and improve config structure
- Add support for discovering all repositories when only owner is specified - Change packages configuration from map to list for better clarity - Add name field to PackageGroup for better metric labeling - Update config example to show both specific repo and owner-only patterns - Add tests for repository discovery functionality - Make MakeGitHubAPIRequest public for better testability
1 parent 38e2ba8 commit 0892678

File tree

4 files changed

+181
-30
lines changed

4 files changed

+181
-30
lines changed

config.example.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ metrics:
1111
default_interval: "60s"
1212

1313
packages:
14-
filesystem-exporter:
14+
# Example 1: Specific repository
15+
- name: "filesystem-exporter"
1516
owner: "d0ugal"
1617
repo: "filesystem-exporter"
17-
package_name: "filesystem-exporter"
1818
interval: "300s" # 5 minutes
19+
20+
# Example 2: All repositories for an owner (repo discovery)
21+
- name: "d0ugal-all-repos"
22+
owner: "d0ugal"
23+
# repo field omitted - will discover all repos for this owner
24+
interval: "600s" # 10 minutes

internal/collectors/ghcr_collector.go

Lines changed: 128 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ type GHCRVersionResponse struct {
6868
} `json:"package_files"`
6969
}
7070

71+
// GitHubRepository represents a GitHub repository
72+
type GitHubRepository struct {
73+
ID int `json:"id"`
74+
NodeID string `json:"node_id"`
75+
Name string `json:"name"`
76+
FullName string `json:"full_name"`
77+
Private bool `json:"private"`
78+
Owner struct {
79+
Login string `json:"login"`
80+
} `json:"owner"`
81+
}
82+
7183
func NewGHCRCollector(cfg *config.Config, registry *metrics.Registry) *GHCRCollector {
7284
return &GHCRCollector{
7385
config: cfg,
@@ -94,25 +106,31 @@ func (gc *GHCRCollector) run(ctx context.Context) {
94106
}()
95107

96108
// Start individual tickers for each package
97-
for groupName, group := range gc.config.Packages {
109+
for _, group := range gc.config.Packages {
98110
interval := gc.config.GetPackageInterval(group)
99111
ticker := time.NewTicker(time.Duration(interval) * time.Second)
100-
tickers[groupName] = ticker
101-
102-
// Initial collection for this package
103-
gc.collectSinglePackage(ctx, group.Repo, group)
104-
105-
// Start goroutine for this package
106-
go func(name string, pkg config.PackageGroup) {
107-
for {
108-
select {
109-
case <-ctx.Done():
110-
return
111-
case <-ticker.C:
112-
gc.collectSinglePackage(ctx, pkg.Repo, pkg)
112+
tickers[group.Name] = ticker
113+
114+
// If repo is specified, collect for that specific repo
115+
if group.Repo != "" {
116+
// Initial collection for this package
117+
gc.collectSinglePackage(ctx, group.Repo, group)
118+
119+
// Start goroutine for this package
120+
go func(pkg config.PackageGroup) {
121+
for {
122+
select {
123+
case <-ctx.Done():
124+
return
125+
case <-ticker.C:
126+
gc.collectSinglePackage(ctx, pkg.Repo, pkg)
127+
}
113128
}
114-
}
115-
}(groupName, group)
129+
}(group)
130+
} else {
131+
// If no repo specified, discover all repos for the owner
132+
gc.collectAllReposForOwner(ctx, group, ticker)
133+
}
116134
}
117135

118136
// Wait for context cancellation
@@ -179,7 +197,7 @@ func (gc *GHCRCollector) collectPackageMetrics(ctx context.Context, repo string,
179197
}
180198

181199
func (gc *GHCRCollector) getPackageInfo(ctx context.Context, owner, repo, packageName string) (*GHCRPackageResponse, error) {
182-
resp, err := gc.makeGitHubAPIRequest(ctx, fmt.Sprintf("/users/%s/packages/container/%s", owner, packageName))
200+
resp, err := gc.MakeGitHubAPIRequest(ctx, fmt.Sprintf("/users/%s/packages/container/%s", owner, packageName))
183201
if err != nil {
184202
return nil, err
185203
}
@@ -198,8 +216,8 @@ func (gc *GHCRCollector) getPackageInfo(ctx context.Context, owner, repo, packag
198216
return &packageInfo, nil
199217
}
200218

201-
// makeGitHubAPIRequest makes a request to GitHub API, trying user endpoint first, then org endpoint
202-
func (gc *GHCRCollector) makeGitHubAPIRequest(ctx context.Context, path string) (*http.Response, error) {
219+
// MakeGitHubAPIRequest makes a request to GitHub API, trying user endpoint first, then org endpoint
220+
func (gc *GHCRCollector) MakeGitHubAPIRequest(ctx context.Context, path string) (*http.Response, error) {
203221
// Try user endpoint first
204222
userURL := fmt.Sprintf("https://api.github.com%s", path)
205223

@@ -271,7 +289,7 @@ func (gc *GHCRCollector) makeGitHubAPIRequest(ctx context.Context, path string)
271289
}
272290

273291
func (gc *GHCRCollector) getPackageVersions(ctx context.Context, owner, repo, packageName string) ([]GHCRVersionResponse, error) {
274-
resp, err := gc.makeGitHubAPIRequest(ctx, fmt.Sprintf("/users/%s/packages/container/%s/versions", owner, packageName))
292+
resp, err := gc.MakeGitHubAPIRequest(ctx, fmt.Sprintf("/users/%s/packages/container/%s/versions", owner, packageName))
275293
if err != nil {
276294
return nil, err
277295
}
@@ -290,6 +308,96 @@ func (gc *GHCRCollector) getPackageVersions(ctx context.Context, owner, repo, pa
290308
return versions, nil
291309
}
292310

311+
// getRepositoriesForOwner lists all repositories for a given owner
312+
func (gc *GHCRCollector) getRepositoriesForOwner(ctx context.Context, owner string) ([]GitHubRepository, error) {
313+
var allRepos []GitHubRepository
314+
315+
page := 1
316+
perPage := 100
317+
318+
for {
319+
// Try user endpoint first
320+
userPath := fmt.Sprintf("/users/%s/repos?page=%d&per_page=%d&type=all", owner, page, perPage)
321+
322+
resp, err := gc.MakeGitHubAPIRequest(ctx, userPath)
323+
if err != nil {
324+
return nil, err
325+
}
326+
327+
defer func() {
328+
if err := resp.Body.Close(); err != nil {
329+
slog.Error("Error closing response body", "error", err)
330+
}
331+
}()
332+
333+
var repos []GitHubRepository
334+
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
335+
return nil, err
336+
}
337+
338+
// If we got no repos, we've reached the end
339+
if len(repos) == 0 {
340+
break
341+
}
342+
343+
allRepos = append(allRepos, repos...)
344+
345+
// If we got fewer repos than requested, we've reached the end
346+
if len(repos) < perPage {
347+
break
348+
}
349+
350+
page++
351+
}
352+
353+
slog.Info("Discovered repositories for owner", "owner", owner, "count", len(allRepos))
354+
355+
return allRepos, nil
356+
}
357+
358+
// collectAllReposForOwner discovers and collects metrics for all repositories of an owner
359+
func (gc *GHCRCollector) collectAllReposForOwner(ctx context.Context, group config.PackageGroup, ticker *time.Ticker) {
360+
// Initial discovery and collection
361+
gc.discoverAndCollectRepos(ctx, group)
362+
363+
// Start goroutine for periodic discovery and collection
364+
go func(pkg config.PackageGroup) {
365+
for {
366+
select {
367+
case <-ctx.Done():
368+
return
369+
case <-ticker.C:
370+
gc.discoverAndCollectRepos(ctx, pkg)
371+
}
372+
}
373+
}(group)
374+
}
375+
376+
// discoverAndCollectRepos discovers repositories and collects metrics for each
377+
func (gc *GHCRCollector) discoverAndCollectRepos(ctx context.Context, group config.PackageGroup) {
378+
slog.Info("Discovering repositories for owner", "owner", group.Owner, "group", group.Name)
379+
380+
// Get all repositories for the owner
381+
repos, err := gc.getRepositoriesForOwner(ctx, group.Owner)
382+
if err != nil {
383+
slog.Error("Failed to discover repositories", "owner", group.Owner, "group", group.Name, "error", err)
384+
return
385+
}
386+
387+
// Collect metrics for each repository
388+
for _, repo := range repos {
389+
// Create a package group for this specific repository
390+
repoPackage := config.PackageGroup{
391+
Name: group.Name,
392+
Owner: group.Owner,
393+
Repo: repo.Name,
394+
}
395+
396+
// Collect metrics for this repository
397+
gc.collectSinglePackage(ctx, repo.Name, repoPackage)
398+
}
399+
}
400+
293401
func (gc *GHCRCollector) updatePackageMetrics(ctx context.Context, pkg config.PackageGroup, packageInfo *GHCRPackageResponse, versions []GHCRVersionResponse) {
294402
// Update package-level metrics with real data
295403
// Note: GitHub API doesn't provide download statistics for packages

internal/collectors/ghcr_collector_test.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ func TestNewGHCRCollector(t *testing.T) {
3636

3737
func TestGHCRCollectorStart(t *testing.T) {
3838
cfg := &config.Config{
39-
Packages: map[string]config.PackageGroup{
40-
"test-package": {
39+
Packages: []config.PackageGroup{
40+
{
41+
Name: "test-package",
4142
Owner: "test-owner",
4243
Repo: "test-repo",
4344
},
@@ -197,3 +198,38 @@ func TestGetPackageDownloadStatsHTTPError(t *testing.T) {
197198
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
198199
}
199200
}
201+
202+
func TestGetRepositoriesForOwner(t *testing.T) {
203+
// Test the GitHubRepository struct and basic functionality
204+
repo := GitHubRepository{
205+
ID: 1,
206+
NodeID: "node1",
207+
Name: "repo1",
208+
FullName: "test-owner/repo1",
209+
Private: false,
210+
Owner: struct {
211+
Login string `json:"login"`
212+
}{Login: "test-owner"},
213+
}
214+
215+
if repo.Name != "repo1" {
216+
t.Errorf("Expected repo name to be 'repo1', got '%s'", repo.Name)
217+
}
218+
219+
if repo.FullName != "test-owner/repo1" {
220+
t.Errorf("Expected repo full name to be 'test-owner/repo1', got '%s'", repo.FullName)
221+
}
222+
223+
if repo.Owner.Login != "test-owner" {
224+
t.Errorf("Expected repo owner to be 'test-owner', got '%s'", repo.Owner.Login)
225+
}
226+
}
227+
228+
func TestGetRepositoriesForOwnerEmpty(t *testing.T) {
229+
// Test empty repository list
230+
repos := []GitHubRepository{}
231+
232+
if len(repos) != 0 {
233+
t.Errorf("Expected 0 repositories, got %d", len(repos))
234+
}
235+
}

internal/config/config.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ func (d *Duration) Seconds() int {
4747
}
4848

4949
type Config struct {
50-
Server ServerConfig `yaml:"server"`
51-
Logging LoggingConfig `yaml:"logging"`
52-
Metrics MetricsConfig `yaml:"metrics"`
53-
GitHub GitHubConfig `yaml:"github"`
54-
Packages map[string]PackageGroup `yaml:"packages"`
50+
Server ServerConfig `yaml:"server"`
51+
Logging LoggingConfig `yaml:"logging"`
52+
Metrics MetricsConfig `yaml:"metrics"`
53+
GitHub GitHubConfig `yaml:"github"`
54+
Packages []PackageGroup `yaml:"packages"`
5555
}
5656

5757
type GitHubConfig struct {
@@ -97,8 +97,9 @@ func (c *CollectionConfig) UnmarshalYAML(unmarshal func(interface{}) error) erro
9797
}
9898

9999
type PackageGroup struct {
100+
Name string `yaml:"name"` // Name for this package group (used in metrics labels)
100101
Owner string `yaml:"owner"`
101-
Repo string `yaml:"repo"`
102+
Repo string `yaml:"repo,omitempty"` // Optional - if empty, will discover all repos for owner
102103
}
103104

104105
// GetPackageInterval returns the interval for a package group

0 commit comments

Comments
 (0)