Activity graphs on owner pages !35
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. | ||
| 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 | func adminAuditCmd() *cobra.Command { | 138 | func 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 @@ | |||
| 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 | func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) { | 75 | func 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 | ||
| 9 | const zeroSHA = "0000000000000000000000000000000000000000" | 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 | // Parents returns a commit's parent shas. | 40 | // Parents returns a commit's parent shas. |
| 12 | func Parents(dir, sha string) []string { | 41 | func 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 @@ | |||
| 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 | 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 | ||
| 341 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { | 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 | .profilehead .meta { margin: var(--sp-1) 0 0; } | 365 | .profilehead .meta { margin: var(--sp-1) 0 0; } |
| 366 | h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); } | 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 | /* 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" .}} |