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) <noreply@anthropic.com>
This commit is contained in:
33
README.md
33
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
|
metrics card. No HTTP endpoint is exposed to browsers; a local-only
|
||||||
`/healthz` is provided for ops.
|
`/healthz` is provided for ops.
|
||||||
|
|
||||||
Metrics: repositories, claw agents (non-admin users), commits in the last
|
Metrics: repositories (split public/private), claw agents (non-admin
|
||||||
7 days, pull requests, merged pull requests.
|
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)
|
### Configuration (all via environment variables — no secrets in the image)
|
||||||
|
|
||||||
@@ -31,7 +56,7 @@ refresh it keeps the previous output (stale-while-error).
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# from repo root
|
# 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
|
### 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_DB_DSN='gitea_ro:***@tcp(mysql:3306)/giteadb?timeout=5s&readTimeout=10s&loc=UTC' \
|
||||||
-e STATS_INTERVAL=12h \
|
-e STATS_INTERVAL=12h \
|
||||||
-v /var/lib/gitea/custom/public/assets:/out \
|
-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
|
> The read-only MySQL user (`gitea_ro`, `SELECT` only on `giteadb`) and its
|
||||||
|
|||||||
@@ -20,10 +20,19 @@ import (
|
|||||||
_ "github.com/go-sql-driver/mysql"
|
_ "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 {
|
type Stats struct {
|
||||||
Repos int64 `json:"repos"`
|
Repos int64 `json:"repos"` // public + private
|
||||||
|
ReposPublic int64 `json:"repos_public"`
|
||||||
|
ReposPrivate int64 `json:"repos_private"`
|
||||||
Agents int64 `json:"agents"`
|
Agents int64 `json:"agents"`
|
||||||
Commits7d int64 `json:"commits_7d"`
|
Commits7d int64 `json:"commits_7d"` // sum of CommitsDaily
|
||||||
|
CommitsDaily []DailyCommit `json:"commits_daily"`
|
||||||
PRs int64 `json:"prs"`
|
PRs int64 `json:"prs"`
|
||||||
Merged int64 `json:"merged"`
|
Merged int64 `json:"merged"`
|
||||||
GeneratedAt string `json:"generated_at"`
|
GeneratedAt string `json:"generated_at"`
|
||||||
@@ -56,18 +65,59 @@ func collect(dsn string, interval time.Duration) (*Stats, error) {
|
|||||||
var s Stats
|
var s Stats
|
||||||
err = db.QueryRowContext(ctx, `
|
err = db.QueryRowContext(ctx, `
|
||||||
SELECT
|
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 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),
|
||||||
(SELECT COUNT(*) FROM pull_request WHERE has_merged=1)
|
(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 {
|
if err != nil {
|
||||||
return nil, err
|
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.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
s.IntervalH = int(interval / time.Hour)
|
s.IntervalH = int(interval / time.Hour)
|
||||||
return &s, nil
|
return &s, nil
|
||||||
@@ -99,8 +149,8 @@ func refresh(dsn, out string, interval time.Duration) {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
last = s
|
last = s
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
log.Printf("ok repos=%d agents=%d commits_7d=%d prs=%d merged=%d",
|
log.Printf("ok repos=%d (pub=%d priv=%d) agents=%d commits_7d=%d prs=%d merged=%d",
|
||||||
s.Repos, s.Agents, s.Commits7d, s.PRs, s.Merged)
|
s.Repos, s.ReposPublic, s.ReposPrivate, s.Agents, s.Commits7d, s.PRs, s.Merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user