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.
This commit is contained in:
2026-10-02 04:28:52 +01:00
commit 5313b56774
110 changed files with 10967 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
package gh
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
const (
jwtLifetime = 9 * time.Minute // GitHub caps app JWTs at 10 minutes
jwtClockSkew = 60 * time.Second
tokenSafety = 5 * time.Minute
maxAuthBody = 1 << 20
installLookup = "/repos/%s/installation"
)
// AppAuth mints GitHub App installation tokens and caches them until shortly before expiry.
type AppAuth struct {
key *rsa.PrivateKey
hc *http.Client
now func() time.Time
tokens map[int64]cachedToken
repoInstalls map[string]int64
base string
appID int64
fixedInstall int64
mu sync.Mutex
}
type cachedToken struct {
expires time.Time
value string
}
func NewAppAuth(appID int64, keyPEM, baseURL string, installationID int64, hc *http.Client) (*AppAuth, error) {
key, err := ParsePrivateKey([]byte(keyPEM))
if err != nil {
return nil, err
}
return &AppAuth{
appID: appID,
key: key,
base: strings.TrimRight(baseURL, "/"),
hc: hc,
fixedInstall: installationID,
now: time.Now,
tokens: map[int64]cachedToken{},
repoInstalls: map[string]int64{},
}, nil
}
// ParsePrivateKey accepts PKCS#1 and PKCS#8 PEM RSA keys.
func ParsePrivateKey(data []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("private key: no PEM block found")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("private key: unsupported format: %w", err)
}
rk, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key: not an RSA key")
}
return rk, nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (a *AppAuth) appJWT() (string, error) {
now := a.now()
header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
claims, _ := json.Marshal(map[string]any{
"iat": now.Add(-jwtClockSkew).Unix(),
"exp": now.Add(jwtLifetime).Unix(),
"iss": fmt.Sprint(a.appID),
})
signing := b64(header) + "." + b64(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:])
if err != nil {
return "", fmt.Errorf("sign app jwt: %w", err)
}
return signing + "." + b64(sig), nil
}
// Token returns an installation token valid for repo ("owner/name").
func (a *AppAuth) Token(ctx context.Context, repo string) (string, error) {
id, err := a.installationID(ctx, repo)
if err != nil {
return "", err
}
a.mu.Lock()
t, ok := a.tokens[id]
a.mu.Unlock()
if ok && a.now().Before(t.expires.Add(-tokenSafety)) {
return t.value, nil
}
return a.refresh(ctx, id)
}
func (a *AppAuth) installationID(ctx context.Context, repo string) (int64, error) {
if a.fixedInstall != 0 {
return a.fixedInstall, nil
}
a.mu.Lock()
id, ok := a.repoInstalls[repo]
a.mu.Unlock()
if ok {
return id, nil
}
var out struct {
ID int64 `json:"id"`
}
if err := a.appRequest(ctx, http.MethodGet, fmt.Sprintf(installLookup, repo), &out); err != nil {
return 0, fmt.Errorf("find installation for %s: %w", repo, err)
}
a.mu.Lock()
a.repoInstalls[repo] = out.ID
a.mu.Unlock()
return out.ID, nil
}
func (a *AppAuth) refresh(ctx context.Context, id int64) (string, error) {
var out struct {
ExpiresAt time.Time `json:"expires_at"`
Token string `json:"token"`
}
if err := a.appRequest(ctx, http.MethodPost, fmt.Sprintf("/app/installations/%d/access_tokens", id), &out); err != nil {
return "", fmt.Errorf("create installation token: %w", err)
}
a.mu.Lock()
a.tokens[id] = cachedToken{value: out.Token, expires: out.ExpiresAt}
a.mu.Unlock()
return out.Token, nil
}
func (a *AppAuth) appRequest(ctx context.Context, method, path string, out any) error {
jwt, err := a.appJWT()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, a.base+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", acceptHeader)
req.Header.Set("X-GitHub-Api-Version", apiVersion)
req.Header.Set("User-Agent", userAgent)
resp, err := a.hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthBody))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return &APIError{Status: resp.StatusCode, Message: apiMessage(body)}
}
return json.Unmarshal(body, out)
}
+291
View File
@@ -0,0 +1,291 @@
// Package gh is a small read-only GitHub REST client authenticated as a GitHub App.
package gh
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const (
acceptHeader = "application/vnd.github+json"
apiVersion = "2022-11-28"
userAgent = "ghtracker"
perPage = 100
maxBody = 16 << 20
maxRetries = 2
// rateReserve is the request budget left untouched so the app never exhausts its hourly quota.
rateReserve = 100
// secondaryBackoff applies when GitHub signals abuse limits without a Retry-After.
secondaryBackoff = time.Minute
)
// APIError is a non-2xx GitHub response.
type APIError struct {
Message string
Status int
}
func (e *APIError) Error() string {
return fmt.Sprintf("github: HTTP %d: %s", e.Status, e.Message)
}
func apiMessage(body []byte) string {
var m struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &m) == nil && m.Message != "" {
return m.Message
}
s := strings.TrimSpace(string(body))
if len(s) > 200 {
s = s[:200]
}
return s
}
func isNotFound(err error) bool {
var ae *APIError
return errors.As(err, &ae) && ae.Status == http.StatusNotFound
}
// TokenSource yields a bearer token for requests against repo.
type TokenSource interface {
Token(ctx context.Context, repo string) (string, error)
}
type Client struct {
rateReset time.Time
blockedUntil time.Time
auth TokenSource
hc *http.Client
// sleep is replaced in tests.
sleep func(context.Context, time.Duration) error
now func() time.Time
cache map[string]cachedResponse
base string
rateRemaining atomic.Int64
maxItems int
mu sync.Mutex
}
// cachedResponse backs conditional requests: a 304 reply does not count against the primary rate limit.
type cachedResponse struct {
etag string
link string
body []byte
}
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
c.rateRemaining.Store(-1)
return c
}
// RateLimitRemaining returns the last reported remaining request budget.
func (c *Client) RateLimitRemaining() (int, bool) {
v := c.rateRemaining.Load()
return int(v), v >= 0
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
if err := c.sleep(ctx, time.Duration(attempt)*time.Second); err != nil {
return nil, "", err
}
}
body, link, retry, err := c.once(ctx, repo, target)
if err == nil {
return body, link, nil
}
lastErr = err
if !retry {
break
}
}
return nil, "", lastErr
}
func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) {
if gerr := c.guard(); gerr != nil {
return nil, "", false, gerr
}
tok, err := c.auth.Token(ctx, repo)
if err != nil {
return nil, "", false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, "", false, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", acceptHeader)
req.Header.Set("X-GitHub-Api-Version", apiVersion)
req.Header.Set("User-Agent", userAgent)
c.mu.Lock()
cached, haveCache := c.cache[target]
c.mu.Unlock()
if haveCache {
req.Header.Set("If-None-Match", cached.etag)
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, "", ctx.Err() == nil, err
}
defer func() { _ = resp.Body.Close() }()
c.noteRate(resp.Header)
if resp.StatusCode == http.StatusNotModified && haveCache {
return cached.body, cached.link, false, nil
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, "", true, err
}
if resp.StatusCode/100 != 2 {
ae := &APIError{Status: resp.StatusCode, Message: apiMessage(data)}
if c.noteLimited(resp, ae) {
return nil, "", false, fmt.Errorf("%w: %v", model.ErrRateLimited, ae)
}
return nil, "", resp.StatusCode >= 500, ae
}
link = resp.Header.Get("Link")
if etag := resp.Header.Get("ETag"); etag != "" {
c.mu.Lock()
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
c.mu.Unlock()
}
return data, link, false, nil
}
// guard refuses requests while a limit block is active or the remaining budget is at the reserve.
func (c *Client) guard() error {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
if now.Before(c.blockedUntil) {
return fmt.Errorf("%w: blocked until %s", model.ErrRateLimited, c.blockedUntil.Format(time.RFC3339))
}
if rem := c.rateRemaining.Load(); rem >= 0 && rem < rateReserve && now.Before(c.rateReset) {
return fmt.Errorf("%w: %d requests left, resets %s", model.ErrRateLimited, rem, c.rateReset.Format(time.RFC3339))
}
return nil
}
func (c *Client) noteRate(h http.Header) {
if v, err := strconv.ParseInt(h.Get("X-RateLimit-Remaining"), 10, 64); err == nil {
c.rateRemaining.Store(v)
}
if v, err := strconv.ParseInt(h.Get("X-RateLimit-Reset"), 10, 64); err == nil {
c.mu.Lock()
c.rateReset = time.Unix(v, 0)
c.mu.Unlock()
}
}
// noteLimited records a block when the response is a primary or secondary rate-limit rejection.
func (c *Client) noteLimited(resp *http.Response, ae *APIError) bool {
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusTooManyRequests {
return false
}
now := c.now()
var until time.Time
switch secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); {
case err == nil:
until = now.Add(time.Duration(secs) * time.Second)
case resp.Header.Get("X-RateLimit-Remaining") == "0":
c.mu.Lock()
until = c.rateReset
c.mu.Unlock()
case strings.Contains(strings.ToLower(ae.Message), "rate limit"):
until = now.Add(secondaryBackoff)
default:
return false
}
c.mu.Lock()
c.blockedUntil = until
c.mu.Unlock()
return true
}
func (c *Client) url(path string, q url.Values) string {
u := c.base + path
if len(q) > 0 {
u += "?" + q.Encode()
}
return u
}
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
body, _, err := c.do(ctx, repo, c.url(path, q))
if err != nil {
return err
}
return json.Unmarshal(body, out)
}
// listAll follows Link pagination, collecting at most limit items.
func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Values, limit int, unwrap func([]byte) ([]T, error)) ([]T, error) {
if q == nil {
q = url.Values{}
}
q.Set("per_page", strconv.Itoa(perPage))
next := c.url(path, q)
var all []T
for next != "" && len(all) < limit {
body, link, err := c.do(ctx, repo, next)
if err != nil {
return all, err
}
page, err := unwrap(body)
if err != nil {
return all, err
}
all = append(all, page...)
next = nextLink(link)
}
if len(all) > limit {
all = all[:limit]
}
return all, nil
}
func unwrapArray[T any](b []byte) ([]T, error) {
var out []T
return out, json.Unmarshal(b, &out)
}
// nextLink extracts rel="next" from a Link header.
func nextLink(h string) string {
for part := range strings.SplitSeq(h, ",") {
target, params, found := strings.Cut(part, ";")
if found && strings.TrimSpace(params) == `rel="next"` {
return strings.Trim(strings.TrimSpace(target), "<>")
}
}
return ""
}
+345
View File
@@ -0,0 +1,345 @@
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
}
+461
View File
@@ -0,0 +1,461 @@
package gh
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
func testKey(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})
return k, string(p)
}
// fakeGitHub serves canned JSON by path and counts hits per path.
type fakeGitHub struct {
srv *httptest.Server
routes map[string]func(w http.ResponseWriter, r *http.Request)
hits map[string]*atomic.Int64
appKeyPub *rsa.PublicKey
t *testing.T
installs atomic.Int64
tokens atomic.Int64
}
func newFake(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}, hits: map[string]*atomic.Int64{}}
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/installation"):
f.installs.Add(1)
f.checkJWT(r)
_, _ = w.Write([]byte(`{"id":42}`))
return
case strings.HasPrefix(r.URL.Path, "/app/installations/"):
f.tokens.Add(1)
f.checkJWT(r)
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
_, _ = fmt.Fprintf(w, `{"token":"inst-token","expires_at":%q}`, exp)
return
}
if r.Header.Get("Authorization") != "Bearer inst-token" {
http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized)
return
}
h, ok := f.routes[r.URL.Path]
if !ok {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
return
}
c := f.hits[r.URL.Path]
if c == nil {
c = &atomic.Int64{}
f.hits[r.URL.Path] = c
}
c.Add(1)
h(w, r)
}
func (f *fakeGitHub) checkJWT(r *http.Request) {
if f.appKeyPub == nil {
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
parts := strings.Split(tok, ".")
if len(parts) != 3 {
f.t.Errorf("jwt has %d parts", len(parts))
return
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(f.appKeyPub, crypto.SHA256, sum[:], sig); err != nil {
f.t.Errorf("jwt signature invalid: %v", err)
}
}
func (f *fakeGitHub) json(path, body string) {
f.routes[path] = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }
}
func (f *fakeGitHub) client(t *testing.T, maxItems int) *Client {
t.Helper()
key, pemStr := testKey(t)
f.appKeyPub = &key.PublicKey
auth, err := NewAppAuth(7, pemStr, f.srv.URL, 0, f.srv.Client())
if err != nil {
t.Fatal(err)
}
c := NewClient(f.srv.URL, auth, f.srv.Client(), maxItems)
c.sleep = func(context.Context, time.Duration) error { return nil }
return c
}
func TestJWTClaimsAndKeyFormats(t *testing.T) {
key, pemStr := testKey(t)
a, err := NewAppAuth(99, pemStr, "http://x", 0, http.DefaultClient)
if err != nil {
t.Fatal(err)
}
fixed := time.Unix(1_700_000_000, 0)
a.now = func() time.Time { return fixed }
jwt, err := a.appJWT()
if err != nil {
t.Fatal(err)
}
parts := strings.Split(jwt, ".")
claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(claimsRaw, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "99" || int64(claims["iat"].(float64)) != fixed.Unix()-60 || int64(claims["exp"].(float64)) != fixed.Unix()+540 {
t.Fatalf("claims = %v", claims)
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil {
t.Fatal(err)
}
der, _ := x509.MarshalPKCS8PrivateKey(key)
if _, err := ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})); err != nil {
t.Fatalf("pkcs8: %v", err)
}
if _, err := ParsePrivateKey([]byte("garbage")); err == nil {
t.Fatal("garbage key must fail")
}
}
func TestTokenCachedAndInstallationDiscoveredOnce(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","default_branch":"main"}`)
for range 3 {
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
}
if f.installs.Load() != 1 || f.tokens.Load() != 1 {
t.Fatalf("installs=%d tokens=%d, want 1 each", f.installs.Load(), f.tokens.Load())
}
}
func TestPaginationStopsAtLimit(t *testing.T) {
f := newFake(t)
c := f.client(t, 3)
f.routes["/repos/o/r/issues"] = func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("page") == "2" {
_, _ = w.Write([]byte(`[{"number":3,"title":"c"},{"number":4,"title":"d"}]`))
return
}
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?page=2>; rel="next", <x>; rel="last"`, f.srv.URL))
_, _ = w.Write([]byte(`[{"number":1,"title":"a"},{"number":2,"title":"b"}]`))
}
got, err := c.openIssues(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d issues, want 3 (limit)", len(got))
}
}
func TestNextLink(t *testing.T) {
h := `<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"`
if got := nextLink(h); got != "https://api/x?page=2" {
t.Fatal(got)
}
if nextLink(`<https://api/x?page=1>; rel="prev"`) != "" || nextLink("") != "" {
t.Fatal("no next link expected")
}
}
func TestETagConditionalRequestServedFromCache(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
var conditional atomic.Int64
f.routes["/repos/o/r"] = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"v1"` {
conditional.Add(1)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
}
for range 2 {
var out struct {
DefaultBranch string `json:"default_branch"`
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &out); err != nil || out.DefaultBranch != "main" {
t.Fatalf("out=%+v err=%v", out, err)
}
}
if conditional.Load() != 1 {
t.Fatalf("conditional requests = %d, want 1", conditional.Load())
}
}
func TestRetriesServerErrorsThenSucceeds(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
var n atomic.Int64
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
if n.Add(1) < 3 {
http.Error(w, `{"message":"boom"}`, http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`{}`))
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
if n.Load() != 3 {
t.Fatalf("attempts = %d", n.Load())
}
}
func TestNoRetryOnClientError(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
err := c.getJSON(context.Background(), "o/r", "/missing", nil, &struct{}{})
if !isNotFound(err) {
t.Fatalf("err = %v", err)
}
}
func TestPrimaryRateLimitBlocksFurtherRequests(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
reset := time.Now().Add(30 * time.Minute)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(reset.Unix()))
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden)
}
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
if !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
hits := f.hits["/repos/o/r"].Load()
err = c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
if !errors.Is(err, model.ErrRateLimited) || f.hits["/repos/o/r"].Load() != hits {
t.Fatalf("second call must be refused locally; err=%v hits=%d->%d", err, hits, f.hits["/repos/o/r"].Load())
}
}
func TestSecondaryRateLimitHonoursRetryAfter(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
now := time.Now()
c.now = func() time.Time { return now }
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "120")
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusTooManyRequests)
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
c.now = func() time.Time { return now.Add(119 * time.Second) }
if err := c.guard(); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("still blocked at 119s, got %v", err)
}
c.now = func() time.Time { return now.Add(121 * time.Second) }
if err := c.guard(); err != nil {
t.Fatalf("unblocked at 121s, got %v", err)
}
}
func TestLowBudgetStopsBeforeQuotaExhausted(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "50")
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(time.Now().Add(time.Hour).Unix()))
_, _ = w.Write([]byte(`{}`))
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("budget below reserve must refuse, got %v", err)
}
if rem, ok := c.RateLimitRemaining(); !ok || rem != 50 {
t.Fatalf("remaining = %d %v", rem, ok)
}
}
func TestPlain403IsNotTreatedAsRateLimit(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
}
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
var ae *APIError
if errors.Is(err, model.ErrRateLimited) || !errors.As(err, &ae) || ae.Status != 403 {
t.Fatalf("err = %v", err)
}
if err := c.guard(); err != nil {
t.Fatalf("plain 403 must not block: %v", err)
}
}
func registerRepo(f *fakeGitHub) {
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","description":"d","default_branch":"main"}`)
f.json("/repos/o/r/issues", `[
{"number":1,"title":"real issue","html_url":"u1","user":{"login":"a"},"labels":[{"name":"bug"}],"comments":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"},
{"number":2,"title":"a PR","html_url":"u2","pull_request":{"url":"x"}}]`)
f.json("/repos/o/r/pulls", `[{"number":2,"title":"a PR","html_url":"u2","user":{"login":"b"},"draft":false,"head":{"sha":"abc"},"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}]`)
f.json("/repos/o/r/commits/abc/check-runs", `{"check_runs":[{"name":"build","status":"completed","conclusion":"failure"},{"name":"lint","status":"completed","conclusion":"success"},{"name":"slow","status":"in_progress"}]}`)
f.json("/repos/o/r/commits/abc/status", `{"total_count":1,"statuses":[{"context":"ci/ext","state":"error"}]}`)
f.json("/repos/o/r/actions/workflows", `{"workflows":[
{"id":1,"name":"CI","html_url":"w1","state":"active"},
{"id":2,"name":"Nightly","html_url":"w2","state":"active"},
{"id":3,"name":"Old","html_url":"w3","state":"disabled_manually"},
{"id":4,"name":"Never ran","html_url":"w4","state":"active"}]}`)
f.json("/repos/o/r/actions/workflows/1/runs", `{"workflow_runs":[
{"id":11,"html_url":"r11","event":"push","head_branch":"main","status":"in_progress","created_at":"2026-01-05T00:00:00Z"},
{"id":10,"html_url":"r10","event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-01-04T00:00:00Z"}]}`)
f.json("/repos/o/r/actions/workflows/2/runs", `{"workflow_runs":[
{"id":20,"html_url":"r20","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-01-05T00:00:00Z"}]}`)
f.json("/repos/o/r/actions/workflows/4/runs", `{"workflow_runs":[]}`)
f.json("/repos/o/r/releases/latest", `{"tag_name":"v1.2.3","name":"x","html_url":"rel","published_at":"2026-01-01T00:00:00Z"}`)
}
func TestFetchRepoEndToEnd(t *testing.T) {
f := newFake(t)
c := f.client(t, 50)
registerRepo(f)
r, err := c.FetchRepo(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(r.Warnings) != 0 {
t.Fatalf("warnings: %v", r.Warnings)
}
if len(r.Issues) != 1 || r.Issues[0].Number != 1 || r.Issues[0].Labels[0] != "bug" {
t.Fatalf("issues must exclude PRs: %+v", r.Issues)
}
if len(r.PRs) != 1 || r.PRs[0].Checks.State != model.ChecksFail || r.PRs[0].Checks.Failed != 2 || r.PRs[0].Checks.Pending != 1 {
t.Fatalf("pr checks: %+v", r.PRs)
}
if got := r.PRs[0].Checks.Failing; len(got) != 2 || got[0] != "build" || got[1] != "ci/ext" {
t.Fatalf("failing names: %v", got)
}
if len(r.Workflows) != 2 {
t.Fatalf("want CI and Nightly only, got %+v", r.Workflows)
}
ci, nightly := r.Workflows[0], r.Workflows[1]
if !ci.Latest.Running() || ci.LastDone == nil || ci.LastDone.ID != 10 || ci.Background {
t.Fatalf("ci: %+v", ci)
}
if !nightly.Background || nightly.LastDone == nil || !nightly.LastDone.Failed() {
t.Fatalf("nightly: %+v", nightly)
}
if r.Release == nil || r.Release.Tag != "v1.2.3" || r.Release.URL != "rel" || r.ReleasesURL != "https://github.com/o/r/releases" {
t.Fatalf("release: %+v %s", r.Release, r.ReleasesURL)
}
}
func TestReleaseFallsBackToTag(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/tags", `[{"name":"v0.1.0"}]`)
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
if err != nil {
t.Fatal(err)
}
if !rel.FromTag || rel.Tag != "v0.1.0" || rel.URL != "https://github.com/o/r/releases/tag/v0.1.0" {
t.Fatalf("%+v", rel)
}
}
func TestReleaseNoneAtAll(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/tags", `[]`)
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
if err != nil || rel != nil {
t.Fatalf("rel=%+v err=%v", rel, err)
}
}
func TestSectionFailureDegradesToWarning(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
registerRepo(f)
f.routes["/repos/o/r/actions/workflows"] = func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
}
r, err := c.FetchRepo(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "workflows") || len(r.Issues) != 1 {
t.Fatalf("warnings=%v issues=%d", r.Warnings, len(r.Issues))
}
}
func TestRateLimitInSectionFailsWholeRepo(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
registerRepo(f)
f.routes["/repos/o/r/pulls"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusForbidden)
}
if _, err := c.FetchRepo(context.Background(), "o/r"); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
}
func TestBuildWorkflowIgnoresPullRequestRuns(t *testing.T) {
runs := []rawRun{
{ID: 2, Event: "pull_request", Status: "completed", Conclusion: "failure", CreatedAt: time.Unix(200, 0)},
{ID: 1, Event: "push", Status: "completed", Conclusion: "success", CreatedAt: time.Unix(100, 0)},
}
wf, ok := buildWorkflow("CI", "u", runs)
if !ok || wf.Latest.ID != 1 || wf.LastDone.ID != 1 {
t.Fatalf("%+v", wf)
}
if _, ok := buildWorkflow("CI", "u", runs[:1]); ok {
t.Fatal("only PR runs must yield no workflow")
}
}
func TestAPIErrorMessageParsing(t *testing.T) {
if got := apiMessage([]byte(`{"message":"nope"}`)); got != "nope" {
t.Fatal(got)
}
if got := apiMessage([]byte(strings.Repeat("x", 500))); len(got) != 200 {
t.Fatalf("len=%d", len(got))
}
}