// Command pipeline-analytics runs the pipeline-analytics server.
package main
import (
"context"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/alrayyes/pipeline-analytics/internal/auth"
authsqlite "github.com/alrayyes/pipeline-analytics/internal/auth/sqlite"
"github.com/alrayyes/pipeline-analytics/internal/config"
"github.com/alrayyes/pipeline-analytics/internal/db"
"github.com/alrayyes/pipeline-analytics/internal/httpserver"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
forgejoclient "github.com/alrayyes/pipeline-analytics/internal/ingestion/forgejo"
githubclient "github.com/alrayyes/pipeline-analytics/internal/ingestion/github"
ingestionsqlite "github.com/alrayyes/pipeline-analytics/internal/ingestion/sqlite"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
metricssqlite "github.com/alrayyes/pipeline-analytics/internal/metrics/sqlite"
"github.com/alrayyes/pipeline-analytics/internal/settings"
settingssqlite "github.com/alrayyes/pipeline-analytics/internal/settings/sqlite"
"github.com/alrayyes/pipeline-analytics/internal/webassets"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// version is set via goreleaser's ldflags (-X main.version={{.Tag}}) on a
// release build -- the actual git tag ("v0.15.1"), not goreleaser's bare-
// semver .Version, since the footer links to /releases/tag/{version} and
// every tag this repo cuts carries the "v" prefix. A local `go build`
// leaves it at "dev".
var version = "dev"
// errReadyzStatus is newHealthcheckCmd's own sentinel - err113 wants a
// wrapped static error rather than a bare fmt.Errorf built from the status
// code alone.
var errReadyzStatus = errors.New("readyz check failed")
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "pipeline-analytics",
Short: "Self-hosted pipeline analytics for GitHub Actions and Forgejo Actions",
}
root.AddCommand(newServeCmd())
root.AddCommand(newHealthcheckCmd())
return root
}
// newHealthcheckCmd exists for the container's own HEALTHCHECK: the image is
// distroless (no shell, no curl, no wget), so there's nothing else inside it
// that could exec a probe. Deliberately does not go through loadConfig -
// that requires CallbackURL and a valid EncryptionKey, neither of which a
// readiness probe needs, so it reads PIPELINE_ANALYTICS_ADDR directly instead,
// falling back to serve's own default. A HEALTHCHECK exec inherits the same
// environment the server itself was started with, so it sees the real value
// if one was set.
func newHealthcheckCmd() *cobra.Command {
return &cobra.Command{
Use: "healthcheck",
Short: "Check that this server is ready: it answers its own /readyz",
Hidden: true,
RunE: func(cmd *cobra.Command, _ []string) error {
addr := os.Getenv("PIPELINE_ANALYTICS_ADDR")
if addr == "" {
addr = ":8080"
}
_, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("parse addr %q: %w", addr, err)
}
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port+"/readyz", nil) //nolint:gosec // G704: loopback-only, port is our own config, not attacker input
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req) //nolint:gosec // G704: same request built above, loopback-only
if err != nil {
return fmt.Errorf("request /readyz: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%w: /readyz returned %d", errReadyzStatus, resp.StatusCode)
}
return nil
},
}
}
func newServeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Short: "Run the pipeline-analytics server",
RunE: func(cmd *cobra.Command, _ []string) error {
return runServe(cmd.Context())
},
}
cmd.Flags().String("addr", ":8080", "address to listen on")
cmd.Flags().String("db", "pipeline-analytics.db", "path to the SQLite database file")
cmd.Flags().String("callback-url", "", "this server's own public base URL, used for forge webhook callbacks")
cmd.Flags().String("encryption-key", "", "hex-encoded 32-byte key repo tokens are encrypted under at rest")
cmd.Flags().Duration("reconcile-interval", time.Hour, "how often to poll tracked repos for reconciliation (forge-ingestion/spec.md requires at least hourly)")
cmd.Flags().Duration("drain-period", 4*time.Second, "how long to keep serving after SIGTERM with /readyz answering 503, so a router notices before the listener closes")
cmd.Flags().Duration("shutdown-timeout", 5*time.Second, "how long in-flight requests get to finish after the drain before shutdown gives up (drain plus this stays under Docker's 10s stop grace)")
cmd.Flags().String("log-level", "info", "log verbosity: debug, info, warn, or error")
for _, name := range []string{"addr", "db", "callback-url", "encryption-key", "reconcile-interval", "drain-period", "shutdown-timeout", "log-level"} {
if err := viper.BindPFlag(name, cmd.Flags().Lookup(name)); err != nil {
panic(err)
}
}
viper.SetEnvPrefix("PIPELINE_ANALYTICS")
// A flag like "callback-url" otherwise maps to the literal env var name
// PIPELINE_ANALYTICS_CALLBACK-URL, which no shell, Docker, or systemd
// unit can set -- environment variable names can't contain a hyphen.
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
return cmd
}
func runServe(ctx context.Context) error {
cfg, err := loadConfig()
if err != nil {
return err
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: cfg.LogLevel})))
if err := db.CheckDir(cfg.DBPath); err != nil {
return fmt.Errorf("preflight: %w", err)
}
conn, err := db.Open(cfg.DBPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() {
if cerr := conn.Close(); cerr != nil {
slog.Error("close database", "error", cerr)
}
}()
if err := db.Migrate(ctx, conn); err != nil {
return fmt.Errorf("migrate database: %w", err)
}
ingestionStore := ingestionsqlite.NewStore(conn, cfg.EncryptionKey)
forgeClients, err := newForgeClients()
if err != nil {
return err
}
reconciler := ingestion.NewReconciler(ingestionStore, ingestionStore, forgeClients)
var draining atomic.Bool
handler, err := buildHandler(cfg, conn, ingestionStore, forgeClients, reconciler, &draining)
if err != nil {
return err
}
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
go reconciler.Run(ctx, cfg.ReconcileInterval)
return listenAndServe(ctx, srv, shutdownPlan{
draining: &draining,
drainPeriod: cfg.DrainPeriod,
timeout: cfg.ShutdownTimeout,
})
}
func listenAndServe(ctx context.Context, srv *http.Server, plan shutdownPlan) error {
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", srv.Addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", srv.Addr, err)
}
return serveUntilDone(ctx, srv, ln, plan)
}
// newForgeClients returns the real GitHub and Forgejo ForgeClient adapters,
// shared between the HTTP handler's repo registrar and the reconciliation
// scheduler.
func newForgeClients() (map[ingestion.Forge]ingestion.ForgeClient, error) {
githubForgeClient, err := githubclient.NewClient("")
if err != nil {
return nil, fmt.Errorf("create github client: %w", err)
}
forgejoForgeClient, err := forgejoclient.NewClient()
if err != nil {
return nil, fmt.Errorf("create forgejo client: %w", err)
}
return map[ingestion.Forge]ingestion.ForgeClient{
ingestion.ForgeGitHub: githubForgeClient,
ingestion.ForgeForgejo: forgejoForgeClient,
}, nil
}
func loadConfig() (config.Config, error) {
encryptionKey, err := hex.DecodeString(viper.GetString("encryption-key"))
if err != nil {
return config.Config{}, fmt.Errorf("decode encryption key: %w", err)
}
logLevel, err := parseLogLevel(viper.GetString("log-level"))
if err != nil {
return config.Config{}, err
}
cfg := config.Config{
Addr: viper.GetString("addr"),
DBPath: viper.GetString("db"),
CallbackURL: viper.GetString("callback-url"),
EncryptionKey: encryptionKey,
ReconcileInterval: viper.GetDuration("reconcile-interval"),
DrainPeriod: viper.GetDuration("drain-period"),
ShutdownTimeout: viper.GetDuration("shutdown-timeout"),
LogLevel: logLevel,
}
if err := cfg.Validate(); err != nil {
return config.Config{}, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
// parseLogLevel parses the --log-level flag's value into a slog.Level.
// slog.Level's own UnmarshalText already accepts "debug"/"info"/"warn"/
// "error" case-insensitively (and a "level+offset" form, e.g. "info+2") --
// this just gives an invalid value a clearer error than the raw parse
// failure would.
func parseLogLevel(s string) (slog.Level, error) {
var level slog.Level
if err := level.UnmarshalText([]byte(s)); err != nil {
return 0, fmt.Errorf("invalid log level %q (want debug, info, warn, or error): %w", s, err)
}
return level, nil
}
// dbReady is the readiness probe: it reads the schema table, so it fails on a
// database that can't actually be queried, and not only when a connection
// can't be opened. sql.DB.PingContext alone would pass on a file that opens
// and then fails its first query (corruption, a bad restore, a disk error).
func dbReady(conn *sql.DB) func(ctx context.Context) error {
return func(ctx context.Context) error {
var tables int
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master").Scan(&tables); err != nil {
return fmt.Errorf("read database schema: %w", err)
}
return nil
}
}
func buildHandler(cfg config.Config, conn *sql.DB, ingestionStore *ingestionsqlite.Store, forgeClients map[ingestion.Forge]ingestion.ForgeClient, reconciler ingestion.RepoReconciler, draining *atomic.Bool) (http.Handler, error) {
registrar := ingestion.NewRegistrar(ingestionStore, forgeClients, cfg.CallbackURL)
authStore := authsqlite.NewStore(conn)
webAuthn, err := newWebAuthn(cfg.CallbackURL)
if err != nil {
return nil, fmt.Errorf("configure webauthn: %w", err)
}
assets, err := webassets.FS()
if err != nil {
return nil, fmt.Errorf("load embedded frontend: %w", err)
}
// Optional: only the GitHub ForgeClient implements this, and reports
// nothing until reconciliation polling (or a webhook-registration call)
// actually makes a real request with a given token.
githubRateLimits, _ := forgeClients[ingestion.ForgeGitHub].(ingestion.RateLimitReporter)
return httpserver.New(httpserver.Deps{
Registrar: registrar,
IngestionStore: ingestionStore,
RunStore: ingestionStore,
GitHubRateLimits: githubRateLimits,
Reconciler: reconciler,
Metrics: metrics.NewService(metricssqlite.NewStore(conn)),
JobLogs: ingestion.NewJobLogService(ingestionStore, ingestionStore, jobLogReaders(forgeClients)),
RunActions: ingestion.NewRunActionService(ingestionStore, ingestionStore, runActors(forgeClients)),
ForgeTokens: ingestionStore,
Auth: auth.NewService(webAuthn, authStore),
AuthStore: authStore,
Settings: settings.NewService(settingssqlite.NewStore(conn)),
Ready: dbReady(conn),
Draining: draining,
Version: version,
Assets: assets,
}), nil
}
// newWebAuthn derives the WebAuthn Relying Party ID (the effective domain,
// no scheme or port) and allowed origin from the server's own public
// callback URL -- the dashboard is same-origin with itself, so there's
// nothing else to configure here.
// jobLogReaders picks out the forge clients that can read a job's log.
func jobLogReaders(forgeClients map[ingestion.Forge]ingestion.ForgeClient) map[ingestion.Forge]ingestion.JobLogReader {
readers := make(map[ingestion.Forge]ingestion.JobLogReader, len(forgeClients))
for forge, client := range forgeClients {
if reader, ok := client.(ingestion.JobLogReader); ok {
readers[forge] = reader
}
}
return readers
}
// runActors picks out the forge clients that can re-run and cancel a run.
func runActors(forgeClients map[ingestion.Forge]ingestion.ForgeClient) map[ingestion.Forge]ingestion.RunActor {
actors := make(map[ingestion.Forge]ingestion.RunActor, len(forgeClients))
for forge, client := range forgeClients {
if actor, ok := client.(ingestion.RunActor); ok {
actors[forge] = actor
}
}
return actors
}
func newWebAuthn(callbackURL string) (*webauthn.WebAuthn, error) {
u, err := url.Parse(callbackURL)
if err != nil {
return nil, fmt.Errorf("parse callback url: %w", err)
}
web, err := webauthn.New(&webauthn.Config{
RPID: u.Hostname(),
RPDisplayName: "pipeline-analytics",
RPOrigins: []string{callbackURL},
})
if err != nil {
return nil, fmt.Errorf("create webauthn instance: %w", err)
}
return web, nil
}
// shutdownPlan is how serveUntilDone leaves: flip draining so /readyz answers
// 503, keep serving for drainPeriod so a router polling it notices, then give
// in-flight requests timeout to finish.
type shutdownPlan struct {
draining *atomic.Bool
drainPeriod time.Duration
timeout time.Duration
}
// serveUntilDone serves srv on ln until ctx is done, then shuts it down in the
// order api.md asks for: readiness first, the listener last. ctx is expected
// to already carry signal-driven cancellation (runServe arms it once, shared
// with the reconciliation scheduler).
func serveUntilDone(ctx context.Context, srv *http.Server, ln net.Listener, plan shutdownPlan) error {
errCh := make(chan error, 1)
go func() {
slog.Info("listening", "addr", ln.Addr().String())
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("serve: %w", err)
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
plan.draining.Store(true)
slog.Info("draining", "period", plan.drainPeriod.String())
// The signal context is already done, so the wait runs on a timer.
time.Sleep(plan.drainPeriod)
shutdownCtx, cancel := context.WithTimeout(context.Background(), plan.timeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
return nil
case err := <-errCh:
return err
}
}
// Package auth holds the dashboard-auth domain: the single dashboard user,
// their WebAuthn credentials, in-flight ceremonies, and sessions. See
// openspec/changes/add-pipeline-dashboard/specs/dashboard-auth/spec.md.
package auth
import (
"context"
"errors"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
// TokenTTLCeiling is the longest an API token can last, whatever a client
// asks for: long enough that a script need not babysit rotation, short enough
// that a forgotten token does not stay valid forever. Revocation is the
// mechanism for anything sooner.
const TokenTTLCeiling = 365 * 24 * time.Hour
// TokenTTLDefault is how long a token lasts when the client does not say. It is
// also the Settings page's default preset, so the two stay one value.
const TokenTTLDefault = 90 * 24 * time.Hour
// Sentinel errors, so a caller can errors.Is against a specific condition
// instead of matching on message text.
var (
ErrUserExists = errors.New("a user account already exists")
ErrNoUser = errors.New("no user account has been registered yet")
ErrCeremonyNotFound = errors.New("ceremony not found or expired")
ErrSessionNotFound = errors.New("session not found or expired")
ErrTokenNotFound = errors.New("token not found, expired, or revoked")
ErrCredentialNotFound = errors.New("credential not found")
ErrLastCredential = errors.New("cannot revoke the account's last remaining credential")
)
// User is the dashboard's single account.
type User struct {
ID string
UserHandle []byte
DisplayName string
CreatedAt time.Time
}
// Token is an API token's metadata -- never its raw secret, which is only
// ever available at creation. See add-api-token-auth/design.md's "Hash the
// token at rest" decision.
type Token struct {
ID string
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
// CredentialInfo is a WebAuthn credential's metadata for the credential-
// management UI -- never the credential itself (public key, sign count,
// ...), which stays internal to the WebAuthn ceremony. ID is the raw
// credential id; the HTTP layer is what encodes it for the wire (see
// support-multiple-passkeys/design.md's "base64url of the credential ID"
// decision).
type CredentialInfo struct {
ID []byte
Label string
CreatedAt time.Time
// Revocable is false only for the account's last remaining credential,
// the one RevokeCredential refuses (ErrLastCredential).
Revocable bool
}
// Store is the port the domain persists users, credentials, in-flight
// ceremonies, and sessions through.
type Store interface {
// CreateUser creates the single dashboard user with the given WebAuthn
// user handle (generated once, at the start of the registration
// ceremony, and threaded through to here so the stored handle matches
// the one the ceremony ran against). Returns ErrUserExists if a user
// already exists.
CreateUser(ctx context.Context, userHandle []byte, displayName string) (User, error)
// GetUser returns the single dashboard user, or ErrNoUser if none has
// registered yet.
GetUser(ctx context.Context) (User, error)
// PutCredential creates or updates (e.g. after a login's sign-count
// bump) a credential belonging to userID.
PutCredential(ctx context.Context, userID string, cred webauthn.Credential) error
// PutCredentialLabeled creates a new credential belonging to userID
// with a user-supplied label -- the authenticated "add a passkey"
// path, as opposed to PutCredential's anonymous-registration and
// sign-count-bump uses, neither of which have a label to set.
PutCredentialLabeled(ctx context.Context, userID string, cred webauthn.Credential, label string) error
// CredentialsForUser returns every credential belonging to userID.
CredentialsForUser(ctx context.Context, userID string) ([]webauthn.Credential, error)
// CredentialInfosForUser returns every credential belonging to userID
// as its listing metadata (label, creation date), for the credential-
// management UI.
CredentialInfosForUser(ctx context.Context, userID string) ([]CredentialInfo, error)
// RevokeCredential deletes a credential belonging to userID, scoped by
// its raw id. Returns ErrCredentialNotFound if no such credential
// exists for that user, or ErrLastCredential if it's the account's
// only remaining credential -- both checked and applied in a single
// transaction, so a concurrent revoke of the account's last two
// credentials can't zero it out (support-multiple-passkeys/design.md's
// "Concurrent revoke" risk).
RevokeCredential(ctx context.Context, userID string, credentialID []byte) error
// SaveCeremony stores the SessionData for an in-flight WebAuthn
// ceremony, keyed by an opaque id the caller generates.
SaveCeremony(ctx context.Context, id string, data []byte, expiresAt time.Time) error
// TakeCeremony reads and deletes a ceremony's data -- a ceremony is
// single-use. Returns ErrCeremonyNotFound if it doesn't exist or has
// expired.
TakeCeremony(ctx context.Context, id string) ([]byte, error)
// CreateSession creates a new session for userID, returning its opaque id.
CreateSession(ctx context.Context, userID string) (string, error)
// Session returns the userID a session id belongs to. Returns
// ErrSessionNotFound if it doesn't exist or has expired.
Session(ctx context.Context, sessionID string) (string, error)
// DeleteSession ends a session.
DeleteSession(ctx context.Context, sessionID string) error
// CreateToken creates a new API token for userID that lasts
// requestedTTL, clamped to TokenTTLCeiling; a zero requestedTTL gets
// TokenTTLDefault. It returns the token's metadata and its raw secret --
// the only time the raw secret is ever available.
CreateToken(ctx context.Context, userID string, requestedTTL time.Duration) (Token, string, error)
// TokenUserID returns the userID a raw token belongs to. Returns
// ErrTokenNotFound if the token doesn't exist, has expired, or has
// been revoked.
TokenUserID(ctx context.Context, rawToken string) (string, error)
// RevokeToken revokes a token by id, scoped to userID. Returns
// ErrTokenNotFound if no such token exists for that user.
RevokeToken(ctx context.Context, userID, tokenID string) error
}
// webauthnUser adapts a User and its credentials to webauthn.User.
type webauthnUser struct {
user User
credentials []webauthn.Credential
}
func (u webauthnUser) WebAuthnID() []byte { return u.user.UserHandle }
func (u webauthnUser) WebAuthnName() string { return u.user.DisplayName }
func (u webauthnUser) WebAuthnDisplayName() string { return u.user.DisplayName }
func (u webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
package auth
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/crypto"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// ceremonyTTL bounds how long a WebAuthn ceremony (the window between
// fetching options and completing the browser/authenticator round trip)
// stays valid.
const ceremonyTTL = 5 * time.Minute
// registrationDisplayName is the single dashboard user's display name.
// There's exactly one account and no UI asks for one, so it's fixed.
const registrationDisplayName = "admin"
// Service orchestrates the WebAuthn registration and login ceremonies:
// begin, persist the in-flight ceremony state, finish, and issue a session.
type Service struct {
webAuthn *webauthn.WebAuthn
store Store
}
// NewService returns a Service.
func NewService(webAuthn *webauthn.WebAuthn, store Store) *Service {
return &Service{webAuthn: webAuthn, store: store}
}
// BeginRegistration starts the registration ceremony. Returns ErrUserExists
// if the single dashboard account already exists.
func (s *Service) BeginRegistration(ctx context.Context) (*protocol.CredentialCreation, string, error) {
if _, err := s.store.GetUser(ctx); err == nil {
return nil, "", ErrUserExists
} else if !errors.Is(err, ErrNoUser) {
return nil, "", fmt.Errorf("check existing user: %w", err)
}
handle := make([]byte, 64)
if _, err := rand.Read(handle); err != nil {
return nil, "", fmt.Errorf("generate user handle: %w", err)
}
tempUser := webauthnUser{user: User{UserHandle: handle, DisplayName: registrationDisplayName}}
creation, session, err := s.webAuthn.BeginRegistration(tempUser)
if err != nil {
return nil, "", fmt.Errorf("begin registration: %w", err)
}
ceremonyID, err := s.saveCeremony(ctx, session)
if err != nil {
return nil, "", err
}
return creation, ceremonyID, nil
}
// FinishRegistration completes the registration ceremony started by
// BeginRegistration, creating the dashboard user, storing their credential,
// and establishing a session (the OpenAPI contract's "account created,
// session established" -- registration logs the new account straight in,
// the same as a login would).
func (s *Service) FinishRegistration(ctx context.Context, ceremonyID string, r *http.Request) (User, string, error) {
session, err := s.loadCeremony(ctx, ceremonyID)
if err != nil {
return User{}, "", err
}
tempUser := webauthnUser{user: User{UserHandle: session.UserID, DisplayName: registrationDisplayName}}
cred, err := s.webAuthn.FinishRegistration(tempUser, *session, r)
if err != nil {
return User{}, "", fmt.Errorf("finish registration: %w", err)
}
user, err := s.store.CreateUser(ctx, session.UserID, registrationDisplayName)
if err != nil {
return User{}, "", fmt.Errorf("create user: %w", err)
}
if err := s.store.PutCredential(ctx, user.ID, *cred); err != nil {
return User{}, "", fmt.Errorf("save credential: %w", err)
}
sessionID, err := s.store.CreateSession(ctx, user.ID)
if err != nil {
return User{}, "", fmt.Errorf("create session: %w", err)
}
return user, sessionID, nil
}
// BeginLogin starts the login ceremony. Returns ErrNoUser if no dashboard
// account has been registered yet.
func (s *Service) BeginLogin(ctx context.Context) (*protocol.CredentialAssertion, string, error) {
user, creds, err := s.userWithCredentials(ctx)
if err != nil {
return nil, "", err
}
assertion, session, err := s.webAuthn.BeginLogin(webauthnUser{user: user, credentials: creds})
if err != nil {
return nil, "", fmt.Errorf("begin login: %w", err)
}
ceremonyID, err := s.saveCeremony(ctx, session)
if err != nil {
return nil, "", err
}
return assertion, ceremonyID, nil
}
// FinishLogin completes the login ceremony started by BeginLogin, returning
// a new session id on success.
func (s *Service) FinishLogin(ctx context.Context, ceremonyID string, r *http.Request) (string, error) {
session, err := s.loadCeremony(ctx, ceremonyID)
if err != nil {
return "", err
}
user, creds, err := s.userWithCredentials(ctx)
if err != nil {
return "", err
}
cred, err := s.webAuthn.FinishLogin(webauthnUser{user: user, credentials: creds}, *session, r)
if err != nil {
return "", fmt.Errorf("finish login: %w", err)
}
if err := s.store.PutCredential(ctx, user.ID, *cred); err != nil {
return "", fmt.Errorf("save credential: %w", err)
}
sessionID, err := s.store.CreateSession(ctx, user.ID)
if err != nil {
return "", fmt.Errorf("create session: %w", err)
}
return sessionID, nil
}
// BeginAddCredential starts an authenticated "add another passkey"
// ceremony for the account's existing user, excluding credentials already
// registered to it -- standard WebAuthn behavior for "register another",
// and the reason this loads the real user via userWithCredentials rather
// than constructing a tempUser the way the anonymous BeginRegistration
// does (support-multiple-passkeys/design.md's "reuse BeginRegistration/
// FinishRegistration against the real user" decision).
func (s *Service) BeginAddCredential(ctx context.Context) (*protocol.CredentialCreation, string, error) {
user, creds, err := s.userWithCredentials(ctx)
if err != nil {
return nil, "", err
}
creation, session, err := s.webAuthn.BeginRegistration(webauthnUser{user: user, credentials: creds})
if err != nil {
return nil, "", fmt.Errorf("begin add credential: %w", err)
}
ceremonyID, err := s.saveCeremony(ctx, session)
if err != nil {
return nil, "", err
}
return creation, ceremonyID, nil
}
// FinishAddCredential completes the ceremony started by BeginAddCredential,
// storing the new credential under a user-supplied label.
func (s *Service) FinishAddCredential(ctx context.Context, ceremonyID, label string, r *http.Request) error {
session, err := s.loadCeremony(ctx, ceremonyID)
if err != nil {
return err
}
user, creds, err := s.userWithCredentials(ctx)
if err != nil {
return err
}
cred, err := s.webAuthn.FinishRegistration(webauthnUser{user: user, credentials: creds}, *session, r)
if err != nil {
return fmt.Errorf("finish add credential: %w", err)
}
if err := s.store.PutCredentialLabeled(ctx, user.ID, *cred, label); err != nil {
return fmt.Errorf("save credential: %w", err)
}
return nil
}
// ListCredentials returns userID's registered credentials' listing
// metadata (label, creation date), oldest first.
func (s *Service) ListCredentials(ctx context.Context, userID string) ([]CredentialInfo, error) {
infos, err := s.store.CredentialInfosForUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list credentials: %w", err)
}
// The same rule RevokeCredential enforces, reported so a client needn't
// reimplement it: only the last remaining credential is protected.
for i := range infos {
infos[i].Revocable = len(infos) > 1
}
return infos, nil
}
// RevokeCredential revokes a credential belonging to userID. Returns
// ErrCredentialNotFound if no such credential exists for that user, or
// ErrLastCredential if it's the account's only remaining credential.
func (s *Service) RevokeCredential(ctx context.Context, userID string, credentialID []byte) error {
if err := s.store.RevokeCredential(ctx, userID, credentialID); err != nil {
return fmt.Errorf("revoke credential: %w", err)
}
return nil
}
// Logout ends a session.
func (s *Service) Logout(ctx context.Context, sessionID string) error {
if err := s.store.DeleteSession(ctx, sessionID); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// IssueToken creates a new API token for userID, returning its metadata
// and its raw secret -- the only time the raw secret is ever available.
func (s *Service) IssueToken(ctx context.Context, userID string, requestedTTL time.Duration) (Token, string, error) {
tok, raw, err := s.store.CreateToken(ctx, userID, requestedTTL)
if err != nil {
return Token{}, "", fmt.Errorf("create token: %w", err)
}
return tok, raw, nil
}
// AuthenticateToken returns the userID a raw API token belongs to. Returns
// ErrTokenNotFound if the token doesn't exist, has expired, or has been
// revoked.
func (s *Service) AuthenticateToken(ctx context.Context, rawToken string) (string, error) {
userID, err := s.store.TokenUserID(ctx, rawToken)
if err != nil {
return "", fmt.Errorf("look up token: %w", err)
}
return userID, nil
}
// RevokeToken revokes tokenID, scoped to userID. Returns ErrTokenNotFound
// if no such token exists for that user.
func (s *Service) RevokeToken(ctx context.Context, userID, tokenID string) error {
if err := s.store.RevokeToken(ctx, userID, tokenID); err != nil {
return fmt.Errorf("revoke token: %w", err)
}
return nil
}
func (s *Service) userWithCredentials(ctx context.Context) (User, []webauthn.Credential, error) {
user, err := s.store.GetUser(ctx)
if err != nil {
return User{}, nil, fmt.Errorf("load user: %w", err)
}
creds, err := s.store.CredentialsForUser(ctx, user.ID)
if err != nil {
return User{}, nil, fmt.Errorf("load credentials: %w", err)
}
return user, creds, nil
}
func (s *Service) saveCeremony(ctx context.Context, session *webauthn.SessionData) (string, error) {
id, err := crypto.RandomHex(32)
if err != nil {
return "", fmt.Errorf("generate ceremony id: %w", err)
}
data, err := json.Marshal(session)
if err != nil {
return "", fmt.Errorf("marshal ceremony: %w", err)
}
if err := s.store.SaveCeremony(ctx, id, data, time.Now().Add(ceremonyTTL)); err != nil {
return "", fmt.Errorf("save ceremony: %w", err)
}
return id, nil
}
func (s *Service) loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
data, err := s.store.TakeCeremony(ctx, id)
if err != nil {
return nil, fmt.Errorf("load ceremony: %w", err)
}
var session webauthn.SessionData
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf("unmarshal ceremony: %w", err)
}
return &session, nil
}
// Package sqlite is the SQLite-backed adapter for the auth.Store port.
package sqlite
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/alrayyes/pipeline-analytics/internal/auth"
"github.com/alrayyes/pipeline-analytics/internal/crypto"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
)
// sessionTTL is how long a session lasts after creation. A solo-dev tool
// with no password-reset flow to fall back on favors a long, low-friction
// lifetime over frequent re-authentication.
const sessionTTL = 30 * 24 * time.Hour
// Store implements auth.Store against a SQLite database.
type Store struct {
db *sql.DB
}
// NewStore returns a Store.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// CreateUser implements auth.Store.
func (s *Store) CreateUser(ctx context.Context, userHandle []byte, displayName string) (auth.User, error) {
var count int
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM webauthn_users").Scan(&count); err != nil {
return auth.User{}, fmt.Errorf("count users: %w", err)
}
if count > 0 {
return auth.User{}, auth.ErrUserExists
}
user := auth.User{
ID: uuid.NewString(),
UserHandle: userHandle,
DisplayName: displayName,
CreatedAt: time.Now().UTC(),
}
_, err := s.db.ExecContext(ctx,
"INSERT INTO webauthn_users (id, user_handle, display_name, created_at) VALUES (?, ?, ?, ?)",
user.ID, user.UserHandle, user.DisplayName, user.CreatedAt,
)
if err != nil {
return auth.User{}, fmt.Errorf("insert user: %w", err)
}
return user, nil
}
// GetUser implements auth.Store.
func (s *Store) GetUser(ctx context.Context) (auth.User, error) {
var user auth.User
err := s.db.QueryRowContext(ctx, "SELECT id, user_handle, display_name, created_at FROM webauthn_users LIMIT 1").
Scan(&user.ID, &user.UserHandle, &user.DisplayName, &user.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, auth.ErrNoUser
}
if err != nil {
return auth.User{}, fmt.Errorf("query user: %w", err)
}
return user, nil
}
// PutCredential implements auth.Store.
func (s *Store) PutCredential(ctx context.Context, userID string, cred webauthn.Credential) error {
data, err := json.Marshal(cred)
if err != nil {
return fmt.Errorf("marshal credential: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO webauthn_credentials (id, user_id, data, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET data = excluded.data
`, cred.ID, userID, data, time.Now().UTC())
if err != nil {
return fmt.Errorf("upsert credential: %w", err)
}
return nil
}
// PutCredentialLabeled implements auth.Store.
func (s *Store) PutCredentialLabeled(ctx context.Context, userID string, cred webauthn.Credential, label string) error {
data, err := json.Marshal(cred)
if err != nil {
return fmt.Errorf("marshal credential: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO webauthn_credentials (id, user_id, data, label, created_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET data = excluded.data
`, cred.ID, userID, data, label, time.Now().UTC())
if err != nil {
return fmt.Errorf("insert labeled credential: %w", err)
}
return nil
}
// CredentialsForUser implements auth.Store.
func (s *Store) CredentialsForUser(ctx context.Context, userID string) ([]webauthn.Credential, error) {
rows, err := s.db.QueryContext(ctx, "SELECT data FROM webauthn_credentials WHERE user_id = ?", userID)
if err != nil {
return nil, fmt.Errorf("query credentials: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []webauthn.Credential
for rows.Next() {
var data []byte
if err := rows.Scan(&data); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
var cred webauthn.Credential
if err := json.Unmarshal(data, &cred); err != nil {
return nil, fmt.Errorf("unmarshal credential: %w", err)
}
creds = append(creds, cred)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate credentials: %w", err)
}
return creds, nil
}
// CredentialInfosForUser implements auth.Store.
func (s *Store) CredentialInfosForUser(ctx context.Context, userID string) ([]auth.CredentialInfo, error) {
rows, err := s.db.QueryContext(ctx,
"SELECT id, label, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at",
userID,
)
if err != nil {
return nil, fmt.Errorf("query credential infos: %w", err)
}
defer func() { _ = rows.Close() }()
var infos []auth.CredentialInfo
for rows.Next() {
var info auth.CredentialInfo
if err := rows.Scan(&info.ID, &info.Label, &info.CreatedAt); err != nil {
return nil, fmt.Errorf("scan credential info: %w", err)
}
infos = append(infos, info)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate credential infos: %w", err)
}
return infos, nil
}
// RevokeCredential implements auth.Store.
func (s *Store) RevokeCredential(ctx context.Context, userID string, credentialID []byte) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
var count int
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = ?", userID).Scan(&count); err != nil {
return fmt.Errorf("count credentials: %w", err)
}
if count <= 1 {
return auth.ErrLastCredential
}
res, err := tx.ExecContext(ctx,
"DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?", credentialID, userID,
)
if err != nil {
return fmt.Errorf("delete credential: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete credential: %w", err)
}
if n == 0 {
return auth.ErrCredentialNotFound
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
// SaveCeremony implements auth.Store.
func (s *Store) SaveCeremony(ctx context.Context, id string, data []byte, expiresAt time.Time) error {
_, err := s.db.ExecContext(ctx,
"INSERT INTO webauthn_ceremonies (id, data, expires_at) VALUES (?, ?, ?)",
id, data, expiresAt.UTC(),
)
if err != nil {
return fmt.Errorf("insert ceremony: %w", err)
}
return nil
}
// TakeCeremony implements auth.Store.
func (s *Store) TakeCeremony(ctx context.Context, id string) ([]byte, error) {
var (
data []byte
expiresAt time.Time
)
err := s.db.QueryRowContext(ctx, "SELECT data, expires_at FROM webauthn_ceremonies WHERE id = ?", id).Scan(&data, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, auth.ErrCeremonyNotFound
}
if err != nil {
return nil, fmt.Errorf("query ceremony: %w", err)
}
if _, err := s.db.ExecContext(ctx, "DELETE FROM webauthn_ceremonies WHERE id = ?", id); err != nil {
return nil, fmt.Errorf("delete ceremony: %w", err)
}
if time.Now().After(expiresAt) {
return nil, auth.ErrCeremonyNotFound
}
return data, nil
}
// CreateSession implements auth.Store.
func (s *Store) CreateSession(ctx context.Context, userID string) (string, error) {
id, err := crypto.RandomHex(32)
if err != nil {
return "", fmt.Errorf("generate session id: %w", err)
}
now := time.Now().UTC()
_, err = s.db.ExecContext(ctx,
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
id, userID, now.Add(sessionTTL), now,
)
if err != nil {
return "", fmt.Errorf("insert session: %w", err)
}
return id, nil
}
// Session implements auth.Store.
func (s *Store) Session(ctx context.Context, sessionID string) (string, error) {
var (
userID string
expiresAt time.Time
)
err := s.db.QueryRowContext(ctx, "SELECT user_id, expires_at FROM sessions WHERE id = ?", sessionID).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return "", auth.ErrSessionNotFound
}
if err != nil {
return "", fmt.Errorf("query session: %w", err)
}
if time.Now().After(expiresAt) {
return "", auth.ErrSessionNotFound
}
return userID, nil
}
// DeleteSession implements auth.Store.
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE id = ?", sessionID); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// CreateToken implements auth.Store.
func (s *Store) CreateToken(ctx context.Context, userID string, requestedTTL time.Duration) (auth.Token, string, error) {
raw, err := crypto.RandomHex(32)
if err != nil {
return auth.Token{}, "", fmt.Errorf("generate token: %w", err)
}
hash := tokenHash(raw)
now := time.Now().UTC()
// The clamp lives here, nearest persistence, so no caller can get past the
// ceiling by skipping the service.
ttl := requestedTTL
if ttl <= 0 {
ttl = auth.TokenTTLDefault
}
ttl = min(ttl, auth.TokenTTLCeiling)
tok := auth.Token{
ID: uuid.NewString(),
UserID: userID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
_, err = s.db.ExecContext(ctx,
"INSERT INTO api_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
tok.ID, tok.UserID, hash, tok.ExpiresAt, tok.CreatedAt,
)
if err != nil {
return auth.Token{}, "", fmt.Errorf("insert token: %w", err)
}
return tok, raw, nil
}
// TokenUserID implements auth.Store.
func (s *Store) TokenUserID(ctx context.Context, rawToken string) (string, error) {
var (
userID string
expiresAt time.Time
revokedAt sql.NullTime
)
err := s.db.QueryRowContext(ctx,
"SELECT user_id, expires_at, revoked_at FROM api_tokens WHERE token_hash = ?",
tokenHash(rawToken),
).Scan(&userID, &expiresAt, &revokedAt)
if errors.Is(err, sql.ErrNoRows) {
return "", auth.ErrTokenNotFound
}
if err != nil {
return "", fmt.Errorf("query token: %w", err)
}
if revokedAt.Valid || time.Now().After(expiresAt) {
return "", auth.ErrTokenNotFound
}
return userID, nil
}
// RevokeToken implements auth.Store.
func (s *Store) RevokeToken(ctx context.Context, userID, tokenID string) error {
res, err := s.db.ExecContext(ctx,
"UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
time.Now().UTC(), tokenID, userID,
)
if err != nil {
return fmt.Errorf("revoke token: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("revoke token: %w", err)
}
if n == 0 {
return auth.ErrTokenNotFound
}
return nil
}
// tokenHash hashes a raw API token for storage/lookup, so the raw secret
// itself is never recoverable from the database. See
// add-api-token-auth/design.md's "Hash the token at rest" decision.
func tokenHash(raw string) []byte {
sum := sha256.Sum256([]byte(raw))
return sum[:]
}
// Package config holds pipeline-analytics' runtime configuration.
package config
import (
"errors"
"log/slog"
"time"
)
// Sentinel validation errors, so a caller can errors.Is against a specific
// missing field instead of matching on message text.
var (
ErrAddrRequired = errors.New("addr must not be empty")
ErrDBPathRequired = errors.New("db path must not be empty")
ErrCallbackURLRequired = errors.New("callback url must not be empty")
ErrEncryptionKeyInvalid = errors.New("encryption key must decode to 32 bytes")
ErrReconcileIntervalNonPositive = errors.New("reconcile interval must be positive")
ErrDrainPeriodNegative = errors.New("drain period must not be negative")
ErrShutdownTimeoutNonPositive = errors.New("shutdown timeout must be positive")
)
// Config is the server's runtime configuration, sourced from flags, the
// environment, and (optionally) a config file, in that precedence.
type Config struct {
Addr string
DBPath string
// CallbackURL is this server's own public base URL, used to build the
// webhook callback URL registered on each tracked repo.
CallbackURL string
// EncryptionKey is the 32-byte AES-256 key repo tokens are encrypted
// under at rest.
EncryptionKey []byte
// ReconcileInterval is how often reconciliation polling runs against
// every tracked repo (forge-ingestion/spec.md requires at least
// hourly).
ReconcileInterval time.Duration
// DrainPeriod is how long the server keeps serving after a shutdown
// signal, with /readyz already answering 503, so a router polling it
// notices before the listener closes (api.md's /readyz bullet). Zero
// skips the wait.
DrainPeriod time.Duration
// ShutdownTimeout bounds how long in-flight requests get to finish once
// the drain is over, so a request that never ends can't hold the process
// open.
ShutdownTimeout time.Duration
// LogLevel is the minimum slog level the server logs at.
LogLevel slog.Level
}
// Validate reports the first invalid or missing required field, if any.
func (c Config) Validate() error {
if c.Addr == "" {
return ErrAddrRequired
}
if c.DBPath == "" {
return ErrDBPathRequired
}
if c.CallbackURL == "" {
return ErrCallbackURLRequired
}
if len(c.EncryptionKey) != 32 {
return ErrEncryptionKeyInvalid
}
if c.ReconcileInterval <= 0 {
return ErrReconcileIntervalNonPositive
}
if c.DrainPeriod < 0 {
return ErrDrainPeriodNegative
}
if c.ShutdownTimeout <= 0 {
return ErrShutdownTimeoutNonPositive
}
return nil
}
// Package crypto seals repo-scoped forge tokens for storage at rest and
// generates the random ids used for webhook secrets, ceremony ids, and
// session ids.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
)
// RandomHex returns n random bytes, hex-encoded.
func RandomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("read random bytes: %w", err)
}
return hex.EncodeToString(b), nil
}
// ErrInvalidKeySize is returned when the supplied key isn't a valid AES-256 key.
var ErrInvalidKeySize = errors.New("encryption key must be 32 bytes")
// ErrCiphertextTooShort is returned when a blob passed to Decrypt is too
// short to contain a nonce.
var ErrCiphertextTooShort = errors.New("ciphertext shorter than nonce")
// Encrypt seals plaintext with AES-256-GCM under key, returning a
// self-contained nonce||ciphertext blob.
func Encrypt(key, plaintext []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// Decrypt opens a blob produced by Encrypt under the same key.
func Decrypt(key, data []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, ErrCiphertextTooShort
}
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decrypt: %w", err)
}
return plaintext, nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
if len(key) != 32 {
return nil, ErrInvalidKeySize
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create gcm: %w", err)
}
return gcm, nil
}
package db
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"syscall"
)
// ErrDirMissing and ErrNotDirectory are what CheckDir wraps for a database
// path whose directory can't be used, so callers and tests match on the kind
// of fault with errors.Is rather than on message text.
var (
ErrDirMissing = errors.New("does not exist")
ErrNotDirectory = errors.New("is not a directory")
)
// CheckDir reports whether a SQLite database at path can be created or
// opened: its directory has to exist and be writable by this process, since
// SQLite writes a journal beside the file. Without it, a data directory owned
// by another user fails at startup as "unable to open database file (14)",
// which names neither the directory nor the cause. An in-memory database has
// no directory to check.
func CheckDir(path string) error {
if path == ":memory:" || path == "" {
return nil
}
dir := filepath.Dir(path)
info, err := os.Stat(dir)
switch {
case errors.Is(err, fs.ErrNotExist):
return fmt.Errorf("data directory %s %w", dir, ErrDirMissing)
case err != nil:
return fmt.Errorf("check data directory %s: %w", dir, err)
case !info.IsDir():
return fmt.Errorf("data directory %s %w", dir, ErrNotDirectory)
}
probe, err := os.CreateTemp(dir, ".write-check-*")
if err != nil {
return fmt.Errorf("data directory %s isn't writable by uid %d (it must be owned by that user, or writable by it): %w",
dir, os.Geteuid(), err)
}
name := probe.Name()
if err := probe.Close(); err != nil {
return fmt.Errorf("close write check in %s: %w", dir, err)
}
if err := os.Remove(name); err != nil && !errors.Is(err, syscall.ENOENT) {
return fmt.Errorf("remove write check in %s: %w", dir, err)
}
return nil
}
// Package db opens the pipeline-analytics SQLite database.
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
)
// Open opens the SQLite database at path (or ":memory:") with the pragmas
// pipeline-analytics needs: foreign keys enforced, and a busy timeout so
// concurrent writers back off instead of failing immediately.
//
// The pool is capped at a single connection: SQLite serializes writers
// regardless, and a ":memory:" DSN backs a separate, empty database per
// connection under database/sql's pool -- a second pooled connection would
// silently see none of the first connection's tables or data.
func Open(path string) (*sql.DB, error) {
conn, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("open sqlite database: %w", err)
}
conn.SetMaxOpenConns(1)
return conn, nil
}
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate applies every pending migration under migrations/.
func Migrate(ctx context.Context, conn *sql.DB) error {
migrations, err := fs.Sub(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("resolve migrations directory: %w", err)
}
provider, err := goose.NewProvider(goose.DialectSQLite3, conn, migrations)
if err != nil {
return fmt.Errorf("create migration provider: %w", err)
}
if _, err := provider.Up(ctx); err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
return nil
}
package httpserver
import (
"encoding/base64"
"errors"
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/auth"
)
const (
ceremonyCookieName = "pa_ceremony"
sessionCookieName = "session"
)
type authHandler struct {
service *auth.Service
}
func (h *authHandler) registerOptions(w http.ResponseWriter, r *http.Request) {
creation, ceremonyID, err := h.service.BeginRegistration(r.Context())
switch {
case errors.Is(err, auth.ErrUserExists):
writeError(w, http.StatusConflict, "user_exists", "a user account already exists")
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "begin registration")
default:
setCookie(w, ceremonyCookieName, ceremonyID, 5*time.Minute)
writeJSON(w, http.StatusOK, creation)
}
}
func (h *authHandler) register(w http.ResponseWriter, r *http.Request) {
ceremonyID, ok := ceremonyCookie(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_body", "no registration in progress")
return
}
user, sessionID, err := h.service.FinishRegistration(r.Context(), ceremonyID, r)
switch {
case errors.Is(err, auth.ErrUserExists):
writeError(w, http.StatusConflict, "user_exists", "a user account already exists")
case err != nil:
writeError(w, http.StatusBadRequest, "invalid_body", "registration failed")
default:
clearCookie(w, ceremonyCookieName)
setCookie(w, sessionCookieName, sessionID, 0)
writeJSON(w, http.StatusCreated, toUserDTO(user))
}
}
func (h *authHandler) loginOptions(w http.ResponseWriter, r *http.Request) {
assertion, ceremonyID, err := h.service.BeginLogin(r.Context())
switch {
case errors.Is(err, auth.ErrNoUser):
writeError(w, http.StatusNotFound, "no_user", "no account has been registered yet")
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "begin login")
default:
setCookie(w, ceremonyCookieName, ceremonyID, 5*time.Minute)
writeJSON(w, http.StatusOK, assertion)
}
}
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
ceremonyID, ok := ceremonyCookie(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "no login in progress")
return
}
sessionID, err := h.service.FinishLogin(r.Context(), ceremonyID, r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthenticated", "login failed")
return
}
clearCookie(w, ceremonyCookieName)
setCookie(w, sessionCookieName, sessionID, 0)
w.WriteHeader(http.StatusOK)
}
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = h.service.Logout(r.Context(), cookie.Value)
}
clearCookie(w, sessionCookieName)
w.WriteHeader(http.StatusNoContent)
}
// issueTokenRequest is the optional body of POST /api/auth/tokens.
type issueTokenRequest struct {
// TTLSeconds is how long the token should last; unset means the default.
TTLSeconds *int64 `json:"ttlSeconds"`
}
// lifetime turns the request into a duration for the service: zero means
// "use the default", and ok is false for a value of zero or less. It compares
// in seconds against the ceiling first, so a huge value cannot overflow the
// conversion to a Duration.
func (r issueTokenRequest) lifetime() (ttl time.Duration, ok bool) {
if r.TTLSeconds == nil {
return 0, true
}
if *r.TTLSeconds <= 0 {
return 0, false
}
if *r.TTLSeconds >= int64(auth.TokenTTLCeiling/time.Second) {
return auth.TokenTTLCeiling, true
}
return time.Duration(*r.TTLSeconds) * time.Second, true
}
// issueToken creates a new API token. Session-only: see
// add-api-token-auth/design.md's "session-only" decision -- a token can't
// mint another token.
func (h *authHandler) issueToken(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", "a session is required to issue a token")
return
}
var in issueTokenRequest
if !readOptionalJSON(w, r, &in) {
return
}
ttl, ok := in.lifetime()
if !ok {
writeError(w, http.StatusBadRequest, "invalid_body", "ttlSeconds must be a positive number of seconds")
return
}
tok, raw, err := h.service.IssueToken(r.Context(), info.UserID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "issue token")
return
}
writeJSON(w, http.StatusCreated, toTokenDTO(tok, raw))
}
// revokeToken revokes an API token by id. Session-only, same reasoning as
// issueToken.
func (h *authHandler) revokeToken(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", "a session is required to revoke a token")
return
}
err := h.service.RevokeToken(r.Context(), info.UserID, r.PathValue("tokenId"))
switch {
case errors.Is(err, auth.ErrTokenNotFound):
writeError(w, http.StatusNotFound, "not_found", "token not found")
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "revoke token")
default:
w.WriteHeader(http.StatusNoContent)
}
}
// addCredentialOptions starts the authenticated "add another passkey"
// ceremony. Session-only, matching issueToken/revokeToken's reasoning: an
// API token shouldn't be able to enroll another credential on the account
// any more than it can mint another token.
func (h *authHandler) addCredentialOptions(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", "a session is required to add a credential")
return
}
creation, ceremonyID, err := h.service.BeginAddCredential(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "begin add credential")
return
}
setCookie(w, ceremonyCookieName, ceremonyID, 5*time.Minute)
writeJSON(w, http.StatusOK, creation)
}
// addCredential completes the ceremony started by addCredentialOptions,
// storing the new credential under the caller-supplied label.
func (h *authHandler) addCredential(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", "a session is required to add a credential")
return
}
ceremonyID, ok := ceremonyCookie(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_body", "no add-credential ceremony in progress")
return
}
label := r.URL.Query().Get("label")
err := h.service.FinishAddCredential(r.Context(), ceremonyID, label, r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "add credential failed")
return
}
clearCookie(w, ceremonyCookieName)
w.WriteHeader(http.StatusCreated)
}
// listCredentials lists the account's registered credentials. Session-only,
// same reasoning as addCredentialOptions.
func (h *authHandler) listCredentials(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", "a session is required to list credentials")
return
}
infos, err := h.service.ListCredentials(r.Context(), info.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list credentials")
return
}
writeJSON(w, http.StatusOK, toCredentialDTOs(infos))
}
// revokeCredential revokes a credential by its base64url-encoded id.
// Session-only, same reasoning as addCredentialOptions.
func (h *authHandler) revokeCredential(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", "a session is required to revoke a credential")
return
}
credentialID, err := base64.RawURLEncoding.DecodeString(r.PathValue("credentialId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "malformed credential id")
return
}
err = h.service.RevokeCredential(r.Context(), info.UserID, credentialID)
switch {
case errors.Is(err, auth.ErrLastCredential):
writeError(w, http.StatusConflict, "last_credential", "cannot revoke the account's last remaining credential")
case errors.Is(err, auth.ErrCredentialNotFound):
writeError(w, http.StatusNotFound, "not_found", "credential not found")
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "revoke credential")
default:
w.WriteHeader(http.StatusNoContent)
}
}
type userDTO struct {
DisplayName string `json:"displayName"`
}
func toUserDTO(u auth.User) userDTO {
return userDTO{DisplayName: u.DisplayName}
}
// tokenDTO is only ever built right after issuance, at the one point the
// raw value exists -- see add-api-token-auth/design.md's "Hash the token
// at rest" decision.
type tokenDTO struct {
ID string `json:"id"`
Token string `json:"token"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
func toTokenDTO(t auth.Token, raw string) tokenDTO {
return tokenDTO{ID: t.ID, Token: raw, CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt}
}
// credentialDTO is a registered credential's listing metadata -- the
// credential id is base64url-encoded for the wire (support-multiple-
// passkeys/design.md's "Credential identifier over the wire" decision),
// matching the same encoding already used for other WebAuthn binary fields
// in this codebase's ceremony JSON.
type credentialDTO struct {
ID string `json:"id"`
Label string `json:"label"`
CreatedAt time.Time `json:"createdAt"`
Revocable bool `json:"revocable"`
}
func toCredentialDTOs(infos []auth.CredentialInfo) []credentialDTO {
dtos := make([]credentialDTO, len(infos))
for i, info := range infos {
dtos[i] = credentialDTO{
ID: base64.RawURLEncoding.EncodeToString(info.ID),
Label: info.Label,
CreatedAt: info.CreatedAt,
Revocable: info.Revocable,
}
}
return dtos
}
func ceremonyCookie(r *http.Request) (string, bool) {
cookie, err := r.Cookie(ceremonyCookieName)
if err != nil || cookie.Value == "" {
return "", false
}
return cookie.Value, true
}
// setCookie sets an HttpOnly, Secure, SameSite=Strict cookie. maxAge of 0
// means a session cookie (cleared when the browser closes) rather than no
// expiry -- appropriate for the short-lived ceremony cookie; the real
// session cookie relies on the server-side session's own TTL instead of a
// client-visible expiry.
func setCookie(w http.ResponseWriter, name, value string, maxAge time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: int(maxAge.Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
package httpserver
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
// Request body limits. A handler that decodes whatever it is sent lets one
// oversized request exhaust memory (OWASP API4), so every body is capped
// before a handler reads it. The API and the MCP endpoint only ever carry
// small JSON; the forge webhook receivers get more room, since a delivery's
// size is the forge's to choose, but not unbounded.
const (
maxAPIBodyBytes = 64 << 10
maxWebhookBodyBytes = 2 << 20
)
// Field limits for the free-text request fields, matching the maxLength in
// openapi/openapi.yaml's request schemas.
const (
maxIdentifierLength = 200
maxTokenLength = 1024
maxInstanceURLLength = 2048
)
func bodyLimitFor(r *http.Request) int64 {
if strings.HasPrefix(r.URL.Path, "/webhooks/") {
return maxWebhookBodyBytes
}
return maxAPIBodyBytes
}
func writePayloadTooLarge(w http.ResponseWriter) {
writeError(w, http.StatusRequestEntityTooLarge, "payload_too_large", "request body too large")
}
// isBodyTooLarge reports whether err came from a body read hitting its cap.
func isBodyTooLarge(err error) bool {
var tooLarge *http.MaxBytesError
return errors.As(err, &tooLarge)
}
// limitBodies caps every request body. A declared Content-Length over the
// limit is refused without reading anything; a body that declares none (or
// lies) is cut by MaxBytesReader at the limit, and the handler that reads it
// turns that into the same 413.
func limitBodies(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := bodyLimitFor(r)
if r.ContentLength > limit {
writePayloadTooLarge(w)
return
}
if r.Body != nil && r.Body != http.NoBody {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
// readJSON decodes the request's JSON body into dst. On failure it has already
// written the response (413 for a body past its cap, 400 for anything
// malformed) and returns false.
func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
if isBodyTooLarge(err) {
writePayloadTooLarge(w)
} else {
writeError(w, http.StatusBadRequest, "invalid_body", "malformed JSON body")
}
return false
}
return true
}
package httpserver
import (
"net/http"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
// branchFilter reads the optional `branch` query parameter, answering 400
// itself when it is longer than the spec allows.
func branchFilter(w http.ResponseWriter, r *http.Request) (string, bool) {
branch := r.URL.Query().Get("branch")
if len(branch) > metrics.MaxBranchLength {
writeError(w, http.StatusBadRequest, "invalid_branch", "branch must be at most 255 characters")
return "", false
}
return branch, true
}
package httpserver
import (
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
type branchEntryDTO struct {
Name string `json:"name"`
RunCount int `json:"runCount"`
}
type branchListDTO struct {
Window string `json:"window"`
Branches []branchEntryDTO `json:"branches"`
}
func toBranchListDTO(window metrics.InsightWindow, branches []metrics.BranchCount) branchListDTO {
entries := make([]branchEntryDTO, 0, len(branches))
for _, b := range branches {
entries = append(entries, branchEntryDTO{Name: b.Name, RunCount: b.RunCount})
}
return branchListDTO{Window: window.Label(), Branches: entries}
}
type branchesHandler struct {
service *metrics.Service
}
// list answers GET /api/branches: the branches with runs in a trailing
// window (24h, 7d or 30d; anything else is 7d), busiest first, with an
// optional repo and forge scope.
func (h *branchesHandler) list(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
window := metrics.ParseInsightWindow(query.Get("window"))
branches, err := h.service.ListBranches(
r.Context(),
time.Now(),
window,
metrics.InsightFilter{RepoID: query.Get("repoId"), Forge: query.Get("forge")},
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list branches")
return
}
writeJSON(w, http.StatusOK, toBranchListDTO(window, branches))
}
package httpserver
import (
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
type stageFailureDTO struct {
Step string `json:"step"`
Failures int `json:"failures"`
Share float64 `json:"share"`
}
type categoryCountDTO struct {
Category string `json:"category"`
Occurrences int `json:"occurrences"`
Share float64 `json:"share"`
}
type failingPipelineDTO struct {
PipelineID string `json:"pipelineId"`
PipelineName string `json:"pipelineName"`
RepoID string `json:"repoId"`
Runs int `json:"runs"`
FailedRuns int `json:"failedRuns"`
}
type failureGroupPipelineDTO struct {
PipelineID string `json:"pipelineId"`
PipelineName string `json:"pipelineName"`
}
type failureGroupDTO struct {
Step string `json:"step"`
Category string `json:"category"`
Conclusion string `json:"conclusion,omitempty"`
Occurrences int `json:"occurrences"`
Pipelines []failureGroupPipelineDTO `json:"pipelines"`
}
// failureInsightsDTO is GET /api/insights/failures' response. The optional
// figures are pointers so an absent value is omitted instead of reading as
// zero, and every list is built non-nil so it encodes as [] rather than null.
type failureInsightsDTO struct {
Window string `json:"window"`
TotalRuns int `json:"totalRuns"`
FailedRuns int `json:"failedRuns"`
PassRate *float64 `json:"passRate,omitempty"`
PassRateDelta *float64 `json:"passRateDelta,omitempty"`
FlakyStepRatio float64 `json:"flakyStepRatio"`
MTTRSeconds *float64 `json:"mttrSeconds,omitempty"`
StageDistribution []stageFailureDTO `json:"stageDistribution"`
CategoryBreakdown []categoryCountDTO `json:"categoryBreakdown"`
TopFailingPipelines []failingPipelineDTO `json:"topFailingPipelines"`
FailureGroups []failureGroupDTO `json:"failureGroups"`
}
func toFailureInsightsDTO(in metrics.FailureInsights) failureInsightsDTO {
dto := failureInsightsDTO{
Window: in.Window.Label(),
TotalRuns: in.TotalRuns,
FailedRuns: in.FailedRuns,
PassRate: in.PassRate,
PassRateDelta: in.PassRateDelta,
FlakyStepRatio: in.FlakyStepRatio,
StageDistribution: make([]stageFailureDTO, 0, len(in.StageDistribution)),
CategoryBreakdown: make([]categoryCountDTO, 0, len(in.CategoryBreakdown)),
TopFailingPipelines: make([]failingPipelineDTO, 0, len(in.TopFailingPipelines)),
FailureGroups: make([]failureGroupDTO, 0, len(in.FailureGroups)),
}
if in.MTTR != nil {
seconds := in.MTTR.Seconds()
dto.MTTRSeconds = &seconds
}
for _, s := range in.StageDistribution {
dto.StageDistribution = append(dto.StageDistribution, stageFailureDTO{Step: s.Step, Failures: s.Failures, Share: s.Share})
}
for _, c := range in.CategoryBreakdown {
dto.CategoryBreakdown = append(dto.CategoryBreakdown, categoryCountDTO{Category: string(c.Category), Occurrences: c.Occurrences, Share: c.Share})
}
for _, p := range in.TopFailingPipelines {
dto.TopFailingPipelines = append(dto.TopFailingPipelines, failingPipelineDTO{
PipelineID: string(p.Pipeline.ID()),
PipelineName: p.Pipeline.Name,
RepoID: p.Pipeline.RepoID,
Runs: p.Runs,
FailedRuns: p.FailedRuns,
})
}
for _, g := range in.FailureGroups {
pipelines := make([]failureGroupPipelineDTO, 0, len(g.Pipelines))
for _, ref := range g.Pipelines {
pipelines = append(pipelines, failureGroupPipelineDTO{PipelineID: string(ref.ID()), PipelineName: ref.Name})
}
dto.FailureGroups = append(dto.FailureGroups, failureGroupDTO{
Step: g.Step,
Category: string(g.Category),
Conclusion: g.Conclusion,
Occurrences: g.Occurrences,
Pipelines: pipelines,
})
}
return dto
}
type failureInsightsHandler struct {
service *metrics.Service
}
// get answers GET /api/insights/failures: the failure overview's figures for
// a trailing window (24h, 7d or 30d; anything else is 7d, per the contract)
// and an optional repo and forge scope.
func (h *failureInsightsHandler) get(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
branch, ok := branchFilter(w, r)
if !ok {
return
}
insights, err := h.service.GetFailureInsights(
r.Context(),
time.Now(),
metrics.ParseInsightWindow(query.Get("window")),
metrics.InsightFilter{RepoID: query.Get("repoId"), Forge: query.Get("forge"), Branch: branch},
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "compute failure insights")
return
}
writeJSON(w, http.StatusOK, toFailureInsightsDTO(insights))
}
package httpserver
import (
"net/http"
"strconv"
"time"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
// flakyStepDTO is one entry of GET /api/steps/flaky.
type flakyStepDTO struct {
PipelineID string `json:"pipelineId"`
PipelineName string `json:"pipelineName"`
RepoID string `json:"repoId"`
Name string `json:"name"`
FlakeRate float64 `json:"flakeRate"`
RunCount int `json:"runCount"`
RecentOutcomes []string `json:"recentOutcomes"`
// Quarantined is true when a person has marked this step as known; it is
// still listed, with these figures unchanged.
Quarantined bool `json:"quarantined"`
Quarantine *quarantineDTO `json:"quarantine,omitempty"`
}
type flakyStepListDTO struct {
Window string `json:"window"`
Steps []flakyStepDTO `json:"steps"`
HasMore bool `json:"hasMore"`
}
func toFlakyStepListDTO(window metrics.InsightWindow, steps []metrics.FlakyStep, hasMore bool) flakyStepListDTO {
dtos := make([]flakyStepDTO, 0, len(steps))
for _, s := range steps {
outcomes := make([]string, 0, len(s.RecentOutcomes))
for _, o := range s.RecentOutcomes {
outcomes = append(outcomes, string(o))
}
dtos = append(dtos, flakyStepDTO{
PipelineID: string(s.Pipeline.ID()),
PipelineName: s.Pipeline.Name,
RepoID: s.Pipeline.RepoID,
Name: s.Name,
FlakeRate: s.FlakeRate,
RunCount: s.RunCount,
RecentOutcomes: outcomes,
Quarantined: s.Quarantine != nil,
Quarantine: toQuarantineDTO(s.Quarantine),
})
}
return flakyStepListDTO{Window: window.Label(), Steps: dtos, HasMore: hasMore}
}
type flakyStepsHandler struct {
service *metrics.Service
}
// list answers GET /api/steps/flaky: the flaky steps across every pipeline
// for a trailing window (24h, 7d or 30d; anything else is 7d), ranked by
// flake rate, with an optional repo and forge scope.
func (h *flakyStepsHandler) list(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
branch, ok := branchFilter(w, r)
if !ok {
return
}
window := metrics.ParseInsightWindow(query.Get("window"))
limit, _ := strconv.Atoi(query.Get("limit"))
offset, _ := strconv.Atoi(query.Get("offset"))
steps, hasMore, err := h.service.ListFlakySteps(
r.Context(),
time.Now(),
window,
metrics.InsightFilter{RepoID: query.Get("repoId"), Forge: query.Get("forge"), Branch: branch},
limit,
offset,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list flaky steps")
return
}
writeJSON(w, http.StatusOK, toFlakyStepListDTO(window, steps, hasMore))
}
package httpserver
import (
"context"
"errors"
"net/http"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
type savedForgeTokenDTO struct {
ID string `json:"id"`
Forge string `json:"forge"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty"`
TokenMasked string `json:"tokenMasked"`
}
func toSavedForgeTokenDTO(t ingestion.SavedToken) savedForgeTokenDTO {
return savedForgeTokenDTO{
ID: t.ID,
Forge: string(t.Forge),
ForgejoInstanceURL: t.ForgejoInstanceURL,
TokenMasked: t.TokenMasked,
}
}
type saveForgeTokenDTO struct {
Forge string `json:"forge"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl"`
Token string `json:"token"`
}
// forgeTokensHandler manages the tokens saved for registering repos. All
// three routes are session-only, like API tokens and passkeys, and none ever
// returns a token: only its masked form (openapi.yaml, /api/forge-tokens).
type forgeTokensHandler struct {
store ingestion.ForgeTokenStore
}
// requireSessionAuth answers 401 unless the request came from a session.
func requireSessionAuth(w http.ResponseWriter, r *http.Request) bool {
return requireViaSession(w, r, "manage saved tokens")
}
// requireViaSession answers 401 unless the request came from a session, saying
// what the session is needed to do.
// sessionRequiredMessage is what a caller with no session is told when it tries
// to do something that needs one, on the endpoints and through the MCP tools.
func sessionRequiredMessage(to string) string {
return "a session is required to " + to
}
func requireViaSession(w http.ResponseWriter, r *http.Request, to string) bool {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", sessionRequiredMessage(to))
return false
}
return true
}
func (h *forgeTokensHandler) list(w http.ResponseWriter, r *http.Request) {
if !requireSessionAuth(w, r) {
return
}
tokens, err := h.store.ListForgeTokens(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list saved tokens")
return
}
dtos := make([]savedForgeTokenDTO, 0, len(tokens))
for _, t := range tokens {
dtos = append(dtos, toSavedForgeTokenDTO(t))
}
writeJSON(w, http.StatusOK, map[string][]savedForgeTokenDTO{"tokens": dtos})
}
func (h *forgeTokensHandler) save(w http.ResponseWriter, r *http.Request) {
if !requireSessionAuth(w, r) {
return
}
var in saveForgeTokenDTO
if !readJSON(w, r, &in) {
return
}
if !validForge(in.Forge) || in.Token == "" || (in.Forge == string(ingestion.ForgeForgejo) && in.ForgejoInstanceURL == "") {
writeError(w, http.StatusBadRequest, "invalid_body", "forge and token are required, and a Forgejo token needs its instance URL")
return
}
if len(in.Token) > maxTokenLength || len(in.ForgejoInstanceURL) > maxInstanceURLLength {
writeError(w, http.StatusBadRequest, "invalid_body", "token or instance URL is too long")
return
}
saved, err := h.store.SaveForgeToken(r.Context(), ingestion.Forge(in.Forge), in.ForgejoInstanceURL, in.Token)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "save token")
return
}
writeJSON(w, http.StatusOK, toSavedForgeTokenDTO(saved))
}
func (h *forgeTokensHandler) delete(w http.ResponseWriter, r *http.Request) {
if !requireSessionAuth(w, r) {
return
}
err := h.store.DeleteForgeToken(r.Context(), r.PathValue("tokenId"))
if errors.Is(err, ingestion.ErrSavedTokenNotFound) {
writeError(w, http.StatusNotFound, "not_found", "saved token not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "delete saved token")
return
}
w.WriteHeader(http.StatusNoContent)
}
func validForge(forge string) bool {
return forge == string(ingestion.ForgeGitHub) || forge == string(ingestion.ForgeForgejo)
}
// givenOrSavedToken is the token a registration or discovery should use: the
// one it carries, else the one saved for its forge and instance. When neither
// exists it is a 400 no_saved_token. The repo endpoints and the MCP tools that
// register or discover repos share it.
func givenOrSavedToken(ctx context.Context, tokens ingestion.ForgeTokenStore, forge, instanceURL, given string) (string, *apiProblem) {
if given != "" {
return given, nil
}
if tokens != nil {
token, err := tokens.ForgeToken(ctx, ingestion.Forge(forge), instanceURL)
if err == nil {
return token, nil
}
if !errors.Is(err, ingestion.ErrSavedTokenNotFound) {
return "", &apiProblem{http.StatusInternalServerError, "internal_error", "read saved token"}
}
}
return "", &apiProblem{http.StatusBadRequest, "no_saved_token", "no token given and none saved for this forge"}
}
package httpserver
import (
"context"
"fmt"
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
type rateLimitStatusDTO struct {
Limit int `json:"limit"`
Remaining int `json:"remaining"`
Used int `json:"used"`
ResetAt time.Time `json:"resetAt"`
}
type githubTokenUsageDTO struct {
TokenMasked string `json:"tokenMasked"`
Repos []string `json:"repos"`
Status *rateLimitStatusDTO `json:"status,omitempty"`
}
type insightsHandler struct {
repos ingestion.Store
rateLimits ingestion.RateLimitReporter
}
// githubTokenGroup accumulates every repo tracked under one GitHub token,
// plus that token's most recently observed rate-limit status.
type githubTokenGroup struct {
tokenMasked string
identifiers []string
status *ingestion.RateLimitSnapshot
}
// githubRateLimit reports, per distinct GitHub token this app holds (one
// row per token, not per repo -- the same token often tracks more than one
// repo), which repos it's used for and its most recently observed
// rate-limit status. That status comes from a real API response's headers
// (ingestion.RateLimitReporter), never a dedicated poll -- GitHub's own
// guidance prefers this over calling GET /rate_limit.
func (h *insightsHandler) githubRateLimit(w http.ResponseWriter, r *http.Request) {
dtos, err := h.tokenUsage(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list repos")
return
}
writeJSON(w, http.StatusOK, dtos)
}
// tokenUsage is the endpoint's body, shared with the MCP get_github_rate_limit
// tool so both group repos by token and report its status the same way.
func (h *insightsHandler) tokenUsage(ctx context.Context) ([]githubTokenUsageDTO, error) {
repos, _, err := h.repos.ListRepos(ctx, ingestion.RepoListFilter{})
if err != nil {
return nil, fmt.Errorf("list repos: %w", err)
}
order, byToken := h.groupGitHubReposByToken(ctx, repos)
return toGitHubTokenUsageDTOs(order, byToken), nil
}
// groupGitHubReposByToken decrypts each GitHub repo's token and groups repo
// identifiers by it, attaching whatever rate-limit status is known for that
// token. order preserves first-seen order, since a map alone wouldn't.
func (h *insightsHandler) groupGitHubReposByToken(
ctx context.Context, repos []ingestion.Repo,
) (order []string, byToken map[string]*githubTokenGroup) {
byToken = make(map[string]*githubTokenGroup)
order = make([]string, 0, len(repos))
for _, repo := range repos {
if repo.Forge != ingestion.ForgeGitHub {
continue
}
token, err := h.repos.RepoToken(ctx, repo.ID)
if err != nil {
continue
}
group, ok := byToken[token]
if !ok {
group = &githubTokenGroup{tokenMasked: repo.TokenMasked}
byToken[token] = group
order = append(order, token)
}
group.identifiers = append(group.identifiers, repo.Identifier)
h.attachStatus(group, token)
}
return order, byToken
}
func (h *insightsHandler) attachStatus(group *githubTokenGroup, token string) {
if group.status != nil || h.rateLimits == nil {
return
}
if snapshot, ok := h.rateLimits.RateLimitFor(token); ok {
group.status = &snapshot
}
}
func toGitHubTokenUsageDTOs(order []string, byToken map[string]*githubTokenGroup) []githubTokenUsageDTO {
dtos := make([]githubTokenUsageDTO, 0, len(order))
for _, token := range order {
group := byToken[token]
dto := githubTokenUsageDTO{TokenMasked: group.tokenMasked, Repos: group.identifiers}
if group.status != nil {
dto.Status = &rateLimitStatusDTO{
Limit: group.status.Limit,
Remaining: group.status.Remaining,
Used: group.status.Used,
ResetAt: group.status.ResetAt,
}
}
dtos = append(dtos, dto)
}
return dtos
}
package httpserver
import (
"errors"
"net/http"
"strconv"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
type jobLogDTO struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Lines []string `json:"lines"`
Truncated bool `json:"truncated"`
ForgeURL string `json:"forgeUrl"`
}
func toJobLogDTO(r ingestion.JobLogResult) jobLogDTO {
lines := r.Lines
if lines == nil {
lines = []string{}
}
return jobLogDTO{
Available: r.Available,
Reason: string(r.Reason),
Lines: lines,
Truncated: r.Truncated,
ForgeURL: r.ForgeURL,
}
}
type jobLogHandler struct {
service *ingestion.JobLogService
}
// get returns the tail of one job's log, fetched from the forge when asked
// and never stored. A log the forge can't supply is a 200 with
// available=false and a reason, so the dashboard can say why and still link
// out; only an unknown run or job is a 404 (openapi.yaml, getJobLog).
func (h *jobLogHandler) get(w http.ResponseWriter, r *http.Request) {
lines := 0
if raw := r.URL.Query().Get("lines"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > ingestion.MaxLogLines {
writeError(w, http.StatusBadRequest, "invalid_lines", "lines must be a whole number from 1 to 1000")
return
}
lines = n
}
result, err := h.service.Tail(r.Context(), r.PathValue("runId"), r.PathValue("jobId"), lines)
if err != nil {
if errors.Is(err, ingestion.ErrJobNotFound) {
writeError(w, http.StatusNotFound, "not_found", "job not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "get job log")
return
}
writeJSON(w, http.StatusOK, toJobLogDTO(result))
}
package httpserver
import (
"log/slog"
"net/http"
"time"
)
// statusRecorder wraps an http.ResponseWriter to capture the status code a
// handler wrote, since net/http exposes no way to read it back afterward.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
// requestLogger wraps next with a middleware that emits one structured log
// record per request after it completes -- method, path, status, duration,
// remote address -- at a level chosen by the response's status class (5xx
// Error, 4xx Warn, else Info). /healthz and /readyz are excluded outright:
// they're polled continuously by whatever checks liveness or readiness and
// add no actionable signal (a failing /readyz logs its own cause).
//
// Wrapped outside requireSession (not the mux directly), so a request
// requireSession rejects before any handler runs is still logged, with the
// 401/403 status the rejection produced.
//
// Never logs the Cookie or Authorization header, the raw query string, or
// request/response bodies -- only the fields listed above.
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
next.ServeHTTP(w, r)
return
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(rec, r)
duration := time.Since(start)
attrs := []any{
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", duration,
"remoteAddr", r.RemoteAddr,
}
switch {
case rec.status >= http.StatusInternalServerError:
slog.ErrorContext(r.Context(), "request", attrs...)
case rec.status >= http.StatusBadRequest:
slog.WarnContext(r.Context(), "request", attrs...)
default:
slog.InfoContext(r.Context(), "request", attrs...)
}
})
}
package httpserver
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
"github.com/alrayyes/pipeline-analytics/internal/settings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errInvalidToolInput is a tool argument outside what its schema allows.
var errInvalidToolInput = errors.New("invalid tool input")
// errJobLogsUnavailable is returned by get_job_log in a build with no job log
// service, which the REST route answers with a 404.
var errJobLogsUnavailable = errors.New("job logs are not available")
// mcpHandler adapts the same metrics.Service / ingestion.Store calls the
// REST handlers in this package already make into MCP tools, per
// mcp-endpoint/spec.md's "MCP tools cover the existing read surface" --
// no new domain logic, the same translation pipelines.go's handlers do,
// just to a different wire format.
type mcpHandler struct {
metrics *metrics.Service
repos ingestion.Store
logs *ingestion.JobLogService
version string
rateLimits ingestion.RateLimitReporter
settings *settings.Service
runActions *ingestion.RunActionService
repoWrites *reposHandler
}
// newMCPHandler builds the Streamable HTTP handler for POST /api/mcp,
// mounted inside the same requireSession gate as every other data
// endpoint (server.go). Stateless: true -- each request is handled
// independently, with no server-held session state between calls. This
// server has no need for the standalone SSE stream a stateful session
// would enable (there's nothing it pushes to a client unprompted), and
// statelessness keeps a single-account, always-on server simple.
func newMCPHandler(deps Deps) http.Handler {
h := &mcpHandler{metrics: deps.Metrics, repos: deps.IngestionStore, logs: deps.JobLogs, version: deps.Version, rateLimits: deps.GitHubRateLimits, settings: deps.Settings, runActions: deps.RunActions, repoWrites: &reposHandler{registrar: deps.Registrar, store: deps.IngestionStore, tokens: deps.ForgeTokens}}
server := mcp.NewServer(&mcp.Implementation{
Name: "pipeline-analytics",
Version: deps.Version,
}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "list_pipelines",
Description: "List tracked pipelines and their current health status. Matches GET /api/pipelines.",
}, h.listPipelines)
mcp.AddTool(server, &mcp.Tool{
Name: "get_pipeline",
Description: "Get one pipeline's health status, duration trend, and failure-rate trend. Matches GET /api/pipelines/{pipelineId}.",
}, h.getPipeline)
mcp.AddTool(server, &mcp.Tool{
Name: "list_pipeline_steps",
Description: "List a pipeline's steps ranked by duration contribution, with queue/exec split, failure rate, flaky flag, and whether it is quarantined. Matches GET /api/pipelines/{pipelineId}/steps.",
}, h.listPipelineSteps)
mcp.AddTool(server, &mcp.Tool{
Name: "list_pipeline_flaky_runs",
Description: "List every run in which a named step failed, most recent first. Matches GET /api/pipelines/{pipelineId}/flaky-runs.",
}, h.listPipelineFlakyRuns)
mcp.AddTool(server, &mcp.Tool{
Name: "get_repo_usage",
Description: "Get runner-minutes usage per workflow for a tracked repository. Matches GET /api/repos/{repoId}/usage.",
}, h.getRepoUsage)
mcp.AddTool(server, &mcp.Tool{
Name: "list_unhealthy_steps",
Description: "List pipelines with at least one flaky or failing step, grouped by pipeline. Matches GET /api/steps/unhealthy.",
}, h.listUnhealthySteps)
mcp.AddTool(server, &mcp.Tool{
Name: "get_failure_insights",
Description: "Get failure aggregates over a 24h, 7d or 30d window: pass rate and its change, MTTR, failures by step, failure categories, top failing pipelines and root-cause groups. Matches GET /api/insights/failures.",
}, h.getFailureInsights)
h.addRunTools(server)
h.addRepoTools(server)
h.addSettingsUpdateTool(server)
h.addRunActionTools(server)
h.addQuarantineTools(server)
h.addRepoWriteTools(server)
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{Stateless: true})
}
// addRunTools registers the tools about individual runs and their jobs.
func (h *mcpHandler) addRunTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "list_runs",
Description: "List runs newest first, each with its steps and a normalized outcome. Matches GET /api/runs.",
}, h.listRuns)
mcp.AddTool(server, &mcp.Tool{
Name: "get_run_steps",
Description: "Get one run's steps, each with a normalized outcome and a link to its job on the forge. Matches GET /api/runs/{runId}/steps.",
}, h.getRunSteps)
mcp.AddTool(server, &mcp.Tool{
Name: "list_branches",
Description: "List the branches with runs in a 24h, 7d or 30d window, busiest first, each with its run count. Pass a name as branch to get_failure_insights, list_runs or list_flaky_steps to scope them. Matches GET /api/branches.",
}, h.listBranches)
mcp.AddTool(server, &mcp.Tool{
Name: "list_flaky_steps",
Description: "List flaky steps across every pipeline, ranked by flake rate, each with its run count, up to 40 recent outcomes (oldest first) and whether it is quarantined. Matches GET /api/steps/flaky.",
}, h.listFlakySteps)
mcp.AddTool(server, &mcp.Tool{
Name: "get_job_log",
Description: "Get the last lines of one job's log, fetched from the forge on demand, with ANSI sequences untouched. When the forge can't supply it, available is false with a reason and the job's forgeUrl. Matches GET /api/runs/{runId}/jobs/{jobId}/log.",
}, h.getJobLog)
}
type listPipelinesInput struct {
RepoID string `json:"repoId,omitempty" jsonschema:"restrict to one tracked repo; omitted returns every repo's pipelines"`
Forge string `json:"forge,omitempty" jsonschema:"restrict to one forge; omitted returns every forge"`
Health string `json:"health,omitempty" jsonschema:"restrict to healthy or unhealthy pipelines; omitted returns every status"`
Sort string `json:"sort,omitempty" jsonschema:"name (default) or lastRun, most recently run first"`
Window string `json:"window,omitempty" jsonschema:"trailing run count the health status is computed over; omitted uses a server-chosen default"`
Limit int `json:"limit,omitempty" jsonschema:"max pipelines to return; omitted returns every matching pipeline"`
Offset int `json:"offset,omitempty" jsonschema:"pipelines to skip before the returned page"`
}
func (h *mcpHandler) listPipelines(ctx context.Context, _ *mcp.CallToolRequest, in listPipelinesInput) (*mcp.CallToolResult, pipelineListDTO, error) {
window := metrics.ParseWindow(in.Window)
health, err := metrics.ParseHealthFilter(in.Health)
if err != nil {
return nil, pipelineListDTO{}, fmt.Errorf("list pipelines: %w", err)
}
sortOrder, err := metrics.ParsePipelineSort(in.Sort)
if err != nil {
return nil, pipelineListDTO{}, fmt.Errorf("list pipelines: %w", err)
}
pipelines, hasMore, err := h.metrics.ListPipelines(ctx, window, metrics.PipelineListFilter{
RepoID: in.RepoID,
Forge: in.Forge,
Health: health,
Sort: sortOrder,
Limit: in.Limit,
Offset: in.Offset,
})
if err != nil {
return nil, pipelineListDTO{}, fmt.Errorf("list pipelines: %w", err)
}
dtos := make([]pipelineSummaryDTO, 0, len(pipelines))
for _, p := range pipelines {
dtos = append(dtos, toPipelineSummaryDTO(p))
}
return nil, pipelineListDTO{Pipelines: dtos, HasMore: hasMore}, nil
}
// pipelineIDWindowInput is get_pipeline's and list_pipeline_steps' shared
// input shape -- both mirror a REST endpoint taking only PipelineId and
// Window (openapi/openapi.yaml).
type pipelineIDWindowInput struct {
PipelineID string `json:"pipelineId" jsonschema:"the pipeline's opaque id, as returned by list_pipelines"`
Window string `json:"window,omitempty" jsonschema:"trailing run count the trend or ranking is computed over; omitted uses a server-chosen default"`
}
func (h *mcpHandler) getPipeline(ctx context.Context, _ *mcp.CallToolRequest, in pipelineIDWindowInput) (*mcp.CallToolResult, pipelineDetailDTO, error) {
window := metrics.ParseWindow(in.Window)
detail, err := h.metrics.GetPipeline(ctx, metrics.PipelineID(in.PipelineID), window)
if err != nil {
if errors.Is(err, metrics.ErrPipelineNotFound) || errors.Is(err, metrics.ErrInvalidPipelineID) {
return nil, pipelineDetailDTO{}, fmt.Errorf("%w: %s", metrics.ErrPipelineNotFound, in.PipelineID)
}
return nil, pipelineDetailDTO{}, fmt.Errorf("get pipeline: %w", err)
}
return nil, pipelineDetailDTO{
pipelineSummaryDTO: toPipelineSummaryDTO(detail.Pipeline),
DurationTrend: toTrendDTO(detail.DurationTrend),
FailureRateTrend: toTrendDTO(detail.FailureRateTrend),
}, nil
}
type pipelineStepsResultDTO struct {
Steps []stepDTO `json:"steps"`
}
// listPipelineSteps mirrors pipelinesHandler.steps: an unknown pipelineId
// isn't distinguished from one with no recorded steps yet -- both report
// an empty list. Only a malformed id is a tool error.
func (h *mcpHandler) listPipelineSteps(ctx context.Context, _ *mcp.CallToolRequest, in pipelineIDWindowInput) (*mcp.CallToolResult, pipelineStepsResultDTO, error) {
window := metrics.ParseWindow(in.Window)
steps, err := h.metrics.GetPipelineSteps(ctx, metrics.PipelineID(in.PipelineID), window)
if err != nil {
if errors.Is(err, metrics.ErrInvalidPipelineID) {
return nil, pipelineStepsResultDTO{}, fmt.Errorf("%w: %s", metrics.ErrInvalidPipelineID, in.PipelineID)
}
return nil, pipelineStepsResultDTO{}, fmt.Errorf("get pipeline steps: %w", err)
}
dtos := make([]stepDTO, 0, len(steps))
for _, s := range steps {
dtos = append(dtos, toStepDTO(s))
}
return nil, pipelineStepsResultDTO{Steps: dtos}, nil
}
type flakyRunsInput struct {
PipelineID string `json:"pipelineId" jsonschema:"the pipeline's opaque id, as returned by list_pipelines"`
Step string `json:"step" jsonschema:"a step's name, exactly as list_pipeline_steps reports it"`
Window string `json:"window,omitempty" jsonschema:"trailing run count to search within; omitted uses a server-chosen default"`
}
type flakyRunsResultDTO struct {
Runs []flakyRunDTO `json:"runs"`
}
func (h *mcpHandler) listPipelineFlakyRuns(ctx context.Context, _ *mcp.CallToolRequest, in flakyRunsInput) (*mcp.CallToolResult, flakyRunsResultDTO, error) {
window := metrics.ParseWindow(in.Window)
runs, err := h.metrics.ListFlakyRuns(ctx, metrics.PipelineID(in.PipelineID), in.Step, window)
if err != nil {
if errors.Is(err, metrics.ErrInvalidPipelineID) {
return nil, flakyRunsResultDTO{}, fmt.Errorf("%w: %s", metrics.ErrInvalidPipelineID, in.PipelineID)
}
return nil, flakyRunsResultDTO{}, fmt.Errorf("list flaky runs: %w", err)
}
dtos := make([]flakyRunDTO, 0, len(runs))
for _, run := range runs {
dtos = append(dtos, toFlakyRunDTO(run))
}
return nil, flakyRunsResultDTO{Runs: dtos}, nil
}
type repoUsageInput struct {
RepoID string `json:"repoId" jsonschema:"the tracked repository's id, as returned by list_pipelines' repoId field"`
Window string `json:"window,omitempty" jsonschema:"trailing run count usage is summed over; omitted uses a server-chosen default"`
}
type usageResultDTO struct {
Usage []usageEntryDTO `json:"usage"`
}
func (h *mcpHandler) getRepoUsage(ctx context.Context, _ *mcp.CallToolRequest, in repoUsageInput) (*mcp.CallToolResult, usageResultDTO, error) {
if _, problem := (&reposHandler{store: h.repos}).trackedRepo(ctx, in.RepoID); problem != nil {
return nil, usageResultDTO{}, problemError(problem)
}
window := metrics.ParseWindow(in.Window)
usage, err := h.metrics.GetRepoUsage(ctx, in.RepoID, window)
if err != nil {
return nil, usageResultDTO{}, fmt.Errorf("get repo usage: %w", err)
}
dtos := make([]usageEntryDTO, 0, len(usage))
for _, u := range usage {
dtos = append(dtos, usageEntryDTO{Workflow: u.Workflow, RunnerMinutes: u.RunnerMinutes})
}
return nil, usageResultDTO{Usage: dtos}, nil
}
type unhealthyStepsInput struct {
Window string `json:"window,omitempty" jsonschema:"trailing run count each pipeline's steps are aggregated over; omitted uses a server-chosen default"`
Limit int `json:"limit,omitempty" jsonschema:"max pipeline groups to return; omitted returns every matching group"`
Offset int `json:"offset,omitempty" jsonschema:"pipeline groups to skip before the returned page"`
}
func (h *mcpHandler) listUnhealthySteps(ctx context.Context, _ *mcp.CallToolRequest, in unhealthyStepsInput) (*mcp.CallToolResult, pipelineStepsGroupListDTO, error) {
window := metrics.ParseWindow(in.Window)
groups, hasMore, err := h.metrics.ListUnhealthySteps(ctx, window, in.Limit, in.Offset)
if err != nil {
return nil, pipelineStepsGroupListDTO{}, fmt.Errorf("list unhealthy steps: %w", err)
}
dtos := make([]pipelineStepsGroupDTO, 0, len(groups))
for _, g := range groups {
steps := make([]stepDTO, 0, len(g.Steps))
for _, s := range g.Steps {
steps = append(steps, toStepDTO(s))
}
dtos = append(dtos, pipelineStepsGroupDTO{
PipelineID: string(g.PipelineID),
PipelineName: g.PipelineName,
RepoID: g.RepoID,
Steps: steps,
})
}
return nil, pipelineStepsGroupListDTO{Groups: dtos, HasMore: hasMore}, nil
}
type failureInsightsInput struct {
RepoID string `json:"repoId,omitempty" jsonschema:"restrict to one tracked repo; omitted covers every repo"`
Forge string `json:"forge,omitempty" jsonschema:"restrict to one forge; omitted covers every forge"`
Branch string `json:"branch,omitempty" jsonschema:"cover only runs on this branch, matched exactly; omitted covers every branch"`
Window string `json:"window,omitempty" jsonschema:"24h, 7d or 30d; anything else, or omitted, is 7d, and the result reports the window used"`
}
func (h *mcpHandler) getFailureInsights(ctx context.Context, _ *mcp.CallToolRequest, in failureInsightsInput) (*mcp.CallToolResult, failureInsightsDTO, error) {
insights, err := h.metrics.GetFailureInsights(
ctx,
time.Now(),
metrics.ParseInsightWindow(in.Window),
metrics.InsightFilter{RepoID: in.RepoID, Forge: in.Forge, Branch: in.Branch},
)
if err != nil {
return nil, failureInsightsDTO{}, fmt.Errorf("get failure insights: %w", err)
}
return nil, toFailureInsightsDTO(insights), nil
}
type listRunsInput struct {
RepoID string `json:"repoId,omitempty" jsonschema:"restrict to one tracked repo; omitted returns every repo's runs"`
Forge string `json:"forge,omitempty" jsonschema:"restrict to one forge; omitted returns every forge"`
Branch string `json:"branch,omitempty" jsonschema:"cover only runs on this branch, matched exactly; omitted covers every branch"`
Status string `json:"status,omitempty" jsonschema:"all (default), failed, running or success; anything else is an error"`
Limit int `json:"limit,omitempty" jsonschema:"max runs to return; omitted returns every matching run"`
Offset int `json:"offset,omitempty" jsonschema:"runs to skip before the returned page"`
}
func (h *mcpHandler) listRuns(ctx context.Context, _ *mcp.CallToolRequest, in listRunsInput) (*mcp.CallToolResult, runListDTO, error) {
status, err := metrics.ParseRunStatus(in.Status)
if err != nil {
return nil, runListDTO{}, fmt.Errorf("list runs: status must be one of all, failed, running, success: %w", err)
}
runs, hasMore, err := h.metrics.ListRuns(ctx, metrics.RunListFilter{
RepoID: in.RepoID,
Forge: in.Forge,
Branch: in.Branch,
Status: status,
Limit: in.Limit,
Offset: in.Offset,
})
if err != nil {
return nil, runListDTO{}, fmt.Errorf("list runs: %w", err)
}
dtos := make([]runSummaryDTO, 0, len(runs))
for _, run := range runs {
dtos = append(dtos, toRunSummaryDTO(run))
}
return nil, runListDTO{Runs: dtos, HasMore: hasMore}, nil
}
type runStepsInput struct {
RunID string `json:"runId" jsonschema:"a run's id, as list_runs or list_pipeline_flaky_runs reports it"`
}
func (h *mcpHandler) getRunSteps(ctx context.Context, _ *mcp.CallToolRequest, in runStepsInput) (*mcp.CallToolResult, runDetailDTO, error) {
detail, err := h.metrics.GetRunSteps(ctx, in.RunID)
if err != nil {
if errors.Is(err, metrics.ErrRunNotFound) {
return nil, runDetailDTO{}, fmt.Errorf("%w: %s", metrics.ErrRunNotFound, in.RunID)
}
return nil, runDetailDTO{}, fmt.Errorf("get run steps: %w", err)
}
steps := make([]runStepDTO, 0, len(detail.Steps))
for _, s := range detail.Steps {
steps = append(steps, toRunStepDTO(s))
}
return nil, runDetailDTO{RunID: detail.RunID, StartedAt: detail.StartedAt, Steps: steps}, nil
}
type jobLogInput struct {
RunID string `json:"runId" jsonschema:"a run's id, as list_runs or get_run_steps reports it"`
JobID string `json:"jobId" jsonschema:"a job's id, as get_run_steps reports it on each step"`
Lines int `json:"lines,omitempty" jsonschema:"how many trailing lines, 1 to 1000; omitted returns 200"`
}
func (h *mcpHandler) getJobLog(ctx context.Context, _ *mcp.CallToolRequest, in jobLogInput) (*mcp.CallToolResult, jobLogDTO, error) {
if in.Lines != 0 && (in.Lines < 1 || in.Lines > ingestion.MaxLogLines) {
return nil, jobLogDTO{}, fmt.Errorf("%w: lines must be from 1 to %d", errInvalidToolInput, ingestion.MaxLogLines)
}
if h.logs == nil {
return nil, jobLogDTO{}, errJobLogsUnavailable
}
result, err := h.logs.Tail(ctx, in.RunID, in.JobID, in.Lines)
if err != nil {
if errors.Is(err, ingestion.ErrJobNotFound) {
return nil, jobLogDTO{}, fmt.Errorf("%w: run %s, job %s", ingestion.ErrJobNotFound, in.RunID, in.JobID)
}
return nil, jobLogDTO{}, fmt.Errorf("get job log: %w", err)
}
return nil, toJobLogDTO(result), nil
}
type listBranchesInput struct {
RepoID string `json:"repoId,omitempty" jsonschema:"restrict to one tracked repo; omitted covers every repo"`
Forge string `json:"forge,omitempty" jsonschema:"restrict to one forge; omitted covers every forge"`
Window string `json:"window,omitempty" jsonschema:"24h, 7d or 30d; anything else, or omitted, is 7d, and the result reports the window used"`
}
func (h *mcpHandler) listBranches(ctx context.Context, _ *mcp.CallToolRequest, in listBranchesInput) (*mcp.CallToolResult, branchListDTO, error) {
window := metrics.ParseInsightWindow(in.Window)
branches, err := h.metrics.ListBranches(ctx, time.Now(), window, metrics.InsightFilter{RepoID: in.RepoID, Forge: in.Forge})
if err != nil {
return nil, branchListDTO{}, fmt.Errorf("list branches: %w", err)
}
return nil, toBranchListDTO(window, branches), nil
}
type listFlakyStepsInput struct {
RepoID string `json:"repoId,omitempty" jsonschema:"restrict to one tracked repo; omitted covers every repo"`
Forge string `json:"forge,omitempty" jsonschema:"restrict to one forge; omitted covers every forge"`
Branch string `json:"branch,omitempty" jsonschema:"cover only runs on this branch, matched exactly; omitted covers every branch"`
Window string `json:"window,omitempty" jsonschema:"24h, 7d or 30d; anything else, or omitted, is 7d, and the result reports the window used"`
Limit int `json:"limit,omitempty" jsonschema:"max steps to return; omitted returns every flaky step"`
Offset int `json:"offset,omitempty" jsonschema:"steps to skip before the returned page"`
}
func (h *mcpHandler) listFlakySteps(ctx context.Context, _ *mcp.CallToolRequest, in listFlakyStepsInput) (*mcp.CallToolResult, flakyStepListDTO, error) {
window := metrics.ParseInsightWindow(in.Window)
steps, hasMore, err := h.metrics.ListFlakySteps(
ctx,
time.Now(),
window,
metrics.InsightFilter{RepoID: in.RepoID, Forge: in.Forge, Branch: in.Branch},
in.Limit,
in.Offset,
)
if err != nil {
return nil, flakyStepListDTO{}, fmt.Errorf("list flaky steps: %w", err)
}
return nil, toFlakyStepListDTO(window, steps, hasMore), nil
}
// mcpToolError is a failed call as a tool reports it: the code and message the
// matching endpoint answers with, so an agent reads what a client of the
// endpoint reads.
type mcpToolError struct {
code, message string
}
func (e mcpToolError) Error() string { return e.code + ": " + e.message }
// requireToolSession is requireViaSession for a tool call: a write needs a
// signed-in session, and an API token is refused before anything is changed,
// with the error the endpoint gives.
func requireToolSession(ctx context.Context, to string) error {
if info, ok := authInfoFrom(ctx); ok && info.ViaSession {
return nil
}
return mcpToolError{code: "unauthenticated", message: sessionRequiredMessage(to)}
}
package httpserver
import (
"context"
"time"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// addQuarantineTools registers the two tools that mark a flaky step as known
// and clear the mark. Like the endpoints they are session-only: a caller
// holding only an API token is refused before anything is written
// (requireToolSession). Neither touches the forge.
func (h *mcpHandler) addQuarantineTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "quarantine_step",
Description: "Mark a flaky step as known for 30 days, with an optional note, so it stops raising the flaky_step health signal. Marking a step that is already quarantined renews it. Needs a session, not an API token. Matches PUT /api/pipelines/{pipelineId}/steps/{step}/quarantine.",
}, h.quarantineStep)
mcp.AddTool(server, &mcp.Tool{
Name: "unquarantine_step",
Description: "Clear a step's quarantine so it raises the flaky_step health signal again. A step with no mark is fine. Needs a session, not an API token. Matches DELETE /api/pipelines/{pipelineId}/steps/{step}/quarantine.",
}, h.unquarantineStep)
}
type stepInput struct {
PipelineID string `json:"pipelineId" jsonschema:"the pipeline's id, as list_pipelines reports it"`
Step string `json:"step" jsonschema:"the step's name, as list_flaky_steps reports it"`
}
type quarantineStepInput struct {
stepInput
Note string `json:"note,omitempty" jsonschema:"why the step is known to be flaky; at most 500 characters"`
}
// checkStepInput refuses an empty pipeline or step. The endpoints' path cannot
// match an empty segment, so a tool must not write a mark for one either.
func checkStepInput(in stepInput) error {
if in.PipelineID == "" || in.Step == "" {
return mcpToolError{code: "invalid_input", message: "pipelineId and step are required"}
}
return nil
}
func (h *mcpHandler) quarantineStep(ctx context.Context, _ *mcp.CallToolRequest, in quarantineStepInput) (*mcp.CallToolResult, stepQuarantineDTO, error) {
if err := requireToolSession(ctx, "quarantine a step"); err != nil {
return nil, stepQuarantineDTO{}, err
}
if err := checkStepInput(in.stepInput); err != nil {
return nil, stepQuarantineDTO{}, err
}
q, err := h.metrics.QuarantineStep(ctx, metrics.PipelineID(in.PipelineID), in.Step, in.Note, time.Now())
if err != nil {
_, code, message := classifyQuarantineError(err)
return nil, stepQuarantineDTO{}, mcpToolError{code: code, message: message}
}
return nil, stepQuarantineDTO{Quarantined: true, Quarantine: toQuarantineDTO(&q)}, nil
}
func (h *mcpHandler) unquarantineStep(ctx context.Context, _ *mcp.CallToolRequest, in stepInput) (*mcp.CallToolResult, stepQuarantineDTO, error) {
if err := requireToolSession(ctx, "un-quarantine a step"); err != nil {
return nil, stepQuarantineDTO{}, err
}
if err := checkStepInput(in); err != nil {
return nil, stepQuarantineDTO{}, err
}
if err := h.metrics.UnquarantineStep(ctx, metrics.PipelineID(in.PipelineID), in.Step); err != nil {
_, code, message := classifyQuarantineError(err)
return nil, stepQuarantineDTO{}, mcpToolError{code: code, message: message}
}
return nil, stepQuarantineDTO{}, nil
}
package httpserver
import (
"context"
"errors"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errRepoWritesUnavailable is returned by the repo-write tools in a build with
// no registrar.
var errRepoWritesUnavailable = errors.New("repo registration is not available")
// addRepoWriteTools registers the tools that start and stop tracking a repo and
// list what a token could track. They run the same functions as the endpoints
// (registerRepo, discoverRepos, untrackRepo in repos.go), so the validation, the
// token lookup and the conflict codes are the endpoints'. The endpoints are not
// session-only, so neither are the tools: any caller the MCP gate lets in may
// use them, an API token included. A registration without a token uses the one
// saved for the forge, exactly as the endpoint does.
func (h *mcpHandler) addRepoWriteTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "register_repo",
Description: "Start tracking a repository: register it with a webhook and begin ingesting its runs. Pass a repo-scoped token, or omit it to use the one saved for the forge and instance. The token is never echoed back. Refused for an archived repo, a fork or a mirror, or one already tracked. Matches POST /api/repos.",
}, h.registerRepoTool)
mcp.AddTool(server, &mcp.Tool{
Name: "discover_repos",
Description: "List the repositories a token can reach on a forge, as identifiers to register. Pass a token, or omit it to use the one saved for the forge and instance. Matches POST /api/repos/discover.",
}, h.discoverReposTool)
mcp.AddTool(server, &mcp.Tool{
Name: "untrack_repo",
Description: "Stop tracking a repository and delete its stored runs, jobs and steps. This does not remove the webhook created on the forge. Matches DELETE /api/repos/{repoId}.",
}, h.untrackRepoTool)
}
func problemError(p *apiProblem) error {
return mcpToolError{code: p.code, message: p.message}
}
type registerRepoInput struct {
Forge string `json:"forge" jsonschema:"github or forgejo"`
Identifier string `json:"identifier" jsonschema:"the repository, as owner/name; at most 200 characters"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty" jsonschema:"for forgejo, the instance's URL"`
Token string `json:"token,omitempty" jsonschema:"a repo-scoped personal access token; omitted uses the token saved for this forge and instance"`
}
func (h *mcpHandler) registerRepoTool(ctx context.Context, _ *mcp.CallToolRequest, in registerRepoInput) (*mcp.CallToolResult, repoDTO, error) {
if h.repoWrites == nil || h.repoWrites.registrar == nil {
return nil, repoDTO{}, errRepoWritesUnavailable
}
repo, problem := h.repoWrites.registerRepo(ctx, repoRegistrationDTO(in))
if problem != nil {
return nil, repoDTO{}, problemError(problem)
}
return nil, toRepoDTO(repo), nil
}
type discoverReposInput struct {
Forge string `json:"forge" jsonschema:"github or forgejo"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty" jsonschema:"for forgejo, the instance's URL"`
Token string `json:"token,omitempty" jsonschema:"a personal access token; omitted uses the token saved for this forge and instance"`
}
func (h *mcpHandler) discoverReposTool(ctx context.Context, _ *mcp.CallToolRequest, in discoverReposInput) (*mcp.CallToolResult, repoIdentifiersDTO, error) {
if h.repoWrites == nil || h.repoWrites.registrar == nil {
return nil, repoIdentifiersDTO{}, errRepoWritesUnavailable
}
identifiers, problem := h.repoWrites.discoverRepos(ctx, repoDiscoveryDTO(in))
if problem != nil {
return nil, repoIdentifiersDTO{}, problemError(problem)
}
return nil, repoIdentifiersDTO{Identifiers: identifiers}, nil
}
type untrackRepoInput struct {
RepoID string `json:"repoId" jsonschema:"the repository's id, as list_repos reports it"`
}
type untrackRepoResult struct {
RepoID string `json:"repoId"`
Untracked bool `json:"untracked"`
}
func (h *mcpHandler) untrackRepoTool(ctx context.Context, _ *mcp.CallToolRequest, in untrackRepoInput) (*mcp.CallToolResult, untrackRepoResult, error) {
if h.repoWrites == nil || h.repoWrites.store == nil {
return nil, untrackRepoResult{}, errRepoWritesUnavailable
}
if problem := h.repoWrites.untrackRepo(ctx, in.RepoID); problem != nil {
return nil, untrackRepoResult{}, problemError(problem)
}
return nil, untrackRepoResult{RepoID: in.RepoID, Untracked: true}, nil
}
package httpserver
import (
"context"
"fmt"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// addRepoTools registers the reads about the service itself and the repos it
// tracks, each matching a GET endpoint that serves the dashboard's settings
// and repos views. The listing and validation come from the same helpers the
// handlers use (repos.go, version.go), so a repo reads the same either way and
// the token stays masked.
func (h *mcpHandler) addRepoTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "get_version",
Description: "Get the version of the running service. Matches GET /api/version.",
}, h.getVersion)
mcp.AddTool(server, &mcp.Tool{
Name: "list_repos",
Description: "List the repositories this service tracks, oldest first, each with its forge, identifier, ingestion status and masked token. Page with limit and offset. Matches GET /api/repos.",
}, h.listRepos)
mcp.AddTool(server, &mcp.Tool{
Name: "get_repo",
Description: "Get one tracked repository by id, with its forge, identifier, ingestion status and masked token. Takes an id from list_repos. Matches GET /api/repos/{repoId}.",
}, h.getRepo)
mcp.AddTool(server, &mcp.Tool{
Name: "list_repo_identifiers",
Description: "List the identifiers of the repos tracked on one forge, for a Forgejo forge on one instance. A forge is required. Matches GET /api/repos/identifiers.",
}, h.listRepoIdentifiers)
mcp.AddTool(server, &mcp.Tool{
Name: "get_github_rate_limit",
Description: "Get, for each GitHub token this service holds, the repos it is used for and its most recently observed rate-limit status. The status comes from real API responses and is absent for a token not yet used. Matches GET /api/insights/github-rate-limit.",
}, h.getGitHubRateLimit)
mcp.AddTool(server, &mcp.Tool{
Name: "get_settings",
Description: "Get the signed-in account's dashboard settings, each resolved against its default, beside the server's defaults. Matches GET /api/settings.",
}, h.getSettings)
}
type noInput struct{}
func (h *mcpHandler) getVersion(context.Context, *mcp.CallToolRequest, noInput) (*mcp.CallToolResult, versionDTO, error) {
return nil, versionDTO{Version: h.version}, nil
}
type listReposInput struct {
Forge string `json:"forge,omitempty" jsonschema:"restrict to one forge, github or forgejo; omitted returns every forge"`
Limit int `json:"limit,omitempty" jsonschema:"max repos to return; omitted returns every repo"`
Offset int `json:"offset,omitempty" jsonschema:"repos to skip before the returned page"`
}
func (h *mcpHandler) listRepos(ctx context.Context, _ *mcp.CallToolRequest, in listReposInput) (*mcp.CallToolResult, repoListDTO, error) {
repos, hasMore, err := h.repos.ListRepos(ctx, ingestion.RepoListFilter{
Forge: ingestion.Forge(in.Forge),
Limit: in.Limit,
Offset: in.Offset,
})
if err != nil {
return nil, repoListDTO{}, fmt.Errorf("list repos: %w", err)
}
return nil, toRepoListDTO(repos, hasMore), nil
}
type getRepoInput struct {
RepoID string `json:"repoId" jsonschema:"the repository's id, as list_repos reports it"`
}
func (h *mcpHandler) getRepo(ctx context.Context, _ *mcp.CallToolRequest, in getRepoInput) (*mcp.CallToolResult, repoDTO, error) {
repo, problem := (&reposHandler{store: h.repos}).trackedRepo(ctx, in.RepoID)
if problem != nil {
return nil, repoDTO{}, problemError(problem)
}
return nil, toRepoDTO(repo), nil
}
type listRepoIdentifiersInput struct {
Forge string `json:"forge" jsonschema:"github or forgejo"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty" jsonschema:"for forgejo, restrict to repos on this instance"`
}
func (h *mcpHandler) listRepoIdentifiers(ctx context.Context, _ *mcp.CallToolRequest, in listRepoIdentifiersInput) (*mcp.CallToolResult, repoIdentifiersDTO, error) {
if !isTrackedForge(in.Forge) {
return nil, repoIdentifiersDTO{}, fmt.Errorf("%w: forge is required", errInvalidToolInput)
}
identifiers, err := h.repos.ListRepoIdentifiers(ctx, ingestion.Forge(in.Forge), in.ForgejoInstanceURL)
if err != nil {
return nil, repoIdentifiersDTO{}, fmt.Errorf("list repo identifiers: %w", err)
}
return nil, repoIdentifiersDTO{Identifiers: identifiers}, nil
}
// githubRateLimitResult wraps the endpoint's array, since an MCP tool's
// structured result is an object.
type githubRateLimitResult struct {
Tokens []githubTokenUsageDTO `json:"tokens"`
}
func (h *mcpHandler) getGitHubRateLimit(ctx context.Context, _ *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, githubRateLimitResult, error) {
insights := &insightsHandler{repos: h.repos, rateLimits: h.rateLimits}
dtos, err := insights.tokenUsage(ctx)
if err != nil {
return nil, githubRateLimitResult{}, fmt.Errorf("get github rate limit: %w", err)
}
return nil, githubRateLimitResult{Tokens: dtos}, nil
}
func (h *mcpHandler) getSettings(ctx context.Context, _ *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, settingsDTO, error) {
info, ok := authInfoFrom(ctx)
if !ok || h.settings == nil {
return nil, settingsDTO{}, fmt.Errorf("%w: settings are not available", errInvalidToolInput)
}
got, err := h.settings.Get(ctx, info.UserID)
if err != nil {
return nil, settingsDTO{}, fmt.Errorf("get settings: %w", err)
}
return nil, toSettingsDTO(got), nil
}
package httpserver
import (
"context"
"errors"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errRunActionsUnavailable is returned by the run-action tools in a build with
// no run-action service, which leaves the REST routes unmounted.
var errRunActionsUnavailable = errors.New("run actions are not available")
// addRunActionTools registers the two tools that write to a forge. They go
// through the same RunActionService as POST /api/runs/{runId}/rerun and
// /cancel and apply the same rule: a session is required, an API token is
// refused (run_actions.go), so an agent holding only a token cannot re-run or
// cancel anything. A caller with a session, such as the in-browser agent, can.
func (h *mcpHandler) addRunActionTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "rerun_run",
Description: "Ask the forge to re-run a concluded run: only the failed jobs when the run failed, the whole run otherwise. Needs a session, not an API token, and a saved token with Actions write. Matches POST /api/runs/{runId}/rerun.",
}, h.act("rerun", func(ctx context.Context, runID string) error { return h.runActions.Rerun(ctx, runID) }))
mcp.AddTool(server, &mcp.Tool{
Name: "cancel_run",
Description: "Ask the forge to cancel a run that has not concluded. Needs a session, not an API token, and a saved token with Actions write. Matches POST /api/runs/{runId}/cancel.",
}, h.act("cancel", func(ctx context.Context, runID string) error { return h.runActions.Cancel(ctx, runID) }))
}
type runActionInput struct {
RunID string `json:"runId" jsonschema:"the run's id, as list_runs reports it"`
}
type runActionResult struct {
RunID string `json:"runId"`
Action string `json:"action"`
Accepted bool `json:"accepted"`
}
// act builds a tool handler around one action. The forge acts on its own
// time, so accepted means the forge took the request, not that the run has
// changed yet.
func (h *mcpHandler) act(action string, do func(context.Context, string) error) func(context.Context, *mcp.CallToolRequest, runActionInput) (*mcp.CallToolResult, runActionResult, error) {
return func(ctx context.Context, _ *mcp.CallToolRequest, in runActionInput) (*mcp.CallToolResult, runActionResult, error) {
if h.runActions == nil {
return nil, runActionResult{}, errRunActionsUnavailable
}
if err := requireToolSession(ctx, "act on a run"); err != nil {
return nil, runActionResult{}, err
}
if err := do(ctx, in.RunID); err != nil {
failure := classifyRunActionError(err)
return nil, runActionResult{}, mcpToolError{code: failure.code, message: failure.message}
}
return nil, runActionResult{RunID: in.RunID, Action: action, Accepted: true}, nil
}
}
package httpserver
import (
"context"
"fmt"
"github.com/alrayyes/pipeline-analytics/internal/settings"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// addSettingsUpdateTool registers update_settings, the PATCH /api/settings
// partial update. Its argument is the endpoint's own body: a key present with a
// value sets it, a key present with null clears it back to its default, a key
// left out is untouched. A struct cannot tell null from absent, so the input is
// a map and the tool carries an explicit schema, built from the settings
// package's keys and allowed values rather than copied by hand.
func (h *mcpHandler) addSettingsUpdateTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "update_settings",
Description: "Change the signed-in account's dashboard settings. Give only the settings to change: a value sets it, null clears it back to its default, and one left out is untouched. Returns the settings after the change. Matches PATCH /api/settings.",
InputSchema: settingsUpdateSchema(),
}, h.updateSettings)
}
// settingsUpdateSchema is the input schema: one optional property per setting,
// each a string or null, limited to the setting's allowed values where it has a
// closed set, and nothing else.
func settingsUpdateSchema() *jsonschema.Schema {
properties := make(map[string]*jsonschema.Schema, len(settings.Keys))
for _, key := range settings.Keys {
prop := &jsonschema.Schema{Types: []string{"string", "null"}}
if allowed := settings.AllowedValues(key); len(allowed) > 0 {
prop.Enum = make([]any, 0, len(allowed)+1)
for _, value := range allowed {
prop.Enum = append(prop.Enum, value)
}
prop.Enum = append(prop.Enum, nil)
}
if key == settings.KeyPipelinesRepoSelector {
maxLength := settings.MaxRepoSelectorLength
prop.MaxLength = &maxLength
}
properties[key] = prop
}
return &jsonschema.Schema{
Type: "object",
Properties: properties,
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
}
}
func (h *mcpHandler) updateSettings(ctx context.Context, _ *mcp.CallToolRequest, in map[string]*string) (*mcp.CallToolResult, settingsDTO, error) {
info, ok := authInfoFrom(ctx)
if !ok || h.settings == nil {
return nil, settingsDTO{}, fmt.Errorf("%w: settings are not available", errInvalidToolInput)
}
got, err := h.settings.Update(ctx, info.UserID, in)
if err != nil {
code, message := classifySettingsError(err)
return nil, settingsDTO{}, mcpToolError{code: code, message: message}
}
return nil, toSettingsDTO(got), nil
}
package httpserver
import "net/http"
// noindex tells search engines to keep every response out of their index and
// not to follow its links. This is an internal tool (alrayyes/dotfiles#810).
// It is wrapped outermost, so a request rejected before any handler runs, the
// health probes and the static files carry it too. robots.txt must stay
// permissive: a Disallow would stop a crawler fetching the response, and it
// would never read this.
func noindex(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
next.ServeHTTP(w, r)
})
}
package httpserver
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
type pipelineSummaryDTO struct {
ID string `json:"id"`
RepoID string `json:"repoId"`
Name string `json:"name"`
HealthStatus string `json:"healthStatus"`
TriggeredSignals []string `json:"triggeredSignals,omitempty"`
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
}
func toPipelineSummaryDTO(p metrics.Pipeline) pipelineSummaryDTO {
signals := make([]string, 0, len(p.TriggeredSignals))
for _, s := range p.TriggeredSignals {
signals = append(signals, string(s))
}
return pipelineSummaryDTO{
ID: string(p.ID),
RepoID: p.RepoID,
Name: p.Name,
HealthStatus: string(p.HealthStatus),
TriggeredSignals: signals,
LastRunAt: p.LastRunAt,
}
}
type pipelineListDTO struct {
Pipelines []pipelineSummaryDTO `json:"pipelines"`
HasMore bool `json:"hasMore"`
}
type trendDTO struct {
Timestamps []time.Time `json:"timestamps"`
P50 []float64 `json:"p50,omitempty"`
P90 []float64 `json:"p90,omitempty"`
Rate []float64 `json:"rate,omitempty"`
}
func toTrendDTO(t metrics.Trend) trendDTO {
timestamps := t.Timestamps
if timestamps == nil {
timestamps = []time.Time{}
}
return trendDTO{Timestamps: timestamps, P50: t.P50, P90: t.P90, Rate: t.Rate}
}
type pipelineDetailDTO struct {
pipelineSummaryDTO
DurationTrend trendDTO `json:"durationTrend"`
FailureRateTrend trendDTO `json:"failureRateTrend"`
}
type stepDTO struct {
ID string `json:"id"`
Name string `json:"name"`
DurationContributionSeconds float64 `json:"durationContributionSeconds"`
QueueSeconds float64 `json:"queueSeconds"`
ExecSeconds float64 `json:"execSeconds"`
FailureRate float64 `json:"failureRate"`
FailureCount int `json:"failureCount"`
Flaky bool `json:"flaky"`
// Quarantined is true when a person has marked this flaky step as known;
// Quarantine then holds the mark.
Quarantined bool `json:"quarantined"`
Quarantine *quarantineDTO `json:"quarantine,omitempty"`
ForgeURL string `json:"forgeUrl,omitempty"`
}
func toStepDTO(s metrics.Step) stepDTO {
return stepDTO{
ID: s.ID,
Name: s.Name,
DurationContributionSeconds: s.DurationContributionSeconds,
QueueSeconds: s.QueueSeconds,
ExecSeconds: s.ExecSeconds,
FailureRate: s.FailureRate,
FailureCount: s.FailureCount,
Flaky: s.Flaky,
Quarantined: s.Quarantine != nil,
Quarantine: toQuarantineDTO(s.Quarantine),
ForgeURL: s.ForgeURL,
}
}
type flakyRunDTO struct {
RunID string `json:"runId"`
StartedAt *time.Time `json:"startedAt,omitempty"`
ForgeURL string `json:"forgeUrl"`
}
func toFlakyRunDTO(r metrics.FlakyRun) flakyRunDTO {
return flakyRunDTO{RunID: r.RunID, StartedAt: r.StartedAt, ForgeURL: r.ForgeURL}
}
type runStepDTO struct {
JobID string `json:"jobId,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
Outcome string `json:"outcome"`
ForgeURL string `json:"forgeUrl,omitempty"`
}
func toRunStepDTO(s metrics.RunStep) runStepDTO {
return runStepDTO{
JobID: s.JobID,
Name: s.Name,
Status: s.Status,
Conclusion: s.Conclusion,
Outcome: string(metrics.OutcomeOf(s.Status, s.Conclusion)),
ForgeURL: s.ForgeURL,
}
}
type runDetailDTO struct {
RunID string `json:"runId"`
StartedAt *time.Time `json:"startedAt,omitempty"`
Steps []runStepDTO `json:"steps"`
}
type pipelinesHandler struct {
service *metrics.Service
}
func (h *pipelinesHandler) list(w http.ResponseWriter, r *http.Request) {
window := metrics.ParseWindow(r.URL.Query().Get("window"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
health, err := metrics.ParseHealthFilter(r.URL.Query().Get("health"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_health", "health must be healthy or unhealthy")
return
}
sortOrder, err := metrics.ParsePipelineSort(r.URL.Query().Get("sort"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_sort", "sort must be name or lastRun")
return
}
pipelines, hasMore, err := h.service.ListPipelines(r.Context(), window, metrics.PipelineListFilter{
RepoID: r.URL.Query().Get("repoId"),
Forge: r.URL.Query().Get("forge"),
Health: health,
Sort: sortOrder,
Limit: limit,
Offset: offset,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list pipelines")
return
}
dtos := make([]pipelineSummaryDTO, 0, len(pipelines))
for _, p := range pipelines {
dtos = append(dtos, toPipelineSummaryDTO(p))
}
writeJSON(w, http.StatusOK, pipelineListDTO{Pipelines: dtos, HasMore: hasMore})
}
func (h *pipelinesHandler) get(w http.ResponseWriter, r *http.Request) {
window := metrics.ParseWindow(r.URL.Query().Get("window"))
detail, err := h.service.GetPipeline(r.Context(), metrics.PipelineID(r.PathValue("pipelineId")), window)
if err != nil {
if errors.Is(err, metrics.ErrPipelineNotFound) || errors.Is(err, metrics.ErrInvalidPipelineID) {
writeError(w, http.StatusNotFound, "not_found", "pipeline not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "get pipeline")
return
}
writeJSON(w, http.StatusOK, pipelineDetailDTO{
pipelineSummaryDTO: toPipelineSummaryDTO(detail.Pipeline),
DurationTrend: toTrendDTO(detail.DurationTrend),
FailureRateTrend: toTrendDTO(detail.FailureRateTrend),
})
}
// steps returns a pipeline's step ranking. Unlike get, an unknown
// pipelineId isn't distinguished from one with no recorded steps yet --
// both report an empty list, since this is a collection endpoint rather
// than a single-resource lookup. Only a malformed id 404s.
func (h *pipelinesHandler) steps(w http.ResponseWriter, r *http.Request) {
window := metrics.ParseWindow(r.URL.Query().Get("window"))
steps, err := h.service.GetPipelineSteps(r.Context(), metrics.PipelineID(r.PathValue("pipelineId")), window)
if err != nil {
if errors.Is(err, metrics.ErrInvalidPipelineID) {
writeError(w, http.StatusNotFound, "not_found", "pipeline not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "get pipeline steps")
return
}
dtos := make([]stepDTO, 0, len(steps))
for _, s := range steps {
dtos = append(dtos, toStepDTO(s))
}
writeJSON(w, http.StatusOK, dtos)
}
// flakyRuns returns every run in which the step named by the "step" query
// parameter failed -- the step-scoped drill-down GetPipelineSteps' Steps
// table links a flaky step into, rather than an arbitrary occurrence.
func (h *pipelinesHandler) flakyRuns(w http.ResponseWriter, r *http.Request) {
window := metrics.ParseWindow(r.URL.Query().Get("window"))
runs, err := h.service.ListFlakyRuns(r.Context(), metrics.PipelineID(r.PathValue("pipelineId")), r.URL.Query().Get("step"), window)
if err != nil {
if errors.Is(err, metrics.ErrInvalidPipelineID) {
writeError(w, http.StatusNotFound, "not_found", "pipeline not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "list flaky runs")
return
}
dtos := make([]flakyRunDTO, 0, len(runs))
for _, run := range runs {
dtos = append(dtos, toFlakyRunDTO(run))
}
writeJSON(w, http.StatusOK, dtos)
}
// runSteps returns one run's own steps and statuses -- what a flaky run
// drills down into, so its forgeUrl points at the exact job that ran.
func (h *pipelinesHandler) runSteps(w http.ResponseWriter, r *http.Request) {
detail, err := h.service.GetRunSteps(r.Context(), r.PathValue("runId"))
if err != nil {
if errors.Is(err, metrics.ErrRunNotFound) {
writeError(w, http.StatusNotFound, "not_found", "run not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "get run steps")
return
}
steps := make([]runStepDTO, 0, len(detail.Steps))
for _, s := range detail.Steps {
steps = append(steps, toRunStepDTO(s))
}
writeJSON(w, http.StatusOK, runDetailDTO{RunID: detail.RunID, StartedAt: detail.StartedAt, Steps: steps})
}
type pipelineStepsGroupDTO struct {
PipelineID string `json:"pipelineId"`
PipelineName string `json:"pipelineName"`
RepoID string `json:"repoId"`
Steps []stepDTO `json:"steps"`
}
type pipelineStepsGroupListDTO struct {
Groups []pipelineStepsGroupDTO `json:"groups"`
HasMore bool `json:"hasMore"`
}
// unhealthySteps returns a page of pipelines with a flaky or failing step,
// grouped by pipeline -- the cross-pipeline counterpart to steps, which is
// scoped to one pipeline.
func (h *pipelinesHandler) unhealthySteps(w http.ResponseWriter, r *http.Request) {
window := metrics.ParseWindow(r.URL.Query().Get("window"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
groups, hasMore, err := h.service.ListUnhealthySteps(r.Context(), window, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list unhealthy steps")
return
}
dtos := make([]pipelineStepsGroupDTO, 0, len(groups))
for _, g := range groups {
steps := make([]stepDTO, 0, len(g.Steps))
for _, s := range g.Steps {
steps = append(steps, toStepDTO(s))
}
dtos = append(dtos, pipelineStepsGroupDTO{
PipelineID: string(g.PipelineID),
PipelineName: g.PipelineName,
RepoID: g.RepoID,
Steps: steps,
})
}
writeJSON(w, http.StatusOK, pipelineStepsGroupListDTO{Groups: dtos, HasMore: hasMore})
}
type usageEntryDTO struct {
Workflow string `json:"workflow"`
RunnerMinutes float64 `json:"runnerMinutes"`
}
type usageHandler struct {
service *metrics.Service
repos ingestion.Store
}
func (h *usageHandler) get(w http.ResponseWriter, r *http.Request) {
repoID := r.PathValue("repoId")
if _, problem := (&reposHandler{store: h.repos}).trackedRepo(r.Context(), repoID); problem != nil {
writeError(w, problem.status, problem.code, problem.message)
return
}
window := metrics.ParseWindow(r.URL.Query().Get("window"))
usage, err := h.service.GetRepoUsage(r.Context(), repoID, window)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "get repo usage")
return
}
dtos := make([]usageEntryDTO, 0, len(usage))
for _, u := range usage {
dtos = append(dtos, usageEntryDTO{Workflow: u.Workflow, RunnerMinutes: u.RunnerMinutes})
}
writeJSON(w, http.StatusOK, dtos)
}
package httpserver
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
// quarantineDTO is a quarantine mark as the API reports it. It is absent from
// a step that isn't quarantined.
type quarantineDTO struct {
Note string `json:"note"`
QuarantinedAt time.Time `json:"quarantinedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
func toQuarantineDTO(q *metrics.Quarantine) *quarantineDTO {
if q == nil {
return nil
}
return &quarantineDTO{Note: q.Note, QuarantinedAt: q.QuarantinedAt, ExpiresAt: q.ExpiresAt}
}
type quarantineRequest struct {
Note string `json:"note"`
}
// stepQuarantineDTO answers the quarantine writes: whether the step is now
// quarantined and, if so, the mark.
type stepQuarantineDTO struct {
Quarantined bool `json:"quarantined"`
Quarantine *quarantineDTO `json:"quarantine,omitempty"`
}
// quarantineHandler marks a flaky step as known. Both routes are session-only,
// like API tokens and saved forge tokens: an API token can read a mark (the
// flaky and step lists carry it) but never set or clear one. The MCP tools hold
// the same line (mcp_quarantine.go). Neither touches the forge.
type quarantineHandler struct {
service *metrics.Service
}
// mountQuarantine registers the two session-only quarantine routes.
func mountQuarantine(mux *http.ServeMux, service *metrics.Service) {
h := &quarantineHandler{service: service}
mux.HandleFunc("PUT /api/pipelines/{pipelineId}/steps/{step}/quarantine", h.quarantine)
mux.HandleFunc("DELETE /api/pipelines/{pipelineId}/steps/{step}/quarantine", h.unquarantine)
}
// quarantine answers PUT /api/pipelines/{pipelineId}/steps/{step}/quarantine.
// Marking a step that is already quarantined renews it.
func (h *quarantineHandler) quarantine(w http.ResponseWriter, r *http.Request) {
if !requireViaSession(w, r, "quarantine a step") {
return
}
var in quarantineRequest
if !readOptionalJSON(w, r, &in) {
return
}
q, err := h.service.QuarantineStep(r.Context(), metrics.PipelineID(r.PathValue("pipelineId")), r.PathValue("step"), in.Note, time.Now())
if err != nil {
writeQuarantineError(w, err)
return
}
writeJSON(w, http.StatusOK, stepQuarantineDTO{Quarantined: true, Quarantine: toQuarantineDTO(&q)})
}
// unquarantine answers DELETE on the same path; a step with no mark is fine.
func (h *quarantineHandler) unquarantine(w http.ResponseWriter, r *http.Request) {
if !requireViaSession(w, r, "un-quarantine a step") {
return
}
if err := h.service.UnquarantineStep(r.Context(), metrics.PipelineID(r.PathValue("pipelineId")), r.PathValue("step")); err != nil {
writeQuarantineError(w, err)
return
}
writeJSON(w, http.StatusOK, stepQuarantineDTO{})
}
func writeQuarantineError(w http.ResponseWriter, err error) {
status, code, message := classifyQuarantineError(err)
writeError(w, status, code, message)
}
// classifyQuarantineError is how a failed quarantine write is reported: the
// status, code and message the endpoints answer with, which the MCP tools put
// in their error text.
func classifyQuarantineError(err error) (status int, code, message string) {
switch {
case errors.Is(err, metrics.ErrPipelineNotFound), errors.Is(err, metrics.ErrInvalidPipelineID):
return http.StatusNotFound, "not_found", "pipeline not found"
case errors.Is(err, metrics.ErrQuarantineNoteTooLong):
return http.StatusUnprocessableEntity, "note_too_long", "a note is at most 500 characters"
default:
return http.StatusInternalServerError, "internal_error", "update quarantine"
}
}
// readOptionalJSON decodes a JSON body into dst, treating an empty body as
// the zero value: a quarantine needs no note.
func readOptionalJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil && !errors.Is(err, io.EOF) {
if isBodyTooLarge(err) {
writePayloadTooLarge(w)
} else {
writeError(w, http.StatusBadRequest, "invalid_body", "malformed JSON body")
}
return false
}
return true
}
package httpserver
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
type repoDTO struct {
ID string `json:"id"`
Forge string `json:"forge"`
Identifier string `json:"identifier"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty"`
TokenMasked string `json:"tokenMasked"`
IngestionStatus string `json:"ingestionStatus"`
IngestionStatusReason string `json:"ingestionStatusReason,omitempty"`
}
type repoRegistrationDTO struct {
Forge string `json:"forge"`
Identifier string `json:"identifier"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty"`
Token string `json:"token"`
}
type repoListDTO struct {
Repos []repoDTO `json:"repos"`
HasMore bool `json:"hasMore"`
}
type repoDiscoveryDTO struct {
Forge string `json:"forge"`
ForgejoInstanceURL string `json:"forgejoInstanceUrl,omitempty"`
Token string `json:"token"`
}
type errorDTO struct {
Code string `json:"code"`
Message string `json:"message"`
}
func toRepoDTO(r ingestion.Repo) repoDTO {
return repoDTO{
ID: r.ID,
Forge: string(r.Forge),
Identifier: r.Identifier,
ForgejoInstanceURL: r.ForgejoInstanceURL,
TokenMasked: r.TokenMasked,
IngestionStatus: string(r.IngestionStatus),
IngestionStatusReason: r.IngestionStatusReason,
}
}
type reposHandler struct {
registrar *ingestion.Registrar
store ingestion.Store
tokens ingestion.ForgeTokenStore
}
func (h *reposHandler) list(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
repos, hasMore, err := h.store.ListRepos(r.Context(), ingestion.RepoListFilter{
Forge: ingestion.Forge(r.URL.Query().Get("forge")),
Limit: limit,
Offset: offset,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list repos")
return
}
writeJSON(w, http.StatusOK, toRepoListDTO(repos, hasMore))
}
// toRepoListDTO is the list endpoint's body, shared with the MCP list_repos
// tool so both report a repo the same way, token masked.
func toRepoListDTO(repos []ingestion.Repo, hasMore bool) repoListDTO {
dtos := make([]repoDTO, 0, len(repos))
for _, repo := range repos {
dtos = append(dtos, toRepoDTO(repo))
}
return repoListDTO{Repos: dtos, HasMore: hasMore}
}
// isTrackedForge reports whether forge names one of the forges a repo can be
// tracked on, which the identifiers endpoint and its MCP tool both require.
func isTrackedForge(forge string) bool {
return forge == string(ingestion.ForgeGitHub) || forge == string(ingestion.ForgeForgejo)
}
// apiProblem is a request that cannot be carried out: the status, code and
// message the endpoint answers with, which the MCP tools put in their error
// text, so an agent reads what a client of the endpoint reads.
type apiProblem struct {
status int
code, message string
}
func (h *reposHandler) register(w http.ResponseWriter, r *http.Request) {
var in repoRegistrationDTO
if !readJSON(w, r, &in) {
return
}
repo, problem := h.registerRepo(r.Context(), in)
if problem != nil {
writeError(w, problem.status, problem.code, problem.message)
return
}
writeJSON(w, http.StatusCreated, toRepoDTO(repo))
}
// registerRepo is the registration, shared with the MCP register_repo tool:
// validate, find the token, register, and say why when it cannot.
func (h *reposHandler) registerRepo(ctx context.Context, in repoRegistrationDTO) (ingestion.Repo, *apiProblem) {
if in.Identifier == "" || !validForge(in.Forge) {
return ingestion.Repo{}, &apiProblem{http.StatusBadRequest, "invalid_body", "forge and identifier are required"}
}
if len(in.Identifier) > maxIdentifierLength || len(in.Token) > maxTokenLength || len(in.ForgejoInstanceURL) > maxInstanceURLLength {
return ingestion.Repo{}, &apiProblem{http.StatusBadRequest, "invalid_body", "identifier, token or instance URL is too long"}
}
token, problem := givenOrSavedToken(ctx, h.tokens, in.Forge, in.ForgejoInstanceURL, in.Token)
if problem != nil {
return ingestion.Repo{}, problem
}
repo, err := h.registrar.Register(ctx, ingestion.NewRepo{
Forge: ingestion.Forge(in.Forge),
Identifier: in.Identifier,
ForgejoInstanceURL: in.ForgejoInstanceURL,
Token: token,
})
if err != nil {
if code, message, ok := registrationConflict(err); ok {
slog.ErrorContext(ctx, "rejected repo registration",
"forge", in.Forge, "identifier", in.Identifier, "reason", code)
return ingestion.Repo{}, &apiProblem{http.StatusConflict, code, message}
}
slog.ErrorContext(ctx, "register repo failed",
"forge", in.Forge, "identifier", in.Identifier, "error", err)
return ingestion.Repo{}, &apiProblem{http.StatusInternalServerError, "internal_error", "register repo"}
}
return repo, nil
}
// registrationConflict maps a Registrar.Register error to the (code,
// message) pair register's 409 response uses, if err is one of the
// registration-rejection sentinels -- already-tracked, or archived/fork/
// mirror (exclude-forge-mirrors-from-registration). Every other error stays
// a 500.
func registrationConflict(err error) (code, message string, ok bool) {
switch {
case errors.Is(err, ingestion.ErrRepoAlreadyTracked):
return "already_tracked", "this repo is already tracked", true
case errors.Is(err, ingestion.ErrRepoArchived):
return "repo_archived", "this repo is archived", true
case errors.Is(err, ingestion.ErrRepoFork):
return "repo_fork", "this repo is a fork", true
case errors.Is(err, ingestion.ErrRepoMirror):
return "repo_mirror", "this repo is a mirror", true
default:
return "", "", false
}
}
type repoIdentifiersDTO struct {
Identifiers []string `json:"identifiers"`
}
func (h *reposHandler) identifiers(w http.ResponseWriter, r *http.Request) {
forge := r.URL.Query().Get("forge")
if !isTrackedForge(forge) {
writeError(w, http.StatusBadRequest, "invalid_query", "forge is required")
return
}
identifiers, err := h.store.ListRepoIdentifiers(r.Context(), ingestion.Forge(forge), r.URL.Query().Get("forgejoInstanceUrl"))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list repo identifiers")
return
}
writeJSON(w, http.StatusOK, repoIdentifiersDTO{Identifiers: identifiers})
}
func (h *reposHandler) discover(w http.ResponseWriter, r *http.Request) {
var in repoDiscoveryDTO
if !readJSON(w, r, &in) {
return
}
repos, problem := h.discoverRepos(r.Context(), in)
if problem != nil {
writeError(w, problem.status, problem.code, problem.message)
return
}
writeJSON(w, http.StatusOK, repos)
}
// discoverRepos is the discovery, shared with the MCP discover_repos tool.
func (h *reposHandler) discoverRepos(ctx context.Context, in repoDiscoveryDTO) ([]string, *apiProblem) {
if !validForge(in.Forge) {
return nil, &apiProblem{http.StatusBadRequest, "invalid_body", "forge is required"}
}
if len(in.Token) > maxTokenLength || len(in.ForgejoInstanceURL) > maxInstanceURLLength {
return nil, &apiProblem{http.StatusBadRequest, "invalid_body", "token or instance URL is too long"}
}
token, problem := givenOrSavedToken(ctx, h.tokens, in.Forge, in.ForgejoInstanceURL, in.Token)
if problem != nil {
return nil, problem
}
repos, err := h.registrar.Discover(ctx, ingestion.Forge(in.Forge), in.ForgejoInstanceURL, token)
if err != nil {
return nil, &apiProblem{http.StatusBadGateway, "forge_error", "could not list repositories: " + err.Error()}
}
return repos, nil
}
func (h *reposHandler) get(w http.ResponseWriter, r *http.Request) {
repo, problem := h.trackedRepo(r.Context(), r.PathValue("repoId"))
if problem != nil {
writeError(w, problem.status, problem.code, problem.message)
return
}
writeJSON(w, http.StatusOK, toRepoDTO(repo))
}
// trackedRepo is the lookup by id, shared with the MCP get_repo tool. Only the
// store's not-found sentinel is a 404; any other error is a store failure, which
// is logged and answered 500 without its detail.
func (h *reposHandler) trackedRepo(ctx context.Context, repoID string) (ingestion.Repo, *apiProblem) {
repo, err := h.store.GetRepo(ctx, repoID)
if errors.Is(err, ingestion.ErrRepoNotFound) {
return ingestion.Repo{}, &apiProblem{http.StatusNotFound, "not_found", "repo not found"}
}
if err != nil {
slog.ErrorContext(ctx, "get repo failed", slog.String("repo_id", repoID), slog.Any("error", err))
return ingestion.Repo{}, &apiProblem{http.StatusInternalServerError, "internal_error", "get repo"}
}
return repo, nil
}
func (h *reposHandler) untrack(w http.ResponseWriter, r *http.Request) {
if problem := h.untrackRepo(r.Context(), r.PathValue("repoId")); problem != nil {
writeError(w, problem.status, problem.code, problem.message)
return
}
w.WriteHeader(http.StatusNoContent)
}
// untrackRepo is the removal, shared with the MCP untrack_repo tool. Like
// trackedRepo, only the not-found sentinel is a 404.
func (h *reposHandler) untrackRepo(ctx context.Context, repoID string) *apiProblem {
err := h.store.DeleteRepo(ctx, repoID)
if errors.Is(err, ingestion.ErrRepoNotFound) {
return &apiProblem{http.StatusNotFound, "not_found", "repo not found"}
}
if err != nil {
slog.ErrorContext(ctx, "untrack repo failed", slog.String("repo_id", repoID), slog.Any("error", err))
return &apiProblem{http.StatusInternalServerError, "internal_error", "untrack repo"}
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorDTO{Code: code, Message: message})
}
package httpserver
import (
"encoding/json"
"io/fs"
"strings"
)
// routesFile is the dashboard's route list, written by web/'s build next to
// the pages (web/scripts/generate-routes.ts). It is how the server tells a
// client route from a mistyped URL.
const routesFile = "routes.json"
// routeSet matches a request path against the dashboard's route patterns,
// where a segment written [name] stands for any one non-empty segment.
type routeSet struct {
patterns [][]string
}
// loadRoutes reads the route list from assets. It returns nil when there is
// none -- a checkout with no frontend build -- and the caller then serves the
// app shell for every path, as before the list existed.
func loadRoutes(assets fs.FS) *routeSet {
if assets == nil {
return nil
}
data, err := fs.ReadFile(assets, routesFile)
if err != nil {
return nil
}
var patterns []string
if err := json.Unmarshal(data, &patterns); err != nil {
return nil
}
routes := &routeSet{}
for _, pattern := range patterns {
routes.patterns = append(routes.patterns, segments(pattern))
}
return routes
}
// match reports whether name, a path with no leading slash, is a route.
func (r *routeSet) match(name string) bool {
parts := segments(name)
next:
for _, pattern := range r.patterns {
if len(pattern) != len(parts) {
continue
}
for i, want := range pattern {
dynamic := strings.HasPrefix(want, "[") && strings.HasSuffix(want, "]")
if parts[i] == "" || (!dynamic && parts[i] != want) {
continue next
}
}
return true
}
return false
}
// segments splits a path into its non-root segments: "/" and "" are none.
func segments(p string) []string {
p = strings.Trim(p, "/")
if p == "" {
return nil
}
return strings.Split(p, "/")
}
package httpserver
import (
"context"
"errors"
"net/http"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
type runActionsHandler struct {
service *ingestion.RunActionService
}
// rerun asks the forge to re-run a concluded run (openapi.yaml, rerunRun).
func (h *runActionsHandler) rerun(w http.ResponseWriter, r *http.Request) {
h.do(w, r, h.service.Rerun)
}
// cancel asks the forge to cancel a run that hasn't concluded (openapi.yaml,
// cancelRun).
func (h *runActionsHandler) cancel(w http.ResponseWriter, r *http.Request) {
h.do(w, r, h.service.Cancel)
}
// do runs one action. Session-only: an API token gets a 401, because this
// writes to the forge and an agent doesn't get that.
func (h *runActionsHandler) do(w http.ResponseWriter, r *http.Request, act func(context.Context, string) error) {
info, ok := authInfoFromContext(r)
if !ok || !info.ViaSession {
writeError(w, http.StatusUnauthorized, "unauthenticated", sessionRequiredMessage("act on a run"))
return
}
err := act(r.Context(), r.PathValue("runId"))
if err == nil {
w.WriteHeader(http.StatusAccepted)
return
}
failure := classifyRunActionError(err)
writeError(w, failure.status, failure.code, failure.message)
}
// runActionFailure is how a failed action is reported: the status and the
// error code and message the endpoint answers with, which the MCP tools put
// in their error text.
type runActionFailure struct {
status int
code, message string
}
func classifyRunActionError(err error) runActionFailure {
switch {
case errors.Is(err, ingestion.ErrRunNotFound):
return runActionFailure{http.StatusNotFound, "not_found", "run not found"}
case errors.Is(err, ingestion.ErrNotActionable):
return runActionFailure{http.StatusConflict, "not_actionable", "the run's state doesn't allow this"}
case errors.Is(err, ingestion.ErrActionForbidden):
return runActionFailure{http.StatusForbidden, "forbidden", "the saved token needs Actions write permission to do this"}
case errors.Is(err, ingestion.ErrActionUnsupported):
return runActionFailure{http.StatusNotImplemented, "unsupported", "this forge has no API for this action"}
case errors.Is(err, ingestion.ErrActionUnreachable):
return runActionFailure{http.StatusBadGateway, "unreachable", "the forge didn't answer"}
default:
return runActionFailure{http.StatusInternalServerError, "internal_error", "act on run"}
}
}
package httpserver
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
// runSummaryDTO is one entry of GET /api/runs. What isn't known (a run still
// going has no conclusion or duration, an older run has no commit fields) is
// omitted rather than sent as an empty string or zero, so a client can tell
// "no data" from a real value.
type runSummaryDTO struct {
ID string `json:"id"`
PipelineID string `json:"pipelineId"`
PipelineName string `json:"pipelineName"`
RepoID string `json:"repoId"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
Outcome string `json:"outcome"`
StartedAt *time.Time `json:"startedAt,omitempty"`
DurationSeconds *float64 `json:"durationSeconds,omitempty"`
Branch string `json:"branch,omitempty"`
SHA string `json:"sha,omitempty"`
Message string `json:"message,omitempty"`
Actor string `json:"actor,omitempty"`
ForgeURL string `json:"forgeUrl,omitempty"`
Actions []string `json:"actions"`
Steps []runStepDTO `json:"steps"`
}
type runListDTO struct {
Runs []runSummaryDTO `json:"runs"`
HasMore bool `json:"hasMore"`
}
func toRunSummaryDTO(e metrics.RunEntry) runSummaryDTO {
dto := runSummaryDTO{
ID: e.ID,
PipelineID: string(e.Pipeline.ID()),
PipelineName: e.Pipeline.Name,
RepoID: e.Pipeline.RepoID,
Status: e.Status,
Conclusion: e.Conclusion,
Outcome: string(metrics.OutcomeOf(e.Status, e.Conclusion)),
StartedAt: e.StartedAt,
Branch: e.Branch,
SHA: e.SHA,
Message: e.Message,
Actor: e.Actor,
ForgeURL: e.ForgeURL,
Actions: actionNames(ingestion.ActionsFor(ingestion.Forge(e.Forge), e.Status)),
Steps: make([]runStepDTO, 0, len(e.Steps)),
}
if secs, ok := e.DurationSeconds(); ok {
dto.DurationSeconds = &secs
}
for _, step := range e.Steps {
dto.Steps = append(dto.Steps, toRunStepDTO(step))
}
return dto
}
func actionNames(actions []ingestion.RunAction) []string {
names := make([]string, 0, len(actions))
for _, a := range actions {
names = append(names, string(a))
}
return names
}
type runListHandler struct {
service *metrics.Service
}
// list answers GET /api/runs: a page of runs, newest first, each with its
// steps for a stage progression bar. An unknown status is a 400 rather than
// an empty list, so a typo can't read as "no failures".
func (h *runListHandler) list(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
status, err := metrics.ParseRunStatus(query.Get("status"))
if errors.Is(err, metrics.ErrInvalidRunStatus) {
writeError(w, http.StatusBadRequest, "invalid_status", "status must be one of all, failed, running, success")
return
}
branch, ok := branchFilter(w, r)
if !ok {
return
}
limit, _ := strconv.Atoi(query.Get("limit"))
offset, _ := strconv.Atoi(query.Get("offset"))
runs, hasMore, err := h.service.ListRuns(r.Context(), metrics.RunListFilter{
RepoID: query.Get("repoId"),
Forge: query.Get("forge"),
Branch: branch,
Status: status,
Limit: limit,
Offset: offset,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list runs")
return
}
dtos := make([]runSummaryDTO, 0, len(runs))
for _, run := range runs {
dtos = append(dtos, toRunSummaryDTO(run))
}
writeJSON(w, http.StatusOK, runListDTO{Runs: dtos, HasMore: hasMore})
}
// Package httpserver builds the pipeline-analytics HTTP handler tree.
package httpserver
import (
"context"
"io/fs"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/alrayyes/pipeline-analytics/internal/auth"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
"github.com/alrayyes/pipeline-analytics/internal/settings"
)
// Deps are New's dependencies.
type Deps struct {
Registrar *ingestion.Registrar
IngestionStore ingestion.Store
RunStore ingestion.RunStore
// Reconciler is called to poll a repo immediately when a Forgejo
// webhook fires -- see webhooksHandler.forgejo.
Reconciler ingestion.RepoReconciler
Metrics *metrics.Service
// ForgeTokens keeps the tokens saved for registering repos. nil is fine:
// the routes aren't served and a token must then be given each time.
ForgeTokens ingestion.ForgeTokenStore
// JobLogs reads a job's log from its forge for GET /api/runs/{runId}/
// jobs/{jobId}/log. nil is fine: the route then answers 404.
JobLogs *ingestion.JobLogService
// RunActions re-runs and cancels runs on their forge for POST
// /api/runs/{runId}/rerun and /cancel. Nil leaves both routes unmounted.
RunActions *ingestion.RunActionService
Auth *auth.Service
AuthStore auth.Store
Settings *settings.Service
// GitHubRateLimits reports the rate-limit status last observed for a
// GitHub token, for GET /api/insights/github-rate-limit. nil is fine --
// the endpoint just reports every token with no status yet.
GitHubRateLimits ingestion.RateLimitReporter
// Ready reports whether the instance can serve, for GET /readyz --
// in production, a database read. nil means always ready, so a Deps
// built without one (most tests) still answers.
Ready func(ctx context.Context) error
// ReadyCacheTTL is how long /readyz reuses the last probe result, so a
// router polling it can't hammer the dependency. Zero means
// defaultReadyCacheTTL.
ReadyCacheTTL time.Duration
// Draining, once true, makes /readyz answer 503 without probing, so a
// shutting-down instance leaves rotation while it keeps serving. nil
// means never draining.
Draining *atomic.Bool
// Version is reported by GET /api/version -- the build's tagged
// version, or "dev" for a local build.
Version string
// Assets is the built frontend (internal/webassets.FS()), served for
// every path the API doesn't claim, falling back to index.html for the
// SPA client router.
Assets fs.FS
}
// mountTelemetry registers the reads behind the telemetry views: failure
// insights, the branch list and the flaky steps.
func mountTelemetry(mux *http.ServeMux, service *metrics.Service) {
mux.HandleFunc("GET /api/insights/failures", (&failureInsightsHandler{service: service}).get)
mux.HandleFunc("GET /api/branches", (&branchesHandler{service: service}).list)
mux.HandleFunc("GET /api/steps/flaky", (&flakyStepsHandler{service: service}).list)
}
// mountForgeTokens registers the saved forge token routes, when there is a
// store to keep them in.
func mountForgeTokens(mux *http.ServeMux, store ingestion.ForgeTokenStore) {
if store == nil {
return
}
h := &forgeTokensHandler{store: store}
mux.HandleFunc("GET /api/forge-tokens", h.list)
mux.HandleFunc("PUT /api/forge-tokens", h.save)
mux.HandleFunc("DELETE /api/forge-tokens/{tokenId}", h.delete)
}
// mountRunActions registers the re-run and cancel routes, when there is a
// service to do them.
func mountRunActions(mux *http.ServeMux, service *ingestion.RunActionService) {
if service == nil {
return
}
h := &runActionsHandler{service: service}
mux.HandleFunc("POST /api/runs/{runId}/rerun", h.rerun)
mux.HandleFunc("POST /api/runs/{runId}/cancel", h.cancel)
}
// New returns the root HTTP handler.
func New(deps Deps) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", readyzHandler(deps.Ready, deps.ReadyCacheTTL, deps.Draining))
mux.HandleFunc("GET /api/version", versionHandler(deps.Version))
repos := &reposHandler{registrar: deps.Registrar, store: deps.IngestionStore, tokens: deps.ForgeTokens}
mux.HandleFunc("GET /api/repos", repos.list)
mux.HandleFunc("POST /api/repos", repos.register)
mux.HandleFunc("GET /api/repos/identifiers", repos.identifiers)
mux.HandleFunc("POST /api/repos/discover", repos.discover)
mux.HandleFunc("GET /api/repos/{repoId}", repos.get)
mux.HandleFunc("DELETE /api/repos/{repoId}", repos.untrack)
usage := &usageHandler{service: deps.Metrics, repos: deps.IngestionStore}
mux.HandleFunc("GET /api/repos/{repoId}/usage", usage.get)
pipelines := &pipelinesHandler{service: deps.Metrics}
mux.HandleFunc("GET /api/pipelines", pipelines.list)
mux.HandleFunc("GET /api/pipelines/{pipelineId}", pipelines.get)
mux.HandleFunc("GET /api/pipelines/{pipelineId}/steps", pipelines.steps)
mux.HandleFunc("GET /api/pipelines/{pipelineId}/flaky-runs", pipelines.flakyRuns)
mountQuarantine(mux, deps.Metrics)
mux.HandleFunc("GET /api/runs/{runId}/steps", pipelines.runSteps)
if deps.JobLogs != nil {
mux.HandleFunc("GET /api/runs/{runId}/jobs/{jobId}/log", (&jobLogHandler{service: deps.JobLogs}).get)
}
mountRunActions(mux, deps.RunActions)
mux.HandleFunc("GET /api/steps/unhealthy", pipelines.unhealthySteps)
insights := &insightsHandler{repos: deps.IngestionStore, rateLimits: deps.GitHubRateLimits}
mux.HandleFunc("GET /api/insights/github-rate-limit", insights.githubRateLimit)
mountTelemetry(mux, deps.Metrics)
mountForgeTokens(mux, deps.ForgeTokens)
runList := &runListHandler{service: deps.Metrics}
mux.HandleFunc("GET /api/runs", runList.list)
registerAuthRoutes(mux, &authHandler{service: deps.Auth})
settingsH := &settingsHandler{service: deps.Settings}
mux.HandleFunc("GET /api/settings", settingsH.get)
mux.HandleFunc("PATCH /api/settings", settingsH.patch)
mux.Handle("/api/mcp", newMCPHandler(deps))
webhooks := &webhooksHandler{store: deps.RunStore, reconciler: deps.Reconciler}
mux.HandleFunc("POST /webhooks/github", webhooks.github)
mux.HandleFunc("POST /webhooks/forgejo", webhooks.forgejo)
mux.Handle("/", staticHandler(deps.Assets))
return noindex(requestLogger(limitBodies(requireSession(deps.AuthStore, mux))))
}
// registerAuthRoutes wires every WebAuthn, token, and credential route --
// split out of New so that function's own route wiring stays under
// funlen's statement limit as the route list grows.
func registerAuthRoutes(mux *http.ServeMux, authH *authHandler) {
mux.HandleFunc("POST /api/auth/register/options", authH.registerOptions)
mux.HandleFunc("POST /api/auth/register", authH.register)
mux.HandleFunc("POST /api/auth/login/options", authH.loginOptions)
mux.HandleFunc("POST /api/auth/login", authH.login)
mux.HandleFunc("POST /api/auth/logout", authH.logout)
mux.HandleFunc("POST /api/auth/tokens", authH.issueToken)
mux.HandleFunc("DELETE /api/auth/tokens/{tokenId}", authH.revokeToken)
mux.HandleFunc("POST /api/auth/credentials/options", authH.addCredentialOptions)
mux.HandleFunc("POST /api/auth/credentials", authH.addCredential)
mux.HandleFunc("GET /api/auth/credentials", authH.listCredentials)
mux.HandleFunc("DELETE /api/auth/credentials/{credentialId}", authH.revokeCredential)
}
// readyzTimeout bounds the readiness probe. It's under the healthcheck
// subcommand's own 2s request timeout, so a hung database still gets a 503
// back to the checker instead of the checker giving up first.
const readyzTimeout = time.Second
// defaultReadyCacheTTL is how long a readiness result is reused: long enough
// that polling every second costs one probe per window, short enough that a
// recovery or an outage shows within a probe interval or two.
const defaultReadyCacheTTL = 3 * time.Second
// readyzHandler answers whether the instance can serve: 200 when ready
// reports nil and the instance isn't draining, 503 otherwise. The result is
// cached for ttl. The cause goes to the log, never the response, since the
// endpoint is unauthenticated.
func readyzHandler(ready func(ctx context.Context) error, ttl time.Duration, draining *atomic.Bool) http.HandlerFunc {
if ttl <= 0 {
ttl = defaultReadyCacheTTL
}
var (
mu sync.Mutex
checkedAt time.Time
lastErr error
)
// probe runs ready at most once per ttl. The lock is held through the
// probe, so concurrent polls share one check instead of each running it.
probe := func(ctx context.Context) error {
mu.Lock()
defer mu.Unlock()
if !checkedAt.IsZero() && time.Since(checkedAt) < ttl {
return lastErr
}
lastErr = ready(ctx)
checkedAt = time.Now()
return lastErr
}
return func(w http.ResponseWriter, r *http.Request) {
if draining != nil && draining.Load() {
writeError(w, http.StatusServiceUnavailable, "not_ready", "not ready")
return
}
if ready != nil {
// Detached from the request: the result is shared through the
// cache, so one client hanging up mustn't cache a cancellation
// as an outage for everyone else.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), readyzTimeout)
defer cancel()
if err := probe(ctx); err != nil {
slog.ErrorContext(r.Context(), "readiness check failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "not_ready", "not ready")
return
}
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
}
package httpserver
import (
"context"
"net/http"
"strings"
"github.com/alrayyes/pipeline-analytics/internal/auth"
)
// authInfo is stashed on the request context by requireSession, so a
// handler downstream can tell who's authenticated and how -- see
// add-api-token-auth/design.md's "session-only" decision for why the
// "how" matters to the token endpoints specifically.
type authInfo struct {
UserID string
ViaSession bool
}
type authInfoKey struct{}
func withAuthInfo(r *http.Request, info authInfo) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authInfoKey{}, info))
}
// authInfoFromContext returns the authInfo requireSession stashed on r, if
// any. Only false on a path requireSession let through unauthenticated
// (isPublicPath) -- every gated path always has one by the time a handler
// runs.
func authInfoFromContext(r *http.Request) (authInfo, bool) {
return authInfoFrom(r.Context())
}
// authInfoFrom is authInfoFromContext for a caller that holds the request's
// context and not the request, as an MCP tool call does.
func authInfoFrom(ctx context.Context) (authInfo, bool) {
info, ok := ctx.Value(authInfoKey{}).(authInfo)
return info, ok
}
// requireSession gates every request behind a valid session or API token,
// except the WebAuthn ceremony endpoints, the forge webhook receivers, the
// static frontend, and -- specifically requiring a session, not a token --
// token issuance/revocation, per dashboard-auth/spec.md's "Session-gated
// access". The static SPA shell has to stay reachable unauthenticated so
// its own login page can load at all; the data APIs behind it are what's
// actually gated.
func requireSession(store auth.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if userID, err := store.Session(r.Context(), cookie.Value); err == nil {
next.ServeHTTP(w, withAuthInfo(r, authInfo{UserID: userID, ViaSession: true}))
return
}
}
if token, ok := bearerToken(r); ok {
if userID, err := store.TokenUserID(r.Context(), token); err == nil {
next.ServeHTTP(w, withAuthInfo(r, authInfo{UserID: userID, ViaSession: false}))
return
}
}
writeError(w, http.StatusUnauthorized, "unauthenticated", "no valid session or token")
})
}
func bearerToken(r *http.Request) (string, bool) {
const prefix = "Bearer "
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, prefix) {
return "", false
}
token := strings.TrimPrefix(auth, prefix)
if token == "" {
return "", false
}
return token, true
}
func isPublicPath(path string) bool {
switch {
case path == "/healthz", path == "/readyz", path == "/api/version":
return true
case path == "/api/auth/tokens", strings.HasPrefix(path, "/api/auth/tokens/"):
return false // session-only, not the general /api/auth/ exemption below
case path == "/api/auth/credentials", strings.HasPrefix(path, "/api/auth/credentials/"):
return false // session-only, same reasoning as tokens above
case strings.HasPrefix(path, "/api/auth/"):
return true
case strings.HasPrefix(path, "/webhooks/"):
return true
case !strings.HasPrefix(path, "/api/"):
return true // the static frontend shell
default:
return false
}
}
package httpserver
import (
"errors"
"net/http"
"github.com/alrayyes/pipeline-analytics/internal/settings"
)
type settingsValuesDTO struct {
Theme string `json:"theme"`
ForgeFilter string `json:"forgeFilter"`
PipelinesHealthFilter string `json:"pipelinesHealthFilter"`
PipelinesRepoSelector string `json:"pipelinesRepoSelector"`
PipelinesSortOrder string `json:"pipelinesSortOrder"`
TelemetryWindow string `json:"telemetryWindow"`
}
// settingsDTO is the settings in force and, beside them, the server's
// defaults, so a client never keeps a copy of those.
type settingsDTO struct {
settingsValuesDTO
Defaults settingsValuesDTO `json:"defaults"`
}
func toSettingsDTO(s settings.Settings) settingsDTO {
return settingsDTO{settingsValuesDTO: toSettingsValuesDTO(s), Defaults: toSettingsValuesDTO(settings.Defaults())}
}
func toSettingsValuesDTO(s settings.Settings) settingsValuesDTO {
return settingsValuesDTO{
Theme: s.Theme,
ForgeFilter: s.ForgeFilter,
PipelinesHealthFilter: s.PipelinesHealthFilter,
PipelinesRepoSelector: s.PipelinesRepoSelector,
PipelinesSortOrder: s.PipelinesSortOrder,
TelemetryWindow: s.TelemetryWindow,
}
}
type settingsHandler struct {
service *settings.Service
}
// get returns every setting, fully resolved against its documented
// default -- account-settings/spec.md's "Settings defaults".
func (h *settingsHandler) get(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "no valid session or token")
return
}
got, err := h.service.Get(r.Context(), info.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "get settings")
return
}
writeJSON(w, http.StatusOK, toSettingsDTO(got))
}
// patch applies a partial update: a key present with a JSON value sets it,
// a key present with JSON null clears it back to its default, a key
// absent from the body is left untouched. Decoding into map[string]*string
// is what makes "present but null" distinguishable from "absent" --
// design.md's "null clears to default" convention.
func (h *settingsHandler) patch(w http.ResponseWriter, r *http.Request) {
info, ok := authInfoFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "no valid session or token")
return
}
var updates map[string]*string
if !readJSON(w, r, &updates) {
return
}
got, err := h.service.Update(r.Context(), info.UserID, updates)
if err != nil {
code, message := classifySettingsError(err)
status := http.StatusInternalServerError
if code == "invalid_body" {
status = http.StatusBadRequest
}
writeError(w, status, code, message)
return
}
writeJSON(w, http.StatusOK, toSettingsDTO(got))
}
// classifySettingsError is how a failed update is reported: the code and
// message the endpoint answers with, which the MCP tool puts in its error text.
// An unknown key or a value outside its set is the caller's mistake (400
// invalid_body); anything else is ours.
func classifySettingsError(err error) (code, message string) {
if errors.Is(err, settings.ErrInvalidKey) || errors.Is(err, settings.ErrInvalidValue) {
return "invalid_body", err.Error()
}
return "internal_error", "update settings"
}
package httpserver
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"log/slog"
"mime"
"net/http"
"path"
"strings"
"sync"
"time"
)
const (
// SvelteKit writes content-hashed files under _app/immutable/, so their
// name changes when their bytes do and they can be kept for good.
immutablePrefix = "_app/immutable/"
immutableCache = "public, max-age=31536000, immutable"
// Everything else keeps its URL across builds, so a cache has to ask
// whether it is still current (the ETag answers with a 304).
revalidateCache = "no-cache"
)
// encodings are the precompressed copies adapter-static writes beside each
// text file, in the order they are preferred.
var encodings = []string{"br", "gzip"}
var encodingSuffix = map[string]string{"br": ".br", "gzip": ".gz"}
type staticFile struct {
data []byte
etag string
}
// staticHandler serves assets from an embedded SPA build. A path that isn't a
// file but is one of the dashboard's routes (routes.json) gets index.html, and
// the SvelteKit client router takes over. Anything else answers 404, so a
// mistyped URL isn't a soft 404 (rules/error-pages.md), but still with the app
// shell: the router finds no route and renders the not-found view inside the
// dashboard's own layout, which a static page couldn't. A file the server can't
// read answers 500.
//
// Hashed assets are cached for a year and the rest revalidated, and a file
// with a .br or .gz copy next to it is served that way to a client that
// accepts it, so nothing is compressed at request time.
func staticHandler(assets fs.FS) http.HandlerFunc {
load := assetLoader(assets)
routes := loadRoutes(assets)
return func(w http.ResponseWriter, r *http.Request) {
// Only reached when no API route matched, so this is a typo, and an
// API client expects the API's error shape, not a page.
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if info, err := fs.Stat(assets, name); err != nil || info.IsDir() {
// The app's own files never fall back: a deleted hashed asset is
// a plain 404, not an HTML page the browser then fails to run.
if strings.HasPrefix(name, "_app/") {
w.Header().Set("Cache-Control", revalidateCache)
http.NotFound(w, r)
return
}
if routes != nil && !routes.match(name) {
serveError(w, r, assets, load, http.StatusNotFound)
return
}
name = "index.html"
}
served, encoding := negotiate(assets, name, r.Header.Get("Accept-Encoding"))
file, err := load(served)
if err != nil {
slog.ErrorContext(r.Context(), "read embedded asset", "asset", served, "error", err)
serveError(w, r, assets, load, http.StatusInternalServerError)
return
}
setAssetHeaders(w.Header(), name, encoding, file.etag)
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(file.data))
}
}
// errorPage is how the handler answers one error status: the files to try in
// order (the first it can read wins), the plain-text body for a build with
// none of them, and the cache policy. The static pages are the ones the web
// build writes, self-contained so they still render when the app can't.
type errorPage struct {
files []string
text string
cache string
}
var errorPages = map[int]errorPage{
// The shell first, so the not-found view renders in the dashboard layout.
http.StatusNotFound: {[]string{"index.html", "404.html"}, "Page not found", revalidateCache},
http.StatusInternalServerError: {[]string{"500.html"}, "Something went wrong", "no-store"},
}
// serveError answers with status and its page. Nothing about the failure is
// sent: the caller logged it.
func serveError(w http.ResponseWriter, r *http.Request, assets fs.FS, load func(string) (staticFile, error), status int) {
page := errorPages[status]
for _, name := range page.files {
served, encoding := negotiate(assets, name, r.Header.Get("Accept-Encoding"))
file, err := load(served)
if err != nil {
continue
}
setAssetHeaders(w.Header(), name, encoding, file.etag)
w.Header().Del("ETag")
w.Header().Set("Cache-Control", page.cache)
w.WriteHeader(status)
if r.Method != http.MethodHead {
_, _ = w.Write(file.data)
}
return
}
w.Header().Set("Cache-Control", page.cache)
http.Error(w, page.text, status)
}
// assetLoader reads an asset once and keeps it with its ETag, since the
// embedded files can't change while the process runs.
func assetLoader(assets fs.FS) func(name string) (staticFile, error) {
var cache sync.Map // asset name -> staticFile
return func(name string) (staticFile, error) {
if cached, ok := cache.Load(name); ok {
file, _ := cached.(staticFile)
return file, nil
}
data, err := fs.ReadFile(assets, name)
if err != nil {
return staticFile{}, fmt.Errorf("read %s: %w", name, err)
}
sum := sha256.Sum256(data)
file := staticFile{data: data, etag: `"` + hex.EncodeToString(sum[:8]) + `"`}
cache.Store(name, file)
return file, nil
}
}
// negotiate picks the precompressed copy of name that the client accepts, or
// name itself with no encoding when there is none.
func negotiate(assets fs.FS, name, acceptEncoding string) (served, encoding string) {
for _, candidate := range encodings {
if !acceptsEncoding(acceptEncoding, candidate) {
continue
}
if _, err := fs.Stat(assets, name+encodingSuffix[candidate]); err == nil {
return name + encodingSuffix[candidate], candidate
}
}
return name, ""
}
func setAssetHeaders(header http.Header, name, encoding, etag string) {
header.Add("Vary", "Accept-Encoding")
header.Set("ETag", etag)
header.Set("Cache-Control", revalidateCache)
if strings.HasPrefix(name, immutablePrefix) {
header.Set("Cache-Control", immutableCache)
}
if contentType := mime.TypeByExtension(path.Ext(name)); contentType != "" {
header.Set("Content-Type", contentType)
}
if encoding != "" {
header.Set("Content-Encoding", encoding)
}
}
// acceptsEncoding reports whether an Accept-Encoding header lists the coding.
// Quality values are ignored beyond an explicit q=0: every client that sends
// br or gzip wants them, and none ranks them against each other here.
func acceptsEncoding(header, coding string) bool {
for part := range strings.SplitSeq(header, ",") {
token, params, _ := strings.Cut(strings.TrimSpace(part), ";")
if !strings.EqualFold(strings.TrimSpace(token), coding) {
continue
}
return strings.ReplaceAll(strings.TrimSpace(params), " ", "") != "q=0"
}
return false
}
package httpserver
import "net/http"
type versionDTO struct {
Version string `json:"version"`
}
func versionHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, versionDTO{Version: version})
}
}
package httpserver
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
type webhooksHandler struct {
store ingestion.RunStore
reconciler ingestion.RepoReconciler
}
func (h *webhooksHandler) github(w http.ResponseWriter, r *http.Request) {
h.receive(w, r, ingestion.ForgeGitHub, "X-Hub-Signature-256", "X-GitHub-Event",
func(ctx context.Context, repo ingestion.Repo, eventType string, payload []byte) error {
return ingestion.ProcessGitHubEvent(ctx, h.store, repo, eventType, payload)
})
}
func (h *webhooksHandler) forgejo(w http.ResponseWriter, r *http.Request) {
h.receive(w, r, ingestion.ForgeForgejo, "X-Forgejo-Signature", "X-Forgejo-Event",
func(ctx context.Context, repo ingestion.Repo, eventType string, _ []byte) error {
return ingestion.ProcessForgejoEvent(ctx, h.reconciler, repo, eventType)
})
}
type eventProcessor func(ctx context.Context, repo ingestion.Repo, eventType string, payload []byte) error
// receive handles a webhook delivery: read the body, resolve which tracked
// repo it's for, verify the signature against that repo's secret, then
// process the event. The 401 response is identical whether the repo isn't
// tracked or the signature is wrong, so a delivery can't be used to probe
// which repos exist.
func (h *webhooksHandler) receive(w http.ResponseWriter, r *http.Request, forge ingestion.Forge, signatureHeader, eventHeader string, process eventProcessor) {
ctx := r.Context()
eventType := r.Header.Get(eventHeader)
slog.DebugContext(ctx, "webhook received", "forge", forge, "event", eventType)
body, err := io.ReadAll(r.Body)
if err != nil {
if isBodyTooLarge(err) {
slog.DebugContext(ctx, "webhook rejected", "forge", forge, "reason", "payload_too_large")
writePayloadTooLarge(w)
return
}
slog.DebugContext(ctx, "webhook rejected", "forge", forge, "reason", "invalid_body")
writeError(w, http.StatusBadRequest, "invalid_body", "could not read request body")
return
}
var payload struct {
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(body, &payload); err != nil {
slog.DebugContext(ctx, "webhook rejected", "forge", forge, "reason", "unparseable_payload")
writeError(w, http.StatusUnauthorized, "invalid_signature", "signature verification failed")
return
}
repo, err := h.store.RepoByIdentifier(ctx, forge, payload.Repository.FullName)
if err != nil {
slog.DebugContext(ctx, "webhook rejected", "forge", forge, "reason", "unknown_repo")
writeError(w, http.StatusUnauthorized, "invalid_signature", "signature verification failed")
return
}
if !ingestion.VerifySignature(repo.WebhookSecret, body, r.Header.Get(signatureHeader)) {
slog.DebugContext(ctx, "webhook rejected", "forge", forge, "repo", repo.Identifier, "reason", "bad_signature")
writeError(w, http.StatusUnauthorized, "invalid_signature", "signature verification failed")
return
}
if err := process(ctx, repo, eventType, body); err != nil {
slog.ErrorContext(ctx, "process webhook event", "forge", forge, "repo", repo.Identifier, "event", eventType, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "process webhook event")
return
}
slog.DebugContext(ctx, "webhook processed", "forge", forge, "repo", repo.Identifier, "event", eventType)
w.WriteHeader(http.StatusAccepted)
}
// Package forgejo is the ingestion.ForgeClient adapter for Forgejo, backed
// by the Gitea SDK (Forgejo's REST API is Gitea-derived).
package forgejo
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
// ErrInvalidIdentifier is returned when a repo identifier isn't "owner/name".
var ErrInvalidIdentifier = errors.New("identifier must be in owner/name form")
// ErrUnexpectedStatus is returned when a Forgejo API response's status code
// is neither success nor a recognized "nothing to report" case.
var ErrUnexpectedStatus = errors.New("unexpected status")
// Client is the Forgejo-backed ingestion.ForgeClient. A single Client
// serves any Forgejo instance URL, since each request carries its own.
type Client struct {
// httpClient is fetchWorkflowJobs' transport, owned by this Client
// rather than http.DefaultClient -- see #305: sharing the global
// default client meant an unrelated parallel test's httptest.Server
// closing could break an in-flight request through this Client, since
// Server.Close calls http.DefaultTransport.CloseIdleConnections.
httpClient *http.Client
}
// NewClient returns a Client.
func NewClient() (*Client, error) {
return &Client{httpClient: &http.Client{}}, nil
}
// CreateWebhook implements ingestion.ForgeClient.
func (c *Client) CreateWebhook(ctx context.Context, req ingestion.CreateWebhookRequest) error {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return err
}
api, err := gitea.NewClient(req.InstanceURL,
gitea.SetToken(req.Token),
gitea.SetContext(ctx),
gitea.SetGiteaVersion(""), // skip the live version-check request
)
if err != nil {
return fmt.Errorf("create forgejo client: %w", err)
}
_, _, err = api.CreateRepoHook(owner, name, gitea.CreateHookOption{
Type: gitea.HookTypeGitea,
Config: map[string]string{
"url": req.CallbackURL,
"content_type": "json",
"secret": req.Secret,
},
// Forgejo has no webhook event for Actions run/job status changes
// as of 11.0.16+gitea-1.22.0 -- confirmed empirically, not just
// undocumented: walked the live "new hook" form's checkbox names
// (the full vocabulary is create/delete/fork/issues/issue_assign/
// issue_comment/issue_label/issue_milestone/package/pull_request/
// pull_request_assign/pull_request_comment/pull_request_label/
// pull_request_milestone/pull_request_review/
// pull_request_review_request/pull_request_sync/push/release/
// repository/wiki, nothing Actions-related), and separately
// confirmed "workflow_run"/"workflow_job" both silently drop from
// a created hook's events (github.com/alrayyes/pipeline-analytics
// #120, citing alrayyes/forge-dashboard#177's earlier finding on
// a different instance). "push" is the closest available signal
// that a workflow run may be starting; ingestion.ProcessForgejoEvent
// treats delivery as a cue to reconcile the repo immediately
// rather than parsed run data, since a push payload carries none.
Events: []string{"push"},
Active: true,
})
if err != nil {
return fmt.Errorf("create forgejo webhook: %w", err)
}
return nil
}
// ListAccessibleRepos implements ingestion.ForgeClient. Returns every repo
// the token can see, excluding archived, forked, and mirror repos -- none
// of the three is something the registration UI's picker should ever offer
// to track (a mirror commonly has no Actions runs of its own to reconcile,
// e.g. a Forgejo repo that just mirrors a GitHub one).
func (c *Client) ListAccessibleRepos(ctx context.Context, req ingestion.ListAccessibleReposRequest) ([]string, error) {
api, err := gitea.NewClient(req.InstanceURL,
gitea.SetToken(req.Token),
gitea.SetContext(ctx),
gitea.SetGiteaVersion(""), // skip the live version-check request
)
if err != nil {
return nil, fmt.Errorf("create forgejo client: %w", err)
}
opts := gitea.ListReposOptions{}
var identifiers []string
for {
repos, resp, err := api.ListMyRepos(opts)
if err != nil {
return nil, fmt.Errorf("list accessible forgejo repos: %w", err)
}
for _, r := range repos {
if r.Archived || r.Fork || r.Mirror {
continue
}
identifiers = append(identifiers, r.FullName)
}
if resp.NextPage == 0 {
return identifiers, nil
}
opts.Page = resp.NextPage
}
}
// GetRepo implements ingestion.ForgeClient. A single-repo lookup, not
// ListAccessibleRepos filtered client-side -- the authoritative check
// registration needs, independent of whatever a prior Discover call saw
// (and of whether the bulk listing endpoint populates these flags as
// reliably as a single-repo fetch does).
func (c *Client) GetRepo(ctx context.Context, req ingestion.GetRepoRequest) (ingestion.RepoMetadata, error) {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return ingestion.RepoMetadata{}, err
}
api, err := gitea.NewClient(req.InstanceURL,
gitea.SetToken(req.Token),
gitea.SetContext(ctx),
gitea.SetGiteaVersion(""), // skip the live version-check request
)
if err != nil {
return ingestion.RepoMetadata{}, fmt.Errorf("create forgejo client: %w", err)
}
r, _, err := api.GetRepo(owner, name)
if err != nil {
return ingestion.RepoMetadata{}, fmt.Errorf("get forgejo repo: %w", err)
}
return ingestion.RepoMetadata{
Archived: r.Archived,
Fork: r.Fork,
Mirror: r.Mirror,
}, nil
}
// ListRecentRuns implements ingestion.ForgeClient. Forgejo has no default
// rate limit and no documented conditional-request support for this
// endpoint (design.md's "Ingestion" decision), so every poll fetches the
// full recent run list -- NotModified and ETag are always unset.
func (c *Client) ListRecentRuns(ctx context.Context, req ingestion.ListRunsRequest) (ingestion.ListRunsResult, error) {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return ingestion.ListRunsResult{}, err
}
api, err := gitea.NewClient(req.InstanceURL,
gitea.SetToken(req.Token),
gitea.SetContext(ctx),
gitea.SetGiteaVersion(""), // skip the live version-check request
)
if err != nil {
return ingestion.ListRunsResult{}, fmt.Errorf("create forgejo client: %w", err)
}
runsResp, resp, err := api.ListRepoActionRuns(owner, name, gitea.ListRepoActionRunsOptions{})
if err != nil {
// Forgejo 404s this whole route -- "The target couldn't be
// found." -- for a repo with Actions disabled or that has never
// had a workflow run, rather than returning a 200 with an empty
// list (confirmed against real deployments,
// github.com/alrayyes/pipeline-analytics#121). That's not a
// reconciliation failure worth an error log every poll; it's a
// legitimate "nothing to reconcile" for a repo someone tracked
// that simply has no pipelines (yet).
if resp != nil && resp.StatusCode == http.StatusNotFound {
return ingestion.ListRunsResult{}, nil
}
return ingestion.ListRunsResult{}, fmt.Errorf("list forgejo workflow runs: %w", err)
}
runs := make([]ingestion.RunSnapshot, 0, len(runsResp.WorkflowRuns))
for _, run := range runsResp.WorkflowRuns {
// A null entry decodes to a nil pointer; skip it rather than trust
// the instance (#446).
if run == nil {
continue
}
jobs, err := c.listWorkflowJobs(ctx, req.InstanceURL, req.Token, owner, name, run.ID)
if err != nil {
return ingestion.ListRunsResult{}, err
}
runs = append(runs, ingestion.RunSnapshot{
ForgeRunID: strconv.FormatInt(run.ID, 10),
PipelineName: workflowNameFromPath(run.Path),
Status: run.Status,
Conclusion: run.Conclusion,
StartedAt: nonZeroTime(run.StartedAt),
CompletedAt: nonZeroTime(run.CompletedAt),
ForgeURL: run.HTMLURL,
Branch: run.HeadBranch,
SHA: run.HeadSha,
Message: ingestion.FirstLine(run.DisplayTitle),
Actor: actorName(run),
Jobs: jobs,
})
}
return ingestion.ListRunsResult{Runs: runs}, nil
}
// listWorkflowJobs fetches a run's jobs with a raw HTTP request rather than
// the Gitea SDK's typed ListRepoActionRunJobs -- that method decodes into
// ActionWorkflowJobsResponse ({"jobs": [...]}), a shape confirmed against a
// real deployment to not match what Forgejo actually returns there (a bare
// array, github.com/alrayyes/pipeline-analytics#123). parseWorkflowJobs
// accepts either shape, so this works whichever one a given Forgejo version
// sends. Also confirmed empirically (Forgejo 11 through 14, every currently
// tagged release) that this endpoint isn't registered at all on some
// versions -- a 404 there is treated as "no job detail available" rather
// than failing the whole run, same spirit as the runs-endpoint 404 handling
// above (#121).
func (c *Client) listWorkflowJobs(ctx context.Context, instanceURL, token, owner, name string, runID int64) ([]ingestion.JobSnapshot, error) {
jobsResp, err := fetchWorkflowJobs(ctx, c.httpClient, instanceURL, token, owner, name, runID)
if err != nil {
return nil, err
}
jobs := make([]ingestion.JobSnapshot, 0, len(jobsResp))
for _, job := range jobsResp {
if job == nil {
continue
}
jobs = append(jobs, convertWorkflowJob(job))
}
return jobs, nil
}
func fetchWorkflowJobs(ctx context.Context, httpClient *http.Client, instanceURL, token, owner, name string, runID int64) ([]*gitea.ActionWorkflowJob, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/actions/runs/%d/jobs", strings.TrimRight(instanceURL, "/"), owner, name, runID)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build forgejo jobs request for run %d: %w", runID, err)
}
httpReq.Header.Set("Authorization", "token "+token)
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("list forgejo workflow jobs for run %d: %w", runID, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read forgejo workflow jobs response for run %d: %w", runID, err)
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("list forgejo workflow jobs for run %d: %w: %d: %s", runID, ErrUnexpectedStatus, resp.StatusCode, body)
}
jobs, err := parseWorkflowJobs(body)
if err != nil {
return nil, fmt.Errorf("parse forgejo workflow jobs response for run %d: %w", runID, err)
}
return jobs, nil
}
// parseWorkflowJobs accepts either shape a Forgejo instance's jobs-listing
// endpoint might return: a bare array (confirmed against a real deployment,
// #123) or the {"jobs": [...]} wrapper the Gitea SDK's own
// ActionWorkflowJobsResponse type expects and upstream Gitea documents.
func parseWorkflowJobs(body []byte) ([]*gitea.ActionWorkflowJob, error) {
var bare []*gitea.ActionWorkflowJob
if err := json.Unmarshal(body, &bare); err == nil {
return bare, nil
}
var wrapped gitea.ActionWorkflowJobsResponse
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, fmt.Errorf("unmarshal forgejo workflow jobs: %w", err)
}
return wrapped.Jobs, nil
}
func convertWorkflowJob(job *gitea.ActionWorkflowJob) ingestion.JobSnapshot {
steps := make([]ingestion.StepSnapshot, 0, len(job.Steps))
for _, step := range job.Steps {
if step == nil {
continue
}
steps = append(steps, ingestion.StepSnapshot{
Number: int(step.Number),
Name: step.Name,
Status: step.Status,
Conclusion: step.Conclusion,
StartedAt: nonZeroTime(step.StartedAt),
CompletedAt: nonZeroTime(step.CompletedAt),
})
}
return ingestion.JobSnapshot{
ForgeJobID: strconv.FormatInt(job.ID, 10),
Name: job.Name,
Status: job.Status,
Conclusion: job.Conclusion,
QueuedAt: nonZeroTime(job.CreatedAt),
StartedAt: nonZeroTime(job.StartedAt),
CompletedAt: nonZeroTime(job.CompletedAt),
ForgeURL: job.HTMLURL,
Steps: steps,
}
}
// workflowNameFromPath derives a display name from a workflow file's
// repo-relative path (e.g. ".gitea/workflows/ci.yml" -> "ci") --
// ActionWorkflowRun carries no separate workflow name field.
func workflowNameFromPath(workflowPath string) string {
base := path.Base(workflowPath)
return strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml")
}
func nonZeroTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func splitIdentifier(identifier string) (owner, name string, err error) {
parts := strings.SplitN(identifier, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("%w: %q", ErrInvalidIdentifier, identifier)
}
return parts[0], parts[1], nil
}
// actorName prefers the user who actually triggered the run over the
// workflow's original actor, matching what a person would call "who ran this".
func actorName(run *gitea.ActionWorkflowRun) string {
if run.TriggerActor != nil && run.TriggerActor.UserName != "" {
return run.TriggerActor.UserName
}
if run.Actor != nil {
return run.Actor.UserName
}
return ""
}
package forgejo
import (
"context"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
var _ ingestion.JobLogReader = (*Client)(nil)
// JobLogTail implements ingestion.JobLogReader. Forgejo's Actions log REST
// API landed in v16 (forgejo/forgejo#12666); earlier versions serve logs only
// through the web UI, behind a session. The instance this service targets
// runs 11.0.16, so there is nothing to call: report it, and let the caller
// show the deep link. Revisit when a v16 instance is a supported target.
func (c *Client) JobLogTail(context.Context, ingestion.JobLogRequest) (ingestion.JobLogTail, error) {
return ingestion.JobLogTail{}, ingestion.ErrLogUnsupported
}
// Package github is the ingestion.ForgeClient adapter for GitHub, backed by
// google/go-github.
package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
ghapi "github.com/google/go-github/v75/github"
)
// defaultBaseURL is GitHub's real API base, used when NewClient wasn't
// given a fake one to test against.
const defaultBaseURL = "https://api.github.com/"
// ErrInvalidIdentifier is returned when a repo identifier isn't "owner/name".
var ErrInvalidIdentifier = errors.New("identifier must be in owner/name form")
// ErrUnexpectedStatus is returned when a GitHub API response's status code
// is neither the expected success code nor 304 Not Modified.
var ErrUnexpectedStatus = errors.New("unexpected status")
// Client is the GitHub-backed ingestion.ForgeClient.
type Client struct {
baseURL *url.URL
// httpClient is conditionalGet's transport, owned by this Client
// rather than http.DefaultClient -- see #305: sharing the global
// default client meant an unrelated parallel test's httptest.Server
// closing could break an in-flight request through this Client, since
// Server.Close calls http.DefaultTransport.CloseIdleConnections. A
// zero http.Client still uses http.DefaultTransport, so it carries a
// transport of its own (newHTTPClient).
httpClient *http.Client
// rateLimitsMu guards rateLimits, keyed by token -- reconciliation
// polling and the registration UI's discover/webhook calls can run
// concurrently against the same shared Client.
rateLimitsMu sync.RWMutex
rateLimits map[string]ingestion.RateLimitSnapshot
}
// NewClient returns a Client. baseURL overrides the GitHub API endpoint
// (for tests against a fake server); pass "" to use the real GitHub API.
func NewClient(baseURL string) (*Client, error) {
if baseURL == "" {
return &Client{httpClient: newHTTPClient()}, nil
}
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parse base url: %w", err)
}
return &Client{baseURL: u, httpClient: newHTTPClient()}, nil
}
// newHTTPClient returns an http.Client with a transport of its own, so its
// connection pool is out of reach of anyone else's CloseIdleConnections. The
// settings are the standard library's own http.DefaultTransport ones.
func newHTTPClient() *http.Client {
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
}}
}
// CreateWebhook implements ingestion.ForgeClient.
func (c *Client) CreateWebhook(ctx context.Context, req ingestion.CreateWebhookRequest) error {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return err
}
api := ghapi.NewClient(nil).WithAuthToken(req.Token)
if c.baseURL != nil {
api.BaseURL = c.baseURL
}
hook := &ghapi.Hook{
Config: &ghapi.HookConfig{
URL: new(req.CallbackURL),
ContentType: new("json"),
Secret: new(req.Secret),
},
Events: []string{"workflow_run", "workflow_job"},
Active: new(true),
}
if _, _, err := api.Repositories.CreateHook(ctx, owner, name, hook); err != nil {
return fmt.Errorf("create github webhook: %w", err)
}
return nil
}
// ListAccessibleRepos implements ingestion.ForgeClient. Returns every repo
// (owner, collaborator, and organization-member repos) the token can see,
// newest-pushed first, excluding archived, forked, and mirror repos -- none
// of the three is something the registration UI's picker should ever offer
// to track.
func (c *Client) ListAccessibleRepos(ctx context.Context, req ingestion.ListAccessibleReposRequest) ([]string, error) {
api := ghapi.NewClient(nil).WithAuthToken(req.Token)
if c.baseURL != nil {
api.BaseURL = c.baseURL
}
opts := &ghapi.RepositoryListByAuthenticatedUserOptions{
Sort: "pushed",
PerPage: 100,
}
var identifiers []string
for {
repos, resp, err := api.Repositories.ListByAuthenticatedUser(ctx, opts)
if err != nil {
return nil, fmt.Errorf("list accessible github repos: %w", err)
}
for _, r := range repos {
if r.GetArchived() || r.GetFork() || r.GetMirrorURL() != "" {
continue
}
identifiers = append(identifiers, r.GetFullName())
}
if resp.NextPage == 0 {
return identifiers, nil
}
opts.Page = resp.NextPage
}
}
// GetRepo implements ingestion.ForgeClient. A single-repo lookup, not
// ListAccessibleRepos filtered client-side -- the authoritative check
// registration needs, independent of whatever a prior Discover call saw.
func (c *Client) GetRepo(ctx context.Context, req ingestion.GetRepoRequest) (ingestion.RepoMetadata, error) {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return ingestion.RepoMetadata{}, err
}
api := ghapi.NewClient(nil).WithAuthToken(req.Token)
if c.baseURL != nil {
api.BaseURL = c.baseURL
}
r, _, err := api.Repositories.Get(ctx, owner, name)
if err != nil {
return ingestion.RepoMetadata{}, fmt.Errorf("get github repo: %w", err)
}
return ingestion.RepoMetadata{
Archived: r.GetArchived(),
Fork: r.GetFork(),
Mirror: r.GetMirrorURL() != "",
}, nil
}
// ListRecentRuns implements ingestion.ForgeClient. The run-list request is
// conditional (If-None-Match/ETag); a 304 short-circuits before any jobs
// are fetched, since nothing about the run list changed. go-github's typed
// client isn't used here -- CheckResponse treats a 304 as an API error, so
// a plain net/http request keeps the conditional-GET contract explicit and
// testable against a fake server.
//
// Below that per-list check, each individual run gets its own, finer-
// grained skip: a run present in req.KnownRuns whose status is completed
// and matches the fresh listing's status and conclusion has nothing new to
// report, so its jobs aren't refetched at all (see ingestion.RunState's
// doc comment).
func (c *Client) ListRecentRuns(ctx context.Context, req ingestion.ListRunsRequest) (ingestion.ListRunsResult, error) {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return ingestion.ListRunsResult{}, err
}
var runsPayload struct {
WorkflowRuns []githubWorkflowRun `json:"workflow_runs"`
}
runsURL := fmt.Sprintf("%srepos/%s/%s/actions/runs?per_page=20", c.baseURLString(), owner, name)
etag, notModified, err := c.conditionalGet(ctx, runsURL, req.Token, req.ETag, &runsPayload)
if err != nil {
return ingestion.ListRunsResult{}, fmt.Errorf("list workflow runs: %w", err)
}
if notModified {
return ingestion.ListRunsResult{NotModified: true}, nil
}
runs := make([]ingestion.RunSnapshot, 0, len(runsPayload.WorkflowRuns))
for _, wr := range runsPayload.WorkflowRuns {
run, err := c.snapshotRun(ctx, owner, name, wr, req.Token, req.KnownRuns)
if err != nil {
return ingestion.ListRunsResult{}, err
}
runs = append(runs, run)
}
return ingestion.ListRunsResult{ETag: etag, Runs: runs}, nil
}
// githubWorkflowRun is one entry from GitHub's list-workflow-runs response.
type githubWorkflowRun struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
RunStartedAt string `json:"run_started_at"`
UpdatedAt string `json:"updated_at"`
HTMLURL string `json:"html_url"`
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
HeadCommit struct {
Message string `json:"message"`
} `json:"head_commit"`
Actor struct {
Login string `json:"login"`
} `json:"actor"`
}
// snapshotRun converts one run listing entry to a RunSnapshot, fetching its
// jobs unless knownRuns says that's unnecessary (runIsUpToDate).
func (c *Client) snapshotRun(ctx context.Context, owner, name string, wr githubWorkflowRun, token string, knownRuns map[string]ingestion.RunState) (ingestion.RunSnapshot, error) {
runID := strconv.FormatInt(wr.ID, 10)
var jobs []ingestion.JobSnapshot
if !runIsUpToDate(knownRuns[runID], wr.Status, wr.Conclusion) {
var err error
jobs, err = c.listWorkflowJobs(ctx, owner, name, wr.ID, token)
if err != nil {
return ingestion.RunSnapshot{}, err
}
}
var completedAt *time.Time
if wr.Status == "completed" {
completedAt = parseGitHubTime(wr.UpdatedAt)
}
return ingestion.RunSnapshot{
ForgeRunID: runID,
PipelineName: wr.Name,
Status: wr.Status,
Conclusion: wr.Conclusion,
StartedAt: parseGitHubTime(wr.RunStartedAt),
CompletedAt: completedAt,
ForgeURL: wr.HTMLURL,
Branch: wr.HeadBranch,
SHA: wr.HeadSHA,
Message: ingestion.FirstLine(wr.HeadCommit.Message),
Actor: wr.Actor.Login,
Jobs: jobs,
}, nil
}
// runIsUpToDate reports whether a run's stored state (absent if it was
// never in KnownRuns) already matches what the fresh listing reports --
// only true for a run that's completed in both, with the same conclusion,
// which is what actually makes its jobs safe to skip refetching.
func runIsUpToDate(known ingestion.RunState, freshStatus, freshConclusion string) bool {
return known.Status == "completed" && freshStatus == "completed" && known.Conclusion == freshConclusion
}
func (c *Client) listWorkflowJobs(ctx context.Context, owner, name string, runID int64, token string) ([]ingestion.JobSnapshot, error) {
var payload struct {
Jobs []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
HTMLURL string `json:"html_url"`
Steps []struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Number int `json:"number"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
} `json:"steps"`
} `json:"jobs"`
}
jobsURL := fmt.Sprintf("%srepos/%s/%s/actions/runs/%d/jobs", c.baseURLString(), owner, name, runID)
if _, _, err := c.conditionalGet(ctx, jobsURL, token, "", &payload); err != nil {
return nil, fmt.Errorf("list workflow jobs for run %d: %w", runID, err)
}
jobs := make([]ingestion.JobSnapshot, 0, len(payload.Jobs))
for _, j := range payload.Jobs {
steps := make([]ingestion.StepSnapshot, 0, len(j.Steps))
for _, s := range j.Steps {
steps = append(steps, ingestion.StepSnapshot{
Number: s.Number,
Name: s.Name,
Status: s.Status,
Conclusion: s.Conclusion,
StartedAt: parseGitHubTime(s.StartedAt),
CompletedAt: parseGitHubTime(s.CompletedAt),
})
}
jobs = append(jobs, ingestion.JobSnapshot{
ForgeJobID: strconv.FormatInt(j.ID, 10),
Name: j.Name,
Status: j.Status,
Conclusion: j.Conclusion,
QueuedAt: parseGitHubTime(j.CreatedAt),
StartedAt: parseGitHubTime(j.StartedAt),
CompletedAt: parseGitHubTime(j.CompletedAt),
ForgeURL: j.HTMLURL,
Steps: steps,
})
}
return jobs, nil
}
// conditionalGet issues an authenticated GET, sending ifNoneMatch as
// If-None-Match when set, and JSON-decodes a 200 response into out. A 304
// reports notModified with no decode attempted.
func (c *Client) conditionalGet(ctx context.Context, url, token, ifNoneMatch string, out any) (etag string, notModified bool, err error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", false, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+token)
httpReq.Header.Set("Accept", "application/vnd.github+json")
if ifNoneMatch != "" {
httpReq.Header.Set("If-None-Match", ifNoneMatch)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return "", false, fmt.Errorf("request %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
c.recordRateLimit(token, resp.Header)
if resp.StatusCode == http.StatusNotModified {
return "", true, nil
}
if resp.StatusCode != http.StatusOK {
return "", false, fmt.Errorf("%s: %w: %d", url, ErrUnexpectedStatus, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return "", false, fmt.Errorf("decode response from %s: %w", url, err)
}
return resp.Header.Get("ETag"), false, nil
}
// RateLimitFor implements ingestion.RateLimitReporter.
func (c *Client) RateLimitFor(token string) (ingestion.RateLimitSnapshot, bool) {
c.rateLimitsMu.RLock()
defer c.rateLimitsMu.RUnlock()
snapshot, ok := c.rateLimits[token]
return snapshot, ok
}
// recordRateLimit captures the X-RateLimit-* headers GitHub sends on every
// REST API response (200 or 304 alike) -- the recommended way to track
// rate-limit usage, per GitHub's own guidance ("you should use the rate
// limit response headers instead of calling the API to check your rate
// limit": docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
// A response missing or malformed headers (a non-GitHub fake server in a
// test, say) is silently ignored rather than erroring the caller's request.
func (c *Client) recordRateLimit(token string, header http.Header) {
limit, errLimit := strconv.Atoi(header.Get("X-RateLimit-Limit"))
remaining, errRemaining := strconv.Atoi(header.Get("X-RateLimit-Remaining"))
used, errUsed := strconv.Atoi(header.Get("X-RateLimit-Used"))
resetUnix, errReset := strconv.ParseInt(header.Get("X-RateLimit-Reset"), 10, 64)
if errLimit != nil || errRemaining != nil || errUsed != nil || errReset != nil {
return
}
snapshot := ingestion.RateLimitSnapshot{
Limit: limit,
Remaining: remaining,
Used: used,
Resource: header.Get("X-RateLimit-Resource"),
ResetAt: time.Unix(resetUnix, 0),
}
c.rateLimitsMu.Lock()
defer c.rateLimitsMu.Unlock()
if c.rateLimits == nil {
c.rateLimits = make(map[string]ingestion.RateLimitSnapshot)
}
c.rateLimits[token] = snapshot
}
func (c *Client) baseURLString() string {
if c.baseURL != nil {
return c.baseURL.String()
}
return defaultBaseURL
}
func parseGitHubTime(s string) *time.Time {
if s == "" {
return nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil
}
return &t
}
func splitIdentifier(identifier string) (owner, name string, err error) {
parts := strings.SplitN(identifier, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("%w: %q", ErrInvalidIdentifier, identifier)
}
return parts[0], parts[1], nil
}
package github
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"unicode/utf8"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
// maxLogBytes bounds how much of one log is read while looking for its tail.
// Past it the lines kept are the last ones seen before the cap, and the
// result is marked truncated.
const maxLogBytes = 64 << 20
var _ ingestion.JobLogReader = (*Client)(nil)
// JobLogTail implements ingestion.JobLogReader. GitHub answers
// GET .../actions/jobs/{id}/logs with a 302 to a plain-text download that
// expires after a minute
// (https://docs.github.com/en/rest/actions/workflow-jobs), so the redirect
// is followed here and the download URL never leaves the server. net/http
// drops the Authorization header on a cross-host redirect, which is what the
// download wants.
func (c *Client) JobLogTail(ctx context.Context, req ingestion.JobLogRequest) (ingestion.JobLogTail, error) {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return ingestion.JobLogTail{}, err
}
base := defaultBaseURL
if c.baseURL != nil {
base = c.baseURL.String()
}
url := fmt.Sprintf("%s/repos/%s/%s/actions/jobs/%s/logs", strings.TrimRight(base, "/"), owner, name, req.ForgeJobID)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ingestion.JobLogTail{}, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
httpReq.Header.Set("Accept", "application/vnd.github+json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return ingestion.JobLogTail{}, fmt.Errorf("%w: %w", ingestion.ErrLogUnreachable, err)
}
defer func() { _ = resp.Body.Close() }()
c.recordRateLimit(req.Token, resp.Header)
switch resp.StatusCode {
case http.StatusOK:
return tailLines(resp.Body, req.Lines)
case http.StatusNotFound, http.StatusGone:
return ingestion.JobLogTail{}, ingestion.ErrLogExpired
case http.StatusUnauthorized, http.StatusForbidden:
return ingestion.JobLogTail{}, ingestion.ErrLogForbidden
default:
return ingestion.JobLogTail{}, fmt.Errorf("%w: status %d", ingestion.ErrLogUnreachable, resp.StatusCode)
}
}
// tailLines streams r and keeps the last n lines.
func tailLines(r io.Reader, n int) (ingestion.JobLogTail, error) {
if n < 1 {
n = 1
}
reader := bufio.NewReaderSize(io.LimitReader(r, maxLogBytes), 64<<10)
ring := make([]string, 0, n)
total := 0
for {
line, err := readLine(reader)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return ingestion.JobLogTail{}, fmt.Errorf("%w: read log: %w", ingestion.ErrLogUnreachable, err)
}
total++
if len(ring) == n {
copy(ring, ring[1:])
ring = ring[:n-1]
}
ring = append(ring, line)
}
return ingestion.JobLogTail{Lines: ring, Truncated: total > n}, nil
}
// readLine reads one line without its newline, cut to MaxLogLineLength and
// to a rune boundary, and discards the rest of an overlong one.
func readLine(r *bufio.Reader) (string, error) {
var line []byte
for {
part, isPrefix, err := r.ReadLine()
if err != nil {
return "", err //nolint:wrapcheck // io.EOF is the caller's loop signal
}
if room := ingestion.MaxLogLineLength - len(line); room > 0 {
line = append(line, part[:min(room, len(part))]...)
}
if !isPrefix {
return finishLine(line), nil
}
}
}
func finishLine(line []byte) string {
line = bytes.TrimSuffix(line, []byte("\r"))
for len(line) > 0 && !utf8.Valid(line) && len(line) == ingestion.MaxLogLineLength {
line = line[:len(line)-1]
}
return string(line)
}
package github
import (
"context"
"fmt"
"net/http"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
)
var _ ingestion.RunActor = (*Client)(nil)
// RerunRun implements ingestion.RunActor. It re-runs only the failed jobs
// when asked to, the whole run otherwise
// (https://docs.github.com/en/rest/actions/workflow-runs).
func (c *Client) RerunRun(ctx context.Context, req ingestion.RunActionRequest) error {
path := "rerun"
if req.FailedOnly {
path = "rerun-failed-jobs"
}
return c.postRunAction(ctx, req, path)
}
// CancelRun implements ingestion.RunActor.
func (c *Client) CancelRun(ctx context.Context, req ingestion.RunActionRequest) error {
return c.postRunAction(ctx, req, "cancel")
}
func (c *Client) postRunAction(ctx context.Context, req ingestion.RunActionRequest, path string) error {
owner, name, err := splitIdentifier(req.Identifier)
if err != nil {
return err
}
url := fmt.Sprintf("%srepos/%s/%s/actions/runs/%s/%s", c.baseURLString(), owner, name, req.ForgeRunID, path)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
httpReq.Header.Set("Accept", "application/vnd.github+json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("%w: %w", ingestion.ErrActionUnreachable, err)
}
defer func() { _ = resp.Body.Close() }()
c.recordRateLimit(req.Token, resp.Header)
switch resp.StatusCode {
case http.StatusCreated, http.StatusAccepted, http.StatusNoContent:
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return ingestion.ErrActionForbidden
case http.StatusNotFound:
// GitHub answers 404 for a repo the token can't write to, too.
return ingestion.ErrActionForbidden
case http.StatusConflict:
return ingestion.ErrNotActionable
default:
return fmt.Errorf("%w: status %d", ingestion.ErrActionUnreachable, resp.StatusCode)
}
}
// Package ingestion holds the forge-ingestion domain: tracked repos and the
// port their storage is reached through. See
// openspec/changes/add-pipeline-dashboard/specs/forge-ingestion/spec.md.
package ingestion
import (
"context"
"errors"
"time"
)
// ErrRepoAlreadyTracked is returned by CreateRepo when a repo with the same
// forge, identifier, and (for Forgejo) instance URL is already tracked.
var ErrRepoAlreadyTracked = errors.New("repo already tracked")
// ErrRepoNotFound is returned by a Store when no repo matches the requested
// id. Any other error from a lookup is a store failure, not a missing repo.
var ErrRepoNotFound = errors.New("repo not found")
// Returned by Register when the repo being registered is archived, a fork,
// or a mirror -- checked authoritatively via GetRepo, not trusted from a
// prior Discover result.
var (
ErrRepoArchived = errors.New("repo is archived")
ErrRepoFork = errors.New("repo is a fork")
ErrRepoMirror = errors.New("repo is a mirror")
)
// Forge identifies which forge a tracked repo lives on.
type Forge string
// The forges pipeline-analytics tracks repos on.
const (
ForgeGitHub Forge = "github"
ForgeForgejo Forge = "forgejo"
)
// Status is a tracked repo's ingestion health.
type Status string
// The ingestion statuses a tracked repo can be in.
const (
StatusPending Status = "pending"
StatusActive Status = "active"
StatusDegraded Status = "degraded"
)
// Repo is a tracked repository. Its raw or encrypted token is never part of
// this type -- TokenMasked is all a caller outside the store ever sees.
type Repo struct {
ID string
Forge Forge
Identifier string
ForgejoInstanceURL string
TokenMasked string
WebhookSecret string
IngestionStatus Status
IngestionStatusReason string
// ReconcileETag is the response ETag from the repo's last reconciliation
// poll (GitHub only), used as If-None-Match on the next poll so an
// unchanged repo costs no rate-limit budget. Empty before the first poll.
ReconcileETag string
CreatedAt time.Time
}
// NewRepo is the input to registering a repo for tracking.
type NewRepo struct {
Forge Forge
Identifier string
ForgejoInstanceURL string
Token string
}
// RepoListFilter narrows ListRepos to one forge and/or one page, ordered
// oldest-created first. The zero value matches every tracked repo,
// unfiltered and unpaginated -- what reconciliation and the rate-limit
// insights page need, since they operate over every repo rather than one
// page a user is looking at.
type RepoListFilter struct {
// Forge restricts the list to one forge. Empty matches every forge.
Forge Forge
// Limit caps how many repos are returned. Zero means unlimited.
Limit int
// Offset skips this many matching repos before the page starts.
Offset int
}
// Store is the port the domain persists tracked repos through.
type Store interface {
CreateRepo(ctx context.Context, repo NewRepo) (Repo, error)
// ListRepos returns the repos matching filter, plus whether more repos
// beyond this page also match -- always false when filter.Limit is 0.
ListRepos(ctx context.Context, filter RepoListFilter) (repos []Repo, hasMore bool, err error)
// ListRepoIdentifiers returns every tracked identifier for forge (and,
// for Forgejo, instanceURL), unpaginated -- for a "is this already
// tracked" check that has to see every tracked repo, not just the page
// a paginated list happens to be showing.
ListRepoIdentifiers(ctx context.Context, forge Forge, instanceURL string) ([]string, error)
// GetRepo returns the repo with the id, or ErrRepoNotFound when none
// matches.
GetRepo(ctx context.Context, id string) (Repo, error)
// DeleteRepo stops tracking a repo. The schema's ON DELETE CASCADE
// removes its stored runs, jobs, and steps along with it -- it does
// not attempt to delete the webhook created on the forge, since no
// webhook ID is stored at registration time to delete it by.
DeleteRepo(ctx context.Context, id string) error
// RepoToken returns the decrypted token for internal use (calling the
// forge's API) -- never exposed over HTTP.
RepoToken(ctx context.Context, id string) (string, error)
// SetIngestionStatus records whether webhook registration (or later
// ingestion) succeeded, and why when it didn't.
SetIngestionStatus(ctx context.Context, id string, status Status, reason string) error
// SetReconcileETag records the ETag from a repo's most recent
// reconciliation poll, for use as If-None-Match on the next one.
SetReconcileETag(ctx context.Context, id string, etag string) error
}
// RepoReconciler is the port webhook ingestion calls to trigger an
// immediate reconciliation poll for one repo, for a forge whose webhook
// delivery carries no usable run data of its own -- Forgejo, see
// ProcessForgejoEvent.
type RepoReconciler interface {
ReconcileRepo(ctx context.Context, repo Repo) error
}
// CreateWebhookRequest is what a ForgeClient needs to register a webhook on
// a tracked repo.
type CreateWebhookRequest struct {
// InstanceURL is set for Forgejo, empty for GitHub.
InstanceURL string
Identifier string
Token string
CallbackURL string
Secret string
}
// ForgeClient is the port the domain calls out to a forge's REST API
// through, to manage the webhook a tracked repo needs and to reconcile
// tracked repos' recent run history.
type ForgeClient interface {
CreateWebhook(ctx context.Context, req CreateWebhookRequest) error
// ListRecentRuns lists a tracked repo's recent workflow runs (with their
// jobs and steps) for reconciliation polling. On GitHub, req.ETag is
// sent as If-None-Match; ListRunsResult.NotModified is true when the
// forge reports nothing changed, costing no rate-limit budget.
ListRecentRuns(ctx context.Context, req ListRunsRequest) (ListRunsResult, error)
// ListAccessibleRepos lists "owner/name" identifiers the given token can
// access, for the registration UI's repo picker. Called with a token
// that hasn't been stored yet -- never persisted or logged.
ListAccessibleRepos(ctx context.Context, req ListAccessibleReposRequest) ([]string, error)
// GetRepo looks up one repo's current archived/fork/mirror status
// directly, for an authoritative check at registration time rather
// than trusting a possibly-stale ListAccessibleRepos result.
GetRepo(ctx context.Context, req GetRepoRequest) (RepoMetadata, error)
}
// GetRepoRequest is what GetRepo needs to look up one repo.
type GetRepoRequest struct {
// InstanceURL is set for Forgejo, empty for GitHub.
InstanceURL string
Identifier string
Token string
}
// RepoMetadata is a repo's archived/fork/mirror status, from a single-repo
// lookup at registration time -- see ErrRepoArchived/ErrRepoFork/
// ErrRepoMirror.
type RepoMetadata struct {
Archived bool
Fork bool
Mirror bool
}
// RateLimitSnapshot is a forge API's most recently observed rate-limit
// status for one token.
type RateLimitSnapshot struct {
Limit int
Remaining int
Used int
Resource string
ResetAt time.Time
}
// RateLimitReporter is implemented by a ForgeClient that can report the
// rate-limit status it last observed a token being given, read from a real
// response's headers rather than a dedicated poll -- GitHub only; Forgejo
// has no equivalent concept and its ForgeClient doesn't implement this.
type RateLimitReporter interface {
// RateLimitFor returns the token's most recently observed status. ok is
// false if this token hasn't been used on a request yet.
RateLimitFor(token string) (snapshot RateLimitSnapshot, ok bool)
}
// ListAccessibleReposRequest is what ListAccessibleRepos needs to ask a
// forge which repos a token can reach.
type ListAccessibleReposRequest struct {
// InstanceURL is set for Forgejo, empty for GitHub.
InstanceURL string
Token string
}
// ListRunsRequest is what ListRecentRuns needs to poll a tracked repo.
type ListRunsRequest struct {
// InstanceURL is set for Forgejo, empty for GitHub.
InstanceURL string
Identifier string
Token string
// ETag is the repo's ReconcileETag from the last poll, sent as
// If-None-Match. Empty on a repo's first poll.
ETag string
// KnownRuns is each already-stored run's status/conclusion, keyed by
// forge run ID -- a GitHub ListRecentRuns skips fetching jobs for a
// run present here whose status and conclusion still match what's
// stored, since that call would return data identical to what's
// already persisted. Forgejo's implementation ignores this; see its
// own ListRecentRuns doc comment for why it fetches unconditionally.
KnownRuns map[string]RunState
}
// ListRunsResult is what a reconciliation poll found.
type ListRunsResult struct {
// NotModified is true when the forge reported nothing changed since
// ETag (a 304 on GitHub); Runs is empty and ignored in that case.
NotModified bool
// ETag is the new value to persist as the repo's ReconcileETag, for
// If-None-Match on the next poll. Always empty on Forgejo.
ETag string
Runs []RunSnapshot
}
// RunSnapshot is a workflow run as reconciliation polling found it on the
// forge, with its jobs and their steps nested -- unlike Run/Job/Step, which
// are flat storage records keyed by ID once persisted.
type RunSnapshot struct {
ForgeRunID string
PipelineName string
Status string
Conclusion string
StartedAt *time.Time
CompletedAt *time.Time
ForgeURL string
Branch string
SHA string
Message string
Actor string
Jobs []JobSnapshot
}
// JobSnapshot is a workflow job nested under a RunSnapshot.
type JobSnapshot struct {
ForgeJobID string
Name string
Status string
Conclusion string
QueuedAt *time.Time
StartedAt *time.Time
CompletedAt *time.Time
ForgeURL string
Steps []StepSnapshot
}
// StepSnapshot is a job step nested under a JobSnapshot.
type StepSnapshot struct {
Number int
Name string
Status string
Conclusion string
StartedAt *time.Time
CompletedAt *time.Time
}
// MaskToken returns a display form of token that reveals at most its last
// four characters, per forge-ingestion/spec.md's "Credential storage"
// requirement.
func MaskToken(token string) string {
const visible = 4
if len(token) <= visible {
return "****"
}
return "****" + token[len(token)-visible:]
}
package ingestion
import (
"context"
"errors"
"fmt"
)
// ErrJobNotFound is returned when a run and job id name no recorded job.
var ErrJobNotFound = errors.New("job not found")
const (
// DefaultLogLines is how many lines a log request gets when it names none.
DefaultLogLines = 200
// MaxLogLines is the most lines one request may ask for.
MaxLogLines = 1000
)
// JobLogReason is why a job's log wasn't returned, as the API reports it.
type JobLogReason string
// The reasons a log can be unavailable.
const (
LogUnsupported JobLogReason = "unsupported"
LogExpired JobLogReason = "expired"
LogForbidden JobLogReason = "forbidden"
LogUnreachable JobLogReason = "unreachable"
)
// JobLocation is where a recorded job lives on its forge.
type JobLocation struct {
RepoID string
ForgeJobID string
ForgeURL string
}
// JobLocator resolves a run's job to its location, or ErrJobNotFound.
type JobLocator interface {
LocateJob(ctx context.Context, runID, jobID string) (JobLocation, error)
}
// JobLogRepos is the slice of the repo store the log service needs.
type JobLogRepos interface {
GetRepo(ctx context.Context, id string) (Repo, error)
RepoToken(ctx context.Context, id string) (string, error)
}
// JobLogResult is a job's log tail, or why there isn't one. ForgeURL is
// always set, so a caller can still link out.
type JobLogResult struct {
Available bool
Reason JobLogReason
Lines []string
Truncated bool
ForgeURL string
}
// JobLogService reads a job's log from its forge on demand. Nothing is
// stored (openspec/changes/api-job-log).
type JobLogService struct {
locator JobLocator
repos JobLogRepos
readers map[Forge]JobLogReader
}
// NewJobLogService returns a JobLogService. A forge with no reader in readers
// is reported as unsupported.
func NewJobLogService(locator JobLocator, repos JobLogRepos, readers map[Forge]JobLogReader) *JobLogService {
return &JobLogService{locator: locator, repos: repos, readers: readers}
}
// Tail returns the last lines of a job's log. Lines is clamped to
// [1, MaxLogLines], defaulting to DefaultLogLines. A log the forge can't
// supply is a result with a Reason; only an unknown job or an unexpected
// failure is an error.
func (s *JobLogService) Tail(ctx context.Context, runID, jobID string, lines int) (JobLogResult, error) {
if lines <= 0 {
lines = DefaultLogLines
}
lines = min(lines, MaxLogLines)
location, err := s.locator.LocateJob(ctx, runID, jobID)
if err != nil {
return JobLogResult{}, fmt.Errorf("locate job: %w", err)
}
unavailable := func(reason JobLogReason) JobLogResult {
return JobLogResult{Reason: reason, ForgeURL: location.ForgeURL}
}
repo, err := s.repos.GetRepo(ctx, location.RepoID)
if errors.Is(err, ErrRepoNotFound) {
return JobLogResult{}, ErrJobNotFound
}
if err != nil {
return JobLogResult{}, fmt.Errorf("get repo: %w", err)
}
reader, ok := s.readers[repo.Forge]
if !ok {
return unavailable(LogUnsupported), nil
}
token, err := s.repos.RepoToken(ctx, repo.ID)
if err != nil {
return JobLogResult{}, fmt.Errorf("get repo token: %w", err)
}
tail, err := reader.JobLogTail(ctx, JobLogRequest{
InstanceURL: repo.ForgejoInstanceURL,
Identifier: repo.Identifier,
Token: token,
ForgeJobID: location.ForgeJobID,
Lines: lines,
})
switch {
case err == nil:
return JobLogResult{Available: true, Lines: tail.Lines, Truncated: tail.Truncated, ForgeURL: location.ForgeURL}, nil
case errors.Is(err, ErrLogUnsupported):
return unavailable(LogUnsupported), nil
case errors.Is(err, ErrLogExpired):
return unavailable(LogExpired), nil
case errors.Is(err, ErrLogForbidden):
return unavailable(LogForbidden), nil
case errors.Is(err, ErrLogUnreachable):
return unavailable(LogUnreachable), nil
default:
return JobLogResult{}, fmt.Errorf("read job log: %w", err)
}
}
package ingestion
import (
"context"
"fmt"
"log/slog"
"time"
)
// Reconciler polls each tracked repo's recent workflow runs on a schedule,
// per forge-ingestion/spec.md's "Reconciliation polling" requirement: it
// backfills a newly tracked repo's history and catches any webhook delivery
// that was missed, at no rate-limit cost when nothing has changed.
type Reconciler struct {
store Store
runStore RunStore
clients map[Forge]ForgeClient
}
// NewReconciler returns a Reconciler.
func NewReconciler(store Store, runStore RunStore, clients map[Forge]ForgeClient) *Reconciler {
return &Reconciler{store: store, runStore: runStore, clients: clients}
}
// Run polls every tracked repo immediately, then again every interval,
// until ctx is done.
func (r *Reconciler) Run(ctx context.Context, interval time.Duration) {
r.ReconcileAll(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.ReconcileAll(ctx)
}
}
}
// ReconcileAll polls every tracked repo once. A single repo's failure is
// logged, not returned, so it doesn't block reconciling the others.
func (r *Reconciler) ReconcileAll(ctx context.Context) {
repos, _, err := r.store.ListRepos(ctx, RepoListFilter{})
if err != nil {
slog.ErrorContext(ctx, "list tracked repos for reconciliation", "error", err)
return
}
slog.DebugContext(ctx, "reconciliation poll started", "repos", len(repos))
for _, repo := range repos {
if err := r.ReconcileRepo(ctx, repo); err != nil {
slog.ErrorContext(ctx, "reconcile repo", "repo", repo.Identifier, "error", err)
}
}
}
// ReconcileRepo polls one tracked repo and updates run/job/step storage
// with whatever the forge reports changed. A repo whose forge has no
// registered client is skipped rather than treated as an error, so a
// partially configured deployment doesn't fail every poll.
func (r *Reconciler) ReconcileRepo(ctx context.Context, repo Repo) error {
client, ok := r.clients[repo.Forge]
if !ok {
return nil
}
token, err := r.store.RepoToken(ctx, repo.ID)
if err != nil {
return fmt.Errorf("load repo token: %w", err)
}
knownRuns, err := r.runStore.RunStates(ctx, repo.ID)
if err != nil {
return fmt.Errorf("load known run states: %w", err)
}
result, err := client.ListRecentRuns(ctx, ListRunsRequest{
InstanceURL: repo.ForgejoInstanceURL,
Identifier: repo.Identifier,
Token: token,
ETag: repo.ReconcileETag,
KnownRuns: knownRuns,
})
if err != nil {
return fmt.Errorf("list recent runs: %w", err)
}
if result.NotModified {
slog.DebugContext(ctx, "reconcile: not modified", "repo", repo.Identifier)
return nil
}
slog.DebugContext(ctx, "reconcile: runs fetched", "repo", repo.Identifier, "runs", len(result.Runs))
for _, snapshot := range result.Runs {
if err := r.storeRunSnapshot(ctx, repo, snapshot); err != nil {
return err
}
}
if result.ETag == "" {
return nil
}
if err := r.store.SetReconcileETag(ctx, repo.ID, result.ETag); err != nil {
return fmt.Errorf("persist reconciliation etag: %w", err)
}
return nil
}
func (r *Reconciler) storeRunSnapshot(ctx context.Context, repo Repo, snapshot RunSnapshot) error {
run, err := r.runStore.UpsertRun(ctx, Run{
RepoID: repo.ID,
ForgeRunID: snapshot.ForgeRunID,
PipelineName: snapshot.PipelineName,
Status: snapshot.Status,
Conclusion: snapshot.Conclusion,
StartedAt: snapshot.StartedAt,
CompletedAt: snapshot.CompletedAt,
ForgeURL: snapshot.ForgeURL,
Branch: snapshot.Branch,
SHA: snapshot.SHA,
Message: snapshot.Message,
Actor: snapshot.Actor,
})
if err != nil {
return fmt.Errorf("upsert run: %w", err)
}
slog.DebugContext(ctx, "run upserted", "repo", repo.Identifier, "run", run.ForgeRunID, "status", run.Status)
for _, job := range snapshot.Jobs {
if err := r.storeJobSnapshot(ctx, run.ID, job); err != nil {
return err
}
}
return nil
}
func (r *Reconciler) storeJobSnapshot(ctx context.Context, runID string, snapshot JobSnapshot) error {
job, err := r.runStore.UpsertJob(ctx, Job{
RunID: runID,
ForgeJobID: snapshot.ForgeJobID,
Name: snapshot.Name,
Status: snapshot.Status,
Conclusion: snapshot.Conclusion,
QueuedAt: snapshot.QueuedAt,
StartedAt: snapshot.StartedAt,
CompletedAt: snapshot.CompletedAt,
ForgeURL: snapshot.ForgeURL,
})
if err != nil {
return fmt.Errorf("upsert job: %w", err)
}
steps := make([]Step, 0, len(snapshot.Steps))
for _, s := range snapshot.Steps {
steps = append(steps, Step{
Number: s.Number,
Name: s.Name,
Status: s.Status,
Conclusion: s.Conclusion,
StartedAt: s.StartedAt,
CompletedAt: s.CompletedAt,
})
}
if err := r.runStore.ReplaceSteps(ctx, job.ID, steps); err != nil {
return fmt.Errorf("replace steps: %w", err)
}
return nil
}
package ingestion
import (
"context"
"errors"
"fmt"
"log/slog"
)
// ErrNoForgeClient is returned when Discover is asked about a forge this
// server has no client configured for.
var ErrNoForgeClient = errors.New("no client configured for forge")
// Registrar orchestrates repo registration: persist the repo, then create
// its webhook on the forge, degrading rather than failing when the webhook
// step doesn't succeed (forge-ingestion/spec.md's "Webhook creation failure
// is surfaced").
type Registrar struct {
store Store
clients map[Forge]ForgeClient
callbackURL string
}
// NewRegistrar returns a Registrar. callbackURL is this server's own public
// base URL; the webhook path for each forge is appended to it.
func NewRegistrar(store Store, clients map[Forge]ForgeClient, callbackURL string) *Registrar {
return &Registrar{store: store, clients: clients, callbackURL: callbackURL}
}
// Register verifies the repo isn't archived, a fork, or a mirror, then
// persists it and attempts to create its webhook. A storage failure, or the
// repo actually being archived/a fork/a mirror, is returned as an error
// before anything is persisted; a webhook-creation failure -- and a
// GetRepo failure, which leaves that status genuinely unverified rather
// than known-rejectable -- is instead recorded on the returned Repo as a
// degraded ingestion status, once it's already tracked. Blocking outright
// on a GetRepo failure (a bad token, a network blip) would make
// registration itself brittle against exactly the kind of forge
// reachability issue the existing degrade path already exists to absorb;
// confirmed live via this app's own end-to-end suite, which registers
// against a token that can't reach the real forge at all.
func (r *Registrar) Register(ctx context.Context, in NewRepo) (Repo, error) {
client, ok := r.clients[in.Forge]
if ok {
meta, err := client.GetRepo(ctx, GetRepoRequest{
InstanceURL: in.ForgejoInstanceURL,
Identifier: in.Identifier,
Token: in.Token,
})
if err == nil {
switch {
case meta.Archived:
return Repo{}, ErrRepoArchived
case meta.Fork:
return Repo{}, ErrRepoFork
case meta.Mirror:
return Repo{}, ErrRepoMirror
}
}
// A GetRepo error falls through to CreateRepo below and on to the
// CreateWebhook attempt, which degrades on the same class of
// failure -- no separate degrade path needed for this case.
}
repo, err := r.store.CreateRepo(ctx, in)
if err != nil {
return Repo{}, fmt.Errorf("create repo: %w", err)
}
if !ok {
return r.degrade(ctx, repo, fmt.Sprintf("no client configured for forge %q", repo.Forge))
}
err = client.CreateWebhook(ctx, CreateWebhookRequest{
InstanceURL: repo.ForgejoInstanceURL,
Identifier: repo.Identifier,
Token: in.Token,
CallbackURL: r.callbackURL + "/webhooks/" + string(repo.Forge),
Secret: repo.WebhookSecret,
})
if err != nil {
return r.degrade(ctx, repo, err.Error())
}
if err := r.store.SetIngestionStatus(ctx, repo.ID, StatusActive, ""); err != nil {
return Repo{}, fmt.Errorf("mark repo active: %w", err)
}
repo.IngestionStatus = StatusActive
repo.IngestionStatusReason = ""
return repo, nil
}
// Discover lists the "owner/name" repos a not-yet-registered token can
// access, for the registration UI's repo picker. Never persists anything.
func (r *Registrar) Discover(ctx context.Context, forge Forge, instanceURL, token string) ([]string, error) {
slog.DebugContext(ctx, "repo discovery requested", "forge", forge)
client, ok := r.clients[forge]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrNoForgeClient, forge)
}
repos, err := client.ListAccessibleRepos(ctx, ListAccessibleReposRequest{
InstanceURL: instanceURL,
Token: token,
})
if err != nil {
return nil, fmt.Errorf("discover repos: %w", err)
}
slog.DebugContext(ctx, "repo discovery completed", "forge", forge, "repos", len(repos))
return repos, nil
}
func (r *Registrar) degrade(ctx context.Context, repo Repo, reason string) (Repo, error) {
if err := r.store.SetIngestionStatus(ctx, repo.ID, StatusDegraded, reason); err != nil {
return Repo{}, fmt.Errorf("mark repo degraded: %w", err)
}
repo.IngestionStatus = StatusDegraded
repo.IngestionStatusReason = reason
return repo, nil
}
package ingestion
import (
"context"
"errors"
"fmt"
"log/slog"
)
var (
// ErrRunNotFound is returned when a run id names no recorded run.
ErrRunNotFound = errors.New("run not found")
// ErrNotActionable means the run's state doesn't allow the action.
ErrNotActionable = errors.New("run state doesn't allow this action")
// ErrActionUnsupported means the forge has no API for the action.
ErrActionUnsupported = errors.New("forge has no API for this action")
// ErrActionForbidden means the stored token can't do the action.
ErrActionForbidden = errors.New("token can't do this action")
// ErrActionUnreachable means the forge didn't answer.
ErrActionUnreachable = errors.New("forge unreachable")
)
// RunAction is something the dashboard can ask a forge to do to a run.
type RunAction string
// The actions on a run.
const (
ActionRerun RunAction = "rerun"
ActionCancel RunAction = "cancel"
)
// ActionsFor lists what a session may ask the forge to do to a run in the
// given status: a re-run once it has concluded, a cancel before. Forgejo
// has no API for either, so it gets nothing. It's the one rule behind the
// run list's `actions` and the state check of the action endpoints.
func ActionsFor(forge Forge, status string) []RunAction {
if forge != ForgeGitHub {
return []RunAction{}
}
if status == runCompleted {
return []RunAction{ActionRerun}
}
return []RunAction{ActionCancel}
}
const runCompleted = "completed"
// RunActionRequest names one run on its forge and what to do to it.
type RunActionRequest struct {
// InstanceURL is set for Forgejo, empty for GitHub.
InstanceURL string
Identifier string
Token string
ForgeRunID string
// FailedOnly re-runs only the failed jobs. Unused by a cancel.
FailedOnly bool
}
// RunActor asks a forge to re-run or cancel a run. It returns one of the
// ErrAction* errors, wrapped, when the forge refuses or can't be reached.
type RunActor interface {
RerunRun(ctx context.Context, req RunActionRequest) error
CancelRun(ctx context.Context, req RunActionRequest) error
}
// RunLookup finds a recorded run by its id, or ErrRunNotFound.
type RunLookup interface {
RunByID(ctx context.Context, id string) (Run, error)
}
// RunActionRepos is the slice of the repo store the action service needs.
type RunActionRepos interface {
GetRepo(ctx context.Context, id string) (Repo, error)
RepoToken(ctx context.Context, id string) (string, error)
}
// RunActionService re-runs and cancels runs on their forge, using the
// repo's stored token (openspec/changes/forge-run-actions).
type RunActionService struct {
runs RunLookup
repos RunActionRepos
actors map[Forge]RunActor
}
// NewRunActionService returns a RunActionService. A forge with no actor in
// actors answers ErrActionUnsupported.
func NewRunActionService(runs RunLookup, repos RunActionRepos, actors map[Forge]RunActor) *RunActionService {
return &RunActionService{runs: runs, repos: repos, actors: actors}
}
// Rerun asks the forge to re-run a concluded run: its failed jobs when it
// failed, the whole run otherwise.
func (s *RunActionService) Rerun(ctx context.Context, runID string) error {
return s.act(ctx, runID, ActionRerun)
}
// Cancel asks the forge to cancel a run that hasn't concluded.
func (s *RunActionService) Cancel(ctx context.Context, runID string) error {
return s.act(ctx, runID, ActionCancel)
}
func (s *RunActionService) act(ctx context.Context, runID string, action RunAction) (err error) {
run, err := s.runs.RunByID(ctx, runID)
if err != nil {
return fmt.Errorf("find run: %w", err)
}
defer func() {
// Never the token: the run, the action and the outcome.
slog.InfoContext(ctx, "run action", slog.String("run", runID), slog.String("action", string(action)), slog.String("outcome", outcome(err)))
}()
if (run.Status == runCompleted) != (action == ActionRerun) {
return ErrNotActionable
}
repo, err := s.repos.GetRepo(ctx, run.RepoID)
if errors.Is(err, ErrRepoNotFound) {
return ErrRunNotFound
}
if err != nil {
return fmt.Errorf("get repo: %w", err)
}
actor, ok := s.actors[repo.Forge]
if !ok {
return ErrActionUnsupported
}
token, err := s.repos.RepoToken(ctx, repo.ID)
if err != nil {
return fmt.Errorf("get repo token: %w", err)
}
req := RunActionRequest{
InstanceURL: repo.ForgejoInstanceURL,
Identifier: repo.Identifier,
Token: token,
ForgeRunID: run.ForgeRunID,
FailedOnly: run.Conclusion == "failure" || run.Conclusion == "timed_out",
}
if action == ActionCancel {
err = actor.CancelRun(ctx, req)
} else {
err = actor.RerunRun(ctx, req)
}
if err != nil {
return fmt.Errorf("%s run: %w", action, err)
}
return nil
}
func outcome(err error) string {
switch {
case err == nil:
return "accepted"
case errors.Is(err, ErrNotActionable):
return "not_actionable"
case errors.Is(err, ErrActionForbidden):
return "forbidden"
case errors.Is(err, ErrActionUnsupported):
return "unsupported"
case errors.Is(err, ErrActionUnreachable):
return "unreachable"
default:
return "error"
}
}
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/google/uuid"
)
// RepoByIdentifier implements ingestion.RunStore.
func (s *Store) RepoByIdentifier(ctx context.Context, forge ingestion.Forge, identifier string) (ingestion.Repo, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, forge, identifier, forgejo_instance_url, token_masked, webhook_secret, ingestion_status, ingestion_status_reason, reconcile_etag, created_at
FROM repos WHERE forge = ? AND identifier = ?
`, string(forge), identifier)
repo, err := scanRepo(row)
if errors.Is(err, sql.ErrNoRows) {
return ingestion.Repo{}, ingestion.ErrRepoNotFound
}
if err != nil {
return ingestion.Repo{}, err
}
return repo, nil
}
// RunStates implements ingestion.RunStore.
func (s *Store) RunStates(ctx context.Context, repoID string) (map[string]ingestion.RunState, error) {
rows, err := s.db.QueryContext(ctx, "SELECT forge_run_id, status, conclusion FROM runs WHERE repo_id = ?", repoID)
if err != nil {
return nil, fmt.Errorf("query run states: %w", err)
}
defer func() { _ = rows.Close() }()
states := make(map[string]ingestion.RunState)
for rows.Next() {
var (
forgeRunID string
state ingestion.RunState
conclusion sql.NullString
)
if err := rows.Scan(&forgeRunID, &state.Status, &conclusion); err != nil {
return nil, fmt.Errorf("scan run state: %w", err)
}
state.Conclusion = conclusion.String
states[forgeRunID] = state
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate run states: %w", err)
}
return states, nil
}
// UpsertRun implements ingestion.RunStore.
func (s *Store) UpsertRun(ctx context.Context, run ingestion.Run) (ingestion.Run, error) {
existingID, err := s.findID(ctx, "SELECT id FROM runs WHERE repo_id = ? AND forge_run_id = ?", run.RepoID, run.ForgeRunID)
if err != nil {
return ingestion.Run{}, err
}
if existingID != "" {
run.ID = existingID
_, err := s.db.ExecContext(ctx, `
UPDATE runs SET pipeline_name = ?, status = ?, conclusion = ?, started_at = ?, completed_at = ?, forge_url = ?,
branch = COALESCE(?, branch), head_sha = COALESCE(?, head_sha),
head_message = COALESCE(?, head_message), actor = COALESCE(?, actor)
WHERE id = ?
`, run.PipelineName, run.Status, nullable(run.Conclusion), run.StartedAt, run.CompletedAt, run.ForgeURL,
nullable(run.Branch), nullable(run.SHA), nullable(run.Message), nullable(run.Actor), run.ID)
if err != nil {
return ingestion.Run{}, fmt.Errorf("update run: %w", err)
}
return run, nil
}
run.ID = uuid.NewString()
_, err = s.db.ExecContext(ctx, `
INSERT INTO runs (id, repo_id, forge_run_id, pipeline_name, status, conclusion, started_at, completed_at, forge_url,
branch, head_sha, head_message, actor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, run.ID, run.RepoID, run.ForgeRunID, run.PipelineName, run.Status, nullable(run.Conclusion), run.StartedAt, run.CompletedAt, run.ForgeURL,
nullable(run.Branch), nullable(run.SHA), nullable(run.Message), nullable(run.Actor))
if err != nil {
return ingestion.Run{}, fmt.Errorf("insert run: %w", err)
}
return run, nil
}
// UpsertJob implements ingestion.RunStore.
func (s *Store) UpsertJob(ctx context.Context, job ingestion.Job) (ingestion.Job, error) {
existingID, err := s.findID(ctx, "SELECT id FROM jobs WHERE run_id = ? AND forge_job_id = ?", job.RunID, job.ForgeJobID)
if err != nil {
return ingestion.Job{}, err
}
if existingID != "" {
job.ID = existingID
_, err := s.db.ExecContext(ctx, `
UPDATE jobs SET name = ?, status = ?, conclusion = ?, queued_at = ?, started_at = ?, completed_at = ?, forge_url = ?
WHERE id = ?
`, job.Name, job.Status, nullable(job.Conclusion), job.QueuedAt, job.StartedAt, job.CompletedAt, job.ForgeURL, job.ID)
if err != nil {
return ingestion.Job{}, fmt.Errorf("update job: %w", err)
}
return job, nil
}
job.ID = uuid.NewString()
_, err = s.db.ExecContext(ctx, `
INSERT INTO jobs (id, run_id, forge_job_id, name, status, conclusion, queued_at, started_at, completed_at, forge_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, job.ID, job.RunID, job.ForgeJobID, job.Name, job.Status, nullable(job.Conclusion), job.QueuedAt, job.StartedAt, job.CompletedAt, job.ForgeURL)
if err != nil {
return ingestion.Job{}, fmt.Errorf("insert job: %w", err)
}
return job, nil
}
// ReplaceSteps implements ingestion.RunStore.
func (s *Store) ReplaceSteps(ctx context.Context, jobID string, steps []ingestion.Step) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, "DELETE FROM steps WHERE job_id = ?", jobID); err != nil {
return fmt.Errorf("clear steps: %w", err)
}
for _, step := range steps {
_, err := tx.ExecContext(ctx, `
INSERT INTO steps (id, job_id, number, name, status, conclusion, started_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.NewString(), jobID, step.Number, step.Name, step.Status, nullable(step.Conclusion), step.StartedAt, step.CompletedAt)
if err != nil {
return fmt.Errorf("insert step %d: %w", step.Number, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit steps: %w", err)
}
return nil
}
// findID returns the id column from the first row query+args matches, or ""
// if there's no match.
func (s *Store) findID(ctx context.Context, query string, args ...any) (string, error) {
var id string
err := s.db.QueryRowContext(ctx, query, args...).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("find id: %w", err)
}
return id, nil
}
// RunByID implements ingestion.RunLookup. It returns what an action on the
// run needs, not the whole row.
func (s *Store) RunByID(ctx context.Context, id string) (ingestion.Run, error) {
var (
run ingestion.Run
conclusion sql.NullString
)
err := s.db.QueryRowContext(ctx,
"SELECT id, repo_id, forge_run_id, status, conclusion, forge_url FROM runs WHERE id = ?", id,
).Scan(&run.ID, &run.RepoID, &run.ForgeRunID, &run.Status, &conclusion, &run.ForgeURL)
if errors.Is(err, sql.ErrNoRows) {
return ingestion.Run{}, ingestion.ErrRunNotFound
}
if err != nil {
return ingestion.Run{}, fmt.Errorf("get run: %w", err)
}
run.Conclusion = conclusion.String
return run, nil
}
// Package sqlite is the SQLite-backed adapter for the ingestion.Store port.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/alrayyes/pipeline-analytics/internal/crypto"
"github.com/alrayyes/pipeline-analytics/internal/ingestion"
"github.com/google/uuid"
sqlitedriver "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// Store implements ingestion.Store against a SQLite database.
type Store struct {
db *sql.DB
key []byte
}
// NewStore returns a Store that encrypts tokens at rest under key (32 bytes,
// AES-256).
func NewStore(db *sql.DB, key []byte) *Store {
return &Store{db: db, key: key}
}
// DB returns the underlying connection, for callers (such as tests) that
// need to observe storage directly rather than through the port.
func (s *Store) DB() *sql.DB {
return s.db
}
// CreateRepo implements ingestion.Store.
func (s *Store) CreateRepo(ctx context.Context, repo ingestion.NewRepo) (ingestion.Repo, error) {
encrypted, err := crypto.Encrypt(s.key, []byte(repo.Token))
if err != nil {
return ingestion.Repo{}, fmt.Errorf("encrypt token: %w", err)
}
webhookSecret, err := crypto.RandomHex(32)
if err != nil {
return ingestion.Repo{}, fmt.Errorf("generate webhook secret: %w", err)
}
repoRecord := ingestion.Repo{
ID: uuid.NewString(),
Forge: repo.Forge,
Identifier: repo.Identifier,
ForgejoInstanceURL: repo.ForgejoInstanceURL,
TokenMasked: ingestion.MaskToken(repo.Token),
WebhookSecret: webhookSecret,
IngestionStatus: ingestion.StatusPending,
CreatedAt: time.Now().UTC(),
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO repos (id, forge, identifier, forgejo_instance_url, token_encrypted, token_masked, webhook_secret, ingestion_status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
repoRecord.ID, string(repoRecord.Forge), repoRecord.Identifier, nullable(repoRecord.ForgejoInstanceURL),
encrypted, repoRecord.TokenMasked, repoRecord.WebhookSecret, string(repoRecord.IngestionStatus), repoRecord.CreatedAt,
)
if err != nil {
if isUniqueConstraintErr(err) {
return ingestion.Repo{}, ingestion.ErrRepoAlreadyTracked
}
return ingestion.Repo{}, fmt.Errorf("insert repo: %w", err)
}
return repoRecord, nil
}
// isUniqueConstraintErr reports whether err is a SQLite UNIQUE constraint
// violation -- detected via the driver's own error code
// (SQLITE_CONSTRAINT_UNIQUE), not by matching the error message string.
func isUniqueConstraintErr(err error) bool {
var sqliteErr *sqlitedriver.Error
return errors.As(err, &sqliteErr) && sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}
// ListRepoIdentifiers implements ingestion.Store.
func (s *Store) ListRepoIdentifiers(ctx context.Context, forge ingestion.Forge, instanceURL string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT identifier FROM repos
WHERE forge = ? AND forgejo_instance_url IS ?
`, string(forge), nullable(instanceURL))
if err != nil {
return nil, fmt.Errorf("query repo identifiers: %w", err)
}
defer func() { _ = rows.Close() }()
var identifiers []string
for rows.Next() {
var identifier string
if err := rows.Scan(&identifier); err != nil {
return nil, fmt.Errorf("scan repo identifier: %w", err)
}
identifiers = append(identifiers, identifier)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate repo identifiers: %w", err)
}
return identifiers, nil
}
// ListRepos implements ingestion.Store.
func (s *Store) ListRepos(ctx context.Context, filter ingestion.RepoListFilter) ([]ingestion.Repo, bool, error) {
query := `
SELECT id, forge, identifier, forgejo_instance_url, token_masked, webhook_secret, ingestion_status, ingestion_status_reason, reconcile_etag, created_at
FROM repos
`
args := []any{}
if filter.Forge != "" {
query += " WHERE forge = ?"
args = append(args, string(filter.Forge))
}
query += " ORDER BY created_at, id"
// Fetching one extra row is what tells the caller whether a next page
// exists, without a separate COUNT(*) round trip.
if filter.Limit > 0 {
query += " LIMIT ? OFFSET ?"
args = append(args, filter.Limit+1, filter.Offset)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, false, fmt.Errorf("query repos: %w", err)
}
defer func() { _ = rows.Close() }()
var repos []ingestion.Repo
for rows.Next() {
repo, err := scanRepo(rows)
if err != nil {
return nil, false, err
}
repos = append(repos, repo)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("iterate repos: %w", err)
}
hasMore := filter.Limit > 0 && len(repos) > filter.Limit
if hasMore {
repos = repos[:filter.Limit]
}
return repos, hasMore, nil
}
// GetRepo implements ingestion.Store.
func (s *Store) GetRepo(ctx context.Context, id string) (ingestion.Repo, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, forge, identifier, forgejo_instance_url, token_masked, webhook_secret, ingestion_status, ingestion_status_reason, reconcile_etag, created_at
FROM repos WHERE id = ?
`, id)
repo, err := scanRepo(row)
if errors.Is(err, sql.ErrNoRows) {
return ingestion.Repo{}, ingestion.ErrRepoNotFound
}
if err != nil {
return ingestion.Repo{}, err
}
return repo, nil
}
// LocateJob implements ingestion.JobLocator. A job id that belongs to a
// different run is not found: the pair must match.
func (s *Store) LocateJob(ctx context.Context, runID, jobID string) (ingestion.JobLocation, error) {
var location ingestion.JobLocation
err := s.db.QueryRowContext(ctx, `
SELECT r.repo_id, j.forge_job_id, j.forge_url
FROM jobs j
JOIN runs r ON r.id = j.run_id
WHERE j.id = ? AND j.run_id = ?
`, jobID, runID).Scan(&location.RepoID, &location.ForgeJobID, &location.ForgeURL)
if errors.Is(err, sql.ErrNoRows) {
return ingestion.JobLocation{}, ingestion.ErrJobNotFound
}
if err != nil {
return ingestion.JobLocation{}, fmt.Errorf("locate job: %w", err)
}
return location, nil
}
// DeleteRepo implements ingestion.Store. The schema's ON DELETE CASCADE
// takes care of its runs, jobs, and steps.
func (s *Store) DeleteRepo(ctx context.Context, id string) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM repos WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete repo: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read rows affected: %w", err)
}
if rows == 0 {
return ingestion.ErrRepoNotFound
}
return nil
}
// SetReconcileETag implements ingestion.Store.
func (s *Store) SetReconcileETag(ctx context.Context, id string, etag string) error {
result, err := s.db.ExecContext(ctx, "UPDATE repos SET reconcile_etag = ? WHERE id = ?", etag, id)
if err != nil {
return fmt.Errorf("update reconcile etag: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read rows affected: %w", err)
}
if rows == 0 {
return ingestion.ErrRepoNotFound
}
return nil
}
// SetIngestionStatus implements ingestion.Store.
func (s *Store) SetIngestionStatus(ctx context.Context, id string, status ingestion.Status, reason string) error {
result, err := s.db.ExecContext(ctx,
"UPDATE repos SET ingestion_status = ?, ingestion_status_reason = ? WHERE id = ?",
string(status), nullable(reason), id,
)
if err != nil {
return fmt.Errorf("update ingestion status: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read rows affected: %w", err)
}
if rows == 0 {
return ingestion.ErrRepoNotFound
}
return nil
}
// RepoToken implements ingestion.Store.
func (s *Store) RepoToken(ctx context.Context, id string) (string, error) {
var encrypted []byte
err := s.db.QueryRowContext(ctx, "SELECT token_encrypted FROM repos WHERE id = ?", id).Scan(&encrypted)
if errors.Is(err, sql.ErrNoRows) {
return "", ingestion.ErrRepoNotFound
}
if err != nil {
return "", fmt.Errorf("query token: %w", err)
}
plaintext, err := crypto.Decrypt(s.key, encrypted)
if err != nil {
return "", fmt.Errorf("decrypt token: %w", err)
}
return string(plaintext), nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanRepo(row rowScanner) (ingestion.Repo, error) {
var (
repo ingestion.Repo
forge string
status string
forgejoInstanceURL sql.NullString
statusReason sql.NullString
)
err := row.Scan(
&repo.ID, &forge, &repo.Identifier, &forgejoInstanceURL, &repo.TokenMasked,
&repo.WebhookSecret, &status, &statusReason, &repo.ReconcileETag, &repo.CreatedAt,
)
if err != nil {
return ingestion.Repo{}, fmt.Errorf("scan repo: %w", err)
}
repo.Forge = ingestion.Forge(forge)
repo.IngestionStatus = ingestion.Status(status)
repo.ForgejoInstanceURL = forgejoInstanceURL.String
repo.IngestionStatusReason = statusReason.String
return repo, nil
}
func nullable(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
}
var _ ingestion.ForgeTokenStore = (*Store)(nil)
// tokenScope normalizes the (forge, instance) a token is saved under: GitHub
// has no instance, and a trailing slash doesn't make a Forgejo instance
// another one.
func tokenScope(forge ingestion.Forge, instanceURL string) string {
if forge != ingestion.ForgeForgejo {
return ""
}
return strings.TrimRight(strings.TrimSpace(instanceURL), "/")
}
// SaveForgeToken implements ingestion.ForgeTokenStore.
func (s *Store) SaveForgeToken(ctx context.Context, forge ingestion.Forge, instanceURL, token string) (ingestion.SavedToken, error) {
encrypted, err := crypto.Encrypt(s.key, []byte(token))
if err != nil {
return ingestion.SavedToken{}, fmt.Errorf("encrypt token: %w", err)
}
scope := tokenScope(forge, instanceURL)
saved := ingestion.SavedToken{
ID: uuid.NewString(),
Forge: forge,
ForgejoInstanceURL: scope,
TokenMasked: ingestion.MaskToken(token),
}
// Replacing keeps the row's id, so a client holding it can still delete
// it. RETURNING gives back the id that is actually stored.
err = s.db.QueryRowContext(ctx, `
INSERT INTO forge_tokens (id, forge, instance_url, token_encrypted, token_masked, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (forge, instance_url) DO UPDATE SET
token_encrypted = excluded.token_encrypted,
token_masked = excluded.token_masked,
created_at = excluded.created_at
RETURNING id
`, saved.ID, string(forge), scope, encrypted, saved.TokenMasked, time.Now().UTC()).Scan(&saved.ID)
if err != nil {
return ingestion.SavedToken{}, fmt.Errorf("save forge token: %w", err)
}
return saved, nil
}
// ListForgeTokens implements ingestion.ForgeTokenStore.
func (s *Store) ListForgeTokens(ctx context.Context) ([]ingestion.SavedToken, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, forge, instance_url, token_masked FROM forge_tokens ORDER BY forge, instance_url")
if err != nil {
return nil, fmt.Errorf("query forge tokens: %w", err)
}
defer func() { _ = rows.Close() }()
var tokens []ingestion.SavedToken
for rows.Next() {
var (
t ingestion.SavedToken
forge string
)
if err := rows.Scan(&t.ID, &forge, &t.ForgejoInstanceURL, &t.TokenMasked); err != nil {
return nil, fmt.Errorf("scan forge token: %w", err)
}
t.Forge = ingestion.Forge(forge)
tokens = append(tokens, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forge tokens: %w", err)
}
return tokens, nil
}
// DeleteForgeToken implements ingestion.ForgeTokenStore.
func (s *Store) DeleteForgeToken(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, "DELETE FROM forge_tokens WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete forge token: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete forge token: %w", err)
}
if n == 0 {
return ingestion.ErrSavedTokenNotFound
}
return nil
}
// ForgeToken implements ingestion.ForgeTokenStore.
func (s *Store) ForgeToken(ctx context.Context, forge ingestion.Forge, instanceURL string) (string, error) {
var encrypted []byte
err := s.db.QueryRowContext(ctx,
"SELECT token_encrypted FROM forge_tokens WHERE forge = ? AND instance_url = ?",
string(forge), tokenScope(forge, instanceURL),
).Scan(&encrypted)
if errors.Is(err, sql.ErrNoRows) {
return "", ingestion.ErrSavedTokenNotFound
}
if err != nil {
return "", fmt.Errorf("query forge token: %w", err)
}
plaintext, err := crypto.Decrypt(s.key, encrypted)
if err != nil {
return "", fmt.Errorf("decrypt forge token: %w", err)
}
return string(plaintext), nil
}
package ingestion
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// VerifySignature reports whether signature is a valid HMAC-SHA256 of body
// under secret. Accepts either GitHub's "sha256=<hex>" form or a bare hex
// digest (Forgejo's format, unverified against a live instance -- see
// design.md's "Gitea SDK's Forgejo Actions endpoint coverage is
// unverified" risk).
func VerifySignature(secret string, body []byte, signature string) bool {
digest := strings.TrimPrefix(signature, "sha256=")
given, err := hex.DecodeString(digest)
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := mac.Sum(nil)
return subtle.ConstantTimeCompare(given, want) == 1
}
// ProcessGitHubEvent updates run/job/step storage for a single GitHub
// Actions webhook delivery already verified and resolved to repo. Event
// types other than workflow_run and workflow_job are ignored, not an error
// -- GitHub's own webhook config on a repo may carry more event types than
// this project subscribes to on purpose.
func ProcessGitHubEvent(ctx context.Context, store RunStore, repo Repo, eventType string, payload []byte) error {
switch eventType {
case "workflow_run":
return processGitHubWorkflowRun(ctx, store, repo, payload)
case "workflow_job":
return processGitHubWorkflowJob(ctx, store, repo, payload)
default:
return nil
}
}
// ProcessForgejoEvent handles a single Forgejo webhook delivery already
// verified and resolved to repo. Forgejo has no webhook event for Actions
// run/job status changes as of 11.0.16+gitea-1.22.0 -- confirmed
// empirically against a live container (github.com/alrayyes/pipeline-
// analytics#120), not just undocumented, so unlike GitHub there's no
// run/job payload to parse here. The webhook forgejoclient.CreateWebhook
// registers subscribes to "push" only, the closest available signal that a
// workflow run may be starting; delivery of one triggers an immediate
// reconciliation poll for the repo instead.
func ProcessForgejoEvent(ctx context.Context, reconciler RepoReconciler, repo Repo, eventType string) error {
if eventType != "push" {
return nil
}
if err := reconciler.ReconcileRepo(ctx, repo); err != nil {
return fmt.Errorf("reconcile repo: %w", err)
}
return nil
}
type githubWorkflowRunEvent struct {
WorkflowRun struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
RunStartedAt string `json:"run_started_at"`
UpdatedAt string `json:"updated_at"`
HTMLURL string `json:"html_url"`
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
HeadCommit struct {
Message string `json:"message"`
} `json:"head_commit"`
Actor struct {
Login string `json:"login"`
} `json:"actor"`
} `json:"workflow_run"`
}
func processGitHubWorkflowRun(ctx context.Context, store RunStore, repo Repo, payload []byte) error {
var event githubWorkflowRunEvent
if err := json.Unmarshal(payload, &event); err != nil {
return fmt.Errorf("unmarshal workflow_run event: %w", err)
}
run := Run{
RepoID: repo.ID,
ForgeRunID: strconv.FormatInt(event.WorkflowRun.ID, 10),
PipelineName: event.WorkflowRun.Name,
Status: event.WorkflowRun.Status,
Conclusion: event.WorkflowRun.Conclusion,
StartedAt: parseTime(event.WorkflowRun.RunStartedAt),
ForgeURL: event.WorkflowRun.HTMLURL,
Branch: event.WorkflowRun.HeadBranch,
SHA: event.WorkflowRun.HeadSHA,
Message: FirstLine(event.WorkflowRun.HeadCommit.Message),
Actor: event.WorkflowRun.Actor.Login,
}
if run.Status == "completed" {
run.CompletedAt = parseTime(event.WorkflowRun.UpdatedAt)
}
if _, err := store.UpsertRun(ctx, run); err != nil {
return fmt.Errorf("upsert run: %w", err)
}
return nil
}
type githubWorkflowJobEvent struct {
WorkflowJob struct {
ID int64 `json:"id"`
RunID int64 `json:"run_id"`
WorkflowName string `json:"workflow_name"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
HTMLURL string `json:"html_url"`
Steps []githubJobStep `json:"steps"`
} `json:"workflow_job"`
}
type githubJobStep struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Number int `json:"number"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
}
func processGitHubWorkflowJob(ctx context.Context, store RunStore, repo Repo, payload []byte) error {
var event githubWorkflowJobEvent
if err := json.Unmarshal(payload, &event); err != nil {
return fmt.Errorf("unmarshal workflow_job event: %w", err)
}
// The parent run may not have arrived yet (webhook delivery order isn't
// guaranteed) -- upsert a stub with what the job payload knows; a later
// workflow_run event, or reconciliation, fills in the rest.
run, err := store.UpsertRun(ctx, Run{
RepoID: repo.ID,
ForgeRunID: strconv.FormatInt(event.WorkflowJob.RunID, 10),
PipelineName: event.WorkflowJob.WorkflowName,
Status: "in_progress",
})
if err != nil {
return fmt.Errorf("upsert parent run: %w", err)
}
job := Job{
RunID: run.ID,
ForgeJobID: strconv.FormatInt(event.WorkflowJob.ID, 10),
Name: event.WorkflowJob.Name,
Status: event.WorkflowJob.Status,
Conclusion: event.WorkflowJob.Conclusion,
QueuedAt: parseTime(event.WorkflowJob.CreatedAt),
StartedAt: parseTime(event.WorkflowJob.StartedAt),
CompletedAt: parseTime(event.WorkflowJob.CompletedAt),
ForgeURL: event.WorkflowJob.HTMLURL,
}
storedJob, err := store.UpsertJob(ctx, job)
if err != nil {
return fmt.Errorf("upsert job: %w", err)
}
steps := make([]Step, 0, len(event.WorkflowJob.Steps))
for _, s := range event.WorkflowJob.Steps {
steps = append(steps, Step{
Number: s.Number,
Name: s.Name,
Status: s.Status,
Conclusion: s.Conclusion,
StartedAt: parseTime(s.StartedAt),
CompletedAt: parseTime(s.CompletedAt),
})
}
if err := store.ReplaceSteps(ctx, storedJob.ID, steps); err != nil {
return fmt.Errorf("replace steps: %w", err)
}
return nil
}
func parseTime(s string) *time.Time {
if s == "" {
return nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil
}
return &t
}
// FirstLine is a commit message's subject: the body is long, often noisy and
// not something a run card needs.
func FirstLine(message string) string {
line, _, _ := strings.Cut(message, "\n")
return strings.TrimSpace(line)
}
package metrics
import (
"context"
"fmt"
"time"
)
// ListBranches returns the branches with runs that started in the trailing
// window, busiest first. now is a parameter so the window is testable.
func (s *Service) ListBranches(ctx context.Context, now time.Time, window InsightWindow, filter InsightFilter) ([]BranchCount, error) {
branches, err := s.store.WindowBranches(ctx, RunWindowFilter{
RepoID: filter.RepoID,
Forge: filter.Forge,
Since: now.Add(-window.Duration),
Until: now,
})
if err != nil {
return nil, fmt.Errorf("list window branches: %w", err)
}
return branches, nil
}
package metrics
import (
"strings"
"unicode"
)
// FailureCategory is a heuristic bucket for why a step failed, per
// pipeline-metrics/spec.md's "Failure grouping by step and category". It's
// inferred from the step's conclusion and name only -- no log is read -- so
// a failure no rule recognises is CategoryUncategorised, never a guess.
type FailureCategory string
// The failure categories a step can be assigned.
const (
CategoryInfrastructure FailureCategory = "infrastructure"
CategoryCodeTests FailureCategory = "code_tests"
CategoryNetworkTimeouts FailureCategory = "network_timeouts"
CategoryConfigSecrets FailureCategory = "config_secrets"
CategoryUncategorised FailureCategory = "uncategorised"
)
// categoryRules are checked in order, so a name matching two rules takes the
// earlier one: "Test credentials" is a credentials problem before it's a test.
var categoryRules = []struct {
category FailureCategory
keywords []string
}{
{CategoryConfigSecrets, []string{"credential", "secret", "token", "login", "authenticat", "authoriz", "kms", "oidc"}},
{CategoryCodeTests, []string{"test", "spec", "lint", "vet", "typecheck", "e2e"}},
{CategoryNetworkTimeouts, []string{"pull", "download", "fetch", "install", "registry"}},
{CategoryInfrastructure, []string{"setup", "runner", "cache", "provision"}},
}
// CategorizeFailure assigns a failed step to a FailureCategory. A step that
// concluded timed_out is a timeout whatever it's called. Otherwise a keyword
// matches a word in the name by prefix, so "tests" finds "test" but "latest"
// and "inspect" don't.
func CategorizeFailure(stepName, conclusion string) FailureCategory {
if conclusion == "timed_out" {
return CategoryNetworkTimeouts
}
words := strings.FieldsFunc(strings.ReplaceAll(strings.ToLower(stepName), "set up", "setup"), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
for _, rule := range categoryRules {
for _, keyword := range rule.keywords {
for _, word := range words {
if strings.HasPrefix(word, keyword) {
return rule.category
}
}
}
}
return CategoryUncategorised
}
package metrics
import (
"cmp"
"context"
"slices"
"time"
)
// FlakyMatrixRuns is how many of a flaky step's most recent runs its result
// matrix shows.
const FlakyMatrixRuns = 40
// FlakyStep is one step of one pipeline that both passed and failed in the
// window, with what the flaky view shows for it.
type FlakyStep struct {
Pipeline PipelineRef
Name string
// FlakeRate is the share of the step's completed runs in the window that
// failed, the same figure Step.FailureRate reports.
FlakeRate float64
// RunCount is the step's completed runs in the window, not just the
// ones in RecentOutcomes.
RunCount int
// RecentOutcomes is the result in the step's most recent runs (at most
// FlakyMatrixRuns), oldest first.
RecentOutcomes []Outcome
// Quarantine is the active mark on this step, or nil. The step is listed
// either way, with the same figures.
Quarantine *Quarantine
}
// ListFlakySteps returns the flaky steps across every pipeline in the window
// ending at now, ranked by flake rate, then run count, then pipeline and step
// name. limit and offset page the ranked list; a limit of zero or less
// returns everything, and hasMore says whether entries follow the page.
//
// A step is flaky by the same definition the step lists use (aggregateSteps):
// it completed with both a success and a failure in the window, and a step
// is one name within one pipeline.
func (s *Service) ListFlakySteps(ctx context.Context, now time.Time, window InsightWindow, filter InsightFilter, limit, offset int) ([]FlakyStep, bool, error) {
steps, err := s.windowSteps(ctx, filter, now.Add(-window.Duration), now)
if err != nil {
return nil, false, err
}
type key struct {
pipeline PipelineRef
name string
}
byStep := make(map[key][]StepOccurrence)
for _, ws := range steps {
if ws.Step.Status != "completed" {
continue
}
k := key{pipeline: ws.Pipeline, name: ws.Step.Name}
byStep[k] = append(byStep[k], ws.Step)
}
active, err := s.activeQuarantines(ctx, now)
if err != nil {
return nil, false, err
}
var flaky []FlakyStep
for k, occurrences := range byStep {
if entry, ok := flakyStep(k.pipeline, k.name, occurrences); ok {
if q, marked := active[QuarantineKey{Pipeline: k.pipeline, Step: k.name}]; marked {
entry.Quarantine = &q
}
flaky = append(flaky, entry)
}
}
slices.SortFunc(flaky, func(a, b FlakyStep) int {
return cmp.Or(
cmp.Compare(b.FlakeRate, a.FlakeRate),
cmp.Compare(b.RunCount, a.RunCount),
cmp.Compare(a.Pipeline.RepoID, b.Pipeline.RepoID),
cmp.Compare(a.Pipeline.Name, b.Pipeline.Name),
cmp.Compare(a.Name, b.Name),
)
})
page, hasMore := paginate(flaky, limit, offset)
return page, hasMore, nil
}
// flakyStep builds the entry for one step's completed occurrences, or reports
// false when it didn't both pass and fail.
func flakyStep(pipeline PipelineRef, name string, occurrences []StepOccurrence) (FlakyStep, bool) {
slices.SortStableFunc(occurrences, func(a, b StepOccurrence) int {
return startedAtCompare(a.RunStartedAt, b.RunStartedAt)
})
var passed, failed int
for _, occ := range occurrences {
switch occ.Conclusion {
case "success":
passed++
case "failure":
failed++
}
}
if passed == 0 || failed == 0 {
return FlakyStep{}, false
}
recent := occurrences
if len(recent) > FlakyMatrixRuns {
recent = recent[len(recent)-FlakyMatrixRuns:]
}
outcomes := make([]Outcome, 0, len(recent))
for _, occ := range recent {
outcomes = append(outcomes, OutcomeOf(occ.Status, occ.Conclusion))
}
return FlakyStep{
Pipeline: pipeline,
Name: name,
FlakeRate: rate(failed, len(occurrences)),
RunCount: len(occurrences),
RecentOutcomes: outcomes,
}, true
}
// startedAtCompare orders oldest first, runs with no start time before any
// that have one.
func startedAtCompare(a, b *time.Time) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return -1
case b == nil:
return 1
default:
return a.Compare(*b)
}
}
package metrics
import (
"cmp"
"context"
"fmt"
"slices"
"time"
)
// InsightWindow is a trailing span of time, for the cross-pipeline failure
// insights. It's separate from Window, the per-pipeline trailing run count:
// "failures this week" has no meaning as a number of runs, because a quiet
// pipeline and a busy one would cover wildly different spans.
type InsightWindow struct {
Duration time.Duration
}
// DefaultInsightWindow is used when no window, or an unrecognised one, is
// requested.
const DefaultInsightWindow = 7 * 24 * time.Hour
// ParseInsightWindow interprets the API's window parameter for the insights
// endpoint: 24h, 7d or 30d. Anything else, including a bare run count, falls
// back to DefaultInsightWindow.
func ParseInsightWindow(raw string) InsightWindow {
switch raw {
case "24h":
return InsightWindow{Duration: 24 * time.Hour}
case "7d":
return InsightWindow{Duration: 7 * 24 * time.Hour}
case "30d":
return InsightWindow{Duration: 30 * 24 * time.Hour}
default:
return InsightWindow{Duration: DefaultInsightWindow}
}
}
// Label is the window as the API names it ("24h", "7d", "30d"). A duration
// the parser would never produce labels as the default, so a response always
// names a window a client can send back.
func (w InsightWindow) Label() string {
switch w.Duration {
case 24 * time.Hour:
return "24h"
case 30 * 24 * time.Hour:
return "30d"
default:
return "7d"
}
}
// MaxBranchLength bounds a branch filter (the OpenAPI spec's BranchFilter
// maxLength).
const MaxBranchLength = 255
// InsightFilter scopes the insights to one repo and/or forge. The zero value
// covers every tracked repo.
type InsightFilter struct {
RepoID string
Forge string
// Branch, when set, covers only runs on that branch.
Branch string
}
// RunWindowFilter selects runs that started in [Since, Until), optionally
// scoped to one repo and/or forge.
type RunWindowFilter struct {
RepoID string
Forge string
Branch string
Since time.Time
Until time.Time
}
// BranchCount is a branch and how many runs on it started in a window.
type BranchCount struct {
Name string
RunCount int
}
// WindowRun is a run with the pipeline it belongs to.
type WindowRun struct {
Pipeline PipelineRef
Run RunRecord
}
// WindowStep is one recorded step execution with the pipeline it ran in.
// Step.RunStartedAt is the owning run's start, which is what a window
// bounds.
type WindowStep struct {
Pipeline PipelineRef
Step StepOccurrence
}
// StageFailureCount is how many times one step failed in the window. "Stage"
// means step: forges have no stage taxonomy.
type StageFailureCount struct {
Step string
Failures int
// Share is Failures over every failed-step occurrence in the window.
Share float64
}
// CategoryCount is how many failed-step occurrences fall in one failure
// category, and that category's share of all of them.
type CategoryCount struct {
Category FailureCategory
Occurrences int
Share float64
}
// FailureGroup is one step's failures across every pipeline it failed in.
type FailureGroup struct {
Step string
// Category is a heuristic from Conclusion and the step's name, never from
// a log; see CategorizeFailure.
Category FailureCategory
// Conclusion is the group's most common conclusion, shown so a heuristic
// category can be checked against it.
Conclusion string
Occurrences int
Pipelines []PipelineRef
}
// FailingPipeline is one pipeline's run and failed-run counts in the window.
type FailingPipeline struct {
Pipeline PipelineRef
Runs int
FailedRuns int
}
// FailureInsights is the failure overview for one window.
type FailureInsights struct {
// Window is the window these figures cover: the one requested, or the
// default when the request named none or an unknown one.
Window InsightWindow
TotalRuns int
FailedRuns int
// PassRate is the fraction in [0, 1] of concluded runs that succeeded.
// Nil when no run concluded, since "no data" isn't 0%.
PassRate *float64
// PassRateDelta is the change in percentage points against the preceding
// window of equal length. Nil when either window has no concluded run.
PassRateDelta *float64
// MTTR is the mean time to recovery: for each pipeline, from the
// completion of a failed run to the completion of the next successful
// one, averaged over every recovery in the window. Nil when nothing
// recovered, since an unrecovered failure has no recovery time.
MTTR *time.Duration
TopFailingPipelines []FailingPipeline
// FlakyStepRatio is flaky steps over distinct steps, counting a step per
// pipeline. A step is flaky when it both passed and failed in the window.
FlakyStepRatio float64
StageDistribution []StageFailureCount
// CategoryBreakdown is the failed-step occurrences by failure category,
// heaviest first; empty when nothing failed.
CategoryBreakdown []CategoryCount
FailureGroups []FailureGroup
}
// windowSteps lists the step occurrences of runs that started in [since, until)
// and match filter.
func (s *Service) windowSteps(ctx context.Context, filter InsightFilter, since, until time.Time) ([]WindowStep, error) {
steps, err := s.store.WindowSteps(ctx, RunWindowFilter{
RepoID: filter.RepoID,
Forge: filter.Forge,
Branch: filter.Branch,
Since: since,
Until: until,
})
if err != nil {
return nil, fmt.Errorf("list window steps: %w", err)
}
return steps, nil
}
// GetFailureInsights computes the failure overview for the window ending at
// now. now is a parameter, not read from the clock, so the preceding-window
// comparison is testable.
func (s *Service) GetFailureInsights(ctx context.Context, now time.Time, window InsightWindow, filter InsightFilter) (FailureInsights, error) {
boundary := now.Add(-window.Duration)
runs, err := s.store.WindowRuns(ctx, RunWindowFilter{
RepoID: filter.RepoID,
Forge: filter.Forge,
Branch: filter.Branch,
Since: boundary.Add(-window.Duration),
Until: now,
})
if err != nil {
return FailureInsights{}, fmt.Errorf("list window runs: %w", err)
}
var current, prior []WindowRun
for _, wr := range runs {
if wr.Run.StartedAt == nil {
continue
}
if wr.Run.StartedAt.Before(boundary) {
prior = append(prior, wr)
} else {
current = append(current, wr)
}
}
steps, err := s.windowSteps(ctx, filter, boundary, now)
if err != nil {
return FailureInsights{}, err
}
active, err := s.activeQuarantines(ctx, now)
if err != nil {
return FailureInsights{}, err
}
currentRate, currentFailed := passRate(current)
priorRate, _ := passRate(prior)
insights := FailureInsights{
Window: window,
TotalRuns: len(current),
FailedRuns: currentFailed,
PassRate: currentRate,
MTTR: meanTimeToRecovery(current),
TopFailingPipelines: topFailingPipelines(current),
FlakyStepRatio: flakyStepRatio(steps, active),
StageDistribution: stageDistribution(steps),
CategoryBreakdown: categoryBreakdown(steps),
FailureGroups: failureGroups(steps),
}
if currentRate != nil && priorRate != nil {
delta := (*currentRate - *priorRate) * 100
insights.PassRateDelta = &delta
}
return insights, nil
}
// isFailed reports whether a run concluded in a failure. A timeout is a
// failure here; a cancelled or skipped run is neither one nor a pass.
func isFailed(conclusion string) bool {
return conclusion == "failure" || conclusion == "timed_out"
}
// passRate returns the fraction of concluded runs that succeeded (nil if
// none concluded) and the failed count.
func passRate(runs []WindowRun) (rate *float64, failed int) {
var passed, concluded int
for _, wr := range runs {
switch {
case wr.Run.Conclusion == "success":
passed++
concluded++
case isFailed(wr.Run.Conclusion):
failed++
concluded++
}
}
if concluded == 0 {
return nil, failed
}
r := float64(passed) / float64(concluded)
return &r, failed
}
func topFailingPipelines(runs []WindowRun) []FailingPipeline {
byPipeline := make(map[PipelineRef]*FailingPipeline)
for _, wr := range runs {
fp, ok := byPipeline[wr.Pipeline]
if !ok {
fp = &FailingPipeline{Pipeline: wr.Pipeline}
byPipeline[wr.Pipeline] = fp
}
fp.Runs++
if isFailed(wr.Run.Conclusion) {
fp.FailedRuns++
}
}
var failing []FailingPipeline
for _, fp := range byPipeline {
if fp.FailedRuns > 0 {
failing = append(failing, *fp)
}
}
slices.SortFunc(failing, func(a, b FailingPipeline) int {
return cmp.Or(
cmp.Compare(b.FailedRuns, a.FailedRuns),
cmp.Compare(a.Pipeline.RepoID, b.Pipeline.RepoID),
cmp.Compare(a.Pipeline.Name, b.Pipeline.Name),
)
})
return failing
}
// meanTimeToRecovery averages, over every recovery, the time from a failed
// run's completion to the next successful run's completion in the same
// pipeline. A run of consecutive failures counts from the first; cancelled
// and unfinished runs neither start nor end an outage.
func meanTimeToRecovery(runs []WindowRun) *time.Duration {
byPipeline := make(map[PipelineRef][]WindowRun)
for _, wr := range runs {
if wr.Run.Conclusion == "success" || isFailed(wr.Run.Conclusion) {
byPipeline[wr.Pipeline] = append(byPipeline[wr.Pipeline], wr)
}
}
var (
total time.Duration
recoveries int
)
for _, pipelineRuns := range byPipeline {
slices.SortFunc(pipelineRuns, func(a, b WindowRun) int {
return finishedAt(a.Run).Compare(finishedAt(b.Run))
})
var downSince *time.Time
for _, wr := range pipelineRuns {
at := finishedAt(wr.Run)
switch {
case isFailed(wr.Run.Conclusion) && downSince == nil:
downSince = &at
case wr.Run.Conclusion == "success" && downSince != nil:
total += at.Sub(*downSince)
recoveries++
downSince = nil
}
}
}
if recoveries == 0 {
return nil
}
mean := total / time.Duration(recoveries)
return &mean
}
// finishedAt is when a run is known to have ended, falling back to its start
// when no completion was recorded.
func finishedAt(run RunRecord) time.Time {
if run.CompletedAt != nil {
return *run.CompletedAt
}
if run.StartedAt != nil {
return *run.StartedAt
}
return time.Time{}
}
// isFailedStep reports whether a step execution ended in a failure. As for
// runs, a timeout is a failure; a skipped or cancelled step is not.
func isFailedStep(occ StepOccurrence) bool {
return occ.Status == "completed" && isFailed(occ.Conclusion)
}
func stageDistribution(steps []WindowStep) []StageFailureCount {
counts := make(map[string]int)
for _, ws := range steps {
if isFailedStep(ws.Step) {
counts[ws.Step.Name]++
}
}
var total int
for _, failures := range counts {
total += failures
}
distribution := make([]StageFailureCount, 0, len(counts))
for name, failures := range counts {
distribution = append(distribution, StageFailureCount{Step: name, Failures: failures, Share: rate(failures, total)})
}
slices.SortFunc(distribution, func(a, b StageFailureCount) int {
return cmp.Or(cmp.Compare(b.Failures, a.Failures), cmp.Compare(a.Step, b.Step))
})
return distribution
}
func failureGroups(steps []WindowStep) []FailureGroup {
type acc struct {
occurrences int
conclusions map[string]int
pipelines map[PipelineRef]struct{}
}
byStep := make(map[string]*acc)
for _, ws := range steps {
if !isFailedStep(ws.Step) {
continue
}
a, ok := byStep[ws.Step.Name]
if !ok {
a = &acc{conclusions: map[string]int{}, pipelines: map[PipelineRef]struct{}{}}
byStep[ws.Step.Name] = a
}
a.occurrences++
a.conclusions[ws.Step.Conclusion]++
a.pipelines[ws.Pipeline] = struct{}{}
}
groups := make([]FailureGroup, 0, len(byStep))
for name, a := range byStep {
conclusion := mostCommon(a.conclusions)
pipelines := make([]PipelineRef, 0, len(a.pipelines))
for ref := range a.pipelines {
pipelines = append(pipelines, ref)
}
slices.SortFunc(pipelines, func(x, y PipelineRef) int {
return cmp.Or(cmp.Compare(x.RepoID, y.RepoID), cmp.Compare(x.Name, y.Name))
})
groups = append(groups, FailureGroup{
Step: name,
Category: CategorizeFailure(name, conclusion),
Conclusion: conclusion,
Occurrences: a.occurrences,
Pipelines: pipelines,
})
}
slices.SortFunc(groups, func(a, b FailureGroup) int {
return cmp.Or(cmp.Compare(b.Occurrences, a.Occurrences), cmp.Compare(a.Step, b.Step))
})
return groups
}
// mostCommon returns the key with the highest count, the alphabetically
// first on a tie so the result doesn't depend on map order.
func mostCommon(counts map[string]int) string {
var (
best string
bestCount int
)
for key, count := range counts {
if count > bestCount || (count == bestCount && key < best) {
best, bestCount = key, count
}
}
return best
}
// flakyStepRatio is flaky steps over distinct steps, a step being one name
// within one pipeline: "test" flaking in web says nothing about "test" in
// api. Flakiness is the existing definition (it both passed and failed), via
// aggregateSteps.
func flakyStepRatio(steps []WindowStep, active map[QuarantineKey]Quarantine) float64 {
byPipeline := make(map[PipelineRef][]StepOccurrence)
for _, ws := range steps {
byPipeline[ws.Pipeline] = append(byPipeline[ws.Pipeline], ws.Step)
}
var total, flaky int
for pipeline, occurrences := range byPipeline {
// A quarantined step still counts among the distinct steps; only its
// flakiness stops counting.
for _, step := range withQuarantines(aggregateSteps(occurrences), pipeline, active) {
total++
if countsAsFlaky(step) {
flaky++
}
}
}
return rate(flaky, total)
}
// categoryBreakdown counts failed-step occurrences by failure category, heaviest
// first (equal weights by category name, so the order is stable). A category
// is decided per step name and its most common conclusion, the same as the
// failure groups, so the two views can't disagree about which category a
// step is in.
func categoryBreakdown(steps []WindowStep) []CategoryCount {
var total int
counts := make(map[FailureCategory]int)
for _, group := range failureGroups(steps) {
counts[group.Category] += group.Occurrences
total += group.Occurrences
}
breakdown := make([]CategoryCount, 0, len(counts))
for category, occurrences := range counts {
breakdown = append(breakdown, CategoryCount{Category: category, Occurrences: occurrences, Share: rate(occurrences, total)})
}
slices.SortFunc(breakdown, func(a, b CategoryCount) int {
return cmp.Or(cmp.Compare(b.Occurrences, a.Occurrences), cmp.Compare(a.Category, b.Category))
})
return breakdown
}
// Package metrics derives health scores and pain-point signals from
// ingested pipeline run data. See
// openspec/changes/add-pipeline-dashboard/specs/pipeline-metrics/spec.md.
package metrics
import (
"cmp"
"context"
"encoding/base64"
"errors"
"fmt"
"math"
"slices"
"sort"
"strconv"
"strings"
"time"
)
// HealthStatus is a pipeline's computed health, per pipeline-metrics/
// spec.md's "Pipeline health status" requirement.
type HealthStatus string
// The health statuses a pipeline can be reported as.
const (
HealthHealthy HealthStatus = "healthy"
HealthUnhealthy HealthStatus = "unhealthy"
)
// Signal identifies which input triggered an unhealthy status.
type Signal string
// The signals that can trigger an unhealthy status.
const (
SignalFailureRate Signal = "failure_rate"
SignalDurationRegression Signal = "duration_regression"
SignalFlakyStep Signal = "flaky_step"
)
// Threshold constants for health-status computation. design.md flags the
// exact formula/weighting as an open question, tunable after
// implementation without changing the underlying per-signal specs; these
// are a defensible starting point, not a final answer.
const (
unhealthyFailureRateThreshold = 0.2
durationRegressionFactor = 1.5
trendBucketSize = 5
)
// PipelineRef identifies a pipeline: a distinct workflow name within a
// tracked repo. A pipeline has no storage row of its own -- it's derived
// from the distinct (repo, name) pairs already present in run history.
type PipelineRef struct {
RepoID string
Name string
}
// PipelineID is a PipelineRef, opaque-encoded for use in a URL path and API
// response.
type PipelineID string
const pipelineIDSeparator = "\x00"
// ID derives r's opaque identifier.
func (r PipelineRef) ID() PipelineID {
return PipelineID(base64.RawURLEncoding.EncodeToString([]byte(r.RepoID + pipelineIDSeparator + r.Name)))
}
// ErrInvalidPipelineID is returned when a PipelineID doesn't decode to a
// valid PipelineRef.
var ErrInvalidPipelineID = errors.New("invalid pipeline id")
// ParsePipelineID recovers the PipelineRef a PipelineID was derived from.
func ParsePipelineID(id PipelineID) (PipelineRef, error) {
raw, err := base64.RawURLEncoding.DecodeString(string(id))
if err != nil {
return PipelineRef{}, fmt.Errorf("%w: %s", ErrInvalidPipelineID, id)
}
parts := strings.SplitN(string(raw), pipelineIDSeparator, 2)
if len(parts) != 2 {
return PipelineRef{}, fmt.Errorf("%w: %s", ErrInvalidPipelineID, id)
}
return PipelineRef{RepoID: parts[0], Name: parts[1]}, nil
}
// Window bounds how much run history a computation considers, as a
// trailing run count. The API's "window" query parameter also allows a
// duration, per its description; only a run count is implemented in v1 --
// a documented scope trim, not an oversight, since every requirement in
// pipeline-metrics/spec.md phrases a window in terms of "recent runs".
type Window struct {
RunCount int
}
// DefaultWindowRunCount is used when no window is requested.
const DefaultWindowRunCount = 20
// ParseWindow interprets raw (the API's window query parameter). An empty
// or unrecognized value falls back to DefaultWindowRunCount.
func ParseWindow(raw string) Window {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return Window{RunCount: n}
}
return Window{RunCount: DefaultWindowRunCount}
}
// doubled is window's own size plus the equally sized window immediately
// before it, for regression detection (current vs. prior).
func (w Window) doubled() Window {
n := w.RunCount
if n <= 0 {
n = DefaultWindowRunCount
}
return Window{RunCount: n * 2}
}
// Pipeline is one tracked pipeline's identity and current health.
type Pipeline struct {
ID PipelineID
RepoID string
Name string
HealthStatus HealthStatus
TriggeredSignals []Signal
// LastRunAt is the most recent run's start time, nil if it has none.
LastRunAt *time.Time
}
// Trend is a time series: parallel Timestamps/P50/P90 (a duration trend) or
// Timestamps/Rate (a failure-rate trend).
type Trend struct {
Timestamps []time.Time
P50 []float64
P90 []float64
Rate []float64
}
// PipelineDetail is a pipeline plus its duration and failure-rate trends.
type PipelineDetail struct {
Pipeline
DurationTrend Trend
FailureRateTrend Trend
}
// Step is one named step's aggregated metrics within a pipeline.
type Step struct {
ID string
Name string
DurationContributionSeconds float64
QueueSeconds float64
ExecSeconds float64
FailureRate float64
FailureCount int
Flaky bool
// Quarantine is the active mark on this step, or nil. A quarantined step
// is still Flaky; it just stops counting toward health (countsAsFlaky).
Quarantine *Quarantine
ForgeURL string
}
// FlakyRun is one run in which a specific step failed -- the step-scoped
// drill-down from a flaky step, so a click lands on a run it actually
// failed on instead of an arbitrarily-picked occurrence.
type FlakyRun struct {
RunID string
StartedAt *time.Time
ForgeURL string
}
// RunStep is one step's status within a single run, for the drill-down a
// FlakyRun leads to.
type RunStep struct {
JobID string
Name string
Status string
Conclusion string
ForgeURL string
}
// RunDetail is one run's own steps, in recorded order.
type RunDetail struct {
RunID string
StartedAt *time.Time
Steps []RunStep
}
// UsageEntry is runner-minutes consumed by one workflow.
type UsageEntry struct {
Workflow string
RunnerMinutes float64
}
// RunRecord is one pipeline run, as metrics computation needs it.
type RunRecord struct {
ID string
Status string
Conclusion string
StartedAt *time.Time
CompletedAt *time.Time
}
// StepOccurrence is one recorded execution of a named step, within one job
// of one run. JobQueuedAt/JobStartedAt bound the parent job's own queue
// wait -- shared by every step within it, since a step can't be
// individually queued.
type StepOccurrence struct {
Name string
Status string
Conclusion string
StartedAt *time.Time
CompletedAt *time.Time
JobQueuedAt *time.Time
JobStartedAt *time.Time
JobForgeURL string
RunID string
RunStartedAt *time.Time
JobID string
}
// UsageRecord is one job's execution duration, attributed to its pipeline
// (workflow) name.
type UsageRecord struct {
PipelineName string
ExecSeconds float64
}
// PipelineListFilter narrows ListPipelines to one repo and/or forge, and/or
// one page, ordered deterministically by (RepoID, Name). The zero value
// matches every tracked pipeline, unfiltered and unpaginated -- what
// ListUnhealthySteps needs, since it operates over every pipeline rather
// than one page a user is looking at. Mirrors ingestion.RepoListFilter.
type PipelineListFilter struct {
// RepoID restricts the list to one tracked repo. Empty matches every repo.
RepoID string
// Forge restricts the list to one forge, via a join on the owning repo
// (a pipeline has no forge column of its own). Empty matches every forge.
Forge string
// Health restricts the list to pipelines with this health status. Empty
// matches every status. Health is computed, so a filter on it makes
// ListPipelines summarise every match before it pages.
Health HealthStatus
// Sort orders the list; the zero value is SortByName. SortByLastRun also
// orders the whole match before it pages.
Sort PipelineSort
// Limit caps how many pipelines are returned. Zero means unlimited.
Limit int
// Offset skips this many matching pipelines before the page starts.
Offset int
}
// Store is the port metrics computations read run/job/step history
// through. Every method that returns runs orders them most-recent-first.
type Store interface {
// ListPipelines returns the distinct pipelines matching filter, plus
// whether more pipelines beyond this page also match -- always false
// when filter.Limit is 0.
ListPipelines(ctx context.Context, filter PipelineListFilter) (pipelines []PipelineRef, hasMore bool, err error)
// PipelineRuns returns a pipeline's runs, most-recent-first, limited to
// window.RunCount.
PipelineRuns(ctx context.Context, ref PipelineRef, window Window) ([]RunRecord, error)
// PipelineSteps returns every step occurrence recorded across a
// pipeline's jobs within window, one entry per occurrence (not
// pre-aggregated).
PipelineSteps(ctx context.Context, ref PipelineRef, window Window) ([]StepOccurrence, error)
// RepoUsage returns every job's execution duration within window for a
// tracked repo, one entry per job.
RepoUsage(ctx context.Context, repoID string, window Window) ([]UsageRecord, error)
// WindowRuns returns every run, across pipelines, that started in
// [filter.Since, filter.Until), with its pipeline. Order is unspecified.
WindowRuns(ctx context.Context, filter RunWindowFilter) ([]WindowRun, error)
// WindowSteps returns every step occurrence, across pipelines, of runs
// that started in [filter.Since, filter.Until), with its pipeline. Order
// is unspecified.
WindowSteps(ctx context.Context, filter RunWindowFilter) ([]WindowStep, error)
// WindowBranches returns each branch with runs that started in
// [filter.Since, filter.Until), busiest first then by name. filter.Branch
// is ignored; runs with no branch are not counted.
WindowBranches(ctx context.Context, filter RunWindowFilter) ([]BranchCount, error)
// ListRuns returns a page of runs matching filter, newest first (a run
// that hasn't started yet before the rest), each with its steps, plus
// whether more beyond this page match -- always false when filter.Limit
// is 0.
ListRuns(ctx context.Context, filter RunListFilter) (runs []RunEntry, hasMore bool, err error)
// RunSteps returns every step occurrence recorded within one run's
// jobs, in recorded order. Empty for an unknown runID.
RunSteps(ctx context.Context, runID string) ([]StepOccurrence, error)
// ActiveQuarantines returns the quarantines in force at now: those whose
// expiry is after it. An expired one is as if it was never set.
ActiveQuarantines(ctx context.Context, now time.Time) (map[QuarantineKey]Quarantine, error)
// QuarantineStep marks a step quarantined from now for QuarantineDuration,
// replacing any earlier mark (and its note), and returns the mark.
QuarantineStep(ctx context.Context, key QuarantineKey, note string, now time.Time) (Quarantine, error)
// UnquarantineStep removes a step's mark. It is not an error to have none.
UnquarantineStep(ctx context.Context, key QuarantineKey) error
}
// ErrPipelineNotFound is returned when a PipelineID resolves to no
// recorded run history.
var ErrPipelineNotFound = errors.New("pipeline not found")
// ErrRunNotFound is returned when a run id resolves to no recorded steps.
var ErrRunNotFound = errors.New("run not found")
// Service computes health, trends, step rankings, and usage from a Store.
type Service struct {
store Store
}
// activeQuarantines loads the quarantines in force at now, once per request, so
// every health figure it feeds agrees on which steps are muted.
func (s *Service) activeQuarantines(ctx context.Context, now time.Time) (map[QuarantineKey]Quarantine, error) {
active, err := s.store.ActiveQuarantines(ctx, now)
if err != nil {
return nil, fmt.Errorf("load quarantines: %w", err)
}
return active, nil
}
// NewService returns a Service.
func NewService(store Store) *Service {
return &Service{store: store}
}
// ListPipelines returns a page of tracked pipelines' summaries and health
// status, plus whether more pipelines beyond this page match filter.
func (s *Service) ListPipelines(ctx context.Context, window Window, filter PipelineListFilter) ([]Pipeline, bool, error) {
// A health filter or a last-run sort needs every match summarised before
// it can page; the plain list pages in the store.
if filter.Health != "" || filter.Sort == SortByLastRun {
return s.listAcrossPipelines(ctx, window, filter)
}
refs, hasMore, err := s.store.ListPipelines(ctx, filter)
if err != nil {
return nil, false, fmt.Errorf("list pipelines: %w", err)
}
pipelines := make([]Pipeline, 0, len(refs))
for _, ref := range refs {
pipeline, err := s.summarize(ctx, ref, window)
if err != nil {
return nil, false, err
}
pipelines = append(pipelines, pipeline)
}
return pipelines, hasMore, nil
}
// GetPipeline returns one pipeline's health, duration trend, and
// failure-rate trend.
func (s *Service) GetPipeline(ctx context.Context, id PipelineID, window Window) (PipelineDetail, error) {
ref, err := ParsePipelineID(id)
if err != nil {
return PipelineDetail{}, err
}
runs, err := s.store.PipelineRuns(ctx, ref, window.doubled())
if err != nil {
return PipelineDetail{}, fmt.Errorf("load pipeline runs: %w", err)
}
if len(runs) == 0 {
return PipelineDetail{}, ErrPipelineNotFound
}
pipeline, err := s.summarizeFromRuns(ctx, ref, window, runs)
if err != nil {
return PipelineDetail{}, err
}
current, _ := splitWindow(runs, window)
oldestFirst := reversed(current)
return PipelineDetail{
Pipeline: pipeline,
DurationTrend: durationTrend(oldestFirst),
FailureRateTrend: failureRateTrend(oldestFirst),
}, nil
}
// GetPipelineSteps returns a pipeline's steps ranked by duration
// contribution, highest first.
func (s *Service) GetPipelineSteps(ctx context.Context, id PipelineID, window Window) ([]Step, error) {
ref, err := ParsePipelineID(id)
if err != nil {
return nil, err
}
occurrences, err := s.store.PipelineSteps(ctx, ref, window)
if err != nil {
return nil, fmt.Errorf("load pipeline steps: %w", err)
}
active, err := s.activeQuarantines(ctx, time.Now())
if err != nil {
return nil, err
}
return withQuarantines(aggregateSteps(occurrences), ref, active), nil
}
// ListFlakyRuns returns every run in which the named step failed within
// window, most-recent-first -- the step-scoped drill-down from a flaky
// step in GetPipelineSteps.
func (s *Service) ListFlakyRuns(ctx context.Context, id PipelineID, stepName string, window Window) ([]FlakyRun, error) {
ref, err := ParsePipelineID(id)
if err != nil {
return nil, err
}
occurrences, err := s.store.PipelineSteps(ctx, ref, window)
if err != nil {
return nil, fmt.Errorf("load pipeline steps: %w", err)
}
var runs []FlakyRun
for _, occ := range occurrences {
if occ.Name != stepName || occ.Conclusion != "failure" {
continue
}
runs = append(runs, FlakyRun{
RunID: occ.RunID,
StartedAt: occ.RunStartedAt,
ForgeURL: occ.JobForgeURL,
})
}
slices.SortFunc(runs, func(a, b FlakyRun) int {
return compareStartedDesc(a.StartedAt, b.StartedAt)
})
return runs, nil
}
// GetRunSteps returns one run's own steps and statuses, in recorded
// order -- the drill-down a FlakyRun leads to.
func (s *Service) GetRunSteps(ctx context.Context, runID string) (RunDetail, error) {
occurrences, err := s.store.RunSteps(ctx, runID)
if err != nil {
return RunDetail{}, fmt.Errorf("load run steps: %w", err)
}
if len(occurrences) == 0 {
return RunDetail{}, ErrRunNotFound
}
steps := make([]RunStep, 0, len(occurrences))
for _, occ := range occurrences {
steps = append(steps, RunStep{
JobID: occ.JobID,
Name: occ.Name,
Status: occ.Status,
Conclusion: occ.Conclusion,
ForgeURL: occ.JobForgeURL,
})
}
return RunDetail{
RunID: runID,
StartedAt: occurrences[0].RunStartedAt,
Steps: steps,
}, nil
}
// compareStartedDesc orders most-recent-first, with an unknown (nil) time
// sorting last rather than panicking on the dereference.
func compareStartedDesc(a, b *time.Time) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return 1
case b == nil:
return -1
default:
return b.Compare(*a)
}
}
// PipelineStepsGroup is one pipeline's flaky or failing steps, for the
// cross-pipeline unhealthy-steps overview.
type PipelineStepsGroup struct {
PipelineID PipelineID
PipelineName string
RepoID string
Steps []Step
}
// ListUnhealthySteps returns a page of pipeline groups with at least one
// flaky or failing step, ordered by (RepoID, PipelineName). A pipeline
// with no such step contributes nothing to the result, so pagination
// applies to the groups this produces, not to the candidate pipeline set
// it computes them from -- "has an unhealthy step" only exists once every
// candidate's steps are aggregated, unlike ListPipelines' own filters,
// which are plain column matches pushed into SQL. limit <= 0 returns
// every group, unpaginated, and hasMore is always false in that case.
func (s *Service) ListUnhealthySteps(ctx context.Context, window Window, limit, offset int) ([]PipelineStepsGroup, bool, error) {
refs, _, err := s.store.ListPipelines(ctx, PipelineListFilter{})
if err != nil {
return nil, false, fmt.Errorf("list pipelines: %w", err)
}
active, err := s.activeQuarantines(ctx, time.Now())
if err != nil {
return nil, false, err
}
var groups []PipelineStepsGroup
for _, ref := range refs {
occurrences, err := s.store.PipelineSteps(ctx, ref, window)
if err != nil {
return nil, false, fmt.Errorf("load steps for %s: %w", ref.Name, err)
}
unhealthy := unhealthyOf(withQuarantines(aggregateSteps(occurrences), ref, active))
if len(unhealthy) == 0 {
continue
}
groups = append(groups, PipelineStepsGroup{
PipelineID: ref.ID(),
PipelineName: ref.Name,
RepoID: ref.RepoID,
Steps: unhealthy,
})
}
if limit <= 0 {
return groups, false, nil
}
start := min(offset, len(groups))
end := min(start+limit, len(groups))
hasMore := len(groups) > start+limit
return groups[start:end], hasMore, nil
}
// GetRepoUsage returns a repo's runner-minutes usage, broken down by
// workflow, highest first.
func (s *Service) GetRepoUsage(ctx context.Context, repoID string, window Window) ([]UsageEntry, error) {
records, err := s.store.RepoUsage(ctx, repoID, window)
if err != nil {
return nil, fmt.Errorf("load repo usage: %w", err)
}
totals := map[string]float64{}
var order []string
for _, rec := range records {
if _, ok := totals[rec.PipelineName]; !ok {
order = append(order, rec.PipelineName)
}
totals[rec.PipelineName] += rec.ExecSeconds
}
entries := make([]UsageEntry, 0, len(order))
for _, name := range order {
entries = append(entries, UsageEntry{Workflow: name, RunnerMinutes: totals[name] / 60})
}
slices.SortFunc(entries, func(a, b UsageEntry) int {
return cmp.Compare(b.RunnerMinutes, a.RunnerMinutes)
})
return entries, nil
}
func (s *Service) summarize(ctx context.Context, ref PipelineRef, window Window) (Pipeline, error) {
runs, err := s.store.PipelineRuns(ctx, ref, window.doubled())
if err != nil {
return Pipeline{}, fmt.Errorf("load runs for %s: %w", ref.Name, err)
}
return s.summarizeFromRuns(ctx, ref, window, runs)
}
func (s *Service) summarizeFromRuns(ctx context.Context, ref PipelineRef, window Window, runs []RunRecord) (Pipeline, error) {
current, prior := splitWindow(runs, window)
occurrences, err := s.store.PipelineSteps(ctx, ref, window)
if err != nil {
return Pipeline{}, fmt.Errorf("load steps for %s: %w", ref.Name, err)
}
active, err := s.activeQuarantines(ctx, time.Now())
if err != nil {
return Pipeline{}, err
}
status, signals := computeHealth(current, prior, anyFlaky(withQuarantines(aggregateSteps(occurrences), ref, active)))
return Pipeline{
ID: ref.ID(),
RepoID: ref.RepoID,
Name: ref.Name,
HealthStatus: status,
TriggeredSignals: signals,
LastRunAt: lastRunAt(runs),
}, nil
}
// lastRunAt returns the most recent run's start time, falling back to its
// completion time when it started before ingestion recorded a start (never
// observed in practice, but StartedAt is a pointer). runs is
// most-recent-first, per the Store contract.
func lastRunAt(runs []RunRecord) *time.Time {
if len(runs) == 0 {
return nil
}
if runs[0].StartedAt != nil {
return runs[0].StartedAt
}
return runs[0].CompletedAt
}
func anyFlaky(steps []Step) bool {
return slices.ContainsFunc(steps, countsAsFlaky)
}
// unhealthyOf keeps the steps that are flaky or failing. A quarantined flaky
// step is a known flake: it stays out even though its failures give it a
// failure rate.
func unhealthyOf(steps []Step) []Step {
var unhealthy []Step
for _, step := range steps {
if step.Flaky && step.Quarantine != nil {
continue
}
if step.Flaky || step.FailureRate > 0 {
unhealthy = append(unhealthy, step)
}
}
return unhealthy
}
// splitWindow divides runsMostRecentFirst into the current window (the
// first window.RunCount runs) and the prior window (the next
// window.RunCount runs immediately before it).
func splitWindow(runsMostRecentFirst []RunRecord, window Window) (current, prior []RunRecord) {
n := window.RunCount
if n <= 0 {
n = DefaultWindowRunCount
}
if len(runsMostRecentFirst) <= n {
return runsMostRecentFirst, nil
}
current = runsMostRecentFirst[:n]
end := min(2*n, len(runsMostRecentFirst))
prior = runsMostRecentFirst[n:end]
return current, prior
}
func reversed(runs []RunRecord) []RunRecord {
out := make([]RunRecord, len(runs))
for i, r := range runs {
out[len(runs)-1-i] = r
}
return out
}
func computeHealth(currentRuns, priorRuns []RunRecord, hasFlakyStep bool) (HealthStatus, []Signal) {
var signals []Signal
if rate := overallFailureRate(currentRuns); rate >= unhealthyFailureRateThreshold {
signals = append(signals, SignalFailureRate)
}
if regressed(currentRuns, priorRuns) {
signals = append(signals, SignalDurationRegression)
}
if hasFlakyStep {
signals = append(signals, SignalFlakyStep)
}
if len(signals) == 0 {
return HealthHealthy, nil
}
return HealthUnhealthy, signals
}
func overallFailureRate(runs []RunRecord) float64 {
var total, failed int
for _, r := range runs {
if r.Status != "completed" {
continue
}
total++
if r.Conclusion == "failure" {
failed++
}
}
return rate(failed, total)
}
func regressed(currentRuns, priorRuns []RunRecord) bool {
currentP90 := runsP90Duration(currentRuns)
priorP90 := runsP90Duration(priorRuns)
if currentP90 == 0 || priorP90 == 0 {
return false
}
return currentP90 >= priorP90*durationRegressionFactor
}
func runsP90Duration(runs []RunRecord) float64 {
durations := make([]float64, 0, len(runs))
for _, r := range runs {
if secs, ok := durationSeconds(r.StartedAt, r.CompletedAt); ok {
durations = append(durations, secs)
}
}
if len(durations) == 0 {
return 0
}
sort.Float64s(durations)
return percentile(durations, 0.9)
}
// durationTrend buckets runsOldestFirst into consecutive groups and
// computes p50/p90 duration per bucket, so the trend has multiple points
// rather than a single aggregate.
func durationTrend(runsOldestFirst []RunRecord) Trend {
var trend Trend
for _, bucket := range bucketRuns(runsOldestFirst) {
durations := make([]float64, 0, len(bucket))
var ts time.Time
for _, run := range bucket {
if secs, ok := durationSeconds(run.StartedAt, run.CompletedAt); ok {
durations = append(durations, secs)
}
if run.StartedAt != nil {
ts = *run.StartedAt
}
}
if len(durations) == 0 {
continue
}
sort.Float64s(durations)
trend.Timestamps = append(trend.Timestamps, ts)
trend.P50 = append(trend.P50, percentile(durations, 0.5))
trend.P90 = append(trend.P90, percentile(durations, 0.9))
}
return trend
}
func failureRateTrend(runsOldestFirst []RunRecord) Trend {
var trend Trend
for _, bucket := range bucketRuns(runsOldestFirst) {
var total, failed int
var ts time.Time
for _, run := range bucket {
if run.Status != "completed" {
continue
}
total++
if run.Conclusion == "failure" {
failed++
}
if run.StartedAt != nil {
ts = *run.StartedAt
}
}
if total == 0 {
continue
}
trend.Timestamps = append(trend.Timestamps, ts)
trend.Rate = append(trend.Rate, rate(failed, total))
}
return trend
}
func bucketRuns(runsOldestFirst []RunRecord) [][]RunRecord {
var buckets [][]RunRecord
for i := 0; i < len(runsOldestFirst); i += trendBucketSize {
end := min(i+trendBucketSize, len(runsOldestFirst))
buckets = append(buckets, runsOldestFirst[i:end])
}
return buckets
}
// stepAcc accumulates every recorded occurrence of one named step.
type stepAcc struct {
execSecs []float64
queueSecs []float64
total int
failed int
sawSuccess bool
sawFailure bool
forgeURL string
}
func (a *stepAcc) add(occ StepOccurrence) {
if secs, ok := durationSeconds(occ.StartedAt, occ.CompletedAt); ok {
a.execSecs = append(a.execSecs, secs)
}
if secs, ok := durationSeconds(occ.JobQueuedAt, occ.JobStartedAt); ok {
a.queueSecs = append(a.queueSecs, secs)
}
if occ.Status == "completed" {
a.total++
switch occ.Conclusion {
case "success":
a.sawSuccess = true
case "failure":
a.failed++
a.sawFailure = true
}
}
if a.forgeURL == "" {
a.forgeURL = occ.JobForgeURL
}
}
// aggregateSteps groups occurrences by step name and computes each one's
// duration contribution, queue/exec split, failure rate, and flakiness.
// Steps are returned ranked by duration contribution, highest first.
func aggregateSteps(occurrences []StepOccurrence) []Step {
byName := map[string]*stepAcc{}
var order []string
for _, occ := range occurrences {
a, ok := byName[occ.Name]
if !ok {
a = &stepAcc{}
byName[occ.Name] = a
order = append(order, occ.Name)
}
a.add(occ)
}
steps := make([]Step, 0, len(order))
for _, name := range order {
a := byName[name]
steps = append(steps, Step{
ID: name,
Name: name,
DurationContributionSeconds: average(a.execSecs),
QueueSeconds: average(a.queueSecs),
ExecSeconds: average(a.execSecs),
FailureRate: rate(a.failed, a.total),
FailureCount: a.failed,
Flaky: a.sawSuccess && a.sawFailure,
ForgeURL: a.forgeURL,
})
}
slices.SortFunc(steps, func(a, b Step) int {
return cmp.Compare(b.DurationContributionSeconds, a.DurationContributionSeconds)
})
return steps
}
func durationSeconds(start, end *time.Time) (float64, bool) {
if start == nil || end == nil {
return 0, false
}
return end.Sub(*start).Seconds(), true
}
func average(values []float64) float64 {
if len(values) == 0 {
return 0
}
var sum float64
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
func rate(numerator, denominator int) float64 {
if denominator == 0 {
return 0
}
return float64(numerator) / float64(denominator)
}
// percentile returns the p-th percentile (0-1) of sorted (ascending),
// using the nearest-rank method.
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return 0
}
rank := int(math.Ceil(p*float64(len(sorted)))) - 1
rank = max(rank, 0)
rank = min(rank, len(sorted)-1)
return sorted[rank]
}
package metrics
// Outcome is what a run's or step's forge state means, decided here so no
// client interprets status strings itself. It's the API's Outcome schema.
type Outcome string
// The outcomes a run or step can have.
const (
OutcomePassed Outcome = "passed"
OutcomeFailed Outcome = "failed"
OutcomeRunning Outcome = "running"
OutcomeQueued Outcome = "queued"
OutcomeCancelled Outcome = "cancelled"
OutcomeSkipped Outcome = "skipped"
OutcomeUnknown Outcome = "unknown"
)
// OutcomeOf maps a forge status and conclusion to an Outcome.
//
// A conclusion wins over the status: a run that still says in_progress next
// to a conclusion hasn't resumed, its status just hasn't caught up. A
// failure is whatever isFailed says, the same rule the failure insights use,
// so "failed" can't mean two things. Anything unrecognised is OutcomeUnknown,
// never a pass: a false all-clear is worse than an unlabelled run.
func OutcomeOf(status, conclusion string) Outcome {
switch {
case conclusion == "success":
return OutcomePassed
case isFailed(conclusion):
return OutcomeFailed
case conclusion == "cancelled":
return OutcomeCancelled
case conclusion == "skipped":
return OutcomeSkipped
case conclusion != "":
return OutcomeUnknown
}
switch status {
case "in_progress":
return OutcomeRunning
case "queued", "waiting", "pending", "requested":
return OutcomeQueued
default:
return OutcomeUnknown
}
}
package metrics
import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"time"
)
// PipelineSort is the order a pipeline list comes back in.
type PipelineSort string
// The pipeline list orders. SortByName is by repository then name, the order
// the store returns; SortByLastRun is the most recently run first.
const (
SortByName PipelineSort = "name"
SortByLastRun PipelineSort = "lastRun"
)
// ErrInvalidHealthFilter and ErrInvalidPipelineSort are returned by the
// parsers for a value outside the documented set, so a typo can't read as
// "no unhealthy pipelines".
var (
ErrInvalidHealthFilter = errors.New("invalid health filter")
ErrInvalidPipelineSort = errors.New("invalid pipeline sort")
)
// ParseHealthFilter interprets the API's health query parameter. Empty means
// no filter.
func ParseHealthFilter(raw string) (HealthStatus, error) {
switch status := HealthStatus(raw); status {
case "", HealthHealthy, HealthUnhealthy:
return status, nil
default:
return "", fmt.Errorf("%w: %q", ErrInvalidHealthFilter, raw)
}
}
// ParsePipelineSort interprets the API's sort query parameter. Empty means
// the default, SortByName.
func ParsePipelineSort(raw string) (PipelineSort, error) {
switch order := PipelineSort(raw); order {
case "":
return SortByName, nil
case SortByName, SortByLastRun:
return order, nil
default:
return "", fmt.Errorf("%w: %q", ErrInvalidPipelineSort, raw)
}
}
// listAcrossPipelines serves a list that needs every matching pipeline
// summarised before it can be cut into a page: a health filter, because
// health is computed from runs and steps rather than stored, and a last-run
// sort, which orders the whole set. It summarises, filters and sorts the full
// match, then pages the result, so page sizes and hasMore are right.
func (s *Service) listAcrossPipelines(ctx context.Context, window Window, filter PipelineListFilter) ([]Pipeline, bool, error) {
all := filter
all.Limit, all.Offset = 0, 0
refs, _, err := s.store.ListPipelines(ctx, all)
if err != nil {
return nil, false, fmt.Errorf("list pipelines: %w", err)
}
pipelines := make([]Pipeline, 0, len(refs))
for _, ref := range refs {
pipeline, err := s.summarize(ctx, ref, window)
if err != nil {
return nil, false, err
}
if filter.Health != "" && pipeline.HealthStatus != filter.Health {
continue
}
pipelines = append(pipelines, pipeline)
}
if filter.Sort == SortByLastRun {
// The store's order (repository, then name) is the tie-break, so the
// stable sort keeps the result deterministic.
slices.SortStableFunc(pipelines, func(a, b Pipeline) int {
return compareLastRunDesc(a.LastRunAt, b.LastRunAt)
})
}
page, hasMore := paginate(pipelines, filter.Limit, filter.Offset)
return page, hasMore, nil
}
// compareLastRunDesc orders a later run before an earlier one and a pipeline
// with no runs after every pipeline that has some.
func compareLastRunDesc(a, b *time.Time) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return 1
case b == nil:
return -1
default:
return cmp.Compare(b.UnixNano(), a.UnixNano())
}
}
// paginate cuts a page out of list. A zero limit means everything from
// offset, and hasMore is true only when matches remain beyond the page.
func paginate[T any](list []T, limit, offset int) ([]T, bool) {
offset = min(max(offset, 0), len(list))
if limit <= 0 {
return list[offset:], false
}
end := min(offset+limit, len(list))
return list[offset:end], len(list) > end
}
package metrics
import (
"context"
"errors"
"fmt"
"time"
"unicode/utf8"
)
// QuarantineDuration is how long a quarantine stays in force from the time it
// was marked or last renewed.
const QuarantineDuration = 30 * 24 * time.Hour
// QuarantineKey names the step a quarantine marks: one step name within one
// pipeline, the same identity FlakyStep uses.
type QuarantineKey struct {
Pipeline PipelineRef
Step string
}
// Quarantine is a person's mark on a flaky step. It mutes the step's
// contribution to its pipeline's health signal and to the flaky-step ratio and
// changes no other figure.
type Quarantine struct {
Note string
QuarantinedAt time.Time
ExpiresAt time.Time
}
// countsAsFlaky is the one place that decides whether a flaky step feeds a
// health signal or the flaky-step ratio: a quarantined step is still flaky, it
// just doesn't count.
func countsAsFlaky(step Step) bool {
return step.Flaky && step.Quarantine == nil
}
// withQuarantines attaches the active mark to each step it names in pipeline.
func withQuarantines(steps []Step, pipeline PipelineRef, active map[QuarantineKey]Quarantine) []Step {
for i := range steps {
if q, ok := active[QuarantineKey{Pipeline: pipeline, Step: steps[i].Name}]; ok {
steps[i].Quarantine = &q
}
}
return steps
}
// MaxQuarantineNote is the longest note a quarantine takes, in characters.
const MaxQuarantineNote = 500
// ErrQuarantineNoteTooLong is returned for a note over MaxQuarantineNote
// characters.
var ErrQuarantineNoteTooLong = errors.New("quarantine note too long")
// QuarantineStep marks a step of a pipeline quarantined from now for
// QuarantineDuration, replacing any earlier mark. It does not check that the
// step is flaky: a mark on a step that isn't does nothing until it is. An
// unknown pipeline is ErrPipelineNotFound.
func (s *Service) QuarantineStep(ctx context.Context, id PipelineID, step, note string, now time.Time) (Quarantine, error) {
if utf8.RuneCountInString(note) > MaxQuarantineNote {
return Quarantine{}, ErrQuarantineNoteTooLong
}
ref, err := s.knownPipeline(ctx, id)
if err != nil {
return Quarantine{}, err
}
q, err := s.store.QuarantineStep(ctx, QuarantineKey{Pipeline: ref, Step: step}, note, now)
if err != nil {
return Quarantine{}, fmt.Errorf("quarantine step: %w", err)
}
return q, nil
}
// UnquarantineStep removes a step's mark. A step with none is fine; an unknown
// pipeline is ErrPipelineNotFound.
func (s *Service) UnquarantineStep(ctx context.Context, id PipelineID, step string) error {
ref, err := s.knownPipeline(ctx, id)
if err != nil {
return err
}
if err := s.store.UnquarantineStep(ctx, QuarantineKey{Pipeline: ref, Step: step}); err != nil {
return fmt.Errorf("un-quarantine step: %w", err)
}
return nil
}
// knownPipeline parses id and checks the pipeline has recorded runs.
func (s *Service) knownPipeline(ctx context.Context, id PipelineID) (PipelineRef, error) {
ref, err := ParsePipelineID(id)
if err != nil {
return PipelineRef{}, err
}
runs, err := s.store.PipelineRuns(ctx, ref, Window{RunCount: 1})
if err != nil {
return PipelineRef{}, fmt.Errorf("load pipeline runs: %w", err)
}
if len(runs) == 0 {
return PipelineRef{}, ErrPipelineNotFound
}
return ref, nil
}
package metrics
import (
"context"
"errors"
"fmt"
"time"
)
// RunStatus is a status bucket the run list can be filtered to, matching the
// API's RunStatusFilter.
type RunStatus string
// The run-list status buckets. Failed is a concluded failure or timeout,
// Running is anything not yet completed (queued or in progress), and Success
// is a concluded success; a cancelled or skipped run is in All only.
const (
RunStatusAll RunStatus = "all"
RunStatusFailed RunStatus = "failed"
RunStatusRunning RunStatus = "running"
RunStatusSuccess RunStatus = "success"
)
// ErrInvalidRunStatus is returned by ParseRunStatus for a value outside the
// documented set.
var ErrInvalidRunStatus = errors.New("invalid run status")
// ParseRunStatus interprets the API's status query parameter. Empty means
// all, the contract's default; anything else outside the set is an error
// rather than a silent fallback, so a typo can't look like "no failures".
func ParseRunStatus(raw string) (RunStatus, error) {
switch status := RunStatus(raw); status {
case "":
return RunStatusAll, nil
case RunStatusAll, RunStatusFailed, RunStatusRunning, RunStatusSuccess:
return status, nil
default:
return "", fmt.Errorf("%w: %q", ErrInvalidRunStatus, raw)
}
}
// RunListFilter narrows ListRuns to one repo, forge and status bucket, and to
// one page. A zero Limit means unlimited.
type RunListFilter struct {
RepoID string
Forge string
Branch string
Status RunStatus
Limit int
Offset int
}
// RunEntry is one run in the run list: its pipeline, commit metadata (empty
// where the forge or an older row lacks it), forge link and steps.
type RunEntry struct {
ID string
Pipeline PipelineRef
// Forge is the repo's forge, "github" or "forgejo".
Forge string
Status string
Conclusion string
StartedAt *time.Time
CompletedAt *time.Time
Branch string
SHA string
Message string
Actor string
ForgeURL string
// Steps are in recorded order (job, then step number). Never nil.
Steps []RunStep
}
// DurationSeconds is the run's wall-clock span. It reports false for a run
// that is still going or never started, rather than a misleading zero.
func (e RunEntry) DurationSeconds() (float64, bool) {
return durationSeconds(e.StartedAt, e.CompletedAt)
}
// ListRuns returns a page of runs, newest first, and whether more match.
func (s *Service) ListRuns(ctx context.Context, filter RunListFilter) ([]RunEntry, bool, error) {
runs, hasMore, err := s.store.ListRuns(ctx, filter)
if err != nil {
return nil, false, fmt.Errorf("list runs: %w", err)
}
return runs, hasMore, nil
}
package sqlite
import (
"context"
"fmt"
"time"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
// ActiveQuarantines implements metrics.Store. A mark whose expiry has passed is
// left in the table and ignored here; the next QuarantineStep replaces it.
func (s *Store) ActiveQuarantines(ctx context.Context, now time.Time) (map[metrics.QuarantineKey]metrics.Quarantine, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT repo_id, pipeline_name, step_name, note, quarantined_at, expires_at
FROM step_quarantines
WHERE expires_at > ?
`, now.UTC())
if err != nil {
return nil, fmt.Errorf("query quarantines: %w", err)
}
defer func() { _ = rows.Close() }()
active := make(map[metrics.QuarantineKey]metrics.Quarantine)
for rows.Next() {
var (
key metrics.QuarantineKey
q metrics.Quarantine
)
if err := rows.Scan(&key.Pipeline.RepoID, &key.Pipeline.Name, &key.Step, &q.Note, &q.QuarantinedAt, &q.ExpiresAt); err != nil {
return nil, fmt.Errorf("scan quarantine: %w", err)
}
active[key] = q
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read quarantines: %w", err)
}
return active, nil
}
// QuarantineStep implements metrics.Store.
func (s *Store) QuarantineStep(ctx context.Context, key metrics.QuarantineKey, note string, now time.Time) (metrics.Quarantine, error) {
q := metrics.Quarantine{Note: note, QuarantinedAt: now.UTC(), ExpiresAt: now.UTC().Add(metrics.QuarantineDuration)}
_, err := s.db.ExecContext(ctx, `
INSERT INTO step_quarantines (repo_id, pipeline_name, step_name, note, quarantined_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (repo_id, pipeline_name, step_name)
DO UPDATE SET note = excluded.note, quarantined_at = excluded.quarantined_at, expires_at = excluded.expires_at
`, key.Pipeline.RepoID, key.Pipeline.Name, key.Step, q.Note, q.QuarantinedAt, q.ExpiresAt)
if err != nil {
return metrics.Quarantine{}, fmt.Errorf("quarantine step: %w", err)
}
return q, nil
}
// UnquarantineStep implements metrics.Store.
func (s *Store) UnquarantineStep(ctx context.Context, key metrics.QuarantineKey) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM step_quarantines WHERE repo_id = ? AND pipeline_name = ? AND step_name = ?`,
key.Pipeline.RepoID, key.Pipeline.Name, key.Step)
if err != nil {
return fmt.Errorf("un-quarantine step: %w", err)
}
return nil
}
// Package sqlite is the SQLite-backed adapter for the metrics.Store port,
// reading the same repos/runs/jobs/steps tables ingestion writes to.
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/alrayyes/pipeline-analytics/internal/metrics"
)
// Store implements metrics.Store against a SQLite database.
type Store struct {
db *sql.DB
}
// NewStore returns a Store.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// pipelineListQuery builds the SQL and args ListPipelines runs for filter.
func pipelineListQuery(filter metrics.PipelineListFilter) (string, []any) {
query := "SELECT DISTINCT r.repo_id, r.pipeline_name FROM runs r"
args := []any{}
// The runs table has no forge column of its own, so filtering by forge
// needs a join to the owning repo -- skipped when Forge is unset, to
// keep the common (unfiltered) query simple.
if filter.Forge != "" {
query += " JOIN repos p ON p.id = r.repo_id"
}
var where string
if filter.RepoID != "" {
where += " r.repo_id = ?"
args = append(args, filter.RepoID)
}
if filter.Forge != "" {
if where != "" {
where += " AND"
}
where += " p.forge = ?"
args = append(args, filter.Forge)
}
if where != "" {
query += " WHERE"
query += where
}
query += " ORDER BY r.repo_id, r.pipeline_name"
// Fetching one extra row is what tells the caller whether a next page
// exists, without a separate COUNT(*) round trip.
if filter.Limit > 0 {
query += " LIMIT ? OFFSET ?"
args = append(args, filter.Limit+1, filter.Offset)
}
return query, args
}
// ListPipelines implements metrics.Store.
func (s *Store) ListPipelines(ctx context.Context, filter metrics.PipelineListFilter) ([]metrics.PipelineRef, bool, error) {
query, args := pipelineListQuery(filter)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, false, fmt.Errorf("query pipelines: %w", err)
}
defer func() { _ = rows.Close() }()
var refs []metrics.PipelineRef
for rows.Next() {
var ref metrics.PipelineRef
if err := rows.Scan(&ref.RepoID, &ref.Name); err != nil {
return nil, false, fmt.Errorf("scan pipeline: %w", err)
}
refs = append(refs, ref)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("iterate pipelines: %w", err)
}
hasMore := filter.Limit > 0 && len(refs) > filter.Limit
if hasMore {
refs = refs[:filter.Limit]
}
return refs, hasMore, nil
}
// PipelineRuns implements metrics.Store.
func (s *Store) PipelineRuns(ctx context.Context, ref metrics.PipelineRef, window metrics.Window) ([]metrics.RunRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, status, conclusion, started_at, completed_at
FROM runs
WHERE repo_id = ? AND pipeline_name = ?
ORDER BY started_at DESC, id DESC
LIMIT ?
`, ref.RepoID, ref.Name, window.RunCount)
if err != nil {
return nil, fmt.Errorf("query pipeline runs: %w", err)
}
defer func() { _ = rows.Close() }()
var runs []metrics.RunRecord
for rows.Next() {
var (
run metrics.RunRecord
conclusion sql.NullString
)
if err := rows.Scan(&run.ID, &run.Status, &conclusion, &run.StartedAt, &run.CompletedAt); err != nil {
return nil, fmt.Errorf("scan run: %w", err)
}
run.Conclusion = conclusion.String
runs = append(runs, run)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pipeline runs: %w", err)
}
return runs, nil
}
// PipelineSteps implements metrics.Store.
func (s *Store) PipelineSteps(ctx context.Context, ref metrics.PipelineRef, window metrics.Window) ([]metrics.StepOccurrence, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT s.name, s.status, s.conclusion, s.started_at, s.completed_at, j.queued_at, j.started_at, j.forge_url, j.run_id, r.started_at, j.id
FROM steps s
JOIN jobs j ON j.id = s.job_id
JOIN runs r ON r.id = j.run_id
WHERE j.run_id IN (
SELECT id FROM runs WHERE repo_id = ? AND pipeline_name = ? ORDER BY started_at DESC, id DESC LIMIT ?
)
`, ref.RepoID, ref.Name, window.RunCount)
if err != nil {
return nil, fmt.Errorf("query pipeline steps: %w", err)
}
defer func() { _ = rows.Close() }()
return scanStepOccurrences(rows)
}
// WindowRuns implements metrics.Store.
func (s *Store) WindowRuns(ctx context.Context, filter metrics.RunWindowFilter) ([]metrics.WindowRun, error) {
query := "SELECT r.id, r.repo_id, r.pipeline_name, r.status, r.conclusion, r.started_at, r.completed_at FROM runs r"
args := []any{}
// As in pipelineListQuery: runs has no forge column, so the join to the
// owning repo is only added when a forge filter needs it.
if filter.Forge != "" {
query += " JOIN repos p ON p.id = r.repo_id"
}
query += " WHERE r.started_at >= ? AND r.started_at < ?"
args = append(args, filter.Since, filter.Until)
query, args = appendRunScope(query, args, filter.RepoID, filter.Forge, filter.Branch)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query window runs: %w", err)
}
defer func() { _ = rows.Close() }()
var runs []metrics.WindowRun
for rows.Next() {
var (
wr metrics.WindowRun
conclusion sql.NullString
)
err := rows.Scan(&wr.Run.ID, &wr.Pipeline.RepoID, &wr.Pipeline.Name, &wr.Run.Status, &conclusion, &wr.Run.StartedAt, &wr.Run.CompletedAt)
if err != nil {
return nil, fmt.Errorf("scan window run: %w", err)
}
wr.Run.Conclusion = conclusion.String
runs = append(runs, wr)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate window runs: %w", err)
}
return runs, nil
}
// WindowBranches implements metrics.Store.
func (s *Store) WindowBranches(ctx context.Context, filter metrics.RunWindowFilter) ([]metrics.BranchCount, error) {
query := "SELECT r.branch, COUNT(*) FROM runs r"
if filter.Forge != "" {
query += " JOIN repos p ON p.id = r.repo_id"
}
query += " WHERE r.started_at >= ? AND r.started_at < ? AND r.branch IS NOT NULL AND r.branch != ''"
args := []any{filter.Since, filter.Until}
query, args = appendRunScope(query, args, filter.RepoID, filter.Forge, "")
query += " GROUP BY r.branch ORDER BY COUNT(*) DESC, r.branch"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query window branches: %w", err)
}
defer func() { _ = rows.Close() }()
var branches []metrics.BranchCount
for rows.Next() {
var b metrics.BranchCount
if err := rows.Scan(&b.Name, &b.RunCount); err != nil {
return nil, fmt.Errorf("scan branch: %w", err)
}
branches = append(branches, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate branches: %w", err)
}
return branches, nil
}
// WindowSteps implements metrics.Store.
func (s *Store) WindowSteps(ctx context.Context, filter metrics.RunWindowFilter) ([]metrics.WindowStep, error) {
query := `
SELECT r.repo_id, r.pipeline_name,
s.name, s.status, s.conclusion, s.started_at, s.completed_at, j.queued_at, j.started_at, j.forge_url, j.run_id, r.started_at
FROM steps s
JOIN jobs j ON j.id = s.job_id
JOIN runs r ON r.id = j.run_id`
args := []any{}
if filter.Forge != "" {
query += " JOIN repos p ON p.id = r.repo_id"
}
query += " WHERE r.started_at >= ? AND r.started_at < ?"
args = append(args, filter.Since, filter.Until)
query, args = appendRunScope(query, args, filter.RepoID, filter.Forge, filter.Branch)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query window steps: %w", err)
}
defer func() { _ = rows.Close() }()
var steps []metrics.WindowStep
for rows.Next() {
var (
ws metrics.WindowStep
conclusion sql.NullString
)
err := rows.Scan(
&ws.Pipeline.RepoID, &ws.Pipeline.Name,
&ws.Step.Name, &ws.Step.Status, &conclusion, &ws.Step.StartedAt, &ws.Step.CompletedAt,
&ws.Step.JobQueuedAt, &ws.Step.JobStartedAt, &ws.Step.JobForgeURL, &ws.Step.RunID, &ws.Step.RunStartedAt,
)
if err != nil {
return nil, fmt.Errorf("scan window step: %w", err)
}
ws.Step.Conclusion = conclusion.String
steps = append(steps, ws)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate window steps: %w", err)
}
return steps, nil
}
// runListQuery builds the SQL and args ListRuns runs for filter.
// appendRunScope narrows a query over runs r (joined to repos p when forge is
// set) to one repo, forge and branch. Empty means unrestricted.
func appendRunScope(query string, args []any, repoID, forge, branch string) (string, []any) {
if repoID != "" {
query += " AND r.repo_id = ?"
args = append(args, repoID)
}
if forge != "" {
query += " AND p.forge = ?"
args = append(args, forge)
}
if branch != "" {
query += " AND r.branch = ?"
args = append(args, branch)
}
return query, args
}
func runListQuery(filter metrics.RunListFilter) (string, []any) {
query := `
SELECT r.id, r.repo_id, r.pipeline_name, r.status, r.conclusion, r.started_at, r.completed_at,
r.branch, r.head_sha, r.head_message, r.actor, r.forge_url, p.forge
FROM runs r
JOIN repos p ON p.id = r.repo_id
WHERE 1 = 1`
args := []any{}
query, args = appendRunScope(query, args, filter.RepoID, filter.Forge, filter.Branch)
switch filter.Status {
case metrics.RunStatusFailed:
query += " AND r.conclusion IN ('failure', 'timed_out')"
case metrics.RunStatusRunning:
query += " AND r.status != 'completed'"
case metrics.RunStatusSuccess:
query += " AND r.conclusion = 'success'"
case metrics.RunStatusAll:
}
// A run that hasn't started has no start time yet and is the newest, so
// NULLs sort first.
query += " ORDER BY r.started_at IS NULL DESC, r.started_at DESC, r.id DESC"
// One extra row tells the caller whether another page exists without a
// separate COUNT(*), as in pipelineListQuery.
if filter.Limit > 0 {
query += " LIMIT ? OFFSET ?"
args = append(args, filter.Limit+1, filter.Offset)
}
return query, args
}
// ListRuns implements metrics.Store.
func (s *Store) ListRuns(ctx context.Context, filter metrics.RunListFilter) ([]metrics.RunEntry, bool, error) {
query, args := runListQuery(filter)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, false, fmt.Errorf("query runs: %w", err)
}
defer func() { _ = rows.Close() }()
var runs []metrics.RunEntry
for rows.Next() {
run, err := scanRunEntry(rows)
if err != nil {
return nil, false, err
}
runs = append(runs, run)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("iterate runs: %w", err)
}
hasMore := filter.Limit > 0 && len(runs) > filter.Limit
if hasMore {
runs = runs[:filter.Limit]
}
if err := s.attachSteps(ctx, runs); err != nil {
return nil, false, err
}
return runs, hasMore, nil
}
func scanRunEntry(rows *sql.Rows) (metrics.RunEntry, error) {
var (
run metrics.RunEntry
conclusion, branch, sha, message, actor sql.NullString
)
err := rows.Scan(
&run.ID, &run.Pipeline.RepoID, &run.Pipeline.Name, &run.Status, &conclusion, &run.StartedAt, &run.CompletedAt,
&branch, &sha, &message, &actor, &run.ForgeURL, &run.Forge,
)
if err != nil {
return metrics.RunEntry{}, fmt.Errorf("scan run: %w", err)
}
run.Conclusion = conclusion.String
run.Branch, run.SHA, run.Message, run.Actor = branch.String, sha.String, message.String, actor.String
run.Steps = []metrics.RunStep{}
return run, nil
}
// attachSteps fills each run's Steps with one query for the whole page,
// rather than one per run.
func (s *Store) attachSteps(ctx context.Context, runs []metrics.RunEntry) error {
if len(runs) == 0 {
return nil
}
index := make(map[string]int, len(runs))
ids := make([]string, 0, len(runs))
for i, run := range runs {
index[run.ID] = i
ids = append(ids, run.ID)
}
// The ids go in as one JSON array read by json_each, so the query text
// is fixed however many runs are on the page -- no SQL assembled from
// a variable-length placeholder list.
idsJSON, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("encode run ids: %w", err)
}
rows, err := s.db.QueryContext(ctx, `
SELECT j.run_id, s.name, s.status, s.conclusion, j.forge_url, j.id
FROM steps s
JOIN jobs j ON j.id = s.job_id
WHERE j.run_id IN (SELECT value FROM json_each(?))
ORDER BY j.run_id, j.started_at, s.number
`, string(idsJSON))
if err != nil {
return fmt.Errorf("query run steps: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
runID string
step metrics.RunStep
conclusion sql.NullString
)
if err := rows.Scan(&runID, &step.Name, &step.Status, &conclusion, &step.ForgeURL, &step.JobID); err != nil {
return fmt.Errorf("scan run step: %w", err)
}
step.Conclusion = conclusion.String
runs[index[runID]].Steps = append(runs[index[runID]].Steps, step)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate run steps: %w", err)
}
return nil
}
// RunSteps implements metrics.Store.
func (s *Store) RunSteps(ctx context.Context, runID string) ([]metrics.StepOccurrence, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT s.name, s.status, s.conclusion, s.started_at, s.completed_at, j.queued_at, j.started_at, j.forge_url, j.run_id, r.started_at, j.id
FROM steps s
JOIN jobs j ON j.id = s.job_id
JOIN runs r ON r.id = j.run_id
WHERE j.run_id = ?
ORDER BY j.started_at, s.number
`, runID)
if err != nil {
return nil, fmt.Errorf("query run steps: %w", err)
}
defer func() { _ = rows.Close() }()
return scanStepOccurrences(rows)
}
func scanStepOccurrences(rows *sql.Rows) ([]metrics.StepOccurrence, error) {
var occurrences []metrics.StepOccurrence
for rows.Next() {
var (
occ metrics.StepOccurrence
conclusion sql.NullString
)
err := rows.Scan(
&occ.Name, &occ.Status, &conclusion, &occ.StartedAt, &occ.CompletedAt,
&occ.JobQueuedAt, &occ.JobStartedAt, &occ.JobForgeURL, &occ.RunID, &occ.RunStartedAt, &occ.JobID,
)
if err != nil {
return nil, fmt.Errorf("scan step: %w", err)
}
occ.Conclusion = conclusion.String
occurrences = append(occurrences, occ)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate steps: %w", err)
}
return occurrences, nil
}
// RepoUsage implements metrics.Store. Each tracked pipeline within the repo
// contributes its own window's worth of runs, so a rarely run workflow
// isn't crowded out of the window by a busy one.
func (s *Store) RepoUsage(ctx context.Context, repoID string, window metrics.Window) ([]metrics.UsageRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.pipeline_name, j.started_at, j.completed_at
FROM jobs j
JOIN runs r ON r.id = j.run_id
WHERE r.id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY pipeline_name ORDER BY started_at DESC, id DESC) AS rn
FROM runs WHERE repo_id = ?
) WHERE rn <= ?
)
`, repoID, window.RunCount)
if err != nil {
return nil, fmt.Errorf("query repo usage: %w", err)
}
defer func() { _ = rows.Close() }()
var records []metrics.UsageRecord
for rows.Next() {
var (
pipelineName string
startedAt, completedAt sql.NullTime
)
if err := rows.Scan(&pipelineName, &startedAt, &completedAt); err != nil {
return nil, fmt.Errorf("scan usage job: %w", err)
}
if !startedAt.Valid || !completedAt.Valid {
continue
}
records = append(records, metrics.UsageRecord{
PipelineName: pipelineName,
ExecSeconds: completedAt.Time.Sub(startedAt.Time).Seconds(),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate repo usage: %w", err)
}
return records, nil
}
package settings
import (
"context"
"fmt"
"slices"
)
// Service resolves stored overrides against documented defaults, and
// validates an update before it ever reaches Store.
type Service struct {
store Store
}
// NewService returns a Service backed by store.
func NewService(store Store) *Service {
return &Service{store: store}
}
// Get returns userID's settings, every field resolved against its
// documented default.
func (s *Service) Get(ctx context.Context, userID string) (Settings, error) {
raw, err := s.store.Get(ctx, userID)
if err != nil {
return Settings{}, fmt.Errorf("get settings: %w", err)
}
return resolve(raw), nil
}
// Update validates every key/value in updates, then applies all of them in
// one Patch call if -- and only if -- every one is valid; an invalid key or
// value rejects the whole request, leaving every previously stored value
// unchanged (account-settings/spec.md's "Invalid value is rejected").
func (s *Service) Update(ctx context.Context, userID string, updates map[string]*string) (Settings, error) {
for key, value := range updates {
if !slices.Contains(Keys, key) {
return Settings{}, fmt.Errorf("%w: %q", ErrInvalidKey, key)
}
if value == nil {
continue
}
if allowed, hasEnum := enumValues[key]; hasEnum && !slices.Contains(allowed, *value) {
return Settings{}, fmt.Errorf("%w: %q for %q", ErrInvalidValue, *value, key)
}
if key == KeyPipelinesRepoSelector && len(*value) > MaxRepoSelectorLength {
return Settings{}, fmt.Errorf("%w: %s is longer than %d", ErrInvalidValue, key, MaxRepoSelectorLength)
}
}
raw, err := s.store.Patch(ctx, userID, updates)
if err != nil {
return Settings{}, fmt.Errorf("patch settings: %w", err)
}
return resolve(raw), nil
}
// ResetPipelinesFilters clears the three Pipelines-page filter settings
// back to their defaults in one request, leaving every other setting
// (theme, forge filter) untouched -- account-settings/spec.md's "Reset
// Pipelines filters to defaults".
func (s *Service) ResetPipelinesFilters(ctx context.Context, userID string) (Settings, error) {
updates := make(map[string]*string, len(PipelinesFilterKeys))
for _, key := range PipelinesFilterKeys {
updates[key] = nil
}
return s.Update(ctx, userID, updates)
}
func resolve(raw map[string]string) Settings {
def := Defaults()
return Settings{
Theme: valueOr(raw, KeyTheme, def.Theme),
ForgeFilter: valueOr(raw, KeyForgeFilter, def.ForgeFilter),
PipelinesHealthFilter: valueOr(raw, KeyPipelinesHealthFilter, def.PipelinesHealthFilter),
PipelinesRepoSelector: valueOr(raw, KeyPipelinesRepoSelector, def.PipelinesRepoSelector),
PipelinesSortOrder: valueOr(raw, KeyPipelinesSortOrder, def.PipelinesSortOrder),
TelemetryWindow: valueOr(raw, KeyTelemetryWindow, def.TelemetryWindow),
}
}
func valueOr(raw map[string]string, key, def string) string {
if v, ok := raw[key]; ok {
return v
}
return def
}
// Package settings holds the account-settings domain: the dashboard
// account's persisted UI preferences (theme and the Pipelines/Repos page
// filters). See
// openspec/changes/persist-account-settings/specs/account-settings/spec.md.
package settings
import (
"context"
"errors"
"slices"
)
// The setting keys, as stored in the JSON blob and used as PATCH request
// body keys -- see Store's doc comment for why a raw map keyed by these
// rather than a struct with pointer fields.
const (
KeyTheme = "theme"
KeyForgeFilter = "forgeFilter"
KeyPipelinesHealthFilter = "pipelinesHealthFilter"
KeyPipelinesRepoSelector = "pipelinesRepoSelector"
KeyPipelinesSortOrder = "pipelinesSortOrder"
KeyTelemetryWindow = "telemetryWindow"
)
// Keys lists every known setting key, in a stable order -- for validating a
// PATCH request's keys and for the "reset Pipelines filters" convenience
// that clears exactly the Pipelines-prefixed ones.
var Keys = []string{
KeyTheme,
KeyForgeFilter,
KeyPipelinesHealthFilter,
KeyPipelinesRepoSelector,
KeyPipelinesSortOrder,
KeyTelemetryWindow,
}
// PipelinesFilterKeys are the three settings "reset filters" clears in one
// request -- account-settings/spec.md's "Reset Pipelines filters to
// defaults".
var PipelinesFilterKeys = []string{
KeyPipelinesHealthFilter,
KeyPipelinesRepoSelector,
KeyPipelinesSortOrder,
}
// The documented default for each setting, resolved server-side whenever a
// key has never been explicitly set for the account (account-settings/
// spec.md's "Settings defaults"). PipelinesRepoSelector's default ("all")
// is a sentinel meaning "every tracked repo", not a real repo ID.
const (
DefaultTheme = "system"
DefaultForgeFilter = "all"
DefaultPipelinesHealthFilter = "unhealthy"
DefaultPipelinesRepoSelector = "all"
DefaultPipelinesSortOrder = "name"
DefaultTelemetryWindow = "7d"
)
// enumValues holds the valid values for every setting that has a closed
// set -- PipelinesRepoSelector is deliberately absent: it's a repo ID (or
// "all"), which this package has no way to validate against the set of
// currently-tracked repos without a store dependency this domain doesn't
// otherwise need.
var enumValues = map[string][]string{
KeyTheme: {"light", "dark", "system"},
KeyForgeFilter: {"all", "github", "forgejo"},
KeyPipelinesHealthFilter: {"all", "healthy", "unhealthy"},
KeyPipelinesSortOrder: {"name", "lastRun"},
KeyTelemetryWindow: {"24h", "7d", "30d"},
}
// AllowedValues lists the values a closed setting accepts, in a stable order,
// or none for a setting with no closed set. The slice is a copy, so a caller
// cannot change what the package accepts.
func AllowedValues(key string) []string {
return slices.Clone(enumValues[key])
}
// MaxRepoSelectorLength bounds the one free-text setting, a repo id or "all",
// so a caller can't store an arbitrarily large value (the OpenAPI spec's
// SettingsUpdate.pipelinesRepoSelector maxLength).
const MaxRepoSelectorLength = 128
// ErrInvalidKey is returned by Update for a key outside Keys.
var ErrInvalidKey = errors.New("unrecognized setting key")
// ErrInvalidValue is returned by Update for a value outside its setting's
// documented set (account-settings/spec.md's "Invalid value is rejected").
var ErrInvalidValue = errors.New("invalid setting value")
// Settings is every setting, fully resolved: a stored override merged onto
// its documented default, so a field here is never ambiguous between
// "unset" and "empty".
type Settings struct {
Theme string
ForgeFilter string
PipelinesHealthFilter string
PipelinesRepoSelector string
PipelinesSortOrder string
TelemetryWindow string
}
// Defaults returns the documented default for every setting, the values a
// cleared key resolves to.
func Defaults() Settings {
return Settings{
Theme: DefaultTheme,
ForgeFilter: DefaultForgeFilter,
PipelinesHealthFilter: DefaultPipelinesHealthFilter,
PipelinesRepoSelector: DefaultPipelinesRepoSelector,
PipelinesSortOrder: DefaultPipelinesSortOrder,
TelemetryWindow: DefaultTelemetryWindow,
}
}
// Store is the port account settings are persisted through. It works in
// terms of the raw, possibly-partial override map (only explicitly-set
// keys present) rather than a resolved Settings struct, so it -- and the
// wire format -- have no opinion on defaults; Service owns default
// resolution.
type Store interface {
// Get returns userID's stored overrides. A key absent from the result
// has never been set (or was cleared) and should fall back to its
// default. A user with no settings row yet returns an empty map, not
// an error.
Get(ctx context.Context, userID string) (map[string]string, error)
// Patch applies updates to userID's stored overrides: a nil value
// clears that key (reverting it to its default on the next Get), a
// non-nil value sets it. Returns the full resulting override map.
Patch(ctx context.Context, userID string, updates map[string]*string) (map[string]string, error)
}
// Package sqlite is the SQLite-backed adapter for the settings.Store port.
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
)
// Store implements settings.Store against a SQLite database.
type Store struct {
db *sql.DB
}
// NewStore returns a Store.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Get implements settings.Store.
func (s *Store) Get(ctx context.Context, userID string) (map[string]string, error) {
var data []byte
err := s.db.QueryRowContext(ctx, "SELECT data FROM account_settings WHERE user_id = ?", userID).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return map[string]string{}, nil
}
if err != nil {
return nil, fmt.Errorf("query account settings: %w", err)
}
return decode(data)
}
// Patch implements settings.Store.
func (s *Store) Patch(ctx context.Context, userID string, updates map[string]*string) (map[string]string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
var existing []byte
err = tx.QueryRowContext(ctx, "SELECT data FROM account_settings WHERE user_id = ?", userID).Scan(&existing)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("query account settings: %w", err)
}
overrides, err := decode(existing)
if err != nil {
return nil, err
}
for key, value := range updates {
if value == nil {
delete(overrides, key)
} else {
overrides[key] = *value
}
}
encoded, err := json.Marshal(overrides)
if err != nil {
return nil, fmt.Errorf("encode account settings: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO account_settings (user_id, data, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`, userID, encoded)
if err != nil {
return nil, fmt.Errorf("upsert account settings: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit account settings: %w", err)
}
return overrides, nil
}
// decode parses a stored settings blob, treating both SQL NULL (no row
// yet) and an empty column as no overrides at all, rather than a JSON
// error.
func decode(data []byte) (map[string]string, error) {
if len(data) == 0 {
return map[string]string{}, nil
}
var overrides map[string]string
if err := json.Unmarshal(data, &overrides); err != nil {
return nil, fmt.Errorf("decode account settings: %w", err)
}
if overrides == nil {
overrides = map[string]string{}
}
return overrides, nil
}
// Package webassets embeds the built SvelteKit frontend into the Go binary.
// web/'s vite.config.ts points adapter-static's output straight at dist/
// here (go:embed can't reach outside its own package's directory), so
// `bun run build` in web/ must run before this package's dist/ has the
// real frontend in it -- see dist/.gitkeep for what's there otherwise.
package webassets
import (
"embed"
"fmt"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// FS returns the embedded frontend's static files, rooted at dist/ so
// paths read e.g. "index.html" rather than "dist/index.html".
func FS() (fs.FS, error) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil, fmt.Errorf("root embedded frontend at dist: %w", err)
}
return sub, nil
}