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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.4.0 | |
| 16 | + | bnema.dev/ermite-api v0.5.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| @@ -13,7 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.2.0 | 15 | bnema.dev/ermite-actions v0.2.0 | ||
| 16 | - | bnema.dev/ermite-api v0.4.0 | 16 | + | bnema.dev/ermite-api v0.5.0 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | bnema.dev/ermite-actions v0.2.0 h1:XJyGNCDfgiEv/svN7FJqSz2nX/nT2SZgYtxBAhz4e/Y= | |
| 2 | 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 3 | - | bnema.dev/ermite-api v0.4.0 h1:KEj5f0cr0qacuo9DMu4zgdKWyhjcFVb3is+gNO9hbUg= | |
| 4 | - | bnema.dev/ermite-api v0.4.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 3 | + | bnema.dev/ermite-api v0.5.0 h1:LAhstFCx4NZuJZFifuavezwue8IOENPdyS7kda4lUQU= | |
| 4 | + | bnema.dev/ermite-api v0.5.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 5 | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| 6 | 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | |
| 7 | 7 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | bnema.dev/ermite-actions v0.2.0 h1:XJyGNCDfgiEv/svN7FJqSz2nX/nT2SZgYtxBAhz4e/Y= | 1 | bnema.dev/ermite-actions v0.2.0 h1:XJyGNCDfgiEv/svN7FJqSz2nX/nT2SZgYtxBAhz4e/Y= | ||
| 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | ||
| 3 | - | bnema.dev/ermite-api v0.4.0 h1:KEj5f0cr0qacuo9DMu4zgdKWyhjcFVb3is+gNO9hbUg= | 3 | + | bnema.dev/ermite-api v0.5.0 h1:LAhstFCx4NZuJZFifuavezwue8IOENPdyS7kda4lUQU= |
| 4 | - | bnema.dev/ermite-api v0.4.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | 4 | + | bnema.dev/ermite-api v0.5.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= |
| 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | ||
| 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | ||
| 7 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | 7 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | ||
M internal/adapters/ermitegit/ermitegit.go +7 -2
| @@ -418,8 +418,13 @@ func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domai | |||
| 418 | 418 | ||
| 419 | 419 | // CountCommits is served by the wrapped reader: git counts from the commit | |
| 420 | 420 | // 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) | |
| 423 | 428 | } | |
| 424 | 429 | ||
| 425 | 430 | // 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 commit | 419 | // 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 @@ | |||
| 3 | 3 | package gitcli | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bufio" | |
| 6 | 7 | "context" | |
| 8 | + | "errors" | |
| 7 | 9 | "fmt" | |
| 10 | + | "io" | |
| 8 | 11 | "strconv" | |
| 9 | 12 | "strings" | |
| 10 | 13 | "time" | |
| 11 | 14 | ||
| 12 | 15 | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| 15 | 19 | // 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. | |
| 19 | 21 | // | |
| 20 | 22 | //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 | 24 | if head.IsZero() { | |
| 23 | 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 | 28 | if err != nil { | |
| 27 | 29 | return 0, err | |
| 28 | 30 | } | |
| 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())...) | |
| 34 | 32 | if err != nil { | |
| 35 | 33 | if exitCode(err) == 128 { | |
| 36 | 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 | 42 | 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 | 93 | // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags. | |
| 48 | 94 | // | |
| 49 | 95 | //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints | |
| @@ -3,22 +3,24 @@ | |||||
| 3 | package gitcli | 3 | 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 the | 19 | // 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 | 20 | + | // 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 prints | 22 | //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, err | 29 | 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, nil | 42 | 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 prints | 95 | //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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "slices" | |
| 9 | 10 | "time" | |
| 10 | 11 | "unicode/utf8" | |
| 11 | 12 | ||
| @@ -201,6 +202,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) { | |||
| 201 | 202 | pb := &storagepb.Repo{ | |
| 202 | 203 | Name: r.Name.String(), | |
| 203 | 204 | Description: r.Description, | |
| 205 | + | Topics: r.Topics, | |
| 204 | 206 | Visibility: vis, | |
| 205 | 207 | CreatedAt: encodeTime(r.CreatedAt), | |
| 206 | 208 | PushedAt: encodeTime(r.PushedAt), | |
| @@ -221,6 +223,7 @@ func decodeRepo(val []byte) (domain.Repo, error) { | |||
| 221 | 223 | return domain.Repo{ | |
| 222 | 224 | Name: name, | |
| 223 | 225 | Description: pb.GetDescription(), | |
| 226 | + | Topics: slices.Clone(pb.GetTopics()), | |
| 224 | 227 | Visibility: decodeVisibility(pb.GetVisibility()), | |
| 225 | 228 | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 226 | 229 | 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) { | |||
| 42 | 42 | ctx := context.Background() | |
| 43 | 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 | 46 | a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()} | |
| 47 | 47 | require.NoError(t, s.Create(ctx, b)) | |
| 48 | 48 | require.NoError(t, s.Create(ctx, a)) | |
| @@ -63,12 +63,19 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 63 | 63 | assert.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 64 | 64 | ||
| 65 | 65 | b.Description = "changed" | |
| 66 | + | b.Topics = []string{"forge"} | |
| 66 | 67 | b.Visibility = domain.VisibilityPrivate | |
| 67 | 68 | require.NoError(t, s.Update(ctx, b)) | |
| 68 | 69 | got, err = s.Get(ctx, b.Name) | |
| 69 | 70 | require.NoError(t, err) | |
| 70 | 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 | 79 | require.NoError(t, s.Delete(ctx, b.Name)) | |
| 73 | 80 | _, err = s.Get(ctx, b.Name) | |
| 74 | 81 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| @@ -337,3 +344,18 @@ func TestCorruptRepoValue(t *testing.T) { | |||
| 337 | 344 | _, err = s.List(ctx) | |
| 338 | 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 | + | } | |
| @@ -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.VisibilityPrivate | 67 | 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 { | |||
| 65 | 65 | return &pb.Repo{ | |
| 66 | 66 | Name: v.Name.String(), | |
| 67 | 67 | Description: v.Description, | |
| 68 | + | Topics: v.Topics, | |
| 68 | 69 | Visibility: visibilityToPB(v.Visibility), | |
| 69 | 70 | DefaultBranch: v.DefaultBranch, | |
| 70 | 71 | 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 | |||
| 54 | 54 | return nil, connectError(ctx, err) | |
| 55 | 55 | } | |
| 56 | 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 | 59 | if err != nil { | |
| 60 | 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 | 81 | // 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 | 83 | // "settings.allowed_merge_modes" and "settings.failing_checks_block", and | |
| 84 | 84 | // nothing else. | |
| 85 | 85 | // | |
| @@ -94,6 +94,8 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |||
| 94 | 94 | switch path { | |
| 95 | 95 | case "description": | |
| 96 | 96 | params.Description = new(msg.GetDescription()) | |
| 97 | + | case "topics": | |
| 98 | + | params.Topics = new(msg.GetTopics()) | |
| 97 | 99 | case "visibility": | |
| 98 | 100 | vis, err := visibilityFromPB(msg.GetVisibility()) | |
| 99 | 101 | 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 least | 81 | // 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", and | 83 | // "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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | + | "slices" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 10 | 11 | "connectrpc.com/connect" | |
| @@ -93,7 +94,7 @@ func TestRepoServiceCreateRepo(t *testing.T) { | |||
| 93 | 94 | wantCode connect.Code | |
| 94 | 95 | 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 | 98 | {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument}, | |
| 98 | 99 | {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument}, | |
| 99 | 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 | 107 | if tt.wantCall { | |
| 107 | 108 | repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate} | |
| 108 | 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 | 111 | })).Return(repo, tt.usecase).Once() | |
| 111 | 112 | if tt.usecase == nil { | |
| 112 | 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 | + | } | |
| @@ -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.Code | 94 | wantCode connect.Code | ||
| 94 | wantCall bool | 95 | 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) { | |||
| 32 | 32 | </div> | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 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. | |
| 38 | 39 | templ HomeRowView(r HomeRow) { | |
| 39 | 40 | <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }> | |
| 40 | 41 | <div class="repo-main"> | |
| @@ -52,17 +53,29 @@ templ HomeRowView(r HomeRow) { | |||
| 52 | 53 | } else { | |
| 53 | 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 | 68 | <dl class="repo-stats" aria-label={ "Figures of " + r.Name }> | |
| 56 | 69 | if s := r.Stats; s != nil { | |
| 57 | 70 | <div><dt>commits</dt><dd>{ s.Commits }</dd></div> | |
| 58 | 71 | <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div> | |
| 59 | 72 | if s.Tag != "" { | |
| 60 | - | <div> | |
| 73 | + | <div class="rs-tag"> | |
| 61 | 74 | <dt>latest tag</dt> | |
| 62 | 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 | 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 | 80 | </div> | |
| 68 | 81 | } | |
| @@ -76,15 +89,19 @@ templ HomeRowView(r HomeRow) { | |||
| 76 | 89 | } | |
| 77 | 90 | </div> | |
| 78 | 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 | 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 | 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 | 105 | for _, c := range r.Commits { | |
| 89 | 106 | <li> | |
| 90 | 107 | <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a> | |
| @@ -101,6 +118,28 @@ templ HomeRowView(r HomeRow) { | |||
| 101 | 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 | 143 | // Outline is the README outline; levels indent by 2ch, the h1 is bold. | |
| 105 | 144 | templ Outline(headings []Heading) { | |
| 106 | 145 | <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, its | 35 | + | // 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; the | 36 | + | // 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 | |||
| 211 | 211 | } | |
| 212 | 212 | 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 | 218 | if t := st.LatestTag; t != nil { | |
| 216 | 219 | hs.Tag, hs.TagWhen = t.Name, ago(now, t.At) | |
| 217 | 220 | if !t.CommitID.IsZero() { | |
| @@ -259,7 +262,7 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow { | |||
| 259 | 262 | name := r.Name.String() | |
| 260 | 263 | row := HomeRow{ | |
| 261 | 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 | 266 | Private: signed && r.Visibility == domain.VisibilityPrivate, | |
| 264 | 267 | } | |
| 265 | 268 | 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 nil | 212 | 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) { | |||
| 66 | 66 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | |
| 67 | 67 | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{ | |
| 68 | 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 | 70 | }, nil).Once() | |
| 70 | 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 | 76 | require.NotNil(t, row.Stats) | |
| 74 | 77 | out := renderRow(t, row) | |
| 75 | 78 | assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>") | |
| 76 | 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 | 81 | assert.Contains(t, out, "<dt>last activity</dt>") | |
| 79 | 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 | 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 | 96 | // A failed read leaves the figures out, not the row. | |
| 83 | 97 | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once() | |
| 84 | 98 | row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false) | |
| @@ -126,6 +140,7 @@ func TestHomeRowShowsFiveCommits(t *testing.T) { | |||
| 126 | 140 | ||
| 127 | 141 | out := renderRow(t, row) | |
| 128 | 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 | 144 | assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`) | |
| 130 | 145 | assert.Contains(t, out, `aria-label="Recent commits of proj"`) | |
| 131 | 146 | 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 @@ | |||
| 48 | 48 | display: flex; flex-direction: column; gap: var(--s3); | |
| 49 | 49 | margin: 0; padding: 0; list-style: none; | |
| 50 | 50 | & .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; | |
| 53 | 53 | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | |
| 54 | 54 | } | |
| 55 | 55 | & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); } | |
| @@ -65,17 +65,43 @@ | |||
| 65 | 65 | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | |
| 66 | 66 | } | |
| 67 | 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 | 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 | 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 | 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 | 99 | & .repo-stats dd.mute { color: var(--ink-mute); } | |
| 74 | 100 | & .repo-stats dd a { color: inherit; text-decoration: none; } | |
| 75 | 101 | & .repo-stats dd a:hover { text-decoration: underline; } | |
| 76 | 102 | & .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; | |
| 79 | 105 | } | |
| 80 | 106 | & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; } | |
| 81 | 107 | & .repo-ci .ck { margin: 0; } | |
| @@ -86,11 +112,17 @@ | |||
| 86 | 112 | & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; } | |
| 87 | 113 | & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); } | |
| 88 | 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 | 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); } | |
| 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; } | |
| 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 | 126 | .list.passkeys { | |
| 95 | 127 | & .c-name { width: auto; font-weight: 600; } | |
| 96 | 128 | & .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 { | |||
| 80 | 80 | Name string | |
| 81 | 81 | Href string | |
| 82 | 82 | Desc string | |
| 83 | + | // Topics classify the repository, as on GitHub. | |
| 84 | + | Topics []string | |
| 83 | 85 | // Active is when the repository last changed (its last push or merge), | |
| 84 | 86 | // ActiveAt its RFC 3339 form, "" when it never got a commit. | |
| 85 | 87 | Active string | |
| @@ -107,6 +109,8 @@ type HomeStats struct { | |||
| 107 | 109 | // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen | |
| 108 | 110 | // tells when it was made. | |
| 109 | 111 | 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 | 116 | // HomeCI is the state of the latest run of the default branch of a repository. | |
| @@ -80,6 +80,8 @@ type HomeRow struct { | |||||
| 80 | Name string | 80 | Name string | ||
| 81 | Href string | 81 | Href string | ||
| 82 | Desc string | 82 | 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 string | 87 | Active string | ||
| @@ -107,6 +109,8 @@ type HomeStats struct { | |||||
| 107 | // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen | 109 | // 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 string | 111 | 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 { | |||
| 115 | 115 | // refsFan shares the fragments of the refs page between the watchers of | |
| 116 | 116 | // a push; entries are few and short-lived. | |
| 117 | 117 | refsFan fanout | |
| 118 | + | // pulses holds the activity curves of the home rows. | |
| 119 | + | pulses pulseCache | |
| 118 | 120 | } | |
| 119 | 121 | ||
| 120 | 122 | // New builds the web handler. | |
| @@ -115,6 +115,8 @@ type Server struct { | |||||
| 115 | // refsFan shares the fragments of the refs page between the watchers of | 115 | // 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 fanout | 117 | 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 { | |||
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | 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"` | |
| 177 | 178 | } | |
| 178 | 179 | ||
| 179 | 180 | type eventOut struct { | |
| @@ -190,7 +191,7 @@ type eventOut struct { | |||
| 190 | 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 | 196 | // AC6: the CLI end to end against a real forge. | |
| 196 | 197 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -209,11 +210,12 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 209 | 210 | w := c.startWatch(t, secret) | |
| 210 | 211 | ||
| 211 | 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 | 214 | assert.Equal(t, repoKeys, keys(t, res.stdout)) | |
| 214 | 215 | created := decode[repoOut](t, res.stdout) | |
| 215 | 216 | assert.Equal(t, "proj", created.Name) | |
| 216 | 217 | assert.Equal(t, "first", created.Description) | |
| 218 | + | assert.Equal(t, []string{"cli", "go"}, created.Topics, "normalized: lowercase, sorted, deduplicated") | |
| 217 | 219 | assert.Equal(t, "public", created.Visibility) | |
| 218 | 220 | assert.Equal(t, "main", created.DefaultBranch) | |
| 219 | 221 | require.NotNil(t, created.CreatedAt) | |
| @@ -282,6 +284,16 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 282 | 284 | assert.Empty(t, decode[repoOut](t, res.stdout).Description) | |
| 283 | 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 | 297 | // list and show: same shape, human tables too. | |
| 286 | 298 | res = c.cli(t, secret, "repo", "list", "--json").ok(t) | |
| 287 | 299 | 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 ( | |||
| 26 | 26 | ||
| 27 | 27 | // jsonRepo is the stable JSON shape of a repository. | |
| 28 | 28 | 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"` | |
| 35 | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |
| 36 | 37 | MergeModes []string `json:"merge_modes"` | |
| 37 | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| @@ -47,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 47 | 48 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | |
| 48 | 49 | Name: r.GetName(), | |
| 49 | 50 | Description: r.GetDescription(), | |
| 51 | + | Topics: append([]string{}, r.GetTopics()...), | |
| 50 | 52 | Visibility: visibilityName(r.GetVisibility()), | |
| 51 | 53 | DefaultBranch: r.GetDefaultBranch(), | |
| 52 | 54 | CreatedAt: jsonTime(r.GetCreatedAt()), | |
| @@ -188,6 +190,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 188 | 190 | {"visibility", visibilityName(r.GetVisibility())}, | |
| 189 | 191 | {"default branch", r.GetDefaultBranch()}, | |
| 190 | 192 | {"description", desc}, | |
| 193 | + | {"topics", orDash(strings.Join(r.GetTopics(), ", "))}, | |
| 191 | 194 | {"created", shortTime(r.GetCreatedAt(), "-")}, | |
| 192 | 195 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 193 | 196 | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| @@ -221,6 +224,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command { | |||
| 221 | 224 | func newRepoCreateCmd(env *cliEnv) *cobra.Command { | |
| 222 | 225 | var ( | |
| 223 | 226 | description string | |
| 227 | + | topics []string | |
| 224 | 228 | visibility string | |
| 225 | 229 | ) | |
| 226 | 230 | cmd := &cobra.Command{ | |
| @@ -231,11 +235,12 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command { | |||
| 231 | 235 | Args: cobra.ExactArgs(1), | |
| 232 | 236 | } | |
| 233 | 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 | 239 | cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private") | |
| 235 | 240 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | |
| 236 | 241 | asJSON := addJSONFlag(cmd) | |
| 237 | 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 | 244 | if err != nil { | |
| 240 | 245 | return err | |
| 241 | 246 | } | |
| @@ -257,23 +262,23 @@ func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.She | |||
| 257 | 262 | } | |
| 258 | 263 | ||
| 259 | 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 | 266 | vis, err := parseVisibility(visibility) | |
| 262 | 267 | if err != nil { | |
| 263 | 268 | return nil, err | |
| 264 | 269 | } | |
| 265 | - | return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil | |
| 270 | + | return &ermite.CreateRepoRequest{Name: name, Description: description, Topics: topics, Visibility: vis}, nil | |
| 266 | 271 | } | |
| 267 | 272 | ||
| 268 | 273 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | |
| 269 | 274 | var description, visibility, defaultBranch string | |
| 270 | - | var mergeModes []string | |
| 275 | + | var topics, mergeModes []string | |
| 271 | 276 | var failingChecksBlock bool | |
| 272 | 277 | cmd := &cobra.Command{ | |
| 273 | 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 | 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 | 282 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 278 | 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 | 284 | "Needs the repo:admin scope." + jsonRepoHelp, | |
| @@ -281,6 +286,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 281 | 286 | ValidArgsFunction: completeRepoNames(env), | |
| 282 | 287 | } | |
| 283 | 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 | 290 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | |
| 285 | 291 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 286 | 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 | 298 | asJSON := addJSONFlag(cmd) | |
| 293 | 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 | 301 | if err != nil { | |
| 296 | 302 | return err | |
| 297 | 303 | } | |
| @@ -310,13 +316,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 310 | 316 | ||
| 311 | 317 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 312 | 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 | 320 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 315 | 321 | var paths []string | |
| 316 | 322 | if cmd.Flags().Changed("description") { | |
| 317 | 323 | req.Description = description | |
| 318 | 324 | paths = append(paths, "description") | |
| 319 | 325 | } | |
| 326 | + | if cmd.Flags().Changed("topics") { | |
| 327 | + | req.Topics = topics | |
| 328 | + | paths = append(paths, "topics") | |
| 329 | + | } | |
| 320 | 330 | if cmd.Flags().Changed("visibility") { | |
| 321 | 331 | vis, err := parseVisibility(visibility) | |
| 322 | 332 | if err != nil { | |
| @@ -356,7 +366,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB | |||
| 356 | 366 | paths = append(paths, "settings.failing_checks_block") | |
| 357 | 367 | } | |
| 358 | 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 | 371 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 362 | 372 | 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 string | 226 | description string | ||
| 227 | + | topics []string | |||
| 224 | visibility string | 228 | 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 err | 245 | 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, err | 268 | return nil, err | ||
| 264 | } | 269 | } | ||
| 265 | - | return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil | 270 | + | 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 string | 274 | var description, visibility, defaultBranch string | ||
| 270 | - | var mergeModes []string | 275 | + | var topics, mergeModes []string |
| 271 | var failingChecksBlock bool | 276 | 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 err | 302 | 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 flags | 317 | // 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 []string | 321 | var paths []string | ||
| 316 | if cmd.Flags().Changed("description") { | 322 | if cmd.Flags().Changed("description") { | ||
| 317 | req.Description = description | 323 | 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, nil | 372 | return req, nil | ||
M internal/app/cli_repo_test.go +22 -12
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | var ( | |
| 18 | 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 | 20 | DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"), | |
| 21 | 21 | Settings: &ermite.RepoSettings{ | |
| 22 | 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 | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | 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}, | |
| 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} | |
| 59 | 59 | ]`, | |
| 60 | 60 | }, | |
| 61 | 61 | { | |
| @@ -111,6 +111,7 @@ func TestRepoShow(t *testing.T) { | |||
| 111 | 111 | "visibility public\n"+ | |
| 112 | 112 | "default branch main\n"+ | |
| 113 | 113 | "description The first one\n"+ | |
| 114 | + | "topics cli, go\n"+ | |
| 114 | 115 | "created 2026-01-02 03:04\n"+ | |
| 115 | 116 | "last push 2026-03-04 05:06\n"+ | |
| 116 | 117 | "merge modes fast-forward, squash\n"+ | |
| @@ -119,7 +120,7 @@ func TestRepoShow(t *testing.T) { | |||
| 119 | 120 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 120 | 121 | ||
| 121 | 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 | 126 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -129,14 +130,16 @@ func TestRepoShowNeedsOneArg(t *testing.T) { | |||
| 129 | 130 | ||
| 130 | 131 | func TestRepoCreateMapsFlags(t *testing.T) { | |
| 131 | 132 | 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 | |
| 136 | 138 | }{ | |
| 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"}}, | |
| 140 | 143 | } | |
| 141 | 144 | for _, tt := range tests { | |
| 142 | 145 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -150,6 +153,7 @@ func TestRepoCreateMapsFlags(t *testing.T) { | |||
| 150 | 153 | assert.Equal(t, "x", got.GetName()) | |
| 151 | 154 | assert.Equal(t, tt.want, got.GetVisibility()) | |
| 152 | 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 | 176 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 173 | 177 | }} | |
| 174 | 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 | 182 | func TestRepoEditMask(t *testing.T) { | |
| @@ -188,6 +192,12 @@ func TestRepoEditMask(t *testing.T) { | |||
| 188 | 192 | {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 189 | 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 | 201 | {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 192 | 202 | assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility()) | |
| 193 | 203 | }}, | |
| @@ -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 string | 133 | + | name string |
| 133 | - | args []string | 134 | + | args []string |
| 134 | - | want ermite.Visibility | 135 | + | want ermite.Visibility |
| 135 | - | desc string | 136 | + | 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}, nil | 176 | 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) { | |||
| 95 | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | |
| 96 | 96 | } | |
| 97 | 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 | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | |
| 100 | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | |
| 101 | 101 | {"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 @@ | |||
| 72 | 72 | <a href="/demo">demo</a> | |
| 73 | 73 | </h2> | |
| 74 | 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 | 103 | <dl class="repo-stats" aria-label="Figures of demo"> | |
| 76 | 104 | <div> | |
| 77 | 105 | <dt>commits</dt> | |
| @@ -81,10 +109,10 @@ | |||
| 81 | 109 | <dt>last 30 days</dt> | |
| 82 | 110 | <dd>0</dd> | |
| 83 | 111 | </div> | |
| 84 | - | <div> | |
| 112 | + | <div class="rs-tag"> | |
| 85 | 113 | <dt>latest tag</dt> | |
| 86 | 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 | 116 | </dd> | |
| 89 | 117 | </div> | |
| 90 | 118 | <div> | |
| @@ -114,6 +142,8 @@ | |||
| 114 | 142 | <a href="/quiet">quiet</a> | |
| 115 | 143 | </h2> | |
| 116 | 144 | <p class="repo-desc mute">no description</p> | |
| 145 | + | </div> | |
| 146 | + | <div class="repo-head"> | |
| 117 | 147 | <dl class="repo-stats" aria-label="Figures of quiet"> | |
| 118 | 148 | <div> | |
| 119 | 149 | <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 ( | |||
| 184 | 184 | reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`) | |
| 185 | 185 | reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`) | |
| 186 | 186 | 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*\)`) | |
| 189 | 192 | ) | |
| 190 | 193 | ||
| 191 | 194 | // 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 or | 194 | // assertNoInline checks the page has no inline script, style element or | ||
M internal/domain/repo.go +52 -3
| @@ -4,6 +4,8 @@ package domain | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
| 7 | + | "slices" | |
| 8 | + | "strings" | |
| 7 | 9 | "time" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -35,10 +37,57 @@ func (v Visibility) String() string { return string(v) } | |||
| 35 | 37 | type Repo struct { | |
| 36 | 38 | Name RepoName | |
| 37 | 39 | 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 | |
| 41 | 45 | // Settings are the merge settings; the stores always fill them in, with | |
| 42 | 46 | // the defaults for a repository that never stored any. | |
| 43 | 47 | 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 | + | } | |
| @@ -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 RepoName | 38 | Name RepoName | ||
| 37 | Description string | 39 | Description string | ||
| 38 | - | Visibility Visibility | 40 | + | // Topics are the normalized topics (see NormalizeTopics); nil for none. |
| 39 | - | CreatedAt time.Time | 41 | + | Topics []string |
| 40 | - | PushedAt time.Time // zero until the first push | 42 | + | 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, with | 45 | // 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 RepoSettings | 47 | 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 { | |||
| 31 | 31 | type CreateRepoParams struct { | |
| 32 | 32 | Name domain.RepoName | |
| 33 | 33 | Description string | |
| 34 | + | // Topics are normalized by Create (see domain.NormalizeTopics). | |
| 35 | + | Topics []string | |
| 34 | 36 | // Visibility defaults to private when empty. | |
| 35 | 37 | Visibility domain.Visibility | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 38 | 40 | // UpdateRepoParams are the changes of Update; a nil field is left alone. | |
| 39 | 41 | type UpdateRepoParams struct { | |
| 40 | - | Description *string | |
| 42 | + | Description *string | |
| 43 | + | // Topics replaces the topics; an empty list clears them. | |
| 44 | + | Topics *[]string | |
| 41 | 45 | Visibility *domain.Visibility | |
| 42 | 46 | DefaultBranch *string | |
| 43 | 47 | // AllowedMergeModes replaces the allowed merge modes; it must hold at | |
| @@ -121,11 +125,20 @@ type RepoStats struct { | |||
| 121 | 125 | Commits, Recent int | |
| 122 | 126 | // LatestTag is the most recently created tag; nil without tags. | |
| 123 | 127 | 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 | 136 | // RecentDays is the window of RepoStats.Recent. | |
| 127 | 137 | const RecentDays = 30 | |
| 128 | 138 | ||
| 139 | + | // ActivityDays is the span of RepoStats.Activity. | |
| 140 | + | const ActivityDays = 90 | |
| 141 | + | ||
| 129 | 142 | // WatchParams select the events of a watch. | |
| 130 | 143 | type WatchParams struct { | |
| 131 | 144 | // 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.RepoName | 32 | Name domain.RepoName | ||
| 33 | Description string | 33 | 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.Visibility | 37 | 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 *string | 42 | + | Description *string |
| 43 | + | // Topics replaces the topics; an empty list clears them. | |||
| 44 | + | Topics *[]string | |||
| 41 | Visibility *domain.Visibility | 45 | Visibility *domain.Visibility | ||
| 42 | DefaultBranch *string | 46 | DefaultBranch *string | ||
| 43 | // AllowedMergeModes replaces the allowed merge modes; it must hold at | 47 | // AllowedMergeModes replaces the allowed merge modes; it must hold at | ||
| @@ -121,11 +125,20 @@ type RepoStats struct { | |||||
| 121 | Commits, Recent int | 125 | 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.TagInfo | 127 | 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 = 30 | 137 | 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 { | |||
| 50 | 50 | return &RepoReader_Expecter{mock: &_m.Mock} | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 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) { | |
| 55 | 55 | ret := _mock.Called(ctx, repo, head, since) | |
| 56 | 56 | ||
| 57 | 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 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 { | |
| 64 | 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 | 68 | r0 = returnFunc(ctx, repo, head, since) | |
| 68 | 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 | 75 | r1 = returnFunc(ctx, repo, head, since) | |
| 73 | 76 | } else { | |
| 74 | - | r1 = ret.Error(1) | |
| 77 | + | r1 = ret.Get(1).(bool) | |
| 75 | 78 | } | |
| 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 | |
| 77 | 85 | } | |
| 78 | 86 | ||
| 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 { | |
| 81 | 89 | *mock.Call | |
| 82 | 90 | } | |
| 83 | 91 | ||
| 84 | - | // CountCommits is a helper method to define mock.On call | |
| 92 | + | // CommitTimes is a helper method to define mock.On call | |
| 85 | 93 | // - ctx context.Context | |
| 86 | 94 | // - repo domain.RepoName | |
| 87 | 95 | // - head domain.ObjectID | |
| 88 | 96 | // - 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)} | |
| 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 | 102 | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | 103 | var arg0 context.Context | |
| 96 | 104 | if args[0] != nil { | |
| @@ -118,12 +126,84 @@ func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo d | |||
| 118 | 126 | 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 | 201 | func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call { | |
| 122 | 202 | _c.Call.Return(n, err) | |
| 123 | 203 | 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 | 207 | _c.Call.Return(run) | |
| 128 | 208 | return _c | |
| 129 | 209 | } | |
| @@ -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 RepoReader | 53 | + | // 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 int | 61 | + | var r0 []time.Time |
| 62 | - | var r1 error | 62 | + | 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, 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 | |||
| 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.Call | 89 | *mock.Call | ||
| 82 | } | 90 | } | ||
| 83 | 91 | ||||
| 84 | - | // CountCommits is a helper method to define mock.On call | 92 | + | // CommitTimes is a helper method to define mock.On call |
| 85 | // - ctx context.Context | 93 | // - ctx context.Context | ||
| 86 | // - repo domain.RepoName | 94 | // - repo domain.RepoName | ||
| 87 | // - head domain.ObjectID | 95 | // - head domain.ObjectID | ||
| 88 | // - since time.Time | 96 | // - 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.Context | 103 | 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 _c | 126 | 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 _c | 203 | 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 _c | 208 | return _c | ||
| 129 | } | 209 | } | ||
M internal/ports/ports.go +9 -3
| @@ -118,6 +118,9 @@ type CommitQuery struct { | |||
| 118 | 118 | Limit int | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | // MaxCommitTimes bounds RepoReader.CommitTimes. | |
| 122 | + | const MaxCommitTimes = 100_000 | |
| 123 | + | ||
| 121 | 124 | // MaxCommitLimit is the most commits one CommitQuery may ask for. | |
| 122 | 125 | const MaxCommitLimit = 1000 | |
| 123 | 126 | ||
| @@ -178,9 +181,12 @@ type RepoReader interface { | |||
| 178 | 181 | // GetCommit returns a commit with its statistics and its diff against the | |
| 179 | 182 | // first parent, bounded by limits. | |
| 180 | 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-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) | |
| 184 | 190 | // LatestTag is the most recently created tag (the commit date for a | |
| 185 | 191 | // lightweight tag); ok is false when the repository has no tag. | |
| 186 | 192 | LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error) | |
| @@ -118,6 +118,9 @@ type CommitQuery struct { | |||||
| 118 | Limit int | 118 | 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 = 1000 | 125 | const MaxCommitLimit = 1000 | ||
| 123 | 126 | ||||
| @@ -178,9 +181,12 @@ type RepoReader interface { | |||||
| 178 | // GetCommit returns a commit with its statistics and its diff against the | 181 | // 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-zero | 184 | + | // 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 a | 190 | // 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 ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | + | "slices" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| 13 | 14 | "time" | |
| @@ -119,16 +120,29 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) { | |||
| 119 | 120 | ids = append(ids, f.Commit("c"+itoa(i))) | |
| 120 | 121 | } | |
| 121 | 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 | 124 | require.NoError(t, err) | |
| 124 | 125 | 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])) | |
| 129 | 127 | require.NoError(t, err) | |
| 130 | 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 | 146 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 133 | 147 | ||
| 134 | 148 | // 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 | 151 | f.When = recent.Add(-48 * time.Hour) | |
| 138 | 152 | old := f.Commit("backdated") | |
| 139 | 153 | 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 | 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 | 158 | // The latest tag is the one made last: an annotated tag by its own date, | |
| 145 | 159 | // 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 ones | 148 | // 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 = recent | 153 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "slices" | |
| 7 | 8 | "sync" | |
| 8 | 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 | 114 | func cloneStats(st ports.RepoStats) ports.RepoStats { | |
| 115 | + | st.Activity = slices.Clone(st.Activity) | |
| 85 | 116 | if st.LatestTag != nil { | |
| 86 | 117 | t := *st.LatestTag | |
| 87 | 118 | st.LatestTag = &t | |
| @@ -89,9 +120,10 @@ func cloneStats(st ports.RepoStats) ports.RepoStats { | |||
| 89 | 120 | return st | |
| 90 | 121 | } | |
| 91 | 122 | ||
| 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. | |
| 95 | 127 | func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) { | |
| 96 | 128 | r, err := b.authorize(ctx, p, repo) | |
| 97 | 129 | if err != nil { | |
| @@ -103,13 +135,17 @@ func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain. | |||
| 103 | 135 | rctx := context.WithoutCancel(ctx) | |
| 104 | 136 | var st ports.RepoStats | |
| 105 | 137 | 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 | 139 | return ports.RepoStats{}, err | |
| 108 | 140 | } | |
| 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 { | |
| 111 | 145 | return ports.RepoStats{}, err | |
| 112 | 146 | } | |
| 147 | + | st.Recent, st.Activity = activity(times, now) | |
| 148 | + | st.Partial = truncated | |
| 113 | 149 | tag, ok, err := b.reader.LatestTag(rctx, repo) | |
| 114 | 150 | if err != nil { | |
| 115 | 151 | 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.LatestTag | 117 | t := *st.LatestTag | ||
| 87 | st.LatestTag = &t | 118 | st.LatestTag = &t | ||
| @@ -89,9 +120,10 @@ func cloneStats(st ports.RepoStats) ports.RepoStats { | |||||
| 89 | return st | 120 | return st | ||
| 90 | } | 121 | } | ||
| 91 | 122 | ||||
| 92 | - | // RepoStats counts the commits reachable from head, all of them and those of | 123 | + | // 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. The | 124 | + | // 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.RepoStats | 136 | var st ports.RepoStats | ||
| 105 | var err error | 137 | 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{}, err | 139 | 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{}, err | 145 | 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{}, err | 151 | return ports.RepoStats{}, err | ||
M internal/usecase/browse_test.go +78 -6
| @@ -113,7 +113,8 @@ func browseOps() []browseOp { | |||
| 113 | 113 | _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now()) | |
| 114 | 114 | return err | |
| 115 | 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 | 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 | 137 | now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC) | |
| 137 | 138 | var reads atomic.Int32 | |
| 138 | 139 | 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) { | |
| 141 | 142 | reads.Add(1) | |
| 142 | 143 | <-gate | |
| 143 | 144 | 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 | 151 | tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")} | |
| 147 | 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 | 165 | assert.Equal(t, 900, st.Commits) | |
| 161 | 166 | assert.Equal(t, 12, st.Recent) | |
| 162 | 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 | 172 | for reads.Load() == 0 { | |
| @@ -182,6 +189,70 @@ func TestRepoStatsCache(t *testing.T) { | |||
| 182 | 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 | 256 | func TestRepoStatsDoesNotKeepAFailure(t *testing.T) { | |
| 186 | 257 | t.Parallel() | |
| 187 | 258 | ctx := context.Background() | |
| @@ -189,10 +260,11 @@ func TestRepoStatsDoesNotKeepAFailure(t *testing.T) { | |||
| 189 | 260 | repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 190 | 261 | f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil) | |
| 191 | 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 | 264 | _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now) | |
| 194 | 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 | 268 | f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once() | |
| 197 | 269 | st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now) | |
| 198 | 270 | 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 err | 114 | 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.Int32 | 138 | 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 | <-gate | 143 | <-gate | ||
| 143 | return 900, nil | 144 | 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 | |||
| 61 | 61 | if err := validateDescription(params.Description); err != nil { | |
| 62 | 62 | return domain.Repo{}, err | |
| 63 | 63 | } | |
| 64 | + | topics, err := domain.NormalizeTopics(params.Topics) | |
| 65 | + | if err != nil { | |
| 66 | + | return domain.Repo{}, err | |
| 67 | + | } | |
| 64 | 68 | vis := params.Visibility | |
| 65 | 69 | if vis == "" { | |
| 66 | 70 | vis = domain.VisibilityPrivate | |
| @@ -71,6 +75,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre | |||
| 71 | 75 | repo := domain.Repo{ | |
| 72 | 76 | Name: params.Name, | |
| 73 | 77 | Description: params.Description, | |
| 78 | + | Topics: topics, | |
| 74 | 79 | Visibility: vis, | |
| 75 | 80 | CreatedAt: r.clock.Now().UTC(), | |
| 76 | 81 | 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{}, err | 62 | 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.Visibility | 68 | vis := params.Visibility | ||
| 65 | if vis == "" { | 69 | if vis == "" { | ||
| 66 | vis = domain.VisibilityPrivate | 70 | 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) { | |||
| 68 | 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 | 93 | t.Run("anonymous is refused before anything runs", func(t *testing.T) { | |
| 72 | 94 | t.Parallel() | |
| 73 | 95 | 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 | |||
| 44 | 44 | return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 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 | |
| 48 | 48 | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | |
| 49 | 49 | // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set, | |
| 50 | 50 | // 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 | 61 | return domain.Repo{}, err | |
| 62 | 62 | } | |
| 63 | 63 | 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 | 65 | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil { | |
| 66 | 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 | 77 | 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 | 87 | if params.Visibility != nil { | |
| 81 | 88 | if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil { | |
| 82 | 89 | return domain.Repo{}, err | |
| @@ -102,7 +109,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 102 | 109 | // branch was checked above, so the remaining causes are a vanished | |
| 103 | 110 | // repository or an I/O failure. | |
| 104 | 111 | 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 | 113 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | |
| 107 | 114 | return domain.Repo{}, err | |
| 108 | 115 | } | |
| @@ -122,6 +129,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 122 | 129 | log.Info(). | |
| 123 | 130 | Str("repo", name.String()). | |
| 124 | 131 | Bool("description", params.Description != nil). | |
| 132 | + | Bool("topics", params.Topics != nil). | |
| 125 | 133 | Bool("visibility", params.Visibility != nil). | |
| 126 | 134 | Bool("default_branch", params.DefaultBranch != nil). | |
| 127 | 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 | 156 | if params.Description != nil { | |
| 149 | 157 | repo.Description = *params.Description | |
| 150 | 158 | } | |
| 159 | + | if params.Topics != nil { | |
| 160 | + | repo.Topics = *params.Topics | |
| 161 | + | } | |
| 151 | 162 | if params.Visibility != nil { | |
| 152 | 163 | repo.Visibility = *params.Visibility | |
| 153 | 164 | } | |
| @@ -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}, nil | 44 | return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 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 |
| 48 | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | 48 | // 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 gives | 50 | // 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{}, err | 61 | return domain.Repo{}, err | ||
| 62 | } | 62 | } | ||
| 63 | before := repo | 63 | 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{}, err | 77 | 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{}, err | 89 | 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 vanished | 109 | // 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 := false | 111 | 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{}, err | 114 | 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.Description | 157 | 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.Visibility | 163 | repo.Visibility = *params.Visibility | ||
| 153 | } | 164 | } | ||
M internal/usecase/repos_write_test.go +40 -0
| @@ -44,6 +44,39 @@ func TestUpdate(t *testing.T) { | |||
| 44 | 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 | 80 | t.Run("a lost race on the metadata is retried", func(t *testing.T) { | |
| 48 | 81 | t.Parallel() | |
| 49 | 82 | f := newFixture(t) | |
| @@ -124,12 +157,19 @@ func TestUpdate(t *testing.T) { | |||
| 124 | 157 | }) | |
| 125 | 158 | ||
| 126 | 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 | 164 | for name, params := range map[string]ports.UpdateRepoParams{ | |
| 128 | 165 | "empty": {}, | |
| 129 | 166 | "empty branch": {DefaultBranch: ptr("")}, | |
| 130 | 167 | "multi-line": {Description: ptr("a\nb")}, | |
| 131 | 168 | "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))}, | |
| 132 | 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 | 173 | "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))}, | |
| 134 | 174 | } { | |
| 135 | 175 | 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 { | |||
| 30 | 30 | // Unset on repositories stored before merge requests: they read as the | |
| 31 | 31 | // default settings. | |
| 32 | 32 | RepoSettings settings = 6; | |
| 33 | + | // Normalized and sorted; empty on repositories stored before topics. | |
| 34 | + | repeated string topics = 7; | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | // 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 the | 30 | // 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". | ||