From 448254001a4169991332b332ba5463ab1b609abf Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 29 May 2026 08:09:21 +0100 Subject: [PATCH] feat(stats): split repos public/private + 7-day daily commit series Collector now reports repos_public/repos_private (repos kept as the sum) and a commits_daily[] series of 7 {date,commits} buckets (UTC days, zero-filled), bucketed in Go from raw created_unix to avoid MySQL session-timezone ambiguity. commits_7d is now the sum of those buckets so the card total matches the sparkline. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 ++++++++++++++++++--- stats/main.go | 80 +++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b36b7c5..c3bb394 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,33 @@ A tiny Go service that periodically snapshots Gitea instance metrics from a metrics card. No HTTP endpoint is exposed to browsers; a local-only `/healthz` is provided for ops. -Metrics: repositories, claw agents (non-admin users), commits in the last -7 days, pull requests, merged pull requests. +Metrics: repositories (split public/private), claw agents (non-admin +users), commits in the last 7 days (total plus a per-day series over the +last 7 UTC calendar days), pull requests, merged pull requests. + +### Output schema (`stats.json`) + +```json +{ + "repos": 42, + "repos_public": 30, + "repos_private": 12, + "agents": 7, + "commits_7d": 91, + "commits_daily": [ + {"date": "2026-05-22", "commits": 10}, + {"date": "2026-05-23", "commits": 14} + // … 7 entries total, oldest → newest (UTC days) + ], + "prs": 18, + "merged": 12, + "generated_at": "2026-05-28T00:00:00Z", + "interval_hours": 12 +} +``` + +`commits_7d` equals the sum of `commits_daily` (7 calendar days, UTC), so +the card total always matches the sparkline. ### Configuration (all via environment variables — no secrets in the image) @@ -31,7 +56,7 @@ refresh it keeps the previous output (stale-while-error). ```sh # from repo root -IMAGE=git.hangman-lab.top/hzhang/gitea-stats TAG=0.1.0 ./publish.sh +IMAGE=git.hangman-lab.top/hzhang/gitea-stats TAG=0.2.0 ./publish.sh ``` ### Run @@ -42,7 +67,7 @@ docker run -d --name git-kc-stats \ -e STATS_DB_DSN='gitea_ro:***@tcp(mysql:3306)/giteadb?timeout=5s&readTimeout=10s&loc=UTC' \ -e STATS_INTERVAL=12h \ -v /var/lib/gitea/custom/public/assets:/out \ - git.hangman-lab.top/hzhang/gitea-stats:0.1.0 + git.hangman-lab.top/hzhang/gitea-stats:0.2.0 ``` > The read-only MySQL user (`gitea_ro`, `SELECT` only on `giteadb`) and its diff --git a/stats/main.go b/stats/main.go index ebb2689..8d2bb34 100644 --- a/stats/main.go +++ b/stats/main.go @@ -20,14 +20,23 @@ import ( _ "github.com/go-sql-driver/mysql" ) +// DailyCommit is one bucket of the last-7-days commit series (UTC days). +type DailyCommit struct { + Date string `json:"date"` // YYYY-MM-DD (UTC) + Commits int64 `json:"commits"` +} + type Stats struct { - Repos int64 `json:"repos"` - Agents int64 `json:"agents"` - Commits7d int64 `json:"commits_7d"` - PRs int64 `json:"prs"` - Merged int64 `json:"merged"` - GeneratedAt string `json:"generated_at"` - IntervalH int `json:"interval_hours"` + Repos int64 `json:"repos"` // public + private + ReposPublic int64 `json:"repos_public"` + ReposPrivate int64 `json:"repos_private"` + Agents int64 `json:"agents"` + Commits7d int64 `json:"commits_7d"` // sum of CommitsDaily + CommitsDaily []DailyCommit `json:"commits_daily"` + PRs int64 `json:"prs"` + Merged int64 `json:"merged"` + GeneratedAt string `json:"generated_at"` + IntervalH int `json:"interval_hours"` } var ( @@ -56,18 +65,59 @@ func collect(dsn string, interval time.Duration) (*Stats, error) { var s Stats err = db.QueryRowContext(ctx, ` SELECT - (SELECT COUNT(*) FROM repository), + (SELECT COUNT(*) FROM repository WHERE is_private=0), + (SELECT COUNT(*) FROM repository WHERE is_private=1), (SELECT COUNT(*) FROM user WHERE type=0 AND is_admin=0), - (SELECT COALESCE(SUM(CASE WHEN JSON_VALID(content) - THEN COALESCE(JSON_LENGTH(content,'$.Commits'),0) ELSE 0 END),0) - FROM action - WHERE op_type=5 AND created_unix > UNIX_TIMESTAMP()-604800), (SELECT COUNT(*) FROM pull_request), (SELECT COUNT(*) FROM pull_request WHERE has_merged=1) -`).Scan(&s.Repos, &s.Agents, &s.Commits7d, &s.PRs, &s.Merged) +`).Scan(&s.ReposPublic, &s.ReposPrivate, &s.Agents, &s.PRs, &s.Merged) if err != nil { return nil, err } + s.Repos = s.ReposPublic + s.ReposPrivate + + // Per-day commit counts for the last 7 calendar days (UTC), oldest first. + // We pull raw (created_unix, commit_count) rows and bucket them in Go to + // avoid any dependence on the MySQL session time zone in date functions. + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + start := today.AddDate(0, 0, -6) // 7 buckets: start .. today + startUnix := start.Unix() + + rows, err := db.QueryContext(ctx, ` +SELECT created_unix, + CASE WHEN JSON_VALID(content) + THEN COALESCE(JSON_LENGTH(content,'$.Commits'),0) ELSE 0 END + FROM action + WHERE op_type=5 AND created_unix >= ?`, startUnix) + if err != nil { + return nil, err + } + defer rows.Close() + + daily := make([]int64, 7) + for rows.Next() { + var cu, c int64 + if err := rows.Scan(&cu, &c); err != nil { + return nil, err + } + if idx := int((cu - startUnix) / 86400); idx >= 0 && idx < 7 { + daily[idx] += c + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + s.CommitsDaily = make([]DailyCommit, 7) + for i := 0; i < 7; i++ { + s.CommitsDaily[i] = DailyCommit{ + Date: start.AddDate(0, 0, i).Format("2006-01-02"), + Commits: daily[i], + } + s.Commits7d += daily[i] + } + s.GeneratedAt = time.Now().UTC().Format(time.RFC3339) s.IntervalH = int(interval / time.Hour) return &s, nil @@ -99,8 +149,8 @@ func refresh(dsn, out string, interval time.Duration) { mu.Lock() last = s mu.Unlock() - log.Printf("ok repos=%d agents=%d commits_7d=%d prs=%d merged=%d", - s.Repos, s.Agents, s.Commits7d, s.PRs, s.Merged) + log.Printf("ok repos=%d (pub=%d priv=%d) agents=%d commits_7d=%d prs=%d merged=%d", + s.Repos, s.ReposPublic, s.ReposPrivate, s.Agents, s.Commits7d, s.PRs, s.Merged) } func main() {