Files
gitea-heatmap-sidecar/main.go
T
didericis e94d2f6481 Add repo names to heatmap tooltip
Extends the JSON response to include per-day repo names via
array_agg on action.repo_name, and surfaces them in the hover
tooltip below the contribution count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:24:55 -04:00

265 lines
7.3 KiB
Go

// Package main is a tiny HTTP service that exposes daily contribution counts
// and repo names for a single allowlisted Gitea user, including activity from
// private repos.
//
// It reads directly from the Gitea database (the `action` table) using a
// read-only DB user. Output is JSON:
// [{"date":"2026-05-04","count":3,"repos":["myrepo"]}, ...].
//
// Pair with templates/profile-snippet.tmpl on the Gitea side to render a
// GitHub-style heatmap with hover tooltips on a user profile page.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/lib/pq"
)
// ---- config ---------------------------------------------------------------
type config struct {
DatabaseURL string
AllowedUsers map[string]bool
AllowedOrigin string
Listen string
OpTypes string // comma-separated list of integer op_type values
WindowDays int
}
func loadConfig() (*config, error) {
c := &config{
DatabaseURL: os.Getenv("DATABASE_URL"),
AllowedOrigin: getEnv("ALLOWED_ORIGIN", "*"),
Listen: getEnv("LISTEN", ":8080"),
// Default: commit pushes (5), tag pushes (9), mirror sync push (18).
// See README for the full Gitea op_type reference and why these are
// the closest analog to "GitHub-style contributions".
OpTypes: getEnv("OP_TYPES", "5,9,18"),
WindowDays: 371, // 53 weeks * 7 days
}
if c.DatabaseURL == "" {
return nil, errors.New("DATABASE_URL is required")
}
users := strings.TrimSpace(os.Getenv("ALLOWED_USERS"))
if users == "" {
return nil, errors.New("ALLOWED_USERS is required (comma-separated lowercase usernames)")
}
c.AllowedUsers = map[string]bool{}
for _, u := range strings.Split(users, ",") {
u = strings.TrimSpace(strings.ToLower(u))
if u != "" {
c.AllowedUsers[u] = true
}
}
// Validate OP_TYPES contains only digits, commas, and whitespace. This is
// admin-controlled config but it gets interpolated into SQL, so be paranoid.
for _, r := range c.OpTypes {
if !((r >= '0' && r <= '9') || r == ',' || r == ' ') {
return nil, fmt.Errorf("OP_TYPES may only contain digits and commas, got: %q", c.OpTypes)
}
}
if strings.TrimSpace(strings.Trim(c.OpTypes, ", ")) == "" {
return nil, errors.New("OP_TYPES must contain at least one value")
}
return c, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ---- handler --------------------------------------------------------------
type heatmapHandler struct {
db *sql.DB
cfg *config
log *slog.Logger
}
type dayCount struct {
Date string `json:"date"`
Count int `json:"count"`
Repos []string `json:"repos"`
}
func (h *heatmapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Path: /heatmap/{username}.json (the .json suffix is optional)
username := strings.TrimPrefix(r.URL.Path, "/heatmap/")
username = strings.TrimSuffix(username, ".json")
username = strings.ToLower(strings.TrimSpace(username))
if username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
if !h.cfg.AllowedUsers[username] {
// 404, not 403, to avoid confirming whether a username exists.
http.Error(w, "not found", http.StatusNotFound)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
counts, err := h.queryHeatmap(ctx, username)
if err != nil {
h.log.Error("heatmap query failed", "user", username, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if counts == nil {
counts = []dayCount{}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", h.cfg.AllowedOrigin)
w.Header().Set("Vary", "Origin")
if err := json.NewEncoder(w).Encode(counts); err != nil {
h.log.Error("encode failed", "err", err)
}
}
func (h *heatmapHandler) queryHeatmap(ctx context.Context, username string) ([]dayCount, error) {
var userID int64
err := h.db.QueryRowContext(ctx,
`SELECT id FROM "user" WHERE lower_name = $1`,
username,
).Scan(&userID)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", username, err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -h.cfg.WindowDays).Unix()
// OP_TYPES is validated at config load to contain only digits and commas,
// so this fmt.Sprintf interpolation is safe. It's not a parameter because
// PostgreSQL doesn't accept lists as a single placeholder.
query := fmt.Sprintf(`
SELECT to_char(to_timestamp(created_unix) AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day,
COUNT(*)::int,
COALESCE(
array_agg(DISTINCT repo_name ORDER BY repo_name)
FILTER (WHERE repo_name IS NOT NULL AND repo_name != ''),
'{}'
) AS repos
FROM "action"
WHERE act_user_id = $1
AND created_unix >= $2
AND op_type IN (%s)
GROUP BY day
ORDER BY day
`, h.cfg.OpTypes)
rows, err := h.db.QueryContext(ctx, query, userID, cutoff)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
defer rows.Close()
var out []dayCount
for rows.Next() {
var d dayCount
if err := rows.Scan(&d.Date, &d.Count, pq.Array(&d.Repos)); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// ---- main -----------------------------------------------------------------
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := loadConfig()
if err != nil {
logger.Error("config error", "err", err)
os.Exit(1)
}
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
logger.Error("db open failed", "err", err)
os.Exit(1)
}
defer db.Close()
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(time.Hour)
pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pingCancel()
if err := db.PingContext(pingCtx); err != nil {
logger.Error("db ping failed", "err", err)
os.Exit(1)
}
h := &heatmapHandler{db: db, cfg: cfg, log: logger}
mux := http.NewServeMux()
mux.Handle("/heatmap/", h)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
http.Error(w, "db unhealthy", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("ok\n"))
})
srv := &http.Server{
Addr: cfg.Listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("listening",
"addr", cfg.Listen,
"users", len(cfg.AllowedUsers),
"op_types", cfg.OpTypes,
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
logger.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "err", err)
}
}