Files
ghtracker/internal/gh/fetch.go
T
lukaszraczylo 5313b56774 feat: add GitHub repository dashboard with Prometheus metrics
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics.

The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary.

The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
2026-10-02 04:28:52 +01:00

346 lines
10 KiB
Go

package gh
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const (
maxFailingNames = 5
runsPerWorkflow = 5
maxWorkflows = 100
eventPullRequest = "pull_request"
eventPullReqTgt = "pull_request_target"
statusStateFail = "failure"
statusStateError = "error"
statusStatePend = "pending"
)
// backgroundEvents are triggers that run without a person pushing code.
var backgroundEvents = []string{"schedule", "dynamic", "workflow_dispatch", "workflow_run", "repository_dispatch"}
type userRef struct {
Login string `json:"login"`
}
type labelRef struct {
Name string `json:"name"`
}
func labelNames(ls []labelRef) []string {
out := make([]string, 0, len(ls))
for _, l := range ls {
out = append(out, l.Name)
}
return out
}
// FetchRepo gathers the dashboard data for one "owner/name" repository.
// A failure of the repository lookup fails the call; other sections degrade into Repo.Warnings.
func (c *Client) FetchRepo(ctx context.Context, full string) (model.Repo, error) {
var info struct {
HTMLURL string `json:"html_url"`
Description string `json:"description"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
}
if err := c.getJSON(ctx, full, "/repos/"+full, nil, &info); err != nil {
return model.Repo{}, fmt.Errorf("repo %s: %w", full, err)
}
r := model.Repo{
FullName: full,
URL: info.HTMLURL,
ReleasesURL: info.HTMLURL + "/releases",
Description: info.Description,
DefaultBranch: info.DefaultBranch,
Archived: info.Archived,
Up: true,
}
var limited error
warn := func(section string, err error) {
if errors.Is(err, model.ErrRateLimited) {
limited = err
}
r.Warnings = append(r.Warnings, fmt.Sprintf("%s: %v", section, err))
}
var err error
if r.Issues, err = c.openIssues(ctx, full); err != nil {
warn("issues", err)
}
if r.PRs, err = c.openPRs(ctx, full); err != nil {
warn("pull requests", err)
}
if cerr := c.attachChecks(ctx, full, r.PRs); cerr != nil {
warn("pr checks", cerr)
}
if r.Workflows, err = c.workflows(ctx, full, r.DefaultBranch); err != nil {
warn("workflows", err)
}
if r.Release, err = c.latestRelease(ctx, full, r.URL); err != nil {
warn("releases", err)
}
if limited != nil {
return model.Repo{}, limited
}
return r, nil
}
func (c *Client) openIssues(ctx context.Context, full string) ([]model.Issue, error) {
type raw struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
User userRef `json:"user"`
Labels []labelRef `json:"labels"`
PullRequest json.RawMessage `json:"pull_request"`
Number int `json:"number"`
Comments int `json:"comments"`
}
// The issues endpoint also returns pull requests; they are dropped here and fetched separately.
q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}}
items, err := listAll(ctx, c, full, "/repos/"+full+"/issues", q, c.maxItems, unwrapArray[raw])
out := make([]model.Issue, 0, len(items))
for _, i := range items {
if len(i.PullRequest) > 0 {
continue
}
out = append(out, model.Issue{
Number: i.Number, Title: i.Title, URL: i.HTMLURL, Author: i.User.Login,
Labels: labelNames(i.Labels), Comments: i.Comments, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt,
})
}
return out, err
}
func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest, error) {
type raw struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
User userRef `json:"user"`
Head struct {
SHA string `json:"sha"`
} `json:"head"`
Labels []labelRef `json:"labels"`
Number int `json:"number"`
Draft bool `json:"draft"`
}
q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}}
items, err := listAll(ctx, c, full, "/repos/"+full+"/pulls", q, c.maxItems, unwrapArray[raw])
out := make([]model.PullRequest, 0, len(items))
for _, p := range items {
out = append(out, model.PullRequest{
Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft,
Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
Checks: model.CheckSummary{State: model.ChecksUnknown},
})
}
return out, err
}
// attachChecks fills each PR's check summary; it reports the first failure but still tries every PR.
func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error {
var first error
for i := range prs {
s, err := c.checkSummary(ctx, full, prs[i].HeadSHA)
if err != nil {
if first == nil {
first = fmt.Errorf("#%d: %w", prs[i].Number, err)
}
continue
}
prs[i].Checks = s
}
return first
}
func (c *Client) checkSummary(ctx context.Context, full, sha string) (model.CheckSummary, error) {
var runs struct {
CheckRuns []struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
} `json:"check_runs"`
}
base := "/repos/" + full + "/commits/" + sha
if err := c.getJSON(ctx, full, base+"/check-runs", url.Values{"per_page": {"100"}}, &runs); err != nil {
return model.CheckSummary{}, err
}
var status struct {
Statuses []struct {
Context string `json:"context"`
State string `json:"state"`
} `json:"statuses"`
TotalCount int `json:"total_count"`
}
if err := c.getJSON(ctx, full, base+"/status", nil, &status); err != nil {
return model.CheckSummary{}, err
}
var s model.CheckSummary
for _, cr := range runs.CheckRuns {
s.Total++
switch {
case cr.Status != "completed":
s.Pending++
case (model.Run{Status: cr.Status, Conclusion: cr.Conclusion}).Failed():
s.Failed++
s.Failing = append(s.Failing, cr.Name)
}
}
for _, st := range status.Statuses {
s.Total++
switch st.State {
case statusStateFail, statusStateError:
s.Failed++
s.Failing = append(s.Failing, st.Context)
case statusStatePend:
s.Pending++
}
}
if len(s.Failing) > maxFailingNames {
s.Failing = s.Failing[:maxFailingNames]
}
switch {
case s.Total == 0:
s.State = model.ChecksNone
case s.Failed > 0:
s.State = model.ChecksFail
case s.Pending > 0:
s.State = model.ChecksPending
default:
s.State = model.ChecksPass
}
return s, nil
}
type rawRun struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
Event string `json:"event"`
HeadBranch string `json:"head_branch"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
ID int64 `json:"id"`
}
func (r rawRun) model() model.Run {
return model.Run{
ID: r.ID, URL: r.HTMLURL, Event: r.Event, Branch: r.HeadBranch, Status: r.Status,
Conclusion: r.Conclusion, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}
}
// workflows returns, per active workflow, its newest default-branch run and newest completed one.
// Pull request runs are excluded because PR checks cover them.
func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Workflow, error) {
var list struct {
Workflows []struct {
Name string `json:"name"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
ID int64 `json:"id"`
} `json:"workflows"`
}
if err := c.getJSON(ctx, full, "/repos/"+full+"/actions/workflows", url.Values{"per_page": {"100"}}, &list); err != nil {
return nil, err
}
var out []model.Workflow
var first error
for _, w := range list.Workflows {
if w.State != "active" || len(out) >= maxWorkflows {
continue
}
var runs struct {
WorkflowRuns []rawRun `json:"workflow_runs"`
}
q := url.Values{
"branch": {branch},
"exclude_pull_requests": {"true"},
"per_page": {fmt.Sprint(runsPerWorkflow)},
}
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID)
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
if first == nil {
first = fmt.Errorf("%s: %w", w.Name, err)
}
continue
}
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
out = append(out, wf)
}
}
return out, first
}
func buildWorkflow(name, htmlURL string, runs []rawRun) (model.Workflow, bool) {
var kept []model.Run
for _, r := range runs {
if r.Event == eventPullRequest || r.Event == eventPullReqTgt {
continue
}
kept = append(kept, r.model())
}
if len(kept) == 0 {
return model.Workflow{}, false
}
slices.SortStableFunc(kept, func(a, b model.Run) int { return b.CreatedAt.Compare(a.CreatedAt) })
wf := model.Workflow{
Name: name,
URL: htmlURL,
Latest: kept[0],
Background: slices.Contains(backgroundEvents, kept[0].Event),
}
for i := range kept {
if !kept[i].Running() {
done := kept[i]
wf.LastDone = &done
break
}
}
return wf, true
}
func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*model.Release, error) {
var rel struct {
PublishedAt time.Time `json:"published_at"`
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
}
err := c.getJSON(ctx, full, "/repos/"+full+"/releases/latest", nil, &rel)
if err == nil {
return &model.Release{Tag: rel.TagName, Name: rel.Name, URL: rel.HTMLURL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease}, nil
}
if !isNotFound(err) {
return nil, err
}
// No published release: fall back to the newest tag so unreleased-but-tagged projects show a version.
var tags []struct {
Name string `json:"name"`
}
if err := c.getJSON(ctx, full, "/repos/"+full+"/tags", url.Values{"per_page": {"1"}}, &tags); err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
return &model.Release{
Tag: tags[0].Name,
URL: repoURL + "/releases/tag/" + url.PathEscape(tags[0].Name),
FromTag: true,
}, nil
}