mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user