Activity graphs on owner pages !35

merged merged by cmc on 2026-08-25 03:10 UTC · krz/gitbay:activity-graph into main

15 files changed, +433 −9

Layout: unified · split

cmd/gitbayd/adminusers.go +51
@@ -6,6 +6,8 @@ import (
6 "github.com/spf13/cobra" 6 "github.com/spf13/cobra"
7 7
8 "gitbay.org/gitbay/internal/config" 8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/store" 11 "gitbay.org/gitbay/internal/store"
10) 12)
11 13
@@ -84,6 +86,55 @@ func adminMigrateCommitRefsCmd() *cobra.Command {
84 } 86 }
85} 87}
86 88
89// adminBackfillActivityCmd walks every repo's default branch and records
90// commit activity for commits authored by verified addresses. Idempotent:
91// (repo, sha) dedup makes re-runs free. Imported history keeps its real
92// author dates, so migrated repos light up their actual timeline.
93func adminBackfillActivityCmd() *cobra.Command {
94 var perRepo int
95 cmd := &cobra.Command{
96 Use: "backfill-activity",
97 Short: "record commit activity for existing default-branch history",
98 RunE: func(cmd *cobra.Command, args []string) error {
99 cfg, err := config.Load(configPath)
100 if err != nil {
101 return err
102 }
103 st, err := openStore(cfg)
104 if err != nil {
105 return err
106 }
107 defer st.Close()
108 repos, err := st.ListAllRepos()
109 if err != nil {
110 return err
111 }
112 total := 0
113 for _, r := range repos {
114 dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)
115 authors, err := gitutil.RevListAuthors(dir, "", r.DefaultBranch, perRepo)
116 if err != nil {
117 continue // empty repo or missing branch
118 }
119 n := 0
120 for _, a := range authors {
121 if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
122 if st.RecordCommitActivity(r.ID, a.SHA, uid, a.Day) {
123 n++
124 }
125 }
126 }
127 total += n
128 fmt.Printf("%s\t%d attributed\n", r.Path(), n)
129 }
130 fmt.Printf("total\t%d commits recorded\n", total)
131 return nil
132 },
133 }
134 cmd.Flags().IntVar(&perRepo, "per-repo", 20000, "max commits walked per repository")
135 return cmd
136}
137
87func adminAuditCmd() *cobra.Command { 138func adminAuditCmd() *cobra.Command {
88 var limit int 139 var limit int
89 cmd := &cobra.Command{ 140 cmd := &cobra.Command{
cmd/gitbayd/main.go +1
@@ -257,6 +257,7 @@ func adminCmd() *cobra.Command {
257 statsCmd(), 257 statsCmd(),
258 adminAuditCmd(), 258 adminAuditCmd(),
259 adminMigrateCommitRefsCmd(), 259 adminMigrateCommitRefsCmd(),
260 adminBackfillActivityCmd(),
260 ) 261 )
261 return admin 262 return admin
262} 263}
e2e/activity_test.go added +121
@@ -0,0 +1,121 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "regexp"
7 "slices"
8 "strconv"
9 "strings"
10 "testing"
11)
12
13func TestActivityGraph(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 // bob's email is NOT verified: his commits must not attribute.
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
21
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatal("repo create failed")
24 }
25 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
26 t.Fatal("grant failed")
27 }
28
29 work := t.TempDir()
30 env := inst.gitEnv(aliceKey)
31 // gitEnv pins GIT_AUTHOR_EMAIL; author identity comes from the env.
32 // Clone before appending: sharing env's backing array would let the
33 // second append clobber the first.
34 aliceEnv := append(slices.Clone(env), "GIT_AUTHOR_EMAIL=alice@example.test", "GIT_AUTHOR_NAME=Alice")
35 bobEnv := append(slices.Clone(env), "GIT_AUTHOR_EMAIL=bob@nowhere.test", "GIT_AUTHOR_NAME=Bob")
36 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
37 dir := filepath.Join(work, "w")
38 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
39 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
40 mustGit(t, dir, env, "add", ".")
41 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "one")
42 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
43 mustGit(t, dir, env, "add", ".")
44 mustGit(t, dir, bobEnv, "commit", "-q", "-m", "two")
45 mustGit(t, dir, env, "push", "-q", "origin", "main")
46
47 // Today's cell counts alice's commit; bob's unverified authorship
48 // contributes nothing and issue creation counts as an event.
49 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 {
50 t.Fatal("issue create failed")
51 }
52 status, body := inst.get(t, "/alice")
53 if status != 200 || !strings.Contains(body, `class="actgraph"`) {
54 t.Fatalf("graph missing: %d", status)
55 }
56 // alice: 1 attributed commit + events (issue.created, ...). The exact
57 // cells depend on author-date timezone vs event UTC, so assert the
58 // year total instead.
59 total := activityTotal(t, body)
60 if total < 2 {
61 t.Fatalf("alice total = %d, want >= 2", total)
62 }
63 // bob authored a commit but his email is unverified: zero activity.
64 _, bobBody := inst.get(t, "/bob")
65 if bt := activityTotal(t, bobBody); bt != 0 {
66 t.Fatalf("unverified author got credit: total %d", bt)
67 }
68
69 // Re-pushing the same history (force) does not double-count.
70 mustGit(t, dir, env, "push", "-q", "--force", "origin", "main")
71 _, body2 := inst.get(t, "/alice")
72 if body2 != body {
73 // Counts must be identical; compare just the graph cells.
74 if excerpt(body, "actgraph") != excerpt(body2, "actgraph") {
75 t.Fatal("re-push changed activity counts")
76 }
77 }
78
79 // Org pages aggregate their repos' activity.
80 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "theorg"); code != 0 {
81 t.Fatal("org create failed")
82 }
83 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 {
84 t.Fatal("transfer failed")
85 }
86 _, orgBody := inst.get(t, "/theorg")
87 if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") {
88 t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity"))
89 }
90
91 // Backfill is idempotent and attributes only verified authors.
92 out := inst.admin(t, "admin", "backfill-activity")
93 if !strings.Contains(out, "theorg/app\t0 attributed") {
94 t.Fatalf("backfill re-attributed existing commits: %s", out)
95 }
96}
97
98// excerpt returns ~600 bytes around the first occurrence of marker.
99func excerpt(s, marker string) string {
100 i := strings.Index(s, marker)
101 if i < 0 {
102 return "(marker missing)"
103 }
104 end := i + 600
105 if end > len(s) {
106 end = len(s)
107 }
108 return s[i:end]
109}
110
111var totalPat = regexp.MustCompile(`([0-9]+) in the last year`)
112
113func activityTotal(t *testing.T, body string) int {
114 t.Helper()
115 m := totalPat.FindStringSubmatch(body)
116 if m == nil {
117 t.Fatal("activity total missing from page")
118 }
119 n, _ := strconv.Atoi(m[1])
120 return n
121}
internal/control/commitrefs.go +16
@@ -56,6 +56,22 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
56 } 56 }
57} 57}
58 58
59// RecordLandedCommits attributes commits that just landed on the default
60// branch to accounts by verified author email, for the activity graph.
61// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
62// authors are simply not activity.
63func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new string) {
64 authors, err := gitutil.RevListAuthors(dir, old, new, maxMessageCommits)
65 if err != nil {
66 return
67 }
68 for _, a := range authors {
69 if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
70 st.RecordCommitActivity(repo.ID, a.SHA, uid, a.Day)
71 }
72 }
73}
74
59func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) { 75func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) {
60 issue, err := st.IssueByNumber(repo.ID, number) 76 issue, err := st.IssueByNumber(repo.ID, number)
61 if err != nil { 77 if err != nil {
internal/control/mr.go +2
@@ -534,6 +534,7 @@ func runMRComment(c *Ctx, args []string) int {
534 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil { 534 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
535 return c.fail(protocol.ExitFailure, "%v", err) 535 return c.fail(protocol.ExitFailure, "%v", err)
536 } 536 }
537 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
537 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 538 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
538 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 539 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
539 notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) 540 notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
@@ -851,6 +852,7 @@ func runMRMerge(c *Ctx, args []string) int {
851 // (closes #N, references) run here for the newly landed commits. 852 // (closes #N, references) run here for the newly landed commits.
852 if mr.TargetRef == repo.DefaultBranch { 853 if mr.TargetRef == repo.DefaultBranch {
853 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) 854 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
855 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
854 } 856 }
855 c.Store.MarkMirrorsDirty(repo.ID, "push") 857 c.Store.MarkMirrorsDirty(repo.ID, "push")
856 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 858 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
internal/gitutil/messages.go +29
@@ -8,6 +8,35 @@ import (
8 8
9const zeroSHA = "0000000000000000000000000000000000000000" 9const zeroSHA = "0000000000000000000000000000000000000000"
10 10
11type CommitAuthor struct {
12 SHA string
13 Email string
14 Day string // author date, YYYY-MM-DD
15}
16
17// RevListAuthors returns sha, author email, and author date for commits
18// reachable from new but not old, capped at max. Empty or zero old lists
19// from new alone.
20func RevListAuthors(dir, old, new string, max int) ([]CommitAuthor, error) {
21 args := []string{"-C", dir, "log", fmt.Sprintf("--max-count=%d", max), "--format=%H%x00%ae%x00%as", new}
22 if old != "" && old != zeroSHA {
23 args = append(args, "^"+old)
24 }
25 out, err := exec.Command("git", args...).Output()
26 if err != nil {
27 return nil, fmt.Errorf("rev-list authors: %w", err)
28 }
29 var authors []CommitAuthor
30 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
31 parts := strings.SplitN(line, "\x00", 3)
32 if len(parts) != 3 {
33 continue
34 }
35 authors = append(authors, CommitAuthor{SHA: parts[0], Email: parts[1], Day: parts[2]})
36 }
37 return authors, nil
38}
39
11// Parents returns a commit's parent shas. 40// Parents returns a commit's parent shas.
12func Parents(dir, sha string) []string { 41func Parents(dir, sha string) []string {
13 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%P", sha).Output() 42 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%P", sha).Output()
internal/hookd/hookd.go +1
@@ -182,6 +182,7 @@ func (s *Server) postReceive(req Request) {
182 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete { 182 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
183 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name) 183 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
184 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New) 184 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
185 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
185 } 186 }
186 // Any branch/tag update schedules the push mirrors. 187 // Any branch/tag update schedules the push mirrors.
187 s.st.MarkMirrorsDirty(req.RepoID, "push") 188 s.st.MarkMirrorsDirty(req.RepoID, "push")
internal/httpd/accounts.go +3
@@ -304,6 +304,7 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
304 http.Error(w, "internal error", http.StatusInternalServerError) 304 http.Error(w, "internal error", http.StatusInternalServerError)
305 return 305 return
306 } 306 }
307 s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
307 // Labels need write access, matching the SSH rule; ignored otherwise. 308 // Labels need write access, matching the SSH rule; ignored otherwise.
308 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 { 309 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
309 grant, _ := s.st.AccessRole(repo.ID, u.ID) 310 grant, _ := s.st.AccessRole(repo.ID, u.ID)
@@ -411,6 +412,7 @@ func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u st
411 http.Error(w, "internal error", http.StatusInternalServerError) 412 http.Error(w, "internal error", http.StatusInternalServerError)
412 return 413 return
413 } 414 }
415 s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n))
414 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) 416 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
415} 417}
416 418
@@ -434,6 +436,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
434 http.Error(w, "internal error", http.StatusInternalServerError) 436 http.Error(w, "internal error", http.StatusInternalServerError)
435 return 437 return
436 } 438 }
439 s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n))
437 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther) 440 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
438} 441}
439 442
internal/httpd/activity.go added +64
@@ -0,0 +1,64 @@
1package httpd
2
3import "time"
4
5// activityDay is one cell of the graph; Level buckets Count into the five
6// intensity classes the stylesheet colors.
7type activityDay struct {
8 Date string
9 Count int
10 Level int // 0..4
11 Pad bool // before the range start / after today
12}
13
14type activityWeek []activityDay // 7 days, Sunday first
15
16// activityGrid lays a day->count map into 53 week columns ending today,
17// GitHub-style: columns are weeks, rows Sunday..Saturday.
18func activityGrid(counts map[string]int) ([]activityWeek, int) {
19 today := time.Now().UTC()
20 // End the grid on the Saturday of the current week.
21 end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
22 start := end.AddDate(0, 0, -53*7+1) // a Sunday, 53 columns back
23
24 total := 0
25 var weeks []activityWeek
26 for d := start; !d.After(end); d = d.AddDate(0, 0, 7) {
27 var week activityWeek
28 for i := 0; i < 7; i++ {
29 day := d.AddDate(0, 0, i)
30 key := day.Format("2006-01-02")
31 if day.After(today) {
32 week = append(week, activityDay{Date: key, Pad: true})
33 continue
34 }
35 n := counts[key]
36 total += n
37 week = append(week, activityDay{Date: key, Count: n, Level: activityLevel(n)})
38 }
39 weeks = append(weeks, week)
40 }
41 return weeks, total
42}
43
44func activityLevel(n int) int {
45 switch {
46 case n == 0:
47 return 0
48 case n <= 2:
49 return 1
50 case n <= 5:
51 return 2
52 case n <= 9:
53 return 3
54 default:
55 return 4
56 }
57}
58
59// activitySince is the first day the grid can show, for the query bound.
60func activitySince() string {
61 today := time.Now().UTC()
62 end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
63 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
64}
internal/httpd/web.go +20 −9
@@ -326,16 +326,27 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
326 visible = append(visible, repo) 326 visible = append(visible, repo)
327 } 327 }
328 } 328 }
329 var counts map[string]int
330 if kind == "user" {
331 counts, _ = s.st.ActivityByDay(ownerID, activitySince())
332 } else {
333 counts, _ = s.st.OrgActivityByDay(ownerID, activitySince())
334 }
335 weeks, activityTotal := activityGrid(counts)
336
329 s.render(w, "owner.html", struct { 337 s.render(w, "owner.html", struct {
330 Site string 338 Site string
331 Viewer string 339 Viewer string
332 Owner string 340 Owner string
333 Kind string 341 Kind string
334 Profile store.Profile 342 Profile store.Profile
335 Repos []describedRepo 343 Repos []describedRepo
336 Members []store.OrgMember 344 Members []store.OrgMember
337 Orgs []store.OrgMember 345 Orgs []store.OrgMember
338 }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs}) 346 Activity []activityWeek
347 ActivityTotal int
348 }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs,
349 weeks, activityTotal})
339} 350}
340 351
341func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 352func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/store/activity.go added +88
@@ -0,0 +1,88 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// UserIDByVerifiedEmail resolves a commit author email to an account, only
9// through addresses the account has verified — the same trust rule as
10// signature attribution.
11func (s *Store) UserIDByVerifiedEmail(address string) (int64, bool) {
12 var id int64
13 err := s.DB.QueryRow(
14 "SELECT user_id FROM emails WHERE address = ? AND verified_at IS NOT NULL", address).Scan(&id)
15 if errors.Is(err, sql.ErrNoRows) || err != nil {
16 return 0, false
17 }
18 return id, true
19}
20
21// RecordCommitActivity is idempotent per (repo, sha); it reports whether
22// this call recorded a new row.
23func (s *Store) RecordCommitActivity(repoID int64, sha string, userID int64, day string) bool {
24 res, err := s.DB.Exec(
25 "INSERT INTO commit_activity (repo_id, sha, user_id, day) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING",
26 repoID, sha, userID, day)
27 if err != nil {
28 return false
29 }
30 n, _ := res.RowsAffected()
31 return n > 0
32}
33
34// ActivityByDay aggregates a user's activity per day since the given day:
35// commits landed on default branches plus everything the events table
36// attributes to them (issues, MRs, comments, releases, pushes).
37func (s *Store) ActivityByDay(userID int64, sinceDay string) (map[string]int, error) {
38 rows, err := s.DB.Query(`
39 SELECT day, COUNT(*) FROM (
40 SELECT day FROM commit_activity WHERE user_id = ?1 AND day >= ?2
41 UNION ALL
42 SELECT date(created_at) AS day FROM events
43 WHERE actor_id = ?1 AND date(created_at) >= ?2 AND kind <> 'push'
44 ) GROUP BY day`, userID, sinceDay)
45 if err != nil {
46 return nil, err
47 }
48 defer rows.Close()
49 out := map[string]int{}
50 for rows.Next() {
51 var day string
52 var n int
53 if err := rows.Scan(&day, &n); err != nil {
54 return nil, err
55 }
56 out[day] = n
57 }
58 return out, rows.Err()
59}
60
61// OrgActivityByDay aggregates activity across an org's repositories.
62func (s *Store) OrgActivityByDay(orgID int64, sinceDay string) (map[string]int, error) {
63 rows, err := s.DB.Query(`
64 SELECT day, COUNT(*) FROM (
65 SELECT ca.day FROM commit_activity ca
66 JOIN repos r ON r.id = ca.repo_id
67 WHERE r.owner_kind = 'org' AND r.owner_id = ?1 AND ca.day >= ?2
68 UNION ALL
69 SELECT date(e.created_at) AS day FROM events e
70 JOIN repos r ON r.id = e.repo_id
71 WHERE r.owner_kind = 'org' AND r.owner_id = ?1
72 AND date(e.created_at) >= ?2 AND e.kind <> 'push'
73 ) GROUP BY day`, orgID, sinceDay)
74 if err != nil {
75 return nil, err
76 }
77 defer rows.Close()
78 out := map[string]int{}
79 for rows.Next() {
80 var day string
81 var n int
82 if err := rows.Scan(&day, &n); err != nil {
83 return nil, err
84 }
85 out[day] = n
86 }
87 return out, rows.Err()
88}
internal/store/migrations/0021_activity.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE commit_activity;
internal/store/migrations/0021_activity.up.sql added +12
@@ -0,0 +1,12 @@
1-- Commits as an activity signal: recorded when they land on the default
2-- branch, attributed by verified author email, deduped by sha so rebases
3-- and re-pushes never double-count. day is the author date (YYYY-MM-DD),
4-- so imported history keeps its real timeline.
5CREATE TABLE commit_activity (
6 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7 sha TEXT NOT NULL,
8 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
9 day TEXT NOT NULL,
10 PRIMARY KEY (repo_id, sha)
11);
12CREATE INDEX commit_activity_user ON commit_activity(user_id, day);
internal/web/static/style.css +16
@@ -365,6 +365,22 @@ ul.repolist .meta { margin-top: var(--sp-1); }
365.profilehead .meta { margin: var(--sp-1) 0 0; } 365.profilehead .meta { margin: var(--sp-1) 0 0; }
366h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); } 366h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); }
367 367
368/* activity graph: 53 week columns x 7 day rows, accent-tinted levels */
369.actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); }
370.actgraph { display: flex; gap: 3px; width: max-content; }
371.actweek { display: flex; flex-direction: column; gap: 3px; }
372.actday {
373 width: 11px;
374 height: 11px;
375 border-radius: 2px;
376 background: var(--faint);
377}
378.actday.pad { background: transparent; }
379.actday.l1 { background: color-mix(in srgb, var(--accent) 25%, var(--bg)); }
380.actday.l2 { background: color-mix(in srgb, var(--accent) 50%, var(--bg)); }
381.actday.l3 { background: color-mix(in srgb, var(--accent) 75%, var(--bg)); }
382.actday.l4 { background: var(--accent); }
383
368/* heading row with actions on the right */ 384/* heading row with actions on the right */
369.headrow { 385.headrow {
370 display: flex; 386 display: flex;
internal/web/templates/owner.html +8
@@ -7,6 +7,14 @@
7{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} 7{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
8{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} 8{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
9</section> 9</section>
10<section class="activity">
11<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
12<div class="actgraph-scroll">
13<div class="actgraph">
14{{range .Activity}}<div class="actweek">{{range .}}<span class="actday{{if .Pad}} pad{{else}} l{{.Level}}{{end}}"{{if not .Pad}} title="{{.Count}} on {{.Date}}"{{end}}></span>{{end}}</div>{{end}}
15</div>
16</div>
17</section>
10<h2>repositories <span class="count">{{len .Repos}}</span></h2> 18<h2>repositories <span class="count">{{len .Repos}}</span></h2>
11<ul class="repolist"> 19<ul class="repolist">
12{{range .Repos}}{{template "reporow" .}} 20{{range .Repos}}{{template "reporow" .}}