Files
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

182 lines
4.6 KiB
Go

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)
}