A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit ac6714f569

ac6714f569b1b7f1cf59ffa97b99c505ae2d6698

parent: 4254359d23

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T03:10:36Z

Add activity graphs to owner pages

Closes #30

The platform records the activity signal at event time — the lesson
from reconstructing this externally with hutch-stats. Commits landing
on the default branch (push post-receive and the MR merge path) are
attributed by verified author email into commit_activity (migration
0021), deduped by (repo, sha) so rebases and re-pushes never
double-count; the day is the author date, so imported history keeps
its real timeline. MR and web comments now record events (new
mr.commented/issue.commented kinds, which webhooks can also subscribe
to). Owner pages render a 53-week CSS-grid graph — no JS, accent-
tinted levels, per-day tooltips: users aggregate their commits plus
event actions (push events excluded to avoid double-counting their own
commits), orgs aggregate across their repositories.
gitbayd admin backfill-activity walks existing default branches,
idempotently.
cmd/gitbayd/adminusers.go +51
@@ -6,6 +6,8 @@ import (
66 "github.com/spf13/cobra"
77
88 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/gitutil"
911 "gitbay.org/gitbay/internal/store"
1012 )
1113
@@ -84,6 +86,55 @@ func adminMigrateCommitRefsCmd() *cobra.Command {
8486 }
8587 }
8688
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
87138 func adminAuditCmd() *cobra.Command {
88139 var limit int
89140 cmd := &cobra.Command{
cmd/gitbayd/main.go +1
@@ -257,6 +257,7 @@ func adminCmd() *cobra.Command {
257257 statsCmd(),
258258 adminAuditCmd(),
259259 adminMigrateCommitRefsCmd(),
260 adminBackfillActivityCmd(),
260261 )
261262 return admin
262263 }
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
5656 }
5757 }
5858
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
5975 func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) {
6076 issue, err := st.IssueByNumber(repo.ID, number)
6177 if err != nil {
internal/control/mr.go +2
@@ -534,6 +534,7 @@ func runMRComment(c *Ctx, args []string) int {
534534 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
535535 return c.fail(protocol.ExitFailure, "%v", err)
536536 }
537 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
537538 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
538539 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
539540 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 {
851852 // (closes #N, references) run here for the newly landed commits.
852853 if mr.TargetRef == repo.DefaultBranch {
853854 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
855 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
854856 }
855857 c.Store.MarkMirrorsDirty(repo.ID, "push")
856858 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
internal/gitutil/messages.go +29
@@ -8,6 +8,35 @@ import (
88
99 const zeroSHA = "0000000000000000000000000000000000000000"
1010
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
1140 // Parents returns a commit's parent shas.
1241 func Parents(dir, sha string) []string {
1342 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) {
182182 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
183183 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
184184 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
185 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
185186 }
186187 // Any branch/tag update schedules the push mirrors.
187188 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
304304 http.Error(w, "internal error", http.StatusInternalServerError)
305305 return
306306 }
307 s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
307308 // Labels need write access, matching the SSH rule; ignored otherwise.
308309 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
309310 grant, _ := s.st.AccessRole(repo.ID, u.ID)
@@ -411,6 +412,7 @@ func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u st
411412 http.Error(w, "internal error", http.StatusInternalServerError)
412413 return
413414 }
415 s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n))
414416 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
415417 }
416418
@@ -434,6 +436,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
434436 http.Error(w, "internal error", http.StatusInternalServerError)
435437 return
436438 }
439 s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n))
437440 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
438441 }
439442
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) {
326326 visible = append(visible, repo)
327327 }
328328 }
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
329337 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})
339350 }
340351
341352 func (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); }
365365 .profilehead .meta { margin: var(--sp-1) 0 0; }
366366 h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); }
367367
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
368384 /* heading row with actions on the right */
369385 .headrow {
370386 display: flex;
internal/web/templates/owner.html +8
@@ -7,6 +7,14 @@
77 {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
88 {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
99 </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>
1018 <h2>repositories <span class="count">{{len .Repos}}</span></h2>
1119 <ul class="repolist">
1220 {{range .Repos}}{{template "reporow" .}}