Commit ac6714f569
Verified · cmc
cmd/gitbayd/adminusers.go +51
| @@ -6,6 +6,8 @@ import ( | ||
| 6 | 6 | "github.com/spf13/cobra" |
| 7 | 7 | |
| 8 | 8 | "gitbay.org/gitbay/internal/config" |
| 9 | "gitbay.org/gitbay/internal/control" | |
| 10 | "gitbay.org/gitbay/internal/gitutil" | |
| 9 | 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. | |
| 93 | func 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 | ||
| 87 | 138 | func adminAuditCmd() *cobra.Command { |
| 88 | 139 | var limit int |
| 89 | 140 | cmd := &cobra.Command{ |
cmd/gitbayd/main.go +1
| @@ -257,6 +257,7 @@ func adminCmd() *cobra.Command { | ||
| 257 | 257 | statsCmd(), |
| 258 | 258 | adminAuditCmd(), |
| 259 | 259 | adminMigrateCommitRefsCmd(), |
| 260 | adminBackfillActivityCmd(), | |
| 260 | 261 | ) |
| 261 | 262 | return admin |
| 262 | 263 | } |
e2e/activity_test.go added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "regexp" | |
| 7 | "slices" | |
| 8 | "strconv" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | ) | |
| 12 | ||
| 13 | func 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. | |
| 99 | func 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 | ||
| 111 | var totalPat = regexp.MustCompile(`([0-9]+) in the last year`) | |
| 112 | ||
| 113 | func 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. | |
| 63 | func 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 | ||
| 59 | 75 | func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) { |
| 60 | 76 | issue, err := st.IssueByNumber(repo.ID, number) |
| 61 | 77 | if err != nil { |
internal/control/mr.go +2
| @@ -534,6 +534,7 @@ func runMRComment(c *Ctx, args []string) int { | ||
| 534 | 534 | if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil { |
| 535 | 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 | 538 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 538 | 539 | notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), |
| 539 | 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 | 852 | // (closes #N, references) run here for the newly landed commits. |
| 852 | 853 | if mr.TargetRef == repo.DefaultBranch { |
| 853 | 854 | ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) |
| 855 | RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA) | |
| 854 | 856 | } |
| 855 | 857 | c.Store.MarkMirrorsDirty(repo.ID, "push") |
| 856 | 858 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
internal/gitutil/messages.go +29
| @@ -8,6 +8,35 @@ import ( | ||
| 8 | 8 | |
| 9 | 9 | const zeroSHA = "0000000000000000000000000000000000000000" |
| 10 | 10 | |
| 11 | type 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. | |
| 20 | func 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 | 40 | // Parents returns a commit's parent shas. |
| 12 | 41 | func Parents(dir, sha string) []string { |
| 13 | 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 | 182 | if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete { |
| 183 | 183 | dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name) |
| 184 | 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 | 187 | // Any branch/tag update schedules the push mirrors. |
| 187 | 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 | 304 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 305 | 305 | return |
| 306 | 306 | } |
| 307 | s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) | |
| 307 | 308 | // Labels need write access, matching the SSH rule; ignored otherwise. |
| 308 | 309 | if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 { |
| 309 | 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 | 412 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 412 | 413 | return |
| 413 | 414 | } |
| 415 | s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n)) | |
| 414 | 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 | 436 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 435 | 437 | return |
| 436 | 438 | } |
| 439 | s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n)) | |
| 437 | 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import "time" | |
| 4 | ||
| 5 | // activityDay is one cell of the graph; Level buckets Count into the five | |
| 6 | // intensity classes the stylesheet colors. | |
| 7 | type activityDay struct { | |
| 8 | Date string | |
| 9 | Count int | |
| 10 | Level int // 0..4 | |
| 11 | Pad bool // before the range start / after today | |
| 12 | } | |
| 13 | ||
| 14 | type 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. | |
| 18 | func 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 | ||
| 44 | func 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. | |
| 60 | func 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 | 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 | 337 | s.render(w, "owner.html", struct { |
| 330 | Site string | |
| 331 | Viewer string | |
| 332 | Owner string | |
| 333 | Kind string | |
| 334 | Profile store.Profile | |
| 335 | Repos []describedRepo | |
| 336 | Members []store.OrgMember | |
| 337 | Orgs []store.OrgMember | |
| 338 | }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs}) | |
| 338 | Site string | |
| 339 | Viewer string | |
| 340 | Owner string | |
| 341 | Kind string | |
| 342 | Profile store.Profile | |
| 343 | Repos []describedRepo | |
| 344 | Members []store.OrgMember | |
| 345 | Orgs []store.OrgMember | |
| 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 | |
| 341 | 352 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
internal/store/activity.go added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 11 | func (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. | |
| 23 | func (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). | |
| 37 | func (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. | |
| 62 | func (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 @@ | ||
| 1 | DROP 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. | |
| 5 | CREATE 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 | ); | |
| 12 | CREATE 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 | 365 | .profilehead .meta { margin: var(--sp-1) 0 0; } |
| 366 | 366 | h2 .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 | 384 | /* heading row with actions on the right */ |
| 369 | 385 | .headrow { |
| 370 | 386 | display: flex; |
internal/web/templates/owner.html +8
| @@ -7,6 +7,14 @@ | ||
| 7 | 7 | {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} |
| 8 | 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 | 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 | 18 | <h2>repositories <span class="count">{{len .Repos}}</span></h2> |
| 11 | 19 | <ul class="repolist"> |
| 12 | 20 | {{range .Repos}}{{template "reporow" .}} |