detached 166837a

feat(web): repository topics, activity heartbeat and figures top right on the home rows (#65)

commit
166837a4d3c5461a2b91e6cf84ab579d182a6688
author
brice · · 2026-10-18 12:50 UTC
committer
bnema · · 2026-10-18 12:50 UTC
parent
b9978b8
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.24.0
38 files · +1570 -389
M gen/ermite/storage/repo.pb.go +13 -3 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.4.0
16+ bnema.dev/ermite-api v0.5.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.4.016+ bnema.dev/ermite-api v0.5.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/ermitegit/ermitegit.go +7 -2
@@ -418,8 +418,13 @@ func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domai
418418
419419 // CountCommits is served by the wrapped reader: git counts from the commit
420420 // graph.
421-func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
422- return a.cli.CountCommits(ctx, repo, head, since)
421+func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
422+ return a.cli.CountCommits(ctx, repo, head)
423+}
424+
425+// CommitTimes is served by the wrapped reader.
426+func (a *Adapter) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
427+ return a.cli.CommitTimes(ctx, repo, head, since)
423428 }
424429
425430 // LatestTag is served by the wrapped reader.
@@ -418,8 +418,13 @@ func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domai
418 418
419 // CountCommits is served by the wrapped reader: git counts from the commit419 // CountCommits is served by the wrapped reader: git counts from the commit
420 // graph.420 // graph.
421-func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {421+func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
422- return a.cli.CountCommits(ctx, repo, head, since)422+ return a.cli.CountCommits(ctx, repo, head)
423+}
424+
425+// CommitTimes is served by the wrapped reader.
426+func (a *Adapter) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
427+ return a.cli.CommitTimes(ctx, repo, head, since)
423 }428 }
424 429
425 // LatestTag is served by the wrapped reader.430 // LatestTag is served by the wrapped reader.
M internal/adapters/gitcli/stats.go +55 -9
@@ -3,22 +3,24 @@
33 package gitcli
44
55 import (
6+ "bufio"
67 "context"
8+ "errors"
79 "fmt"
10+ "io"
811 "strconv"
912 "strings"
1013 "time"
1114
1215 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
1317 )
1418
1519 // CountCommits runs `git rev-list --count`, from the commit graph when the
16-// repository has one. A bound uses --since-as-filter, which looks at every
17-// reachable commit: --since stops the walk at the first older one, and
18-// misses newer commits behind it when dates are out of order.
20+// repository has one.
1921 //
2022 //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
21-func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
23+func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
2224 if head.IsZero() {
2325 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
2426 }
@@ -26,11 +28,7 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
2628 if err != nil {
2729 return 0, err
2830 }
29- args := []string{"rev-list", "--count"}
30- if !since.IsZero() {
31- args = append(args, "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10))
32- }
33- out, err := g.run(ctx, readArgs(dir, append(args, "--end-of-options", head.String())...)...)
31+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--end-of-options", head.String())...)
3432 if err != nil {
3533 if exitCode(err) == 128 {
3634 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
@@ -44,6 +42,54 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
4442 return n, nil
4543 }
4644
45+// CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
46+// looks at every reachable commit: --since stops the walk at the first older
47+// one, and misses newer commits behind it when dates are out of order.
48+//
49+//hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port
50+func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
51+ if head.IsZero() {
52+ return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)
53+ }
54+ dir, err := g.repoDir(repo)
55+ if err != nil {
56+ return nil, false, err
57+ }
58+ args := readArgs(dir, "rev-list", "--no-commit-header", "--format=%ct",
59+ "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10), "--end-of-options", head.String())
60+ var (
61+ times []time.Time
62+ truncated bool
63+ )
64+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
65+ for {
66+ line, err := r.ReadString('\n')
67+ if errors.Is(err, io.EOF) && line == "" {
68+ return nil
69+ }
70+ if err != nil && !errors.Is(err, io.EOF) {
71+ return err
72+ }
73+ sec, perr := strconv.ParseInt(strings.TrimSpace(line), 10, 64)
74+ if perr != nil {
75+ return fmt.Errorf("git rev-list: unexpected line %q", truncateText(line))
76+ }
77+ if len(times) == ports.MaxCommitTimes {
78+ truncated = true
79+ return errStop
80+ }
81+ times = append(times, time.Unix(sec, 0).UTC())
82+ }
83+ })
84+ if err != nil {
85+ if exitCode(err) == 128 {
86+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, domain.ErrNotFound)
87+ }
88+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, err)
89+ }
90+ return times, truncated, nil
91+}
92+
4793 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
4894 //
4995 //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
@@ -3,22 +3,24 @@
3 package gitcli3 package gitcli
4 4
5 import (5 import (
6+ "bufio"
6 "context"7 "context"
8+ "errors"
7 "fmt"9 "fmt"
10+ "io"
8 "strconv"11 "strconv"
9 "strings"12 "strings"
10 "time"13 "time"
11 14
12 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
13 )17 )
14 18
15 // CountCommits runs `git rev-list --count`, from the commit graph when the19 // CountCommits runs `git rev-list --count`, from the commit graph when the
16-// repository has one. A bound uses --since-as-filter, which looks at every20+// repository has one.
17-// reachable commit: --since stops the walk at the first older one, and
18-// misses newer commits behind it when dates are out of order.
19 //21 //
20 //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints22 //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
21-func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {23+func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
22 if head.IsZero() {24 if head.IsZero() {
23 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)25 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
24 }26 }
@@ -26,11 +28,7 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
26 if err != nil {28 if err != nil {
27 return 0, err29 return 0, err
28 }30 }
29- args := []string{"rev-list", "--count"}31+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--end-of-options", head.String())...)
30- if !since.IsZero() {
31- args = append(args, "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10))
32- }
33- out, err := g.run(ctx, readArgs(dir, append(args, "--end-of-options", head.String())...)...)
34 if err != nil {32 if err != nil {
35 if exitCode(err) == 128 {33 if exitCode(err) == 128 {
36 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)34 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
@@ -44,6 +42,54 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
44 return n, nil42 return n, nil
45 }43 }
46 44
45+// CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
46+// looks at every reachable commit: --since stops the walk at the first older
47+// one, and misses newer commits behind it when dates are out of order.
48+//
49+//hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port
50+func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
51+ if head.IsZero() {
52+ return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)
53+ }
54+ dir, err := g.repoDir(repo)
55+ if err != nil {
56+ return nil, false, err
57+ }
58+ args := readArgs(dir, "rev-list", "--no-commit-header", "--format=%ct",
59+ "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10), "--end-of-options", head.String())
60+ var (
61+ times []time.Time
62+ truncated bool
63+ )
64+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
65+ for {
66+ line, err := r.ReadString('\n')
67+ if errors.Is(err, io.EOF) && line == "" {
68+ return nil
69+ }
70+ if err != nil && !errors.Is(err, io.EOF) {
71+ return err
72+ }
73+ sec, perr := strconv.ParseInt(strings.TrimSpace(line), 10, 64)
74+ if perr != nil {
75+ return fmt.Errorf("git rev-list: unexpected line %q", truncateText(line))
76+ }
77+ if len(times) == ports.MaxCommitTimes {
78+ truncated = true
79+ return errStop
80+ }
81+ times = append(times, time.Unix(sec, 0).UTC())
82+ }
83+ })
84+ if err != nil {
85+ if exitCode(err) == 128 {
86+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, domain.ErrNotFound)
87+ }
88+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, err)
89+ }
90+ return times, truncated, nil
91+}
92+
47 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.93 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
48 //94 //
49 //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints95 //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
M internal/adapters/kvstore/repos.go +3 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "slices"
910 "time"
1011 "unicode/utf8"
1112
@@ -201,6 +202,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
201202 pb := &storagepb.Repo{
202203 Name: r.Name.String(),
203204 Description: r.Description,
205+ Topics: r.Topics,
204206 Visibility: vis,
205207 CreatedAt: encodeTime(r.CreatedAt),
206208 PushedAt: encodeTime(r.PushedAt),
@@ -221,6 +223,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
221223 return domain.Repo{
222224 Name: name,
223225 Description: pb.GetDescription(),
226+ Topics: slices.Clone(pb.GetTopics()),
224227 Visibility: decodeVisibility(pb.GetVisibility()),
225228 CreatedAt: decodeTime(pb.GetCreatedAt()),
226229 PushedAt: decodeTime(pb.GetPushedAt()),
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "slices"
9 "time"10 "time"
10 "unicode/utf8"11 "unicode/utf8"
11 12
@@ -201,6 +202,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
201 pb := &storagepb.Repo{202 pb := &storagepb.Repo{
202 Name: r.Name.String(),203 Name: r.Name.String(),
203 Description: r.Description,204 Description: r.Description,
205+ Topics: r.Topics,
204 Visibility: vis,206 Visibility: vis,
205 CreatedAt: encodeTime(r.CreatedAt),207 CreatedAt: encodeTime(r.CreatedAt),
206 PushedAt: encodeTime(r.PushedAt),208 PushedAt: encodeTime(r.PushedAt),
@@ -221,6 +223,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
221 return domain.Repo{223 return domain.Repo{
222 Name: name,224 Name: name,
223 Description: pb.GetDescription(),225 Description: pb.GetDescription(),
226+ Topics: slices.Clone(pb.GetTopics()),
224 Visibility: decodeVisibility(pb.GetVisibility()),227 Visibility: decodeVisibility(pb.GetVisibility()),
225 CreatedAt: decodeTime(pb.GetCreatedAt()),228 CreatedAt: decodeTime(pb.GetCreatedAt()),
226 PushedAt: decodeTime(pb.GetPushedAt()),229 PushedAt: decodeTime(pb.GetPushedAt()),
M internal/adapters/kvstore/store_test.go +23 -1
@@ -42,7 +42,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
4242 ctx := context.Background()
4343 s := openStore(t, t.TempDir())
4444
45- b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
45+ b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
4646 a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
4747 require.NoError(t, s.Create(ctx, b))
4848 require.NoError(t, s.Create(ctx, a))
@@ -63,12 +63,19 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
6363 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
6464
6565 b.Description = "changed"
66+ b.Topics = []string{"forge"}
6667 b.Visibility = domain.VisibilityPrivate
6768 require.NoError(t, s.Update(ctx, b))
6869 got, err = s.Get(ctx, b.Name)
6970 require.NoError(t, err)
7071 assert.Equal(t, b, got)
7172
73+ b.Topics = nil
74+ require.NoError(t, s.Update(ctx, b))
75+ got, err = s.Get(ctx, b.Name)
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics, "cleared topics read back as nil")
78+
7279 require.NoError(t, s.Delete(ctx, b.Name))
7380 _, err = s.Get(ctx, b.Name)
7481 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -337,3 +344,18 @@ func TestCorruptRepoValue(t *testing.T) {
337344 _, err = s.List(ctx)
338345 assert.Error(t, err)
339346 }
347+
348+func TestRepoWithoutTopicsReadsNil(t *testing.T) {
349+ ctx := context.Background()
350+ dir := t.TempDir()
351+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
352+ putRaw(t, dir, "repo/new", mustProto(t, &storagepb.Repo{Name: "new", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC, Topics: []string{"a", "b"}}))
353+ s := openStore(t, dir)
354+
355+ old, err := s.Get(ctx, name(t, "old"))
356+ require.NoError(t, err)
357+ assert.Nil(t, old.Topics)
358+ got, err := s.Get(ctx, name(t, "new"))
359+ require.NoError(t, err)
360+ assert.Equal(t, []string{"a", "b"}, got.Topics)
361+}
@@ -42,7 +42,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
42 ctx := context.Background()42 ctx := context.Background()
43 s := openStore(t, t.TempDir())43 s := openStore(t, t.TempDir())
44 44
45- b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}45+ b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46 a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}46 a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
47 require.NoError(t, s.Create(ctx, b))47 require.NoError(t, s.Create(ctx, b))
48 require.NoError(t, s.Create(ctx, a))48 require.NoError(t, s.Create(ctx, a))
@@ -63,12 +63,19 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
63 assert.ErrorIs(t, err, domain.ErrAlreadyExists)63 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
64 64
65 b.Description = "changed"65 b.Description = "changed"
66+ b.Topics = []string{"forge"}
66 b.Visibility = domain.VisibilityPrivate67 b.Visibility = domain.VisibilityPrivate
67 require.NoError(t, s.Update(ctx, b))68 require.NoError(t, s.Update(ctx, b))
68 got, err = s.Get(ctx, b.Name)69 got, err = s.Get(ctx, b.Name)
69 require.NoError(t, err)70 require.NoError(t, err)
70 assert.Equal(t, b, got)71 assert.Equal(t, b, got)
71 72
73+ b.Topics = nil
74+ require.NoError(t, s.Update(ctx, b))
75+ got, err = s.Get(ctx, b.Name)
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics, "cleared topics read back as nil")
78+
72 require.NoError(t, s.Delete(ctx, b.Name))79 require.NoError(t, s.Delete(ctx, b.Name))
73 _, err = s.Get(ctx, b.Name)80 _, err = s.Get(ctx, b.Name)
74 assert.ErrorIs(t, err, domain.ErrNotFound)81 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -337,3 +344,18 @@ func TestCorruptRepoValue(t *testing.T) {
337 _, err = s.List(ctx)344 _, err = s.List(ctx)
338 assert.Error(t, err)345 assert.Error(t, err)
339 }346 }
347+
348+func TestRepoWithoutTopicsReadsNil(t *testing.T) {
349+ ctx := context.Background()
350+ dir := t.TempDir()
351+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
352+ putRaw(t, dir, "repo/new", mustProto(t, &storagepb.Repo{Name: "new", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC, Topics: []string{"a", "b"}}))
353+ s := openStore(t, dir)
354+
355+ old, err := s.Get(ctx, name(t, "old"))
356+ require.NoError(t, err)
357+ assert.Nil(t, old.Topics)
358+ got, err := s.Get(ctx, name(t, "new"))
359+ require.NoError(t, err)
360+ assert.Equal(t, []string{"a", "b"}, got.Topics)
361+}
M internal/adapters/rpc/convert.go +1 -0
@@ -65,6 +65,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
6565 return &pb.Repo{
6666 Name: v.Name.String(),
6767 Description: v.Description,
68+ Topics: v.Topics,
6869 Visibility: visibilityToPB(v.Visibility),
6970 DefaultBranch: v.DefaultBranch,
7071 CreatedAt: timestamp(v.CreatedAt),
@@ -65,6 +65,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
65 return &pb.Repo{65 return &pb.Repo{
66 Name: v.Name.String(),66 Name: v.Name.String(),
67 Description: v.Description,67 Description: v.Description,
68+ Topics: v.Topics,
68 Visibility: visibilityToPB(v.Visibility),69 Visibility: visibilityToPB(v.Visibility),
69 DefaultBranch: v.DefaultBranch,70 DefaultBranch: v.DefaultBranch,
70 CreatedAt: timestamp(v.CreatedAt),71 CreatedAt: timestamp(v.CreatedAt),
M internal/adapters/rpc/repo.go +4 -2
@@ -54,7 +54,7 @@ func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.Cr
5454 return nil, connectError(ctx, err)
5555 }
5656 repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{
57- Name: name, Description: req.Msg.GetDescription(), Visibility: vis,
57+ Name: name, Description: req.Msg.GetDescription(), Topics: req.Msg.GetTopics(), Visibility: vis,
5858 })
5959 if err != nil {
6060 return nil, connectError(ctx, err)
@@ -79,7 +79,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
7979 }
8080
8181 // updateParams maps the field mask of an update. The mask must name at least
82-// one of "description", "visibility", "default_branch", "settings",
82+// one of "description", "topics", "visibility", "default_branch", "settings",
8383 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
8484 // nothing else.
8585 //
@@ -94,6 +94,8 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
9494 switch path {
9595 case "description":
9696 params.Description = new(msg.GetDescription())
97+ case "topics":
98+ params.Topics = new(msg.GetTopics())
9799 case "visibility":
98100 vis, err := visibilityFromPB(msg.GetVisibility())
99101 if err != nil {
@@ -54,7 +54,7 @@ func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.Cr
54 return nil, connectError(ctx, err)54 return nil, connectError(ctx, err)
55 }55 }
56 repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{56 repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{
57- Name: name, Description: req.Msg.GetDescription(), Visibility: vis,57+ Name: name, Description: req.Msg.GetDescription(), Topics: req.Msg.GetTopics(), Visibility: vis,
58 })58 })
59 if err != nil {59 if err != nil {
60 return nil, connectError(ctx, err)60 return nil, connectError(ctx, err)
@@ -79,7 +79,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
79 }79 }
80 80
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82-// one of "description", "visibility", "default_branch", "settings",82+// one of "description", "topics", "visibility", "default_branch", "settings",
83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84 // nothing else.84 // nothing else.
85 //85 //
@@ -94,6 +94,8 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
94 switch path {94 switch path {
95 case "description":95 case "description":
96 params.Description = new(msg.GetDescription())96 params.Description = new(msg.GetDescription())
97+ case "topics":
98+ params.Topics = new(msg.GetTopics())
97 case "visibility":99 case "visibility":
98 vis, err := visibilityFromPB(msg.GetVisibility())100 vis, err := visibilityFromPB(msg.GetVisibility())
99 if err != nil {101 if err != nil {
M internal/adapters/rpc/repo_test.go +30 -2
@@ -5,6 +5,7 @@ package rpc
55 import (
66 "context"
77 "errors"
8+ "slices"
89 "testing"
910
1011 "connectrpc.com/connect"
@@ -93,7 +94,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
9394 wantCode connect.Code
9495 wantCall bool
9596 }{
96- {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
97+ {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Topics: []string{"go", "cli"}, Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
9798 {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},
9899 {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},
99100 {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
@@ -106,7 +107,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
106107 if tt.wantCall {
107108 repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}
108109 repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {
109- return p.Name.String() == "demo"
110+ return p.Name.String() == "demo" && slices.Equal(p.Topics, tt.req.GetTopics())
110111 })).Return(repo, tt.usecase).Once()
111112 if tt.usecase == nil {
112113 repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()
@@ -252,3 +253,30 @@ func TestRepoServiceListRepos(t *testing.T) {
252253 })
253254 }
254255 }
256+
257+func TestUpdateParamsTopics(t *testing.T) {
258+ t.Parallel()
259+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
260+
261+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics"), Topics: []string{"go", "cli"}})
262+ require.NoError(t, err)
263+ require.NotNil(t, params.Topics)
264+ assert.Equal(t, []string{"go", "cli"}, *params.Topics)
265+ assert.Nil(t, params.Description, "only the masked fields are set")
266+
267+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics")})
268+ require.NoError(t, err)
269+ require.NotNil(t, params.Topics, "a masked empty list is a clear, not an absent field")
270+ assert.Empty(t, *params.Topics)
271+
272+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("description"), Topics: []string{"go"}})
273+ require.NoError(t, err)
274+ assert.Nil(t, params.Topics, "topics outside the mask are ignored")
275+}
276+
277+func TestRepoToPBTopics(t *testing.T) {
278+ t.Parallel()
279+ got := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo"), Topics: []string{"cli", "go"}}})
280+ assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281+ assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282+}
@@ -5,6 +5,7 @@ package rpc
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8+ "slices"
8 "testing"9 "testing"
9 10
10 "connectrpc.com/connect"11 "connectrpc.com/connect"
@@ -93,7 +94,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
93 wantCode connect.Code94 wantCode connect.Code
94 wantCall bool95 wantCall bool
95 }{96 }{
96- {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},97+ {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Topics: []string{"go", "cli"}, Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
97 {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},98 {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},
98 {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},99 {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},
99 {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},100 {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
@@ -106,7 +107,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
106 if tt.wantCall {107 if tt.wantCall {
107 repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}108 repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}
108 repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {109 repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {
109- return p.Name.String() == "demo"110+ return p.Name.String() == "demo" && slices.Equal(p.Topics, tt.req.GetTopics())
110 })).Return(repo, tt.usecase).Once()111 })).Return(repo, tt.usecase).Once()
111 if tt.usecase == nil {112 if tt.usecase == nil {
112 repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()113 repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()
@@ -252,3 +253,30 @@ func TestRepoServiceListRepos(t *testing.T) {
252 })253 })
253 }254 }
254 }255 }
256+
257+func TestUpdateParamsTopics(t *testing.T) {
258+ t.Parallel()
259+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
260+
261+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics"), Topics: []string{"go", "cli"}})
262+ require.NoError(t, err)
263+ require.NotNil(t, params.Topics)
264+ assert.Equal(t, []string{"go", "cli"}, *params.Topics)
265+ assert.Nil(t, params.Description, "only the masked fields are set")
266+
267+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics")})
268+ require.NoError(t, err)
269+ require.NotNil(t, params.Topics, "a masked empty list is a clear, not an absent field")
270+ assert.Empty(t, *params.Topics)
271+
272+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("description"), Topics: []string{"go"}})
273+ require.NoError(t, err)
274+ assert.Nil(t, params.Topics, "topics outside the mask are ignored")
275+}
276+
277+func TestRepoToPBTopics(t *testing.T) {
278+ t.Parallel()
279+ got := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo"), Topics: []string{"cli", "go"}}})
280+ assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281+ assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282+}
M internal/adapters/web/components.templ +52 -13
@@ -32,9 +32,10 @@ templ CloneBlock(c Clone) {
3232 </div>
3333 }
3434
35-// HomeRowView is the row of a repository in the home list: what it is, its
36-// latest run, and its newest commits. Its id is the target of SSE swaps; the
37-// name link comes first, `enter` follows it.
35+// HomeRowView is the row of a repository in the home list. On the left what
36+// it is: name, description, topics and the pulse of its activity; on the
37+// right its figures and CI state over its newest commits. Its id is the
38+// target of SSE swaps; the name link comes first, `enter` follows it.
3839 templ HomeRowView(r HomeRow) {
3940 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
4041 <div class="repo-main">
@@ -52,17 +53,29 @@ templ HomeRowView(r HomeRow) {
5253 } else {
5354 <p class="repo-desc mute">no description</p>
5455 }
56+ if len(r.Topics) > 0 {
57+ <ul class="repo-topics" aria-label={ "Topics of " + r.Name }>
58+ for _, t := range r.Topics {
59+ <li>{ t }</li>
60+ }
61+ </ul>
62+ }
63+ if r.Stats != nil && r.Stats.Pulse != nil {
64+ @pulse(r.Stats.Pulse)
65+ }
66+ </div>
67+ <div class="repo-head">
5568 <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>
5669 if s := r.Stats; s != nil {
5770 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
5871 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
5972 if s.Tag != "" {
60- <div>
73+ <div class="rs-tag">
6174 <dt>latest tag</dt>
6275 if s.TagHref != "" {
63- <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd>
76+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
6477 } else {
65- <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd>
78+ <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
6679 }
6780 </div>
6881 }
@@ -76,15 +89,19 @@ templ HomeRowView(r HomeRow) {
7689 }
7790 </div>
7891 </dl>
92+ if r.CI != nil {
93+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
94+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
95+ <span class="sr">CI: </span>{ r.CI.Label }
96+ </a>
97+ }
7998 </div>
80- if r.CI != nil {
81- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
82- <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
83- <span class="sr">CI: </span>{ r.CI.Label }
84- </a>
85- }
8699 if len(r.Commits) > 0 {
87- <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>
100+ <ul
101+ class="repo-commits"
102+ data-full?={ len(r.Commits) == homeRecent }
103+ aria-label={ "Recent commits of " + r.Name }
104+ >
88105 for _, c := range r.Commits {
89106 <li>
90107 <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
@@ -101,6 +118,28 @@ templ HomeRowView(r HomeRow) {
101118 </li>
102119 }
103120
121+// pulse is the activity curve of a home row: a smooth line over a fading
122+// area on a faint grid, and a dot at today that beats while the repository
123+// is active. The SVG scales evenly with its column, so the dot stays round.
124+templ pulse(h *Heartbeat) {
125+ <figure class="repo-pulse" data-live?={ h.Live }>
126+ <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">
127+ <defs>
128+ <linearGradient id={ h.GradID } x1="0" y1="0" x2="0" y2="1">
129+ <stop class="pulse-stop-top" offset="0"></stop>
130+ <stop class="pulse-stop-bottom" offset="1"></stop>
131+ </linearGradient>
132+ </defs>
133+ <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>
134+ <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>
135+ <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>
136+ <circle class="pulse-ring" cx={ h.X } cy={ h.Y } r="4"></circle>
137+ <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>
138+ </svg>
139+ <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption>
140+ </figure>
141+}
142+
104143 // Outline is the README outline; levels indent by 2ch, the h1 is bold.
105144 templ Outline(headings []Heading) {
106145 <nav class="outline" aria-label="Outline">
@@ -32,9 +32,10 @@ templ CloneBlock(c Clone) {
32 </div>32 </div>
33 }33 }
34 34
35-// HomeRowView is the row of a repository in the home list: what it is, its35+// HomeRowView is the row of a repository in the home list. On the left what
36-// latest run, and its newest commits. Its id is the target of SSE swaps; the36+// it is: name, description, topics and the pulse of its activity; on the
37-// name link comes first, `enter` follows it.37+// right its figures and CI state over its newest commits. Its id is the
38+// target of SSE swaps; the name link comes first, `enter` follows it.
38 templ HomeRowView(r HomeRow) {39 templ HomeRowView(r HomeRow) {
39 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>40 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
40 <div class="repo-main">41 <div class="repo-main">
@@ -52,17 +53,29 @@ templ HomeRowView(r HomeRow) {
52 } else {53 } else {
53 <p class="repo-desc mute">no description</p>54 <p class="repo-desc mute">no description</p>
54 }55 }
56+ if len(r.Topics) > 0 {
57+ <ul class="repo-topics" aria-label={ "Topics of " + r.Name }>
58+ for _, t := range r.Topics {
59+ <li>{ t }</li>
60+ }
61+ </ul>
62+ }
63+ if r.Stats != nil && r.Stats.Pulse != nil {
64+ @pulse(r.Stats.Pulse)
65+ }
66+ </div>
67+ <div class="repo-head">
55 <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>68 <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>
56 if s := r.Stats; s != nil {69 if s := r.Stats; s != nil {
57 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>70 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
58 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>71 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
59 if s.Tag != "" {72 if s.Tag != "" {
60- <div>73+ <div class="rs-tag">
61 <dt>latest tag</dt>74 <dt>latest tag</dt>
62 if s.TagHref != "" {75 if s.TagHref != "" {
63- <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd>76+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
64 } else {77 } else {
65- <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd>78+ <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
66 }79 }
67 </div>80 </div>
68 }81 }
@@ -76,15 +89,19 @@ templ HomeRowView(r HomeRow) {
76 }89 }
77 </div>90 </div>
78 </dl>91 </dl>
92+ if r.CI != nil {
93+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
94+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
95+ <span class="sr">CI: </span>{ r.CI.Label }
96+ </a>
97+ }
79 </div>98 </div>
80- if r.CI != nil {
81- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
82- <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
83- <span class="sr">CI: </span>{ r.CI.Label }
84- </a>
85- }
86 if len(r.Commits) > 0 {99 if len(r.Commits) > 0 {
87- <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>100+ <ul
101+ class="repo-commits"
102+ data-full?={ len(r.Commits) == homeRecent }
103+ aria-label={ "Recent commits of " + r.Name }
104+ >
88 for _, c := range r.Commits {105 for _, c := range r.Commits {
89 <li>106 <li>
90 <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>107 <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
@@ -101,6 +118,28 @@ templ HomeRowView(r HomeRow) {
101 </li>118 </li>
102 }119 }
103 120
121+// pulse is the activity curve of a home row: a smooth line over a fading
122+// area on a faint grid, and a dot at today that beats while the repository
123+// is active. The SVG scales evenly with its column, so the dot stays round.
124+templ pulse(h *Heartbeat) {
125+ <figure class="repo-pulse" data-live?={ h.Live }>
126+ <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">
127+ <defs>
128+ <linearGradient id={ h.GradID } x1="0" y1="0" x2="0" y2="1">
129+ <stop class="pulse-stop-top" offset="0"></stop>
130+ <stop class="pulse-stop-bottom" offset="1"></stop>
131+ </linearGradient>
132+ </defs>
133+ <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>
134+ <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>
135+ <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>
136+ <circle class="pulse-ring" cx={ h.X } cy={ h.Y } r="4"></circle>
137+ <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>
138+ </svg>
139+ <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption>
140+ </figure>
141+}
142+
104 // Outline is the README outline; levels indent by 2ch, the h1 is bold.143 // Outline is the README outline; levels indent by 2ch, the h1 is bold.
105 templ Outline(headings []Heading) {144 templ Outline(headings []Heading) {
106 <nav class="outline" aria-label="Outline">145 <nav class="outline" aria-label="Outline">
M internal/adapters/web/components_templ.go +476 -254 generated file: collapsed
A internal/adapters/web/heartbeat.go +191 -0
@@ -0,0 +1,191 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "encoding/binary"
7+ "math"
8+ "strconv"
9+ "strings"
10+ "sync"
11+)
12+
13+// Heartbeat is the activity curve of a home row, drawn on a box of
14+// heartbeatW by heartbeatH that the page scales to its width.
15+type Heartbeat struct {
16+ // Line is the curve, Area the same closed down to the bottom edge.
17+ Line, Area string
18+ // X and Y are where the curve ends: the pulse of today.
19+ X, Y string
20+ // GradID names the fill gradient of this row (ids are page-wide).
21+ GradID string
22+ // Label tells the curve in words.
23+ Label string
24+ // Live is set when the last week had commits: the pulse beats.
25+ Live bool
26+}
27+
28+const (
29+ heartbeatW = 600.0
30+ heartbeatH = 64.0
31+ // heartbeatPad keeps the stroke and the dot inside the box; the right
32+ // end stops before the edge so that the beating ring fits.
33+ heartbeatPad = 6.0
34+ heartbeatEndX = heartbeatW - 10
35+)
36+
37+// heartbeatOf draws the commits per day, oldest first. The days are smoothed
38+// over about a week so that the curve breathes instead of spiking, then
39+// scaled by the square root so that one big day does not flatten the rest.
40+// nil for no data.
41+// partial says the days count only some of the commits (heartbeatLabel).
42+func heartbeatOf(id string, days []int, partial bool) *Heartbeat {
43+ n := len(days)
44+ if n < 2 {
45+ return nil
46+ }
47+ total, lastWeek := 0, 0
48+ for i, d := range days {
49+ total += d
50+ if i >= n-7 {
51+ lastWeek += d
52+ }
53+ }
54+ top, bottom := heartbeatPad, heartbeatH-heartbeatPad
55+ var pts [][2]float64
56+ if total == 0 {
57+ // Asleep: a straight line on the base, two points.
58+ pts = [][2]float64{{0, bottom}, {heartbeatEndX, bottom}}
59+ } else {
60+ smooth := gaussian(days, 2.2)
61+ peak := 0.0
62+ for _, v := range smooth {
63+ peak = max(peak, math.Sqrt(v))
64+ }
65+ // One point every heartbeatStep days, and today: the smoothing
66+ // leaves nothing finer to draw, and the row goes out on every live
67+ // update.
68+ for i := 0; i < n; i += heartbeatStep {
69+ pts = append(pts, heartbeatPoint(i, n, smooth[i], peak, top, bottom))
70+ }
71+ if (n-1)%heartbeatStep != 0 {
72+ pts = append(pts, heartbeatPoint(n-1, n, smooth[n-1], peak, top, bottom))
73+ }
74+ }
75+ line := curve(pts)
76+ end := pts[len(pts)-1]
77+ return &Heartbeat{
78+ Line: line,
79+ Area: line + " L" + num(heartbeatEndX) + " " + num(heartbeatH) + " L0 " + num(heartbeatH) + " Z",
80+ X: num(end[0]),
81+ Y: num(end[1]),
82+ GradID: "hb-" + id,
83+ Label: heartbeatLabel(total, n, partial),
84+ Live: lastWeek > 0,
85+ }
86+}
87+
88+// heartbeatStep is the number of days between two points of the curve.
89+const heartbeatStep = 2
90+
91+func heartbeatPoint(i, n int, v, peak, top, bottom float64) [2]float64 {
92+ return [2]float64{float64(i) / float64(n-1) * heartbeatEndX, bottom - math.Sqrt(v)/peak*(bottom-top)}
93+}
94+
95+func heartbeatLabel(total, days int, partial bool) string {
96+ count := plural(total, "commit", "commits")
97+ if partial {
98+ count = "at least " + count
99+ }
100+ return "Activity: " + count + " in the last " + itoa(days) + " days"
101+}
102+
103+// pulseCache keeps the curves already drawn: the figures of a row stay the
104+// same for minutes (the stats cache of Browse) while the home page and its
105+// live updates render the row on every request. Keyed by the repository and
106+// its days, so a new count draws a new curve; bounded, cleared when full.
107+type pulseCache struct {
108+ mu sync.Mutex
109+ m map[pulseKey]*Heartbeat
110+}
111+
112+type pulseKey struct {
113+ id string
114+ days string // the counts, packed
115+ partial bool
116+}
117+
118+// maxPulses bounds pulseCache; a few per repository at most are live.
119+const maxPulses = 4096
120+
121+// get is heartbeatOf, from the cache when it was drawn already. The
122+// Heartbeat returned is shared: callers must not change it.
123+func (c *pulseCache) get(id string, days []int, partial bool) *Heartbeat {
124+ if len(days) < 2 {
125+ return nil
126+ }
127+ packed := make([]byte, 0, len(days)*2)
128+ for _, d := range days {
129+ packed = binary.AppendUvarint(packed, uint64(max(d, 0)))
130+ }
131+ key := pulseKey{id: id, days: string(packed), partial: partial}
132+ c.mu.Lock()
133+ h, ok := c.m[key]
134+ c.mu.Unlock()
135+ if ok {
136+ return h
137+ }
138+ h = heartbeatOf(id, days, partial)
139+ c.mu.Lock()
140+ if c.m == nil || len(c.m) >= maxPulses {
141+ c.m = make(map[pulseKey]*Heartbeat)
142+ }
143+ c.m[key] = h
144+ c.mu.Unlock()
145+ return h
146+}
147+
148+// gaussian blurs the values with a gaussian of the given width, the edges
149+// clamped: a quiet start or end does not drag the curve to zero.
150+func gaussian(days []int, sigma float64) []float64 {
151+ r := int(math.Ceil(3 * sigma))
152+ k := make([]float64, 2*r+1)
153+ for i := range k {
154+ d := float64(i - r)
155+ k[i] = math.Exp(-d * d / (2 * sigma * sigma))
156+ }
157+ out := make([]float64, len(days))
158+ for i := range days {
159+ sum, w := 0.0, 0.0
160+ for j, kj := range k {
161+ at := min(max(i+j-r, 0), len(days)-1)
162+ sum += kj * float64(days[at])
163+ w += kj
164+ }
165+ out[i] = sum / w
166+ }
167+ return out
168+}
169+
170+// curve is a smooth path through the points: a Catmull-Rom spline as cubic
171+// Béziers, its handles clamped to the box so that it never overshoots below
172+// the base line.
173+func curve(p [][2]float64) string {
174+ var b strings.Builder
175+ b.WriteString("M" + num(p[0][0]) + " " + num(p[0][1]))
176+ clampY := func(y float64) float64 { return min(max(y, heartbeatPad/2), heartbeatH-heartbeatPad) }
177+ for i := 0; i < len(p)-1; i++ {
178+ p0 := p[max(i-1, 0)]
179+ p1, p2 := p[i], p[i+1]
180+ p3 := p[min(i+2, len(p)-1)]
181+ c1x, c1y := p1[0]+(p2[0]-p0[0])/6, clampY(p1[1]+(p2[1]-p0[1])/6)
182+ c2x, c2y := p2[0]-(p3[0]-p1[0])/6, clampY(p2[1]-(p3[1]-p1[1])/6)
183+ b.WriteString(" C" + num(c1x) + " " + num(c1y) + " " + num(c2x) + " " + num(c2y) + " " + num(p2[0]) + " " + num(p2[1]))
184+ }
185+ return b.String()
186+}
187+
188+// num writes a coordinate with one decimal at most.
189+func num(f float64) string {
190+ return strconv.FormatFloat(math.Round(f*10)/10, 'f', -1, 64)
191+}
@@ -0,0 +1,191 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "encoding/binary"
7+ "math"
8+ "strconv"
9+ "strings"
10+ "sync"
11+)
12+
13+// Heartbeat is the activity curve of a home row, drawn on a box of
14+// heartbeatW by heartbeatH that the page scales to its width.
15+type Heartbeat struct {
16+ // Line is the curve, Area the same closed down to the bottom edge.
17+ Line, Area string
18+ // X and Y are where the curve ends: the pulse of today.
19+ X, Y string
20+ // GradID names the fill gradient of this row (ids are page-wide).
21+ GradID string
22+ // Label tells the curve in words.
23+ Label string
24+ // Live is set when the last week had commits: the pulse beats.
25+ Live bool
26+}
27+
28+const (
29+ heartbeatW = 600.0
30+ heartbeatH = 64.0
31+ // heartbeatPad keeps the stroke and the dot inside the box; the right
32+ // end stops before the edge so that the beating ring fits.
33+ heartbeatPad = 6.0
34+ heartbeatEndX = heartbeatW - 10
35+)
36+
37+// heartbeatOf draws the commits per day, oldest first. The days are smoothed
38+// over about a week so that the curve breathes instead of spiking, then
39+// scaled by the square root so that one big day does not flatten the rest.
40+// nil for no data.
41+// partial says the days count only some of the commits (heartbeatLabel).
42+func heartbeatOf(id string, days []int, partial bool) *Heartbeat {
43+ n := len(days)
44+ if n < 2 {
45+ return nil
46+ }
47+ total, lastWeek := 0, 0
48+ for i, d := range days {
49+ total += d
50+ if i >= n-7 {
51+ lastWeek += d
52+ }
53+ }
54+ top, bottom := heartbeatPad, heartbeatH-heartbeatPad
55+ var pts [][2]float64
56+ if total == 0 {
57+ // Asleep: a straight line on the base, two points.
58+ pts = [][2]float64{{0, bottom}, {heartbeatEndX, bottom}}
59+ } else {
60+ smooth := gaussian(days, 2.2)
61+ peak := 0.0
62+ for _, v := range smooth {
63+ peak = max(peak, math.Sqrt(v))
64+ }
65+ // One point every heartbeatStep days, and today: the smoothing
66+ // leaves nothing finer to draw, and the row goes out on every live
67+ // update.
68+ for i := 0; i < n; i += heartbeatStep {
69+ pts = append(pts, heartbeatPoint(i, n, smooth[i], peak, top, bottom))
70+ }
71+ if (n-1)%heartbeatStep != 0 {
72+ pts = append(pts, heartbeatPoint(n-1, n, smooth[n-1], peak, top, bottom))
73+ }
74+ }
75+ line := curve(pts)
76+ end := pts[len(pts)-1]
77+ return &Heartbeat{
78+ Line: line,
79+ Area: line + " L" + num(heartbeatEndX) + " " + num(heartbeatH) + " L0 " + num(heartbeatH) + " Z",
80+ X: num(end[0]),
81+ Y: num(end[1]),
82+ GradID: "hb-" + id,
83+ Label: heartbeatLabel(total, n, partial),
84+ Live: lastWeek > 0,
85+ }
86+}
87+
88+// heartbeatStep is the number of days between two points of the curve.
89+const heartbeatStep = 2
90+
91+func heartbeatPoint(i, n int, v, peak, top, bottom float64) [2]float64 {
92+ return [2]float64{float64(i) / float64(n-1) * heartbeatEndX, bottom - math.Sqrt(v)/peak*(bottom-top)}
93+}
94+
95+func heartbeatLabel(total, days int, partial bool) string {
96+ count := plural(total, "commit", "commits")
97+ if partial {
98+ count = "at least " + count
99+ }
100+ return "Activity: " + count + " in the last " + itoa(days) + " days"
101+}
102+
103+// pulseCache keeps the curves already drawn: the figures of a row stay the
104+// same for minutes (the stats cache of Browse) while the home page and its
105+// live updates render the row on every request. Keyed by the repository and
106+// its days, so a new count draws a new curve; bounded, cleared when full.
107+type pulseCache struct {
108+ mu sync.Mutex
109+ m map[pulseKey]*Heartbeat
110+}
111+
112+type pulseKey struct {
113+ id string
114+ days string // the counts, packed
115+ partial bool
116+}
117+
118+// maxPulses bounds pulseCache; a few per repository at most are live.
119+const maxPulses = 4096
120+
121+// get is heartbeatOf, from the cache when it was drawn already. The
122+// Heartbeat returned is shared: callers must not change it.
123+func (c *pulseCache) get(id string, days []int, partial bool) *Heartbeat {
124+ if len(days) < 2 {
125+ return nil
126+ }
127+ packed := make([]byte, 0, len(days)*2)
128+ for _, d := range days {
129+ packed = binary.AppendUvarint(packed, uint64(max(d, 0)))
130+ }
131+ key := pulseKey{id: id, days: string(packed), partial: partial}
132+ c.mu.Lock()
133+ h, ok := c.m[key]
134+ c.mu.Unlock()
135+ if ok {
136+ return h
137+ }
138+ h = heartbeatOf(id, days, partial)
139+ c.mu.Lock()
140+ if c.m == nil || len(c.m) >= maxPulses {
141+ c.m = make(map[pulseKey]*Heartbeat)
142+ }
143+ c.m[key] = h
144+ c.mu.Unlock()
145+ return h
146+}
147+
148+// gaussian blurs the values with a gaussian of the given width, the edges
149+// clamped: a quiet start or end does not drag the curve to zero.
150+func gaussian(days []int, sigma float64) []float64 {
151+ r := int(math.Ceil(3 * sigma))
152+ k := make([]float64, 2*r+1)
153+ for i := range k {
154+ d := float64(i - r)
155+ k[i] = math.Exp(-d * d / (2 * sigma * sigma))
156+ }
157+ out := make([]float64, len(days))
158+ for i := range days {
159+ sum, w := 0.0, 0.0
160+ for j, kj := range k {
161+ at := min(max(i+j-r, 0), len(days)-1)
162+ sum += kj * float64(days[at])
163+ w += kj
164+ }
165+ out[i] = sum / w
166+ }
167+ return out
168+}
169+
170+// curve is a smooth path through the points: a Catmull-Rom spline as cubic
171+// Béziers, its handles clamped to the box so that it never overshoots below
172+// the base line.
173+func curve(p [][2]float64) string {
174+ var b strings.Builder
175+ b.WriteString("M" + num(p[0][0]) + " " + num(p[0][1]))
176+ clampY := func(y float64) float64 { return min(max(y, heartbeatPad/2), heartbeatH-heartbeatPad) }
177+ for i := 0; i < len(p)-1; i++ {
178+ p0 := p[max(i-1, 0)]
179+ p1, p2 := p[i], p[i+1]
180+ p3 := p[min(i+2, len(p)-1)]
181+ c1x, c1y := p1[0]+(p2[0]-p0[0])/6, clampY(p1[1]+(p2[1]-p0[1])/6)
182+ c2x, c2y := p2[0]-(p3[0]-p1[0])/6, clampY(p2[1]-(p3[1]-p1[1])/6)
183+ b.WriteString(" C" + num(c1x) + " " + num(c1y) + " " + num(c2x) + " " + num(c2y) + " " + num(p2[0]) + " " + num(p2[1]))
184+ }
185+ return b.String()
186+}
187+
188+// num writes a coordinate with one decimal at most.
189+func num(f float64) string {
190+ return strconv.FormatFloat(math.Round(f*10)/10, 'f', -1, 64)
191+}
A internal/adapters/web/heartbeat_test.go +79 -0
@@ -0,0 +1,79 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strconv"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestHeartbeat(t *testing.T) {
15+ t.Parallel()
16+ assert.Nil(t, heartbeatOf("x", nil, false), "no data, no curve")
17+
18+ days := make([]int, 90)
19+ days[10], days[60], days[88] = 30, 4, 2
20+ h := heartbeatOf("proj", days, false)
21+ require.NotNil(t, h)
22+ assert.True(t, strings.HasPrefix(h.Line, "M0 "), h.Line)
23+ assert.Equal(t, 45, strings.Count(h.Line, " C"), "a point every two days, and today")
24+ assert.Less(t, len(h.Line)+len(h.Area), 4000, "light enough for every live update")
25+ assert.True(t, strings.HasPrefix(h.Area, h.Line) && strings.HasSuffix(h.Area, " Z"))
26+ assert.Equal(t, "hb-proj", h.GradID)
27+ assert.Equal(t, "Activity: 36 commits in the last 90 days", h.Label)
28+ assert.True(t, h.Live, "commits in the last week")
29+ assert.Equal(t, num(heartbeatEndX), h.X)
30+
31+ // Every point stays inside the box: no overshoot below the base line or
32+ // above the top.
33+ for _, f := range strings.FieldsFunc(h.Line, func(r rune) bool { return r == 'M' || r == 'C' || r == ' ' }) {
34+ v, err := strconv.ParseFloat(f, 64)
35+ require.NoError(t, err, f)
36+ assert.GreaterOrEqual(t, v, 0.0)
37+ assert.LessOrEqual(t, v, heartbeatW)
38+ }
39+
40+ quiet := heartbeatOf("q", make([]int, 90), false)
41+ require.NotNil(t, quiet)
42+ assert.False(t, quiet.Live)
43+ assert.Equal(t, num(heartbeatH-heartbeatPad), quiet.Y, "a flat line on the base")
44+ assert.Equal(t, 1, strings.Count(quiet.Line, " C"), "two points")
45+ assert.Equal(t, "Activity: 0 commits in the last 90 days", quiet.Label)
46+
47+ assert.Equal(t, "Activity: at least 36 commits in the last 90 days", heartbeatOf("proj", days, true).Label)
48+}
49+
50+func TestPulseCache(t *testing.T) {
51+ t.Parallel()
52+ var c pulseCache
53+ days := make([]int, 90)
54+ days[80] = 3
55+ a := c.get("proj", days, false)
56+ require.NotNil(t, a)
57+ assert.Same(t, a, c.get("proj", days, false), "same figures, same curve")
58+ assert.Equal(t, heartbeatOf("proj", days, false), a)
59+
60+ assert.NotSame(t, a, c.get("other", days, false), "per repository: the gradient id differs")
61+ assert.NotSame(t, a, c.get("proj", days, true), "partial changes the label")
62+ days[89] = 1
63+ b := c.get("proj", days, false)
64+ assert.NotSame(t, a, b, "new figures, new curve")
65+ assert.True(t, b.Live)
66+ assert.Nil(t, c.get("proj", nil, false))
67+}
68+
69+func BenchmarkPulseCache(b *testing.B) {
70+ var c pulseCache
71+ days := make([]int, 90)
72+ for i := range days {
73+ days[i] = i * 7 % 11
74+ }
75+ b.ReportAllocs()
76+ for b.Loop() {
77+ c.get("neferwl", days, false)
78+ }
79+}
@@ -0,0 +1,79 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strconv"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestHeartbeat(t *testing.T) {
15+ t.Parallel()
16+ assert.Nil(t, heartbeatOf("x", nil, false), "no data, no curve")
17+
18+ days := make([]int, 90)
19+ days[10], days[60], days[88] = 30, 4, 2
20+ h := heartbeatOf("proj", days, false)
21+ require.NotNil(t, h)
22+ assert.True(t, strings.HasPrefix(h.Line, "M0 "), h.Line)
23+ assert.Equal(t, 45, strings.Count(h.Line, " C"), "a point every two days, and today")
24+ assert.Less(t, len(h.Line)+len(h.Area), 4000, "light enough for every live update")
25+ assert.True(t, strings.HasPrefix(h.Area, h.Line) && strings.HasSuffix(h.Area, " Z"))
26+ assert.Equal(t, "hb-proj", h.GradID)
27+ assert.Equal(t, "Activity: 36 commits in the last 90 days", h.Label)
28+ assert.True(t, h.Live, "commits in the last week")
29+ assert.Equal(t, num(heartbeatEndX), h.X)
30+
31+ // Every point stays inside the box: no overshoot below the base line or
32+ // above the top.
33+ for _, f := range strings.FieldsFunc(h.Line, func(r rune) bool { return r == 'M' || r == 'C' || r == ' ' }) {
34+ v, err := strconv.ParseFloat(f, 64)
35+ require.NoError(t, err, f)
36+ assert.GreaterOrEqual(t, v, 0.0)
37+ assert.LessOrEqual(t, v, heartbeatW)
38+ }
39+
40+ quiet := heartbeatOf("q", make([]int, 90), false)
41+ require.NotNil(t, quiet)
42+ assert.False(t, quiet.Live)
43+ assert.Equal(t, num(heartbeatH-heartbeatPad), quiet.Y, "a flat line on the base")
44+ assert.Equal(t, 1, strings.Count(quiet.Line, " C"), "two points")
45+ assert.Equal(t, "Activity: 0 commits in the last 90 days", quiet.Label)
46+
47+ assert.Equal(t, "Activity: at least 36 commits in the last 90 days", heartbeatOf("proj", days, true).Label)
48+}
49+
50+func TestPulseCache(t *testing.T) {
51+ t.Parallel()
52+ var c pulseCache
53+ days := make([]int, 90)
54+ days[80] = 3
55+ a := c.get("proj", days, false)
56+ require.NotNil(t, a)
57+ assert.Same(t, a, c.get("proj", days, false), "same figures, same curve")
58+ assert.Equal(t, heartbeatOf("proj", days, false), a)
59+
60+ assert.NotSame(t, a, c.get("other", days, false), "per repository: the gradient id differs")
61+ assert.NotSame(t, a, c.get("proj", days, true), "partial changes the label")
62+ days[89] = 1
63+ b := c.get("proj", days, false)
64+ assert.NotSame(t, a, b, "new figures, new curve")
65+ assert.True(t, b.Live)
66+ assert.Nil(t, c.get("proj", nil, false))
67+}
68+
69+func BenchmarkPulseCache(b *testing.B) {
70+ var c pulseCache
71+ days := make([]int, 90)
72+ for i := range days {
73+ days[i] = i * 7 % 11
74+ }
75+ b.ReportAllocs()
76+ for b.Loop() {
77+ c.get("neferwl", days, false)
78+ }
79+}
M internal/adapters/web/home.go +5 -2
@@ -211,7 +211,10 @@ func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, n
211211 }
212212 return nil
213213 }
214- hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)}
214+ hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
215+ if st.Partial {
216+ hs.Recent += "+" // a lower bound: the walk stopped at its cap
217+ }
215218 if t := st.LatestTag; t != nil {
216219 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
217220 if !t.CommitID.IsZero() {
@@ -259,7 +262,7 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
259262 name := r.Name.String()
260263 row := HomeRow{
261264 ID: "repo-" + name, Name: name, Href: repoHref(name),
262- Desc: r.Description, Cursor: cursor,
265+ Desc: r.Description, Topics: r.Topics, Cursor: cursor,
263266 Private: signed && r.Visibility == domain.VisibilityPrivate,
264267 }
265268 if !r.PushedAt.IsZero() {
@@ -211,7 +211,10 @@ func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, n
211 }211 }
212 return nil212 return nil
213 }213 }
214- hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)}214+ hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
215+ if st.Partial {
216+ hs.Recent += "+" // a lower bound: the walk stopped at its cap
217+ }
215 if t := st.LatestTag; t != nil {218 if t := st.LatestTag; t != nil {
216 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)219 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
217 if !t.CommitID.IsZero() {220 if !t.CommitID.IsZero() {
@@ -259,7 +262,7 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
259 name := r.Name.String()262 name := r.Name.String()
260 row := HomeRow{263 row := HomeRow{
261 ID: "repo-" + name, Name: name, Href: repoHref(name),264 ID: "repo-" + name, Name: name, Href: repoHref(name),
262- Desc: r.Description, Cursor: cursor,265+ Desc: r.Description, Topics: r.Topics, Cursor: cursor,
263 Private: signed && r.Visibility == domain.VisibilityPrivate,266 Private: signed && r.Visibility == domain.VisibilityPrivate,
264 }267 }
265 if !r.PushedAt.IsZero() {268 if !r.PushedAt.IsZero() {
M internal/adapters/web/home_test.go +17 -2
@@ -66,19 +66,33 @@ func TestHomeRowFigures(t *testing.T) {
6666 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
6767 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
6868 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69+ Activity: make([]int, ports.ActivityDays),
6970 }, nil).Once()
7071 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
7172
72- row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
73+ view := pushedRepo(t, "proj")
74+ view.Topics = []string{"go", "git"}
75+ row := srv.homeRowOf(context.Background(), view, now, false)
7376 require.NotNil(t, row.Stats)
7477 out := renderRow(t, row)
7578 assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
7679 assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")
77- assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
80+ assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
7881 assert.Contains(t, out, "<dt>last activity</dt>")
7982 assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83+ assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj"><li>go</li><li>git</li></ul>`)
84+ assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
85+ assert.Contains(t, out, `<linearGradient id="hb-proj"`)
8086 assert.NotContains(t, out, "pushed")
8187
88+ // Past the cap of the walk, the figures are lower bounds.
89+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
90+ Commits: 300000, Recent: 100000, Activity: make([]int, ports.ActivityDays), Partial: true,
91+ }, nil).Once()
92+ out = renderRow(t, srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false))
93+ assert.Contains(t, out, "<dt>last 30 days</dt><dd>100\u202f000+</dd>")
94+ assert.Contains(t, out, `aria-label="Activity: at least 0 commits in the last 90 days"`)
95+
8296 // A failed read leaves the figures out, not the row.
8397 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
8498 row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
@@ -126,6 +140,7 @@ func TestHomeRowShowsFiveCommits(t *testing.T) {
126140
127141 out := renderRow(t, row)
128142 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
143+ assert.Contains(t, out, `class="repo-commits" data-full`, "a full list fades its last commit")
129144 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
130145 assert.Contains(t, out, `aria-label="Recent commits of proj"`)
131146 assert.NotContains(t, out, `class="repo-ci"`)
@@ -66,19 +66,33 @@ func TestHomeRowFigures(t *testing.T) {
66 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)66 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
67 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{67 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
68 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},68 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69+ Activity: make([]int, ports.ActivityDays),
69 }, nil).Once()70 }, nil).Once()
70 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})71 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71 72
72- row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)73+ view := pushedRepo(t, "proj")
74+ view.Topics = []string{"go", "git"}
75+ row := srv.homeRowOf(context.Background(), view, now, false)
73 require.NotNil(t, row.Stats)76 require.NotNil(t, row.Stats)
74 out := renderRow(t, row)77 out := renderRow(t, row)
75 assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")78 assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
76 assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")79 assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")
77- assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")80+ assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
78 assert.Contains(t, out, "<dt>last activity</dt>")81 assert.Contains(t, out, "<dt>last activity</dt>")
79 assert.NotContains(t, out, "i-branch", "the default branch is not shown")82 assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83+ assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj"><li>go</li><li>git</li></ul>`)
84+ assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
85+ assert.Contains(t, out, `<linearGradient id="hb-proj"`)
80 assert.NotContains(t, out, "pushed")86 assert.NotContains(t, out, "pushed")
81 87
88+ // Past the cap of the walk, the figures are lower bounds.
89+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
90+ Commits: 300000, Recent: 100000, Activity: make([]int, ports.ActivityDays), Partial: true,
91+ }, nil).Once()
92+ out = renderRow(t, srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false))
93+ assert.Contains(t, out, "<dt>last 30 days</dt><dd>100\u202f000+</dd>")
94+ assert.Contains(t, out, `aria-label="Activity: at least 0 commits in the last 90 days"`)
95+
82 // A failed read leaves the figures out, not the row.96 // A failed read leaves the figures out, not the row.
83 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()97 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
84 row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)98 row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
@@ -126,6 +140,7 @@ func TestHomeRowShowsFiveCommits(t *testing.T) {
126 140
127 out := renderRow(t, row)141 out := renderRow(t, row)
128 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))142 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
143+ assert.Contains(t, out, `class="repo-commits" data-full`, "a full list fades its last commit")
129 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)144 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
130 assert.Contains(t, out, `aria-label="Recent commits of proj"`)145 assert.Contains(t, out, `aria-label="Recent commits of proj"`)
131 assert.NotContains(t, out, `class="repo-ci"`)146 assert.NotContains(t, out, `class="repo-ci"`)
M internal/adapters/web/static/css/30-list.css +40 -8
@@ -48,8 +48,8 @@
4848 display: flex; flex-direction: column; gap: var(--s3);
4949 margin: 0; padding: 0; list-style: none;
5050 & .repo {
51- display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";
52- grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;
51+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main head" "main recent";
52+ grid-template-rows: auto 1fr; column-gap: var(--s6); row-gap: var(--s3); min-width: 0;
5353 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
5454 }
5555 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
@@ -65,17 +65,43 @@
6565 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
6666 }
6767 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
68+ /* topics: small quiet chips, as on GitHub */
69+ & .repo-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
70+ & .repo-topics li {
71+ padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
72+ font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);
73+ }
74+ /* the pulse: the commits per day over 90 days, smoothed, under the text */
75+ & .repo-pulse { position: relative; margin: auto 0 0; padding-top: var(--s2); }
76+ & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }
77+ & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }
78+ & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }
79+ & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }
80+ & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
81+ & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }
82+ & .pulse-ring { fill: none; stroke: var(--teal); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: center; }
83+ & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }
84+ & .pulse-cap { display: flex; justify-content: space-between; margin-top: 2px; font-size: 10px; line-height: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
85+ & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }
86+ & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }
87+ & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }
88+ @media (prefers-reduced-motion: no-preference) {
89+ & .repo-pulse[data-live] .pulse-ring { animation: pulse-beat 2.4s cubic-bezier(.2, .6, .3, 1) infinite; }
90+ }
91+ /* the right column: figures, then the CI state, over the commits */
92+ & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }
6893 /* the figures: a small label over a mono value, side by side */
69- & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: var(--s1) 0 0; }
94+ & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: 0; min-width: 0; }
7095 & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
71- & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
96+ & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); white-space: nowrap; }
7297 & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
98+ & .repo-stats .rs-tag dd { max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
7399 & .repo-stats dd.mute { color: var(--ink-mute); }
74100 & .repo-stats dd a { color: inherit; text-decoration: none; }
75101 & .repo-stats dd a:hover { text-decoration: underline; }
76102 & .repo-ci {
77- grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
78- font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
103+ flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;
104+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
79105 }
80106 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
81107 & .repo-ci .ck { margin: 0; }
@@ -86,11 +112,17 @@
86112 & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
87113 & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
88114 & .repo-commits time { flex: none; white-space: nowrap; }
115+ /* a full list goes on: its last commit fades */
116+ & .repo-commits[data-full] li:last-child { opacity: .65; }
89117 @media (max-width: 720px) {
90- & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }
91- & .repo-ci { justify-self: start; margin-bottom: 0; }
118+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }
119+ & .repo-head { flex-wrap: wrap; }
92120 }
93121 }
122+ @keyframes pulse-beat {
123+ 0% { opacity: .9; transform: scale(1); }
124+ 70%, 100% { opacity: 0; transform: scale(3.2); }
125+ }
94126 .list.passkeys {
95127 & .c-name { width: auto; font-weight: 600; }
96128 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -48,8 +48,8 @@
48 display: flex; flex-direction: column; gap: var(--s3);48 display: flex; flex-direction: column; gap: var(--s3);
49 margin: 0; padding: 0; list-style: none;49 margin: 0; padding: 0; list-style: none;
50 & .repo {50 & .repo {
51- display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";51+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main head" "main recent";
52- grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;52+ grid-template-rows: auto 1fr; column-gap: var(--s6); row-gap: var(--s3); min-width: 0;
53 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);53 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
54 }54 }
55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
@@ -65,17 +65,43 @@
65 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;65 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
66 }66 }
67 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }67 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
68+ /* topics: small quiet chips, as on GitHub */
69+ & .repo-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
70+ & .repo-topics li {
71+ padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
72+ font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);
73+ }
74+ /* the pulse: the commits per day over 90 days, smoothed, under the text */
75+ & .repo-pulse { position: relative; margin: auto 0 0; padding-top: var(--s2); }
76+ & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }
77+ & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }
78+ & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }
79+ & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }
80+ & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
81+ & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }
82+ & .pulse-ring { fill: none; stroke: var(--teal); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: center; }
83+ & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }
84+ & .pulse-cap { display: flex; justify-content: space-between; margin-top: 2px; font-size: 10px; line-height: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
85+ & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }
86+ & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }
87+ & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }
88+ @media (prefers-reduced-motion: no-preference) {
89+ & .repo-pulse[data-live] .pulse-ring { animation: pulse-beat 2.4s cubic-bezier(.2, .6, .3, 1) infinite; }
90+ }
91+ /* the right column: figures, then the CI state, over the commits */
92+ & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }
68 /* the figures: a small label over a mono value, side by side */93 /* the figures: a small label over a mono value, side by side */
69- & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: var(--s1) 0 0; }94+ & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: 0; min-width: 0; }
70 & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }95 & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
71- & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }96+ & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); white-space: nowrap; }
72 & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }97 & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
98+ & .repo-stats .rs-tag dd { max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
73 & .repo-stats dd.mute { color: var(--ink-mute); }99 & .repo-stats dd.mute { color: var(--ink-mute); }
74 & .repo-stats dd a { color: inherit; text-decoration: none; }100 & .repo-stats dd a { color: inherit; text-decoration: none; }
75 & .repo-stats dd a:hover { text-decoration: underline; }101 & .repo-stats dd a:hover { text-decoration: underline; }
76 & .repo-ci {102 & .repo-ci {
77- grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);103+ flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;
78- font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;104+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
79 }105 }
80 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }106 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
81 & .repo-ci .ck { margin: 0; }107 & .repo-ci .ck { margin: 0; }
@@ -86,11 +112,17 @@
86 & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }112 & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
87 & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }113 & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
88 & .repo-commits time { flex: none; white-space: nowrap; }114 & .repo-commits time { flex: none; white-space: nowrap; }
115+ /* a full list goes on: its last commit fades */
116+ & .repo-commits[data-full] li:last-child { opacity: .65; }
89 @media (max-width: 720px) {117 @media (max-width: 720px) {
90- & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }118+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }
91- & .repo-ci { justify-self: start; margin-bottom: 0; }119+ & .repo-head { flex-wrap: wrap; }
92 }120 }
93 }121 }
122+ @keyframes pulse-beat {
123+ 0% { opacity: .9; transform: scale(1); }
124+ 70%, 100% { opacity: 0; transform: scale(3.2); }
125+ }
94 .list.passkeys {126 .list.passkeys {
95 & .c-name { width: auto; font-weight: 600; }127 & .c-name { width: auto; font-weight: 600; }
96 & .c-when { width: 120px; font-size: var(--fs-s); }128 & .c-when { width: 120px; font-size: var(--fs-s); }
M internal/adapters/web/view.go +4 -0
@@ -80,6 +80,8 @@ type HomeRow struct {
8080 Name string
8181 Href string
8282 Desc string
83+ // Topics classify the repository, as on GitHub.
84+ Topics []string
8385 // Active is when the repository last changed (its last push or merge),
8486 // ActiveAt its RFC 3339 form, "" when it never got a commit.
8587 Active string
@@ -107,6 +109,8 @@ type HomeStats struct {
107109 // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen
108110 // tells when it was made.
109111 Tag, TagHref, TagWhen string
112+ // Pulse is the activity curve; nil when it could not be drawn.
113+ Pulse *Heartbeat
110114 }
111115
112116 // HomeCI is the state of the latest run of the default branch of a repository.
@@ -80,6 +80,8 @@ type HomeRow struct {
80 Name string80 Name string
81 Href string81 Href string
82 Desc string82 Desc string
83+ // Topics classify the repository, as on GitHub.
84+ Topics []string
83 // Active is when the repository last changed (its last push or merge),85 // Active is when the repository last changed (its last push or merge),
84 // ActiveAt its RFC 3339 form, "" when it never got a commit.86 // ActiveAt its RFC 3339 form, "" when it never got a commit.
85 Active string87 Active string
@@ -107,6 +109,8 @@ type HomeStats struct {
107 // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen109 // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen
108 // tells when it was made.110 // tells when it was made.
109 Tag, TagHref, TagWhen string111 Tag, TagHref, TagWhen string
112+ // Pulse is the activity curve; nil when it could not be drawn.
113+ Pulse *Heartbeat
110 }114 }
111 115
112 // HomeCI is the state of the latest run of the default branch of a repository.116 // HomeCI is the state of the latest run of the default branch of a repository.
M internal/adapters/web/web.go +2 -0
@@ -115,6 +115,8 @@ type Server struct {
115115 // refsFan shares the fragments of the refs page between the watchers of
116116 // a push; entries are few and short-lived.
117117 refsFan fanout
118+ // pulses holds the activity curves of the home rows.
119+ pulses pulseCache
118120 }
119121
120122 // New builds the web handler.
@@ -115,6 +115,8 @@ type Server struct {
115 // refsFan shares the fragments of the refs page between the watchers of115 // refsFan shares the fragments of the refs page between the watchers of
116 // a push; entries are few and short-lived.116 // a push; entries are few and short-lived.
117 refsFan fanout117 refsFan fanout
118+ // pulses holds the activity curves of the home rows.
119+ pulses pulseCache
118 }120 }
119 121
120 // New builds the web handler.122 // New builds the web handler.
M internal/app/cli_e2e_test.go +20 -8
@@ -168,12 +168,13 @@ type tokenOut struct {
168168 }
169169
170170 type repoOut struct {
171- Name string `json:"name"`
172- Description string `json:"description"`
173- Visibility string `json:"visibility"`
174- DefaultBranch string `json:"default_branch"`
175- CreatedAt *string `json:"created_at"`
176- PushedAt *string `json:"pushed_at"`
171+ Name string `json:"name"`
172+ Description string `json:"description"`
173+ Topics []string `json:"topics"`
174+ Visibility string `json:"visibility"`
175+ DefaultBranch string `json:"default_branch"`
176+ CreatedAt *string `json:"created_at"`
177+ PushedAt *string `json:"pushed_at"`
177178 }
178179
179180 type eventOut struct {
@@ -190,7 +191,7 @@ type eventOut struct {
190191 MergeRequest *mrOut `json:"merge_request"`
191192 }
192193
193-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "visibility"}
194+var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"}
194195
195196 // AC6: the CLI end to end against a real forge.
196197 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -209,11 +210,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
209210 w := c.startWatch(t, secret)
210211
211212 // Create. Private by default; here public with a description.
212- res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--visibility", "public", "--json").ok(t)
213+ res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--topics", "Go,cli,go", "--visibility", "public", "--json").ok(t)
213214 assert.Equal(t, repoKeys, keys(t, res.stdout))
214215 created := decode[repoOut](t, res.stdout)
215216 assert.Equal(t, "proj", created.Name)
216217 assert.Equal(t, "first", created.Description)
218+ assert.Equal(t, []string{"cli", "go"}, created.Topics, "normalized: lowercase, sorted, deduplicated")
217219 assert.Equal(t, "public", created.Visibility)
218220 assert.Equal(t, "main", created.DefaultBranch)
219221 require.NotNil(t, created.CreatedAt)
@@ -282,6 +284,16 @@ func TestAC6CLIEndToEnd(t *testing.T) {
282284 assert.Empty(t, decode[repoOut](t, res.stdout).Description)
283285 assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
284286
287+ // Topics are replaced as a whole and cleared with an empty value.
288+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "forge,git", "--json").ok(t)
289+ assert.Equal(t, []string{"forge", "git"}, decode[repoOut](t, res.stdout).Topics)
290+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
291+ _, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--topics", "Bad_Topic")
292+ requireOneLineError(t, stderr, code, "invalid request")
293+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "", "--json").ok(t)
294+ assert.Empty(t, decode[repoOut](t, res.stdout).Topics)
295+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
296+
285297 // list and show: same shape, human tables too.
286298 res = c.cli(t, secret, "repo", "list", "--json").ok(t)
287299 list := decode[[]json.RawMessage](t, res.stdout)
@@ -168,12 +168,13 @@ type tokenOut struct {
168 }168 }
169 169
170 type repoOut struct {170 type repoOut struct {
171- Name string `json:"name"`171+ Name string `json:"name"`
172- Description string `json:"description"`172+ Description string `json:"description"`
173- Visibility string `json:"visibility"`173+ Topics []string `json:"topics"`
174- DefaultBranch string `json:"default_branch"`174+ Visibility string `json:"visibility"`
175- CreatedAt *string `json:"created_at"`175+ DefaultBranch string `json:"default_branch"`
176- PushedAt *string `json:"pushed_at"`176+ CreatedAt *string `json:"created_at"`
177+ PushedAt *string `json:"pushed_at"`
177 }178 }
178 179
179 type eventOut struct {180 type eventOut struct {
@@ -190,7 +191,7 @@ type eventOut struct {
190 MergeRequest *mrOut `json:"merge_request"`191 MergeRequest *mrOut `json:"merge_request"`
191 }192 }
192 193
193-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "visibility"}194+var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"}
194 195
195 // AC6: the CLI end to end against a real forge.196 // AC6: the CLI end to end against a real forge.
196 func TestAC6CLIEndToEnd(t *testing.T) {197 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -209,11 +210,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
209 w := c.startWatch(t, secret)210 w := c.startWatch(t, secret)
210 211
211 // Create. Private by default; here public with a description.212 // Create. Private by default; here public with a description.
212- res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--visibility", "public", "--json").ok(t)213+ res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--topics", "Go,cli,go", "--visibility", "public", "--json").ok(t)
213 assert.Equal(t, repoKeys, keys(t, res.stdout))214 assert.Equal(t, repoKeys, keys(t, res.stdout))
214 created := decode[repoOut](t, res.stdout)215 created := decode[repoOut](t, res.stdout)
215 assert.Equal(t, "proj", created.Name)216 assert.Equal(t, "proj", created.Name)
216 assert.Equal(t, "first", created.Description)217 assert.Equal(t, "first", created.Description)
218+ assert.Equal(t, []string{"cli", "go"}, created.Topics, "normalized: lowercase, sorted, deduplicated")
217 assert.Equal(t, "public", created.Visibility)219 assert.Equal(t, "public", created.Visibility)
218 assert.Equal(t, "main", created.DefaultBranch)220 assert.Equal(t, "main", created.DefaultBranch)
219 require.NotNil(t, created.CreatedAt)221 require.NotNil(t, created.CreatedAt)
@@ -282,6 +284,16 @@ func TestAC6CLIEndToEnd(t *testing.T) {
282 assert.Empty(t, decode[repoOut](t, res.stdout).Description)284 assert.Empty(t, decode[repoOut](t, res.stdout).Description)
283 assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)285 assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
284 286
287+ // Topics are replaced as a whole and cleared with an empty value.
288+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "forge,git", "--json").ok(t)
289+ assert.Equal(t, []string{"forge", "git"}, decode[repoOut](t, res.stdout).Topics)
290+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
291+ _, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--topics", "Bad_Topic")
292+ requireOneLineError(t, stderr, code, "invalid request")
293+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "", "--json").ok(t)
294+ assert.Empty(t, decode[repoOut](t, res.stdout).Topics)
295+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
296+
285 // list and show: same shape, human tables too.297 // list and show: same shape, human tables too.
286 res = c.cli(t, secret, "repo", "list", "--json").ok(t)298 res = c.cli(t, secret, "repo", "list", "--json").ok(t)
287 list := decode[[]json.RawMessage](t, res.stdout)299 list := decode[[]json.RawMessage](t, res.stdout)
M internal/app/cli_repo.go +25 -15
@@ -26,12 +26,13 @@ const (
2626
2727 // jsonRepo is the stable JSON shape of a repository.
2828 type jsonRepo struct {
29- Name string `json:"name"`
30- Description string `json:"description"`
31- Visibility string `json:"visibility"`
32- DefaultBranch string `json:"default_branch"`
33- CreatedAt *string `json:"created_at"`
34- PushedAt *string `json:"pushed_at"`
29+ Name string `json:"name"`
30+ Description string `json:"description"`
31+ Topics []string `json:"topics"`
32+ Visibility string `json:"visibility"`
33+ DefaultBranch string `json:"default_branch"`
34+ CreatedAt *string `json:"created_at"`
35+ PushedAt *string `json:"pushed_at"`
3536 // MergeModes are the allowed merge modes ("fast-forward", "squash").
3637 MergeModes []string `json:"merge_modes"`
3738 FailingChecksBlock bool `json:"failing_checks_block"`
@@ -47,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4748 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
4849 Name: r.GetName(),
4950 Description: r.GetDescription(),
51+ Topics: append([]string{}, r.GetTopics()...),
5052 Visibility: visibilityName(r.GetVisibility()),
5153 DefaultBranch: r.GetDefaultBranch(),
5254 CreatedAt: jsonTime(r.GetCreatedAt()),
@@ -188,6 +190,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
188190 {"visibility", visibilityName(r.GetVisibility())},
189191 {"default branch", r.GetDefaultBranch()},
190192 {"description", desc},
193+ {"topics", orDash(strings.Join(r.GetTopics(), ", "))},
191194 {"created", shortTime(r.GetCreatedAt(), "-")},
192195 {"last push", shortTime(r.GetPushedAt(), "never")},
193196 {"merge modes", orDash(strings.Join(modes, ", "))},
@@ -221,6 +224,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
221224 func newRepoCreateCmd(env *cliEnv) *cobra.Command {
222225 var (
223226 description string
227+ topics []string
224228 visibility string
225229 )
226230 cmd := &cobra.Command{
@@ -231,11 +235,12 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
231235 Args: cobra.ExactArgs(1),
232236 }
233237 cmd.Flags().StringVar(&description, "description", "", "one-line description")
238+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "topics, comma-separated (1-50 characters of a-z, 0-9 and -; at most 20)")
234239 cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
235240 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
236241 asJSON := addJSONFlag(cmd)
237242 cmd.RunE = func(cmd *cobra.Command, args []string) error {
238- req, err := buildCreateRepo(args[0], description, visibility)
243+ req, err := buildCreateRepo(args[0], description, topics, visibility)
239244 if err != nil {
240245 return err
241246 }
@@ -257,23 +262,23 @@ func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.She
257262 }
258263
259264 // buildCreateRepo maps the flags of `repo create` to the request.
260-func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRequest, error) {
265+func buildCreateRepo(name, description string, topics []string, visibility string) (*ermite.CreateRepoRequest, error) {
261266 vis, err := parseVisibility(visibility)
262267 if err != nil {
263268 return nil, err
264269 }
265- return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil
270+ return &ermite.CreateRepoRequest{Name: name, Description: description, Topics: topics, Visibility: vis}, nil
266271 }
267272
268273 func newRepoEditCmd(env *cliEnv) *cobra.Command {
269274 var description, visibility, defaultBranch string
270- var mergeModes []string
275+ var topics, mergeModes []string
271276 var failingChecksBlock bool
272277 cmd := &cobra.Command{
273278 Use: "edit <name>",
274- Short: "Change a repository's description, visibility, default branch or merge settings",
279+ Short: "Change a repository's description, topics, visibility, default branch or merge settings",
275280 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
276- "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
281+ "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
277282 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
278283 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
279284 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -281,6 +286,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
281286 ValidArgsFunction: completeRepoNames(env),
282287 }
283288 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
289+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
284290 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
285291 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
286292 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
@@ -291,7 +297,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291297 })
292298 asJSON := addJSONFlag(cmd)
293299 cmd.RunE = func(cmd *cobra.Command, args []string) error {
294- req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch, mergeModes, failingChecksBlock)
300+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock)
295301 if err != nil {
296302 return err
297303 }
@@ -310,13 +316,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
310316
311317 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
312318 // the user passed go into the update mask.
313-func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
319+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
314320 req := &ermite.UpdateRepoRequest{Repo: name}
315321 var paths []string
316322 if cmd.Flags().Changed("description") {
317323 req.Description = description
318324 paths = append(paths, "description")
319325 }
326+ if cmd.Flags().Changed("topics") {
327+ req.Topics = topics
328+ paths = append(paths, "topics")
329+ }
320330 if cmd.Flags().Changed("visibility") {
321331 vis, err := parseVisibility(visibility)
322332 if err != nil {
@@ -356,7 +366,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB
356366 paths = append(paths, "settings.failing_checks_block")
357367 }
358368 if len(paths) == 0 {
359- return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch, --merge-modes, --failing-checks-block")
369+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block")
360370 }
361371 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
362372 return req, nil
@@ -26,12 +26,13 @@ const (
26 26
27 // jsonRepo is the stable JSON shape of a repository.27 // jsonRepo is the stable JSON shape of a repository.
28 type jsonRepo struct {28 type jsonRepo struct {
29- Name string `json:"name"`29+ Name string `json:"name"`
30- Description string `json:"description"`30+ Description string `json:"description"`
31- Visibility string `json:"visibility"`31+ Topics []string `json:"topics"`
32- DefaultBranch string `json:"default_branch"`32+ Visibility string `json:"visibility"`
33- CreatedAt *string `json:"created_at"`33+ DefaultBranch string `json:"default_branch"`
34- PushedAt *string `json:"pushed_at"`34+ CreatedAt *string `json:"created_at"`
35+ PushedAt *string `json:"pushed_at"`
35 // MergeModes are the allowed merge modes ("fast-forward", "squash").36 // MergeModes are the allowed merge modes ("fast-forward", "squash").
36 MergeModes []string `json:"merge_modes"`37 MergeModes []string `json:"merge_modes"`
37 FailingChecksBlock bool `json:"failing_checks_block"`38 FailingChecksBlock bool `json:"failing_checks_block"`
@@ -47,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
47 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),48 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
48 Name: r.GetName(),49 Name: r.GetName(),
49 Description: r.GetDescription(),50 Description: r.GetDescription(),
51+ Topics: append([]string{}, r.GetTopics()...),
50 Visibility: visibilityName(r.GetVisibility()),52 Visibility: visibilityName(r.GetVisibility()),
51 DefaultBranch: r.GetDefaultBranch(),53 DefaultBranch: r.GetDefaultBranch(),
52 CreatedAt: jsonTime(r.GetCreatedAt()),54 CreatedAt: jsonTime(r.GetCreatedAt()),
@@ -188,6 +190,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
188 {"visibility", visibilityName(r.GetVisibility())},190 {"visibility", visibilityName(r.GetVisibility())},
189 {"default branch", r.GetDefaultBranch()},191 {"default branch", r.GetDefaultBranch()},
190 {"description", desc},192 {"description", desc},
193+ {"topics", orDash(strings.Join(r.GetTopics(), ", "))},
191 {"created", shortTime(r.GetCreatedAt(), "-")},194 {"created", shortTime(r.GetCreatedAt(), "-")},
192 {"last push", shortTime(r.GetPushedAt(), "never")},195 {"last push", shortTime(r.GetPushedAt(), "never")},
193 {"merge modes", orDash(strings.Join(modes, ", "))},196 {"merge modes", orDash(strings.Join(modes, ", "))},
@@ -221,6 +224,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
221 func newRepoCreateCmd(env *cliEnv) *cobra.Command {224 func newRepoCreateCmd(env *cliEnv) *cobra.Command {
222 var (225 var (
223 description string226 description string
227+ topics []string
224 visibility string228 visibility string
225 )229 )
226 cmd := &cobra.Command{230 cmd := &cobra.Command{
@@ -231,11 +235,12 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
231 Args: cobra.ExactArgs(1),235 Args: cobra.ExactArgs(1),
232 }236 }
233 cmd.Flags().StringVar(&description, "description", "", "one-line description")237 cmd.Flags().StringVar(&description, "description", "", "one-line description")
238+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "topics, comma-separated (1-50 characters of a-z, 0-9 and -; at most 20)")
234 cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")239 cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
235 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)240 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
236 asJSON := addJSONFlag(cmd)241 asJSON := addJSONFlag(cmd)
237 cmd.RunE = func(cmd *cobra.Command, args []string) error {242 cmd.RunE = func(cmd *cobra.Command, args []string) error {
238- req, err := buildCreateRepo(args[0], description, visibility)243+ req, err := buildCreateRepo(args[0], description, topics, visibility)
239 if err != nil {244 if err != nil {
240 return err245 return err
241 }246 }
@@ -257,23 +262,23 @@ func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.She
257 }262 }
258 263
259 // buildCreateRepo maps the flags of `repo create` to the request.264 // buildCreateRepo maps the flags of `repo create` to the request.
260-func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRequest, error) {265+func buildCreateRepo(name, description string, topics []string, visibility string) (*ermite.CreateRepoRequest, error) {
261 vis, err := parseVisibility(visibility)266 vis, err := parseVisibility(visibility)
262 if err != nil {267 if err != nil {
263 return nil, err268 return nil, err
264 }269 }
265- return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil270+ return &ermite.CreateRepoRequest{Name: name, Description: description, Topics: topics, Visibility: vis}, nil
266 }271 }
267 272
268 func newRepoEditCmd(env *cliEnv) *cobra.Command {273 func newRepoEditCmd(env *cliEnv) *cobra.Command {
269 var description, visibility, defaultBranch string274 var description, visibility, defaultBranch string
270- var mergeModes []string275+ var topics, mergeModes []string
271 var failingChecksBlock bool276 var failingChecksBlock bool
272 cmd := &cobra.Command{277 cmd := &cobra.Command{
273 Use: "edit <name>",278 Use: "edit <name>",
274- Short: "Change a repository's description, visibility, default branch or merge settings",279+ Short: "Change a repository's description, topics, visibility, default branch or merge settings",
275 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +280 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
276- "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" +281+ "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
277 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +282 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
278 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +283 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
279 "Needs the repo:admin scope." + jsonRepoHelp,284 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -281,6 +286,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
281 ValidArgsFunction: completeRepoNames(env),286 ValidArgsFunction: completeRepoNames(env),
282 }287 }
283 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")288 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
289+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
284 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")290 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
285 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")291 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
286 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")292 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
@@ -291,7 +297,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291 })297 })
292 asJSON := addJSONFlag(cmd)298 asJSON := addJSONFlag(cmd)
293 cmd.RunE = func(cmd *cobra.Command, args []string) error {299 cmd.RunE = func(cmd *cobra.Command, args []string) error {
294- req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch, mergeModes, failingChecksBlock)300+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock)
295 if err != nil {301 if err != nil {
296 return err302 return err
297 }303 }
@@ -310,13 +316,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
310 316
311 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags317 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
312 // the user passed go into the update mask.318 // the user passed go into the update mask.
313-func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {319+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
314 req := &ermite.UpdateRepoRequest{Repo: name}320 req := &ermite.UpdateRepoRequest{Repo: name}
315 var paths []string321 var paths []string
316 if cmd.Flags().Changed("description") {322 if cmd.Flags().Changed("description") {
317 req.Description = description323 req.Description = description
318 paths = append(paths, "description")324 paths = append(paths, "description")
319 }325 }
326+ if cmd.Flags().Changed("topics") {
327+ req.Topics = topics
328+ paths = append(paths, "topics")
329+ }
320 if cmd.Flags().Changed("visibility") {330 if cmd.Flags().Changed("visibility") {
321 vis, err := parseVisibility(visibility)331 vis, err := parseVisibility(visibility)
322 if err != nil {332 if err != nil {
@@ -356,7 +366,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB
356 paths = append(paths, "settings.failing_checks_block")366 paths = append(paths, "settings.failing_checks_block")
357 }367 }
358 if len(paths) == 0 {368 if len(paths) == 0 {
359- return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch, --merge-modes, --failing-checks-block")369+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block")
360 }370 }
361 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}371 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
362 return req, nil372 return req, nil
M internal/app/cli_repo_test.go +22 -12
@@ -16,7 +16,7 @@ import (
1616
1717 var (
1818 repoAlpha = &ermite.Repo{
19- Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
19+ Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
2020 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
2121 Settings: &ermite.RepoSettings{
2222 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 wantStdout: `[
57- {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
5959 ]`,
6060 },
6161 {
@@ -111,6 +111,7 @@ func TestRepoShow(t *testing.T) {
111111 "visibility public\n"+
112112 "default branch main\n"+
113113 "description The first one\n"+
114+ "topics cli, go\n"+
114115 "created 2026-01-02 03:04\n"+
115116 "last push 2026-03-04 05:06\n"+
116117 "merge modes fast-forward, squash\n"+
@@ -119,7 +120,7 @@ func TestRepoShow(t *testing.T) {
119120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
120121
121122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
122- assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
123+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
123124 }
124125
125126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -129,14 +130,16 @@ func TestRepoShowNeedsOneArg(t *testing.T) {
129130
130131 func TestRepoCreateMapsFlags(t *testing.T) {
131132 tests := []struct {
132- name string
133- args []string
134- want ermite.Visibility
135- desc string
133+ name string
134+ args []string
135+ want ermite.Visibility
136+ desc string
137+ topics []string
136138 }{
137- {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
138- {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d"},
139- {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, ""},
139+ {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, "", nil},
140+ {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d", nil},
141+ {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, "", nil},
142+ {"--topics", []string{"x", "--topics", "go,cli"}, ermite.Visibility_VISIBILITY_PRIVATE, "", []string{"go", "cli"}},
140143 }
141144 for _, tt := range tests {
142145 t.Run(tt.name, func(t *testing.T) {
@@ -150,6 +153,7 @@ func TestRepoCreateMapsFlags(t *testing.T) {
150153 assert.Equal(t, "x", got.GetName())
151154 assert.Equal(t, tt.want, got.GetVisibility())
152155 assert.Equal(t, tt.desc, got.GetDescription())
156+ assert.Equal(t, tt.topics, got.GetTopics())
153157 })
154158 }
155159 }
@@ -172,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
172176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
173177 }}
174178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
175- assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
179+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
176180 }
177181
178182 func TestRepoEditMask(t *testing.T) {
@@ -188,6 +192,12 @@ func TestRepoEditMask(t *testing.T) {
188192 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
189193 assert.Empty(t, r.GetDescription())
190194 }},
195+ {"topics", []string{"--topics", "Go,cli"}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
196+ assert.Equal(t, []string{"Go", "cli"}, r.GetTopics())
197+ }},
198+ {"clear topics", []string{"--topics", ""}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
199+ assert.Empty(t, r.GetTopics())
200+ }},
191201 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
192202 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
193203 }},
@@ -16,7 +16,7 @@ import (
16 16
17 var (17 var (
18 repoAlpha = &ermite.Repo{18 repoAlpha = &ermite.Repo{
19- Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,19+ Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21 Settings: &ermite.RepoSettings{21 Settings: &ermite.RepoSettings{
22 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},22 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
54 {54 {
55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56 wantStdout: `[56 wantStdout: `[
57- {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
59 ]`,59 ]`,
60 },60 },
61 {61 {
@@ -111,6 +111,7 @@ func TestRepoShow(t *testing.T) {
111 "visibility public\n"+111 "visibility public\n"+
112 "default branch main\n"+112 "default branch main\n"+
113 "description The first one\n"+113 "description The first one\n"+
114+ "topics cli, go\n"+
114 "created 2026-01-02 03:04\n"+115 "created 2026-01-02 03:04\n"+
115 "last push 2026-03-04 05:06\n"+116 "last push 2026-03-04 05:06\n"+
116 "merge modes fast-forward, squash\n"+117 "merge modes fast-forward, squash\n"+
@@ -119,7 +120,7 @@ func TestRepoShow(t *testing.T) {
119 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
120 121
121 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
122- assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)123+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
123 }124 }
124 125
125 func TestRepoShowNeedsOneArg(t *testing.T) {126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -129,14 +130,16 @@ func TestRepoShowNeedsOneArg(t *testing.T) {
129 130
130 func TestRepoCreateMapsFlags(t *testing.T) {131 func TestRepoCreateMapsFlags(t *testing.T) {
131 tests := []struct {132 tests := []struct {
132- name string133+ name string
133- args []string134+ args []string
134- want ermite.Visibility135+ want ermite.Visibility
135- desc string136+ desc string
137+ topics []string
136 }{138 }{
137- {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},139+ {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, "", nil},
138- {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d"},140+ {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d", nil},
139- {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, ""},141+ {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, "", nil},
142+ {"--topics", []string{"x", "--topics", "go,cli"}, ermite.Visibility_VISIBILITY_PRIVATE, "", []string{"go", "cli"}},
140 }143 }
141 for _, tt := range tests {144 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {145 t.Run(tt.name, func(t *testing.T) {
@@ -150,6 +153,7 @@ func TestRepoCreateMapsFlags(t *testing.T) {
150 assert.Equal(t, "x", got.GetName())153 assert.Equal(t, "x", got.GetName())
151 assert.Equal(t, tt.want, got.GetVisibility())154 assert.Equal(t, tt.want, got.GetVisibility())
152 assert.Equal(t, tt.desc, got.GetDescription())155 assert.Equal(t, tt.desc, got.GetDescription())
156+ assert.Equal(t, tt.topics, got.GetTopics())
153 })157 })
154 }158 }
155 }159 }
@@ -172,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
172 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
173 }}177 }}
174 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
175- assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)179+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
176 }180 }
177 181
178 func TestRepoEditMask(t *testing.T) {182 func TestRepoEditMask(t *testing.T) {
@@ -188,6 +192,12 @@ func TestRepoEditMask(t *testing.T) {
188 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {192 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
189 assert.Empty(t, r.GetDescription())193 assert.Empty(t, r.GetDescription())
190 }},194 }},
195+ {"topics", []string{"--topics", "Go,cli"}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
196+ assert.Equal(t, []string{"Go", "cli"}, r.GetTopics())
197+ }},
198+ {"clear topics", []string{"--topics", ""}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
199+ assert.Empty(t, r.GetTopics())
200+ }},
191 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {201 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
192 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())202 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
193 }},203 }},
M internal/app/cli_watch_test.go +1 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96 }96 }
97 require.Len(t, lines, 5)97 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
M internal/app/testdata/web/home.html +32 -2
@@ -72,6 +72,34 @@
7272 <a href="/demo">demo</a>
7373 </h2>
7474 <p class="repo-desc">A demo repository</p>
75+<figure class="repo-pulse">
76+<svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">
77+<defs>
78+<linearGradient id="hb-demo" x1="0" y1="0" x2="0" y2="1">
79+<stop class="pulse-stop-top" offset="0">
80+</stop>
81+<stop class="pulse-stop-bottom" offset="1">
82+</stop>
83+</linearGradient>
84+</defs>
85+<path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke">
86+</path>
87+<path class="pulse-area" d="M0 58 C98.3 58 491.7 58 590 58 L590 64 L0 64 Z" fill="url(#hb-demo)">
88+</path>
89+<path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">
90+</path>
91+<circle class="pulse-ring" cx="590" cy="58" r="4">
92+</circle>
93+<circle class="pulse-dot" cx="590" cy="58" r="3.5">
94+</circle>
95+</svg>
96+<figcaption class="pulse-cap" aria-hidden="true">
97+<span>90 days ago</span>
98+<span>today</span>
99+</figcaption>
100+</figure>
101+</div>
102+<div class="repo-head">
75103 <dl class="repo-stats" aria-label="Figures of demo">
76104 <div>
77105 <dt>commits</dt>
@@ -81,10 +109,10 @@
81109 <dt>last 30 days</dt>
82110 <dd>0</dd>
83111 </div>
84-<div>
112+<div class="rs-tag">
85113 <dt>latest tag</dt>
86114 <dd>
87-<a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a>
115+<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
88116 </dd>
89117 </div>
90118 <div>
@@ -114,6 +142,8 @@
114142 <a href="/quiet">quiet</a>
115143 </h2>
116144 <p class="repo-desc mute">no description</p>
145+</div>
146+<div class="repo-head">
117147 <dl class="repo-stats" aria-label="Figures of quiet">
118148 <div>
119149 <dt>last activity</dt>
@@ -72,6 +72,34 @@
72 <a href="/demo">demo</a>72 <a href="/demo">demo</a>
73 </h2>73 </h2>
74 <p class="repo-desc">A demo repository</p>74 <p class="repo-desc">A demo repository</p>
75+<figure class="repo-pulse">
76+<svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">
77+<defs>
78+<linearGradient id="hb-demo" x1="0" y1="0" x2="0" y2="1">
79+<stop class="pulse-stop-top" offset="0">
80+</stop>
81+<stop class="pulse-stop-bottom" offset="1">
82+</stop>
83+</linearGradient>
84+</defs>
85+<path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke">
86+</path>
87+<path class="pulse-area" d="M0 58 C98.3 58 491.7 58 590 58 L590 64 L0 64 Z" fill="url(#hb-demo)">
88+</path>
89+<path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">
90+</path>
91+<circle class="pulse-ring" cx="590" cy="58" r="4">
92+</circle>
93+<circle class="pulse-dot" cx="590" cy="58" r="3.5">
94+</circle>
95+</svg>
96+<figcaption class="pulse-cap" aria-hidden="true">
97+<span>90 days ago</span>
98+<span>today</span>
99+</figcaption>
100+</figure>
101+</div>
102+<div class="repo-head">
75 <dl class="repo-stats" aria-label="Figures of demo">103 <dl class="repo-stats" aria-label="Figures of demo">
76 <div>104 <div>
77 <dt>commits</dt>105 <dt>commits</dt>
@@ -81,10 +109,10 @@
81 <dt>last 30 days</dt>109 <dt>last 30 days</dt>
82 <dd>0</dd>110 <dd>0</dd>
83 </div>111 </div>
84-<div>112+<div class="rs-tag">
85 <dt>latest tag</dt>113 <dt>latest tag</dt>
86 <dd>114 <dd>
87-<a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a>115+<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
88 </dd>116 </dd>
89 </div>117 </div>
90 <div>118 <div>
@@ -114,6 +142,8 @@
114 <a href="/quiet">quiet</a>142 <a href="/quiet">quiet</a>
115 </h2>143 </h2>
116 <p class="repo-desc mute">no description</p>144 <p class="repo-desc mute">no description</p>
145+</div>
146+<div class="repo-head">
117 <dl class="repo-stats" aria-label="Figures of quiet">147 <dl class="repo-stats" aria-label="Figures of quiet">
118 <div>148 <div>
119 <dt>last activity</dt>149 <dt>last activity</dt>
M internal/app/web_test.go +5 -2
@@ -184,8 +184,11 @@ var (
184184 reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
185185 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
186186 reStyleTag = regexp.MustCompile(`(?i)<style\b`)
187- reImport = regexp.MustCompile(`(?i)@import|url\(`)
188- reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
187+ // reImport finds what loads a resource from a page: an @import, or a url()
188+ // that is not a reference to an element of the same document (an SVG
189+ // gradient, fill="url(#id)").
190+ reImport = regexp.MustCompile(`(?i)@import|url\(\s*[^#\s]`)
191+ reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
189192 )
190193
191194 // assertNoInline checks the page has no inline script, style element or
@@ -184,8 +184,11 @@ var (
184 reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)184 reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
185 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)185 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
186 reStyleTag = regexp.MustCompile(`(?i)<style\b`)186 reStyleTag = regexp.MustCompile(`(?i)<style\b`)
187- reImport = regexp.MustCompile(`(?i)@import|url\(`)187+ // reImport finds what loads a resource from a page: an @import, or a url()
188- reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)188+ // that is not a reference to an element of the same document (an SVG
189+ // gradient, fill="url(#id)").
190+ reImport = regexp.MustCompile(`(?i)@import|url\(\s*[^#\s]`)
191+ reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
189 )192 )
190 193
191 // assertNoInline checks the page has no inline script, style element or194 // assertNoInline checks the page has no inline script, style element or
M internal/domain/repo.go +52 -3
@@ -4,6 +4,8 @@ package domain
44
55 import (
66 "fmt"
7+ "slices"
8+ "strings"
79 "time"
810 )
911
@@ -35,10 +37,57 @@ func (v Visibility) String() string { return string(v) }
3537 type Repo struct {
3638 Name RepoName
3739 Description string
38- Visibility Visibility
39- CreatedAt time.Time
40- PushedAt time.Time // zero until the first push
40+ // Topics are the normalized topics (see NormalizeTopics); nil for none.
41+ Topics []string
42+ Visibility Visibility
43+ CreatedAt time.Time
44+ PushedAt time.Time // zero until the first push
4145 // Settings are the merge settings; the stores always fill them in, with
4246 // the defaults for a repository that never stored any.
4347 Settings RepoSettings
4448 }
49+
50+// Limits of the topics of a repository.
51+const (
52+ // MaxTopics is the largest number of topics of a repository.
53+ MaxTopics = 20
54+ // MaxTopicLen is the longest topic, in characters.
55+ MaxTopicLen = 50
56+)
57+
58+// NormalizeTopics lowercases, validates, deduplicates and sorts topics. A
59+// topic is 1 to MaxTopicLen characters of a-z, 0-9 and "-", starting with a
60+// letter or a digit; there are at most MaxTopics of them once deduplicated.
61+// No topic gives nil. A broken rule gives an error matching ErrInvalid.
62+func NormalizeTopics(topics []string) ([]string, error) {
63+ var out []string
64+ for _, t := range topics {
65+ t = strings.ToLower(t)
66+ if !validTopic(t) {
67+ return nil, fmt.Errorf("%w: topic %q must be 1 to %d characters of a-z, 0-9 and -, starting with a letter or a digit", ErrInvalid, t, MaxTopicLen)
68+ }
69+ out = append(out, t)
70+ }
71+ slices.Sort(out)
72+ out = slices.Compact(out)
73+ if len(out) > MaxTopics {
74+ return nil, fmt.Errorf("%w: %d topics, at most %d", ErrInvalid, len(out), MaxTopics)
75+ }
76+ return out, nil
77+}
78+
79+func validTopic(t string) bool {
80+ if t == "" || len(t) > MaxTopicLen {
81+ return false
82+ }
83+ for i := range len(t) {
84+ c := t[i]
85+ switch {
86+ case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
87+ case c == '-' && i > 0:
88+ default:
89+ return false
90+ }
91+ }
92+ return true
93+}
@@ -4,6 +4,8 @@ package domain
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "slices"
8+ "strings"
7 "time"9 "time"
8 )10 )
9 11
@@ -35,10 +37,57 @@ func (v Visibility) String() string { return string(v) }
35 type Repo struct {37 type Repo struct {
36 Name RepoName38 Name RepoName
37 Description string39 Description string
38- Visibility Visibility40+ // Topics are the normalized topics (see NormalizeTopics); nil for none.
39- CreatedAt time.Time41+ Topics []string
40- PushedAt time.Time // zero until the first push42+ Visibility Visibility
43+ CreatedAt time.Time
44+ PushedAt time.Time // zero until the first push
41 // Settings are the merge settings; the stores always fill them in, with45 // Settings are the merge settings; the stores always fill them in, with
42 // the defaults for a repository that never stored any.46 // the defaults for a repository that never stored any.
43 Settings RepoSettings47 Settings RepoSettings
44 }48 }
49+
50+// Limits of the topics of a repository.
51+const (
52+ // MaxTopics is the largest number of topics of a repository.
53+ MaxTopics = 20
54+ // MaxTopicLen is the longest topic, in characters.
55+ MaxTopicLen = 50
56+)
57+
58+// NormalizeTopics lowercases, validates, deduplicates and sorts topics. A
59+// topic is 1 to MaxTopicLen characters of a-z, 0-9 and "-", starting with a
60+// letter or a digit; there are at most MaxTopics of them once deduplicated.
61+// No topic gives nil. A broken rule gives an error matching ErrInvalid.
62+func NormalizeTopics(topics []string) ([]string, error) {
63+ var out []string
64+ for _, t := range topics {
65+ t = strings.ToLower(t)
66+ if !validTopic(t) {
67+ return nil, fmt.Errorf("%w: topic %q must be 1 to %d characters of a-z, 0-9 and -, starting with a letter or a digit", ErrInvalid, t, MaxTopicLen)
68+ }
69+ out = append(out, t)
70+ }
71+ slices.Sort(out)
72+ out = slices.Compact(out)
73+ if len(out) > MaxTopics {
74+ return nil, fmt.Errorf("%w: %d topics, at most %d", ErrInvalid, len(out), MaxTopics)
75+ }
76+ return out, nil
77+}
78+
79+func validTopic(t string) bool {
80+ if t == "" || len(t) > MaxTopicLen {
81+ return false
82+ }
83+ for i := range len(t) {
84+ c := t[i]
85+ switch {
86+ case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
87+ case c == '-' && i > 0:
88+ default:
89+ return false
90+ }
91+ }
92+ return true
93+}
A internal/domain/repo_test.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestNormalizeTopics(t *testing.T) {
16+ t.Parallel()
17+ many := func(n int) []string {
18+ var out []string
19+ for i := range n {
20+ out = append(out, "t"+strings.Repeat("x", i))
21+ }
22+ return out
23+ }
24+ tests := []struct {
25+ name string
26+ in []string
27+ want []string
28+ bad bool
29+ }{
30+ {name: "nil", in: nil, want: nil},
31+ {name: "empty", in: []string{}, want: nil},
32+ {name: "lowercased", in: []string{"Go"}, want: []string{"go"}},
33+ {name: "sorted", in: []string{"zig", "c", "go"}, want: []string{"c", "go", "zig"}},
34+ {name: "deduplicated after lowercasing", in: []string{"Go", "go", "GO"}, want: []string{"go"}},
35+ {name: "digits and hyphens", in: []string{"3d-printing", "0"}, want: []string{"0", "3d-printing"}},
36+ {name: "50 characters", in: []string{strings.Repeat("a", 50)}, want: []string{strings.Repeat("a", 50)}},
37+ {name: "exactly 20", in: many(20), want: many(20)},
38+ {name: "duplicates do not count twice", in: append(many(20), "t", "tx"), want: many(20)},
39+ {name: "21", in: many(21), bad: true},
40+ {name: "51 characters", in: []string{strings.Repeat("a", 51)}, bad: true},
41+ {name: "empty topic", in: []string{""}, bad: true},
42+ {name: "leading hyphen", in: []string{"-go"}, bad: true},
43+ {name: "underscore", in: []string{"a_b"}, bad: true},
44+ {name: "space", in: []string{"a b"}, bad: true},
45+ {name: "dot", in: []string{"a.b"}, bad: true},
46+ {name: "non-ASCII", in: []string{"café"}, bad: true},
47+ {name: "one bad among good", in: []string{"go", "no!"}, bad: true},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ t.Parallel()
52+ got, err := domain.NormalizeTopics(tt.in)
53+ if tt.bad {
54+ require.ErrorIs(t, err, domain.ErrInvalid)
55+ assert.Nil(t, got)
56+ return
57+ }
58+ require.NoError(t, err)
59+ assert.Equal(t, tt.want, got)
60+ })
61+ }
62+}
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestNormalizeTopics(t *testing.T) {
16+ t.Parallel()
17+ many := func(n int) []string {
18+ var out []string
19+ for i := range n {
20+ out = append(out, "t"+strings.Repeat("x", i))
21+ }
22+ return out
23+ }
24+ tests := []struct {
25+ name string
26+ in []string
27+ want []string
28+ bad bool
29+ }{
30+ {name: "nil", in: nil, want: nil},
31+ {name: "empty", in: []string{}, want: nil},
32+ {name: "lowercased", in: []string{"Go"}, want: []string{"go"}},
33+ {name: "sorted", in: []string{"zig", "c", "go"}, want: []string{"c", "go", "zig"}},
34+ {name: "deduplicated after lowercasing", in: []string{"Go", "go", "GO"}, want: []string{"go"}},
35+ {name: "digits and hyphens", in: []string{"3d-printing", "0"}, want: []string{"0", "3d-printing"}},
36+ {name: "50 characters", in: []string{strings.Repeat("a", 50)}, want: []string{strings.Repeat("a", 50)}},
37+ {name: "exactly 20", in: many(20), want: many(20)},
38+ {name: "duplicates do not count twice", in: append(many(20), "t", "tx"), want: many(20)},
39+ {name: "21", in: many(21), bad: true},
40+ {name: "51 characters", in: []string{strings.Repeat("a", 51)}, bad: true},
41+ {name: "empty topic", in: []string{""}, bad: true},
42+ {name: "leading hyphen", in: []string{"-go"}, bad: true},
43+ {name: "underscore", in: []string{"a_b"}, bad: true},
44+ {name: "space", in: []string{"a b"}, bad: true},
45+ {name: "dot", in: []string{"a.b"}, bad: true},
46+ {name: "non-ASCII", in: []string{"café"}, bad: true},
47+ {name: "one bad among good", in: []string{"go", "no!"}, bad: true},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ t.Parallel()
52+ got, err := domain.NormalizeTopics(tt.in)
53+ if tt.bad {
54+ require.ErrorIs(t, err, domain.ErrInvalid)
55+ assert.Nil(t, got)
56+ return
57+ }
58+ require.NoError(t, err)
59+ assert.Equal(t, tt.want, got)
60+ })
61+ }
62+}
M internal/ports/dto.go +14 -1
@@ -31,13 +31,17 @@ type RepoView struct {
3131 type CreateRepoParams struct {
3232 Name domain.RepoName
3333 Description string
34+ // Topics are normalized by Create (see domain.NormalizeTopics).
35+ Topics []string
3436 // Visibility defaults to private when empty.
3537 Visibility domain.Visibility
3638 }
3739
3840 // UpdateRepoParams are the changes of Update; a nil field is left alone.
3941 type UpdateRepoParams struct {
40- Description *string
42+ Description *string
43+ // Topics replaces the topics; an empty list clears them.
44+ Topics *[]string
4145 Visibility *domain.Visibility
4246 DefaultBranch *string
4347 // AllowedMergeModes replaces the allowed merge modes; it must hold at
@@ -121,11 +125,20 @@ type RepoStats struct {
121125 Commits, Recent int
122126 // LatestTag is the most recently created tag; nil without tags.
123127 LatestTag *domain.TagInfo
128+ // Activity is the number of commits of each of the last ActivityDays
129+ // days, oldest first; the last day ends now. A day is 24 hours.
130+ Activity []int
131+ // Partial tells that the recent commits were more than MaxCommitTimes:
132+ // Recent and Activity count only some of them.
133+ Partial bool
124134 }
125135
126136 // RecentDays is the window of RepoStats.Recent.
127137 const RecentDays = 30
128138
139+// ActivityDays is the span of RepoStats.Activity.
140+const ActivityDays = 90
141+
129142 // WatchParams select the events of a watch.
130143 type WatchParams struct {
131144 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
@@ -31,13 +31,17 @@ type RepoView struct {
31 type CreateRepoParams struct {31 type CreateRepoParams struct {
32 Name domain.RepoName32 Name domain.RepoName
33 Description string33 Description string
34+ // Topics are normalized by Create (see domain.NormalizeTopics).
35+ Topics []string
34 // Visibility defaults to private when empty.36 // Visibility defaults to private when empty.
35 Visibility domain.Visibility37 Visibility domain.Visibility
36 }38 }
37 39
38 // UpdateRepoParams are the changes of Update; a nil field is left alone.40 // UpdateRepoParams are the changes of Update; a nil field is left alone.
39 type UpdateRepoParams struct {41 type UpdateRepoParams struct {
40- Description *string42+ Description *string
43+ // Topics replaces the topics; an empty list clears them.
44+ Topics *[]string
41 Visibility *domain.Visibility45 Visibility *domain.Visibility
42 DefaultBranch *string46 DefaultBranch *string
43 // AllowedMergeModes replaces the allowed merge modes; it must hold at47 // AllowedMergeModes replaces the allowed merge modes; it must hold at
@@ -121,11 +125,20 @@ type RepoStats struct {
121 Commits, Recent int125 Commits, Recent int
122 // LatestTag is the most recently created tag; nil without tags.126 // LatestTag is the most recently created tag; nil without tags.
123 LatestTag *domain.TagInfo127 LatestTag *domain.TagInfo
128+ // Activity is the number of commits of each of the last ActivityDays
129+ // days, oldest first; the last day ends now. A day is 24 hours.
130+ Activity []int
131+ // Partial tells that the recent commits were more than MaxCommitTimes:
132+ // Recent and Activity count only some of them.
133+ Partial bool
124 }134 }
125 135
126 // RecentDays is the window of RepoStats.Recent.136 // RecentDays is the window of RepoStats.Recent.
127 const RecentDays = 30137 const RecentDays = 30
128 138
139+// ActivityDays is the span of RepoStats.Activity.
140+const ActivityDays = 90
141+
129 // WatchParams select the events of a watch.142 // WatchParams select the events of a watch.
130 type WatchParams struct {143 type WatchParams struct {
131 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.144 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
M internal/ports/mocks/RepoReader.go +98 -18
@@ -50,47 +50,55 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
5050 return &RepoReader_Expecter{mock: &_m.Mock}
5151 }
5252
53-// CountCommits provides a mock function for the type RepoReader
54-func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
53+// CommitTimes provides a mock function for the type RepoReader
54+func (_mock *RepoReader) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
5555 ret := _mock.Called(ctx, repo, head, since)
5656
5757 if len(ret) == 0 {
58- panic("no return value specified for CountCommits")
58+ panic("no return value specified for CommitTimes")
5959 }
6060
61- var r0 int
62- var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok {
61+ var r0 []time.Time
62+ var r1 bool
63+ var r2 error
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) ([]time.Time, bool, error)); ok {
6465 return returnFunc(ctx, repo, head, since)
6566 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok {
67+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) []time.Time); ok {
6768 r0 = returnFunc(ctx, repo, head, since)
6869 } else {
69- r0 = ret.Get(0).(int)
70+ if ret.Get(0) != nil {
71+ r0 = ret.Get(0).([]time.Time)
72+ }
7073 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
74+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) bool); ok {
7275 r1 = returnFunc(ctx, repo, head, since)
7376 } else {
74- r1 = ret.Error(1)
77+ r1 = ret.Get(1).(bool)
7578 }
76- return r0, r1
79+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
80+ r2 = returnFunc(ctx, repo, head, since)
81+ } else {
82+ r2 = ret.Error(2)
83+ }
84+ return r0, r1, r2
7785 }
7886
79-// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
80-type RepoReader_CountCommits_Call struct {
87+// RepoReader_CommitTimes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitTimes'
88+type RepoReader_CommitTimes_Call struct {
8189 *mock.Call
8290 }
8391
84-// CountCommits is a helper method to define mock.On call
92+// CommitTimes is a helper method to define mock.On call
8593 // - ctx context.Context
8694 // - repo domain.RepoName
8795 // - head domain.ObjectID
8896 // - since time.Time
89-func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call {
90- return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)}
97+func (_e *RepoReader_Expecter) CommitTimes(ctx any, repo any, head any, since any) *RepoReader_CommitTimes_Call {
98+ return &RepoReader_CommitTimes_Call{Call: _e.mock.On("CommitTimes", ctx, repo, head, since)}
9199 }
92100
93-func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call {
101+func (_c *RepoReader_CommitTimes_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CommitTimes_Call {
94102 _c.Call.Run(func(args mock.Arguments) {
95103 var arg0 context.Context
96104 if args[0] != nil {
@@ -118,12 +126,84 @@ func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo d
118126 return _c
119127 }
120128
129+func (_c *RepoReader_CommitTimes_Call) Return(times []time.Time, truncated bool, err error) *RepoReader_CommitTimes_Call {
130+ _c.Call.Return(times, truncated, err)
131+ return _c
132+}
133+
134+func (_c *RepoReader_CommitTimes_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error)) *RepoReader_CommitTimes_Call {
135+ _c.Call.Return(run)
136+ return _c
137+}
138+
139+// CountCommits provides a mock function for the type RepoReader
140+func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
141+ ret := _mock.Called(ctx, repo, head)
142+
143+ if len(ret) == 0 {
144+ panic("no return value specified for CountCommits")
145+ }
146+
147+ var r0 int
148+ var r1 error
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) (int, error)); ok {
150+ return returnFunc(ctx, repo, head)
151+ }
152+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) int); ok {
153+ r0 = returnFunc(ctx, repo, head)
154+ } else {
155+ r0 = ret.Get(0).(int)
156+ }
157+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID) error); ok {
158+ r1 = returnFunc(ctx, repo, head)
159+ } else {
160+ r1 = ret.Error(1)
161+ }
162+ return r0, r1
163+}
164+
165+// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
166+type RepoReader_CountCommits_Call struct {
167+ *mock.Call
168+}
169+
170+// CountCommits is a helper method to define mock.On call
171+// - ctx context.Context
172+// - repo domain.RepoName
173+// - head domain.ObjectID
174+func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any) *RepoReader_CountCommits_Call {
175+ return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head)}
176+}
177+
178+func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID)) *RepoReader_CountCommits_Call {
179+ _c.Call.Run(func(args mock.Arguments) {
180+ var arg0 context.Context
181+ if args[0] != nil {
182+ arg0 = args[0].(context.Context)
183+ }
184+ var arg1 domain.RepoName
185+ if args[1] != nil {
186+ arg1 = args[1].(domain.RepoName)
187+ }
188+ var arg2 domain.ObjectID
189+ if args[2] != nil {
190+ arg2 = args[2].(domain.ObjectID)
191+ }
192+ run(
193+ arg0,
194+ arg1,
195+ arg2,
196+ )
197+ })
198+ return _c
199+}
200+
121201 func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {
122202 _c.Call.Return(n, err)
123203 return _c
124204 }
125205
126-func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)) *RepoReader_CountCommits_Call {
206+func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)) *RepoReader_CountCommits_Call {
127207 _c.Call.Return(run)
128208 return _c
129209 }
@@ -50,47 +50,55 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
50 return &RepoReader_Expecter{mock: &_m.Mock}50 return &RepoReader_Expecter{mock: &_m.Mock}
51 }51 }
52 52
53-// CountCommits provides a mock function for the type RepoReader53+// CommitTimes provides a mock function for the type RepoReader
54-func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {54+func (_mock *RepoReader) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
55 ret := _mock.Called(ctx, repo, head, since)55 ret := _mock.Called(ctx, repo, head, since)
56 56
57 if len(ret) == 0 {57 if len(ret) == 0 {
58- panic("no return value specified for CountCommits")58+ panic("no return value specified for CommitTimes")
59 }59 }
60 60
61- var r0 int61+ var r0 []time.Time
62- var r1 error62+ var r1 bool
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok {63+ var r2 error
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) ([]time.Time, bool, error)); ok {
64 return returnFunc(ctx, repo, head, since)65 return returnFunc(ctx, repo, head, since)
65 }66 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok {67+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) []time.Time); ok {
67 r0 = returnFunc(ctx, repo, head, since)68 r0 = returnFunc(ctx, repo, head, since)
68 } else {69 } else {
69- r0 = ret.Get(0).(int)70+ if ret.Get(0) != nil {
71+ r0 = ret.Get(0).([]time.Time)
72+ }
70 }73 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {74+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) bool); ok {
72 r1 = returnFunc(ctx, repo, head, since)75 r1 = returnFunc(ctx, repo, head, since)
73 } else {76 } else {
74- r1 = ret.Error(1)77+ r1 = ret.Get(1).(bool)
75 }78 }
76- return r0, r179+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
80+ r2 = returnFunc(ctx, repo, head, since)
81+ } else {
82+ r2 = ret.Error(2)
83+ }
84+ return r0, r1, r2
77 }85 }
78 86
79-// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'87+// RepoReader_CommitTimes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitTimes'
80-type RepoReader_CountCommits_Call struct {88+type RepoReader_CommitTimes_Call struct {
81 *mock.Call89 *mock.Call
82 }90 }
83 91
84-// CountCommits is a helper method to define mock.On call92+// CommitTimes is a helper method to define mock.On call
85 // - ctx context.Context93 // - ctx context.Context
86 // - repo domain.RepoName94 // - repo domain.RepoName
87 // - head domain.ObjectID95 // - head domain.ObjectID
88 // - since time.Time96 // - since time.Time
89-func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call {97+func (_e *RepoReader_Expecter) CommitTimes(ctx any, repo any, head any, since any) *RepoReader_CommitTimes_Call {
90- return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)}98+ return &RepoReader_CommitTimes_Call{Call: _e.mock.On("CommitTimes", ctx, repo, head, since)}
91 }99 }
92 100
93-func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call {101+func (_c *RepoReader_CommitTimes_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CommitTimes_Call {
94 _c.Call.Run(func(args mock.Arguments) {102 _c.Call.Run(func(args mock.Arguments) {
95 var arg0 context.Context103 var arg0 context.Context
96 if args[0] != nil {104 if args[0] != nil {
@@ -118,12 +126,84 @@ func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo d
118 return _c126 return _c
119 }127 }
120 128
129+func (_c *RepoReader_CommitTimes_Call) Return(times []time.Time, truncated bool, err error) *RepoReader_CommitTimes_Call {
130+ _c.Call.Return(times, truncated, err)
131+ return _c
132+}
133+
134+func (_c *RepoReader_CommitTimes_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error)) *RepoReader_CommitTimes_Call {
135+ _c.Call.Return(run)
136+ return _c
137+}
138+
139+// CountCommits provides a mock function for the type RepoReader
140+func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
141+ ret := _mock.Called(ctx, repo, head)
142+
143+ if len(ret) == 0 {
144+ panic("no return value specified for CountCommits")
145+ }
146+
147+ var r0 int
148+ var r1 error
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) (int, error)); ok {
150+ return returnFunc(ctx, repo, head)
151+ }
152+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) int); ok {
153+ r0 = returnFunc(ctx, repo, head)
154+ } else {
155+ r0 = ret.Get(0).(int)
156+ }
157+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID) error); ok {
158+ r1 = returnFunc(ctx, repo, head)
159+ } else {
160+ r1 = ret.Error(1)
161+ }
162+ return r0, r1
163+}
164+
165+// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
166+type RepoReader_CountCommits_Call struct {
167+ *mock.Call
168+}
169+
170+// CountCommits is a helper method to define mock.On call
171+// - ctx context.Context
172+// - repo domain.RepoName
173+// - head domain.ObjectID
174+func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any) *RepoReader_CountCommits_Call {
175+ return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head)}
176+}
177+
178+func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID)) *RepoReader_CountCommits_Call {
179+ _c.Call.Run(func(args mock.Arguments) {
180+ var arg0 context.Context
181+ if args[0] != nil {
182+ arg0 = args[0].(context.Context)
183+ }
184+ var arg1 domain.RepoName
185+ if args[1] != nil {
186+ arg1 = args[1].(domain.RepoName)
187+ }
188+ var arg2 domain.ObjectID
189+ if args[2] != nil {
190+ arg2 = args[2].(domain.ObjectID)
191+ }
192+ run(
193+ arg0,
194+ arg1,
195+ arg2,
196+ )
197+ })
198+ return _c
199+}
200+
121 func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {201 func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {
122 _c.Call.Return(n, err)202 _c.Call.Return(n, err)
123 return _c203 return _c
124 }204 }
125 205
126-func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)) *RepoReader_CountCommits_Call {206+func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)) *RepoReader_CountCommits_Call {
127 _c.Call.Return(run)207 _c.Call.Return(run)
128 return _c208 return _c
129 }209 }
M internal/ports/ports.go +9 -3
@@ -118,6 +118,9 @@ type CommitQuery struct {
118118 Limit int
119119 }
120120
121+// MaxCommitTimes bounds RepoReader.CommitTimes.
122+const MaxCommitTimes = 100_000
123+
121124 // MaxCommitLimit is the most commits one CommitQuery may ask for.
122125 const MaxCommitLimit = 1000
123126
@@ -178,9 +181,12 @@ type RepoReader interface {
178181 // GetCommit returns a commit with its statistics and its diff against the
179182 // first parent, bounded by limits.
180183 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
181- // CountCommits counts the commits reachable from head; with a non-zero
182- // since, only those committed at or after it.
183- CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)
184+ // CountCommits counts the commits reachable from head.
185+ CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)
186+ // CommitTimes are the committer dates of the commits reachable from head
187+ // committed at or after since, in no particular order; at most
188+ // MaxCommitTimes, truncated tells there were more.
189+ CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (times []time.Time, truncated bool, err error)
184190 // LatestTag is the most recently created tag (the commit date for a
185191 // lightweight tag); ok is false when the repository has no tag.
186192 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
@@ -118,6 +118,9 @@ type CommitQuery struct {
118 Limit int118 Limit int
119 }119 }
120 120
121+// MaxCommitTimes bounds RepoReader.CommitTimes.
122+const MaxCommitTimes = 100_000
123+
121 // MaxCommitLimit is the most commits one CommitQuery may ask for.124 // MaxCommitLimit is the most commits one CommitQuery may ask for.
122 const MaxCommitLimit = 1000125 const MaxCommitLimit = 1000
123 126
@@ -178,9 +181,12 @@ type RepoReader interface {
178 // GetCommit returns a commit with its statistics and its diff against the181 // GetCommit returns a commit with its statistics and its diff against the
179 // first parent, bounded by limits.182 // first parent, bounded by limits.
180 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)183 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
181- // CountCommits counts the commits reachable from head; with a non-zero184+ // CountCommits counts the commits reachable from head.
182- // since, only those committed at or after it.185+ CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)
183- CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)186+ // CommitTimes are the committer dates of the commits reachable from head
187+ // committed at or after since, in no particular order; at most
188+ // MaxCommitTimes, truncated tells there were more.
189+ CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (times []time.Time, truncated bool, err error)
184 // LatestTag is the most recently created tag (the commit date for a190 // LatestTag is the most recently created tag (the commit date for a
185 // lightweight tag); ok is false when the repository has no tag.191 // lightweight tag); ok is false when the repository has no tag.
186 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)192 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
M internal/readertest/conformance.go +22 -8
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "slices"
1112 "strings"
1213 "testing"
1314 "time"
@@ -119,16 +120,29 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
119120 ids = append(ids, f.Commit("c"+itoa(i)))
120121 }
121122 head := MustID(t, ids[3])
122- n, err := g.CountCommits(ctx, f.Name, head, time.Time{})
123+ n, err := g.CountCommits(ctx, f.Name, head)
123124 require.NoError(t, err)
124125 assert.Equal(t, 4, n)
125- n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second))
126- require.NoError(t, err)
127- assert.Equal(t, 2, n, "the commits of 12:03 and 12:04")
128- n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{})
126+ n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]))
129127 require.NoError(t, err)
130128 assert.Equal(t, 2, n, "from another commit")
131- _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})
129+ _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)))
130+ assert.ErrorIs(t, err, domain.ErrNotFound)
131+
132+ unix := func(ts []time.Time) []int64 {
133+ var out []int64
134+ for _, x := range ts {
135+ out = append(out, x.Unix())
136+ }
137+ slices.Sort(out)
138+ return out
139+ }
140+ base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
141+ times, more, err := g.CommitTimes(ctx, f.Name, head, base.Add(150*time.Second))
142+ require.NoError(t, err)
143+ assert.False(t, more)
144+ assert.Equal(t, []int64{base.Add(3 * time.Minute).Unix(), base.Add(4 * time.Minute).Unix()}, unix(times), "12:03 and 12:04")
145+ _, _, err = g.CommitTimes(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), base)
132146 assert.ErrorIs(t, err, domain.ErrNotFound)
133147
134148 // Dates out of order: a commit dated in the past on top of recent ones
@@ -137,9 +151,9 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
137151 f.When = recent.Add(-48 * time.Hour)
138152 old := f.Commit("backdated")
139153 f.When = recent
140- n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))
154+ times, _, err = g.CommitTimes(ctx, f.Name, MustID(t, old), base.Add(150*time.Second))
141155 require.NoError(t, err)
142- assert.Equal(t, 2, n, "the recent commits behind an old one count")
156+ assert.Len(t, times, 2, "the recent commits behind an old one count")
143157
144158 // The latest tag is the one made last: an annotated tag by its own date,
145159 // a lightweight one by the date of its commit.
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "io"10 "io"
11+ "slices"
11 "strings"12 "strings"
12 "testing"13 "testing"
13 "time"14 "time"
@@ -119,16 +120,29 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
119 ids = append(ids, f.Commit("c"+itoa(i)))120 ids = append(ids, f.Commit("c"+itoa(i)))
120 }121 }
121 head := MustID(t, ids[3])122 head := MustID(t, ids[3])
122- n, err := g.CountCommits(ctx, f.Name, head, time.Time{})123+ n, err := g.CountCommits(ctx, f.Name, head)
123 require.NoError(t, err)124 require.NoError(t, err)
124 assert.Equal(t, 4, n)125 assert.Equal(t, 4, n)
125- n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second))126+ n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]))
126- require.NoError(t, err)
127- assert.Equal(t, 2, n, "the commits of 12:03 and 12:04")
128- n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{})
129 require.NoError(t, err)127 require.NoError(t, err)
130 assert.Equal(t, 2, n, "from another commit")128 assert.Equal(t, 2, n, "from another commit")
131- _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})129+ _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)))
130+ assert.ErrorIs(t, err, domain.ErrNotFound)
131+
132+ unix := func(ts []time.Time) []int64 {
133+ var out []int64
134+ for _, x := range ts {
135+ out = append(out, x.Unix())
136+ }
137+ slices.Sort(out)
138+ return out
139+ }
140+ base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
141+ times, more, err := g.CommitTimes(ctx, f.Name, head, base.Add(150*time.Second))
142+ require.NoError(t, err)
143+ assert.False(t, more)
144+ assert.Equal(t, []int64{base.Add(3 * time.Minute).Unix(), base.Add(4 * time.Minute).Unix()}, unix(times), "12:03 and 12:04")
145+ _, _, err = g.CommitTimes(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), base)
132 assert.ErrorIs(t, err, domain.ErrNotFound)146 assert.ErrorIs(t, err, domain.ErrNotFound)
133 147
134 // Dates out of order: a commit dated in the past on top of recent ones148 // Dates out of order: a commit dated in the past on top of recent ones
@@ -137,9 +151,9 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
137 f.When = recent.Add(-48 * time.Hour)151 f.When = recent.Add(-48 * time.Hour)
138 old := f.Commit("backdated")152 old := f.Commit("backdated")
139 f.When = recent153 f.When = recent
140- n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))154+ times, _, err = g.CommitTimes(ctx, f.Name, MustID(t, old), base.Add(150*time.Second))
141 require.NoError(t, err)155 require.NoError(t, err)
142- assert.Equal(t, 2, n, "the recent commits behind an old one count")156+ assert.Len(t, times, 2, "the recent commits behind an old one count")
143 157
144 // The latest tag is the one made last: an annotated tag by its own date,158 // The latest tag is the one made last: an annotated tag by its own date,
145 // a lightweight one by the date of its commit.159 // a lightweight one by the date of its commit.
M internal/usecase/browse_stats.go +42 -6
@@ -4,6 +4,7 @@ package usecase
44
55 import (
66 "context"
7+ "slices"
78 "sync"
89 "time"
910
@@ -81,7 +82,37 @@ func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read
8182 }
8283 }
8384
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.
89+func 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.
110+func daysBefore(now time.Time, n int) time.Time {
111+ return now.Add(-time.Duration(n) * 24 * time.Hour)
112+}
113+
84114 func cloneStats(st ports.RepoStats) ports.RepoStats {
115+ st.Activity = slices.Clone(st.Activity)
85116 if st.LatestTag != nil {
86117 t := *st.LatestTag
87118 st.LatestTag = &t
@@ -89,9 +120,10 @@ func cloneStats(st ports.RepoStats) ports.RepoStats {
89120 return st
90121 }
91122
92-// RepoStats counts the commits reachable from head, all of them and those of
93-// the last ports.RecentDays days before now, and names the latest tag. The
94-// answer is kept for statsTTL, or until the repository is pushed to.
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.
95127 func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
96128 r, err := b.authorize(ctx, p, repo)
97129 if err != nil {
@@ -103,13 +135,17 @@ func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.
103135 rctx := context.WithoutCancel(ctx)
104136 var st ports.RepoStats
105137 var err error
106- if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {
138+ if st.Commits, err = b.reader.CountCommits(rctx, repo, head); err != nil {
107139 return ports.RepoStats{}, err
108140 }
109- since := now.AddDate(0, 0, -ports.RecentDays)
110- if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {
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 {
111145 return ports.RepoStats{}, err
112146 }
147+ st.Recent, st.Activity = activity(times, now)
148+ st.Partial = truncated
113149 tag, ok, err := b.reader.LatestTag(rctx, repo)
114150 if err != nil {
115151 return ports.RepoStats{}, err
@@ -4,6 +4,7 @@ package usecase
4 4
5 import (5 import (
6 "context"6 "context"
7+ "slices"
7 "sync"8 "sync"
8 "time"9 "time"
9 10
@@ -81,7 +82,37 @@ func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read
81 }82 }
82 }83 }
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.
89+func 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.
110+func daysBefore(now time.Time, n int) time.Time {
111+ return now.Add(-time.Duration(n) * 24 * time.Hour)
112+}
113+
84 func cloneStats(st ports.RepoStats) ports.RepoStats {114 func cloneStats(st ports.RepoStats) ports.RepoStats {
115+ st.Activity = slices.Clone(st.Activity)
85 if st.LatestTag != nil {116 if st.LatestTag != nil {
86 t := *st.LatestTag117 t := *st.LatestTag
87 st.LatestTag = &t118 st.LatestTag = &t
@@ -89,9 +120,10 @@ func cloneStats(st ports.RepoStats) ports.RepoStats {
89 return st120 return st
90 }121 }
91 122
92-// RepoStats counts the commits reachable from head, all of them and those of123+// RepoStats counts the commits reachable from head, all of them, those of
93-// the last ports.RecentDays days before now, and names the latest tag. The124+// the last ports.RecentDays days and those of each recent day, and names the
94-// answer is kept for statsTTL, or until the repository is pushed to.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.
95 func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {127 func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
96 r, err := b.authorize(ctx, p, repo)128 r, err := b.authorize(ctx, p, repo)
97 if err != nil {129 if err != nil {
@@ -103,13 +135,17 @@ func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.
103 rctx := context.WithoutCancel(ctx)135 rctx := context.WithoutCancel(ctx)
104 var st ports.RepoStats136 var st ports.RepoStats
105 var err error137 var err error
106- if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {138+ if st.Commits, err = b.reader.CountCommits(rctx, repo, head); err != nil {
107 return ports.RepoStats{}, err139 return ports.RepoStats{}, err
108 }140 }
109- since := now.AddDate(0, 0, -ports.RecentDays)141+ // One walk gives both the recent count and the activity.
110- if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {142+ span := max(ports.RecentDays, ports.ActivityDays)
143+ times, truncated, err := b.reader.CommitTimes(rctx, repo, head, daysBefore(now, span))
144+ if err != nil {
111 return ports.RepoStats{}, err145 return ports.RepoStats{}, err
112 }146 }
147+ st.Recent, st.Activity = activity(times, now)
148+ st.Partial = truncated
113 tag, ok, err := b.reader.LatestTag(rctx, repo)149 tag, ok, err := b.reader.LatestTag(rctx, repo)
114 if err != nil {150 if err != nil {
115 return ports.RepoStats{}, err151 return ports.RepoStats{}, err
M internal/usecase/browse_test.go +78 -6
@@ -113,7 +113,8 @@ func browseOps() []browseOp {
113113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114114 return err
115115 }, func(r *mocks.RepoReader) {
116- r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
116+ r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117+ r.EXPECT().CommitTimes(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, false, nil)
117118 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118119 }},
119120 }
@@ -136,13 +137,17 @@ func TestRepoStatsCache(t *testing.T) {
136137 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137138 var reads atomic.Int32
138139 gate := make(chan struct{})
139- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn(
140- func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) {
140+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything).RunAndReturn(
141+ func(context.Context, domain.RepoName, domain.ObjectID) (int, error) {
141142 reads.Add(1)
142143 <-gate
143144 return 900, nil
144145 })
145- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil)
146+ recent := make([]time.Time, 12)
147+ for i := range recent {
148+ recent[i] = now.Add(-time.Duration(i) * time.Hour)
149+ }
150+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, mock.Anything, mock.Anything).Return(recent, false, nil)
146151 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147152 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148153
@@ -160,6 +165,8 @@ func TestRepoStatsCache(t *testing.T) {
160165 assert.Equal(t, 900, st.Commits)
161166 assert.Equal(t, 12, st.Recent)
162167 assert.Equal(t, tag, *st.LatestTag)
168+ require.Len(t, st.Activity, ports.ActivityDays)
169+ assert.Equal(t, 12, st.Activity[ports.ActivityDays-1], "all in the last day")
163170 })
164171 }
165172 for reads.Load() == 0 {
@@ -182,6 +189,70 @@ func TestRepoStatsCache(t *testing.T) {
182189 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183190 }
184191
192+// The activity spreads the commits over the last days, oldest first, and
193+// the recent count covers 30 days.
194+func TestRepoStatsActivity(t *testing.T) {
195+ t.Parallel()
196+ ctx := context.Background()
197+ f := newBrowseFixture(t)
198+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
199+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
200+ now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
201+ day := 24 * time.Hour
202+ times := []time.Time{
203+ now.Add(time.Hour), // in the future: the last day
204+ now.Add(-time.Minute), // the last day
205+ now.Add(-day - 1), // the day before
206+ now.Add(-29 * day), // recent, day 29 ago
207+ now.Add(-31 * day), // not recent
208+ now.Add(-89*day - 1), // the first day of the span
209+ }
210+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(100, nil)
211+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), now.Add(-ports.ActivityDays*day)).Return(times, false, nil)
212+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
213+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
214+ require.NoError(t, err)
215+ assert.Equal(t, 4, st.Recent)
216+ last := ports.ActivityDays - 1
217+ assert.Equal(t, 2, st.Activity[last])
218+ assert.Equal(t, 1, st.Activity[last-1])
219+ assert.Equal(t, 1, st.Activity[last-29])
220+ assert.Equal(t, 1, st.Activity[last-31])
221+ assert.Equal(t, 1, st.Activity[0])
222+ sum := 0
223+ for _, n := range st.Activity {
224+ sum += n
225+ }
226+ assert.Equal(t, 6, sum)
227+ assert.False(t, st.Partial)
228+}
229+
230+// The days are 24 hours whatever the zone of now: across a change of
231+// daylight saving time, the walk and the bins agree and no hour is lost.
232+func TestRepoStatsActivityAcrossDST(t *testing.T) {
233+ t.Parallel()
234+ paris, err := time.LoadLocation("Europe/Paris")
235+ require.NoError(t, err)
236+ ctx := context.Background()
237+ f := newBrowseFixture(t)
238+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
239+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
240+ // 90 days of 24 hours before this noon cross the spring change (March 29).
241+ now := time.Date(2026, 4, 1, 12, 0, 0, 0, paris)
242+ day := 24 * time.Hour
243+ since := now.Add(-ports.ActivityDays * day)
244+ require.NotEqual(t, now.AddDate(0, 0, -ports.ActivityDays), since, "the calendar would differ by an hour")
245+ times := []time.Time{since.Add(time.Minute), now.Add(-30*day + time.Minute)}
246+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), since).Return(times, true, nil)
247+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(200_000, nil)
248+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
249+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
250+ require.NoError(t, err)
251+ assert.Equal(t, 1, st.Activity[0], "the start of the span is in the first day")
252+ assert.Equal(t, 1, st.Recent, "30 days of 24 hours")
253+ assert.True(t, st.Partial, "the walk was truncated")
254+}
255+
185256 func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186257 t.Parallel()
187258 ctx := context.Background()
@@ -189,10 +260,11 @@ func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
189260 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190261 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191262 now := time.Now()
192- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()
263+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(0, errors.New("boom")).Once()
193264 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194265 require.Error(t, err)
195- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()
266+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(3, nil).Once()
267+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), mock.Anything).Return([]time.Time{now}, false, nil).Once()
196268 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
197269 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198270 require.NoError(t, err)
@@ -113,7 +113,8 @@ func browseOps() []browseOp {
113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114 return err114 return err
115 }, func(r *mocks.RepoReader) {115 }, func(r *mocks.RepoReader) {
116- r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)116+ r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117+ r.EXPECT().CommitTimes(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, false, nil)
117 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)118 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118 }},119 }},
119 }120 }
@@ -136,13 +137,17 @@ func TestRepoStatsCache(t *testing.T) {
136 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)137 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137 var reads atomic.Int32138 var reads atomic.Int32
138 gate := make(chan struct{})139 gate := make(chan struct{})
139- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn(140+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything).RunAndReturn(
140- func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) {141+ func(context.Context, domain.RepoName, domain.ObjectID) (int, error) {
141 reads.Add(1)142 reads.Add(1)
142 <-gate143 <-gate
143 return 900, nil144 return 900, nil
144 })145 })
145- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil)146+ recent := make([]time.Time, 12)
147+ for i := range recent {
148+ recent[i] = now.Add(-time.Duration(i) * time.Hour)
149+ }
150+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, mock.Anything, mock.Anything).Return(recent, false, nil)
146 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}151 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)152 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148 153
@@ -160,6 +165,8 @@ func TestRepoStatsCache(t *testing.T) {
160 assert.Equal(t, 900, st.Commits)165 assert.Equal(t, 900, st.Commits)
161 assert.Equal(t, 12, st.Recent)166 assert.Equal(t, 12, st.Recent)
162 assert.Equal(t, tag, *st.LatestTag)167 assert.Equal(t, tag, *st.LatestTag)
168+ require.Len(t, st.Activity, ports.ActivityDays)
169+ assert.Equal(t, 12, st.Activity[ports.ActivityDays-1], "all in the last day")
163 })170 })
164 }171 }
165 for reads.Load() == 0 {172 for reads.Load() == 0 {
@@ -182,6 +189,70 @@ func TestRepoStatsCache(t *testing.T) {
182 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")189 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183 }190 }
184 191
192+// The activity spreads the commits over the last days, oldest first, and
193+// the recent count covers 30 days.
194+func TestRepoStatsActivity(t *testing.T) {
195+ t.Parallel()
196+ ctx := context.Background()
197+ f := newBrowseFixture(t)
198+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
199+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
200+ now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
201+ day := 24 * time.Hour
202+ times := []time.Time{
203+ now.Add(time.Hour), // in the future: the last day
204+ now.Add(-time.Minute), // the last day
205+ now.Add(-day - 1), // the day before
206+ now.Add(-29 * day), // recent, day 29 ago
207+ now.Add(-31 * day), // not recent
208+ now.Add(-89*day - 1), // the first day of the span
209+ }
210+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(100, nil)
211+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), now.Add(-ports.ActivityDays*day)).Return(times, false, nil)
212+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
213+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
214+ require.NoError(t, err)
215+ assert.Equal(t, 4, st.Recent)
216+ last := ports.ActivityDays - 1
217+ assert.Equal(t, 2, st.Activity[last])
218+ assert.Equal(t, 1, st.Activity[last-1])
219+ assert.Equal(t, 1, st.Activity[last-29])
220+ assert.Equal(t, 1, st.Activity[last-31])
221+ assert.Equal(t, 1, st.Activity[0])
222+ sum := 0
223+ for _, n := range st.Activity {
224+ sum += n
225+ }
226+ assert.Equal(t, 6, sum)
227+ assert.False(t, st.Partial)
228+}
229+
230+// The days are 24 hours whatever the zone of now: across a change of
231+// daylight saving time, the walk and the bins agree and no hour is lost.
232+func TestRepoStatsActivityAcrossDST(t *testing.T) {
233+ t.Parallel()
234+ paris, err := time.LoadLocation("Europe/Paris")
235+ require.NoError(t, err)
236+ ctx := context.Background()
237+ f := newBrowseFixture(t)
238+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
239+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
240+ // 90 days of 24 hours before this noon cross the spring change (March 29).
241+ now := time.Date(2026, 4, 1, 12, 0, 0, 0, paris)
242+ day := 24 * time.Hour
243+ since := now.Add(-ports.ActivityDays * day)
244+ require.NotEqual(t, now.AddDate(0, 0, -ports.ActivityDays), since, "the calendar would differ by an hour")
245+ times := []time.Time{since.Add(time.Minute), now.Add(-30*day + time.Minute)}
246+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), since).Return(times, true, nil)
247+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(200_000, nil)
248+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
249+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
250+ require.NoError(t, err)
251+ assert.Equal(t, 1, st.Activity[0], "the start of the span is in the first day")
252+ assert.Equal(t, 1, st.Recent, "30 days of 24 hours")
253+ assert.True(t, st.Partial, "the walk was truncated")
254+}
255+
185 func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {256 func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186 t.Parallel()257 t.Parallel()
187 ctx := context.Background()258 ctx := context.Background()
@@ -189,10 +260,11 @@ func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
189 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}260 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)261 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191 now := time.Now()262 now := time.Now()
192- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()263+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(0, errors.New("boom")).Once()
193 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)264 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194 require.Error(t, err)265 require.Error(t, err)
195- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()266+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(3, nil).Once()
267+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), mock.Anything).Return([]time.Time{now}, false, nil).Once()
196 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()268 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
197 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)269 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198 require.NoError(t, err)270 require.NoError(t, err)
M internal/usecase/repos.go +5 -0
@@ -61,6 +61,10 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
6161 if err := validateDescription(params.Description); err != nil {
6262 return domain.Repo{}, err
6363 }
64+ topics, err := domain.NormalizeTopics(params.Topics)
65+ if err != nil {
66+ return domain.Repo{}, err
67+ }
6468 vis := params.Visibility
6569 if vis == "" {
6670 vis = domain.VisibilityPrivate
@@ -71,6 +75,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
7175 repo := domain.Repo{
7276 Name: params.Name,
7377 Description: params.Description,
78+ Topics: topics,
7479 Visibility: vis,
7580 CreatedAt: r.clock.Now().UTC(),
7681 Settings: domain.DefaultRepoSettings(),
@@ -61,6 +61,10 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
61 if err := validateDescription(params.Description); err != nil {61 if err := validateDescription(params.Description); err != nil {
62 return domain.Repo{}, err62 return domain.Repo{}, err
63 }63 }
64+ topics, err := domain.NormalizeTopics(params.Topics)
65+ if err != nil {
66+ return domain.Repo{}, err
67+ }
64 vis := params.Visibility68 vis := params.Visibility
65 if vis == "" {69 if vis == "" {
66 vis = domain.VisibilityPrivate70 vis = domain.VisibilityPrivate
@@ -71,6 +75,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
71 repo := domain.Repo{75 repo := domain.Repo{
72 Name: params.Name,76 Name: params.Name,
73 Description: params.Description,77 Description: params.Description,
78+ Topics: topics,
74 Visibility: vis,79 Visibility: vis,
75 CreatedAt: r.clock.Now().UTC(),80 CreatedAt: r.clock.Now().UTC(),
76 Settings: domain.DefaultRepoSettings(),81 Settings: domain.DefaultRepoSettings(),
M internal/usecase/repos_test.go +22 -0
@@ -68,6 +68,28 @@ func TestCreate(t *testing.T) {
6868 assert.Equal(t, want, got)
6969 })
7070
71+ t.Run("topics are normalized and stored", func(t *testing.T) {
72+ t.Parallel()
73+ f := newFixture(t)
74+ n := name(t, "tools")
75+ want := domain.Repo{Name: n, Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
76+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
77+ f.meta.EXPECT().Create(ctx, want).Return(nil)
78+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
79+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
80+
81+ got, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n, Topics: []string{"Go", "cli", "go"}})
82+ require.NoError(t, err)
83+ assert.Equal(t, want, got)
84+ })
85+
86+ t.Run("an invalid topic is refused before anything runs", func(t *testing.T) {
87+ t.Parallel()
88+ f := newFixture(t)
89+ _, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Topics: []string{"bad topic"}})
90+ assert.ErrorIs(t, err, domain.ErrInvalid)
91+ })
92+
7193 t.Run("anonymous is refused before anything runs", func(t *testing.T) {
7294 t.Parallel()
7395 f := newFixture(t)
@@ -68,6 +68,28 @@ func TestCreate(t *testing.T) {
68 assert.Equal(t, want, got)68 assert.Equal(t, want, got)
69 })69 })
70 70
71+ t.Run("topics are normalized and stored", func(t *testing.T) {
72+ t.Parallel()
73+ f := newFixture(t)
74+ n := name(t, "tools")
75+ want := domain.Repo{Name: n, Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
76+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
77+ f.meta.EXPECT().Create(ctx, want).Return(nil)
78+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
79+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
80+
81+ got, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n, Topics: []string{"Go", "cli", "go"}})
82+ require.NoError(t, err)
83+ assert.Equal(t, want, got)
84+ })
85+
86+ t.Run("an invalid topic is refused before anything runs", func(t *testing.T) {
87+ t.Parallel()
88+ f := newFixture(t)
89+ _, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Topics: []string{"bad topic"}})
90+ assert.ErrorIs(t, err, domain.ErrInvalid)
91+ })
92+
71 t.Run("anonymous is refused before anything runs", func(t *testing.T) {93 t.Run("anonymous is refused before anything runs", func(t *testing.T) {
72 t.Parallel()94 t.Parallel()
73 f := newFixture(t)95 f := newFixture(t)
M internal/usecase/repos_write.go +14 -3
@@ -44,7 +44,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
4444 return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil
4545 }
4646
47-// Update changes the description, the visibility, the default branch and the
47+// Update changes the description, the topics, the visibility, the default branch and the
4848 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
4949 // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
5050 // else domain.ErrInvalid. A default branch that does not exist gives
@@ -61,7 +61,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6161 return domain.Repo{}, err
6262 }
6363 before := repo
64- if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
64+ if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
6565 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
@@ -77,6 +77,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7777 return domain.Repo{}, err
7878 }
7979 }
80+ if params.Topics != nil {
81+ topics, err := domain.NormalizeTopics(*params.Topics)
82+ if err != nil {
83+ return domain.Repo{}, err
84+ }
85+ params.Topics = &topics
86+ }
8087 if params.Visibility != nil {
8188 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
8289 return domain.Repo{}, err
@@ -102,7 +109,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
102109 // branch was checked above, so the remaining causes are a vanished
103110 // repository or an I/O failure.
104111 metaDone := false
105- if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
112+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
106113 if repo, err = r.updateMeta(ctx, name, params); err != nil {
107114 return domain.Repo{}, err
108115 }
@@ -122,6 +129,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
122129 log.Info().
123130 Str("repo", name.String()).
124131 Bool("description", params.Description != nil).
132+ Bool("topics", params.Topics != nil).
125133 Bool("visibility", params.Visibility != nil).
126134 Bool("default_branch", params.DefaultBranch != nil).
127135 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
@@ -148,6 +156,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
148156 if params.Description != nil {
149157 repo.Description = *params.Description
150158 }
159+ if params.Topics != nil {
160+ repo.Topics = *params.Topics
161+ }
151162 if params.Visibility != nil {
152163 repo.Visibility = *params.Visibility
153164 }
@@ -44,7 +44,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
44 return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil44 return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil
45 }45 }
46 46
47-// Update changes the description, the visibility, the default branch and the47+// Update changes the description, the topics, the visibility, the default branch and the
48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49 // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,49 // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
50 // else domain.ErrInvalid. A default branch that does not exist gives50 // else domain.ErrInvalid. A default branch that does not exist gives
@@ -61,7 +61,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
61 return domain.Repo{}, err61 return domain.Repo{}, err
62 }62 }
63 before := repo63 before := repo
64- if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&64+ if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {65 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
@@ -77,6 +77,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
77 return domain.Repo{}, err77 return domain.Repo{}, err
78 }78 }
79 }79 }
80+ if params.Topics != nil {
81+ topics, err := domain.NormalizeTopics(*params.Topics)
82+ if err != nil {
83+ return domain.Repo{}, err
84+ }
85+ params.Topics = &topics
86+ }
80 if params.Visibility != nil {87 if params.Visibility != nil {
81 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {88 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
82 return domain.Repo{}, err89 return domain.Repo{}, err
@@ -102,7 +109,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
102 // branch was checked above, so the remaining causes are a vanished109 // branch was checked above, so the remaining causes are a vanished
103 // repository or an I/O failure.110 // repository or an I/O failure.
104 metaDone := false111 metaDone := false
105- if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {112+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
106 if repo, err = r.updateMeta(ctx, name, params); err != nil {113 if repo, err = r.updateMeta(ctx, name, params); err != nil {
107 return domain.Repo{}, err114 return domain.Repo{}, err
108 }115 }
@@ -122,6 +129,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
122 log.Info().129 log.Info().
123 Str("repo", name.String()).130 Str("repo", name.String()).
124 Bool("description", params.Description != nil).131 Bool("description", params.Description != nil).
132+ Bool("topics", params.Topics != nil).
125 Bool("visibility", params.Visibility != nil).133 Bool("visibility", params.Visibility != nil).
126 Bool("default_branch", params.DefaultBranch != nil).134 Bool("default_branch", params.DefaultBranch != nil).
127 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).135 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
@@ -148,6 +156,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
148 if params.Description != nil {156 if params.Description != nil {
149 repo.Description = *params.Description157 repo.Description = *params.Description
150 }158 }
159+ if params.Topics != nil {
160+ repo.Topics = *params.Topics
161+ }
151 if params.Visibility != nil {162 if params.Visibility != nil {
152 repo.Visibility = *params.Visibility163 repo.Visibility = *params.Visibility
153 }164 }
M internal/usecase/repos_write_test.go +40 -0
@@ -44,6 +44,39 @@ func TestUpdate(t *testing.T) {
4444 assert.Equal(t, want, got)
4545 })
4646
47+ t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48+ t.Parallel()
49+ f := newFixture(t)
50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
51+ want := priv
52+ want.Topics = []string{"cli", "go"}
53+ want.Settings = domain.DefaultRepoSettings()
54+ f.meta.EXPECT().Update(ctx, want).Return(nil)
55+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56+ Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57+ }).Return().Once()
58+
59+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{"Go", "cli", "go"}})
60+ require.NoError(t, err)
61+ assert.Equal(t, want, got)
62+ })
63+
64+ t.Run("an empty list clears the topics", func(t *testing.T) {
65+ t.Parallel()
66+ f := newFixture(t)
67+ withTopics := priv
68+ withTopics.Topics = []string{"go"}
69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
70+ want := priv
71+ want.Settings = domain.DefaultRepoSettings()
72+ f.meta.EXPECT().Update(ctx, want).Return(nil)
73+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74+
75+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics)
78+ })
79+
4780 t.Run("a lost race on the metadata is retried", func(t *testing.T) {
4881 t.Parallel()
4982 f := newFixture(t)
@@ -124,12 +157,19 @@ func TestUpdate(t *testing.T) {
124157 })
125158
126159 t.Run("invalid input changes nothing", func(t *testing.T) {
160+ tooMany := make([]string, 0, domain.MaxTopics+1)
161+ for i := range domain.MaxTopics + 1 {
162+ tooMany = append(tooMany, fmt.Sprintf("t%d", i))
163+ }
127164 for name, params := range map[string]ports.UpdateRepoParams{
128165 "empty": {},
129166 "empty branch": {DefaultBranch: ptr("")},
130167 "multi-line": {Description: ptr("a\nb")},
131168 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
132169 "bad utf-8": {Description: ptr("\xff")},
170+ "bad topic": {Topics: &[]string{"Bad_Topic"}},
171+ "empty topic": {Topics: &[]string{""}},
172+ "too many topics": {Topics: &tooMany},
133173 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134174 } {
135175 f := newFixture(t)
@@ -44,6 +44,39 @@ func TestUpdate(t *testing.T) {
44 assert.Equal(t, want, got)44 assert.Equal(t, want, got)
45 })45 })
46 46
47+ t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48+ t.Parallel()
49+ f := newFixture(t)
50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
51+ want := priv
52+ want.Topics = []string{"cli", "go"}
53+ want.Settings = domain.DefaultRepoSettings()
54+ f.meta.EXPECT().Update(ctx, want).Return(nil)
55+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56+ Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57+ }).Return().Once()
58+
59+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{"Go", "cli", "go"}})
60+ require.NoError(t, err)
61+ assert.Equal(t, want, got)
62+ })
63+
64+ t.Run("an empty list clears the topics", func(t *testing.T) {
65+ t.Parallel()
66+ f := newFixture(t)
67+ withTopics := priv
68+ withTopics.Topics = []string{"go"}
69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
70+ want := priv
71+ want.Settings = domain.DefaultRepoSettings()
72+ f.meta.EXPECT().Update(ctx, want).Return(nil)
73+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74+
75+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics)
78+ })
79+
47 t.Run("a lost race on the metadata is retried", func(t *testing.T) {80 t.Run("a lost race on the metadata is retried", func(t *testing.T) {
48 t.Parallel()81 t.Parallel()
49 f := newFixture(t)82 f := newFixture(t)
@@ -124,12 +157,19 @@ func TestUpdate(t *testing.T) {
124 })157 })
125 158
126 t.Run("invalid input changes nothing", func(t *testing.T) {159 t.Run("invalid input changes nothing", func(t *testing.T) {
160+ tooMany := make([]string, 0, domain.MaxTopics+1)
161+ for i := range domain.MaxTopics + 1 {
162+ tooMany = append(tooMany, fmt.Sprintf("t%d", i))
163+ }
127 for name, params := range map[string]ports.UpdateRepoParams{164 for name, params := range map[string]ports.UpdateRepoParams{
128 "empty": {},165 "empty": {},
129 "empty branch": {DefaultBranch: ptr("")},166 "empty branch": {DefaultBranch: ptr("")},
130 "multi-line": {Description: ptr("a\nb")},167 "multi-line": {Description: ptr("a\nb")},
131 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},168 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
132 "bad utf-8": {Description: ptr("\xff")},169 "bad utf-8": {Description: ptr("\xff")},
170+ "bad topic": {Topics: &[]string{"Bad_Topic"}},
171+ "empty topic": {Topics: &[]string{""}},
172+ "too many topics": {Topics: &tooMany},
133 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},173 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134 } {174 } {
135 f := newFixture(t)175 f := newFixture(t)
M proto/ermite/storage/repo.proto +2 -0
@@ -30,6 +30,8 @@ message Repo {
3030 // Unset on repositories stored before merge requests: they read as the
3131 // default settings.
3232 RepoSettings settings = 6;
33+ // Normalized and sorted; empty on repositories stored before topics.
34+ repeated string topics = 7;
3335 }
3436
3537 // SchemaVersion is stored under the key "meta/schema".
@@ -30,6 +30,8 @@ message Repo {
30 // Unset on repositories stored before merge requests: they read as the30 // Unset on repositories stored before merge requests: they read as the
31 // default settings.31 // default settings.
32 RepoSettings settings = 6;32 RepoSettings settings = 6;
33+ // Normalized and sorted; empty on repositories stored before topics.
34+ repeated string topics = 7;
33 }35 }
34 36
35 // SchemaVersion is stored under the key "meta/schema".37 // SchemaVersion is stored under the key "meta/schema".

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close