1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "slices"
8 "sync"
9 "time"
10
11 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"
13)
14
15const (
16 // maxStatsEntries bounds the cache of figures; when full it is emptied,
17 // and the entries of the current heads come back on the next read.
18 maxStatsEntries = 1024
19 // statsTTL is how long figures are served: the 30-day window moves
20 // with time even when the repository does not.
21 statsTTL = 10 * time.Minute
22)
23
24// statsKey names the figures of one state of a repository: its life (the
25// creation time), its last push or merge (which every ref change, tags
26// included, moves) and the head counted from.
27type statsKey struct {
28 repo domain.RepoName
29 created int64
30 pushed int64
31 head domain.ObjectID
32}
33
34type statsEntry struct {
35 st ports.RepoStats
36 at time.Time
37 err error
38 // done is closed when st and err are set; until then the entry is the
39 // read in flight that the other callers wait for.
40 done chan struct{}
41}
42
43// statsCache keeps the figures of the home page, so that a request reads Git
44// once per repository state and per statsTTL, and concurrent requests share
45// one read.
46type statsCache struct {
47 mu sync.Mutex
48 m map[statsKey]*statsEntry
49}
50
51// get returns the figures of key, computing them with read when they are
52// missing, failed or older than statsTTL.
53func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) {
54 c.mu.Lock()
55 e, ok := c.m[key]
56 if ok {
57 select {
58 case <-e.done:
59 if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) {
60 ok = false
61 }
62 default: // in flight
63 }
64 }
65 if !ok {
66 if c.m == nil || len(c.m) >= maxStatsEntries {
67 c.m = make(map[statsKey]*statsEntry)
68 }
69 e = &statsEntry{at: now, done: make(chan struct{})}
70 c.m[key] = e
71 c.mu.Unlock()
72 e.st, e.err = read()
73 close(e.done)
74 return cloneStats(e.st), e.err
75 }
76 c.mu.Unlock()
77 select {
78 case <-e.done:
79 return cloneStats(e.st), e.err
80 case <-ctx.Done():
81 return ports.RepoStats{}, ctx.Err()
82 }
83}
84
85// activity counts the commits of the last ports.RecentDays days, and those
86// of each of the last ports.ActivityDays days, oldest first. A day is the 24
87// hours that end at now minus a whole number of days; a commit dated after
88// now counts in the last day.
89func activity(times []time.Time, now time.Time) (recent int, days []int) {
90 days = make([]int, ports.ActivityDays)
91 recentSince := daysBefore(now, ports.RecentDays)
92 for _, t := range times {
93 if !t.Before(recentSince) {
94 recent++
95 }
96 ago := int(now.Sub(t) / (24 * time.Hour))
97 if ago < 0 {
98 ago = 0
99 }
100 if ago < ports.ActivityDays {
101 days[ports.ActivityDays-1-ago]++
102 }
103 }
104 return recent, days
105}
106
107// daysBefore is now minus n days of 24 hours: the days of activity are
108// elapsed time, not calendar days, so that a change of daylight saving time
109// neither drops nor doubles an hour.
110func daysBefore(now time.Time, n int) time.Time {
111 return now.Add(-time.Duration(n) * 24 * time.Hour)
112}
113
114func cloneStats(st ports.RepoStats) ports.RepoStats {
115 st.Activity = slices.Clone(st.Activity)
116 if st.LatestTag != nil {
117 t := *st.LatestTag
118 st.LatestTag = &t
119 }
120 return st
121}
122
123// RepoStats counts the commits reachable from head, all of them, those of
124// the last ports.RecentDays days and those of each recent day, and names the
125// latest tag. The answer is kept for statsTTL, or until the repository is
126// pushed to: the home page and its live updates read it on every row.
127func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
128 r, err := b.authorize(ctx, p, repo)
129 if err != nil {
130 return ports.RepoStats{}, err
131 }
132 key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head}
133 return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) {
134 // The read outlives a caller that gives up: the others wait for it.
135 rctx := context.WithoutCancel(ctx)
136 var st ports.RepoStats
137 var err error
138 if st.Commits, err = b.reader.CountCommits(rctx, repo, head); err != nil {
139 return ports.RepoStats{}, err
140 }
141 // One walk gives both the recent count and the activity.
142 span := max(ports.RecentDays, ports.ActivityDays)
143 times, truncated, err := b.reader.CommitTimes(rctx, repo, head, daysBefore(now, span))
144 if err != nil {
145 return ports.RepoStats{}, err
146 }
147 st.Recent, st.Activity = activity(times, now)
148 st.Partial = truncated
149 tag, ok, err := b.reader.LatestTag(rctx, repo)
150 if err != nil {
151 return ports.RepoStats{}, err
152 }
153 if ok {
154 st.LatestTag = &tag
155 }
156 return st, nil
157 })
158}