Compare commits
8 Commits
3299daf061
...
98bb6c568f
| Author | SHA1 | Date | |
|---|---|---|---|
| 98bb6c568f | |||
| 72168aa8fa | |||
| 28dde5b9b1 | |||
| c2e0ba200c | |||
| 2eb23ea7ea | |||
| 9906f71c5d | |||
| 593c0d9abc | |||
| 53dfd60b4c |
+15
-7
@@ -187,19 +187,27 @@ in {
|
||||
readModulesRecursive' = path: extraArgs:
|
||||
with lib;
|
||||
with builtins; let
|
||||
paths = pipe "${path}" [
|
||||
(filesystem.listFilesRecursive)
|
||||
(filter (hasSuffix ".nix"))
|
||||
];
|
||||
collectPaths = dir: prefix:
|
||||
concatLists (mapAttrsToList (name: type: let
|
||||
path' = dir + "/${name}";
|
||||
name' = if prefix == "" then name else "${prefix}/${name}";
|
||||
in
|
||||
if type == "directory"
|
||||
then collectPaths path' name'
|
||||
else [{
|
||||
inherit path';
|
||||
name = name';
|
||||
}]
|
||||
) (readDir dir));
|
||||
paths = filter (path': hasSuffix ".nix" path'.name) (collectPaths path "");
|
||||
pathToName = flip pipe [
|
||||
(removePrefix "${path}/")
|
||||
(replaceStrings ["/" ".nix"] ["." ""])
|
||||
(removeSuffix ".nix")
|
||||
];
|
||||
attrList =
|
||||
map (path': {
|
||||
name = pathToName (unsafeDiscardStringContext path');
|
||||
value = import path' extraArgs;
|
||||
name = pathToName path'.name;
|
||||
value = import path'.path' extraArgs;
|
||||
})
|
||||
paths;
|
||||
in
|
||||
|
||||
@@ -9,11 +9,11 @@ with self.lib;
|
||||
let
|
||||
# Combine hectic modules into one
|
||||
hectic.imports = attrValues (
|
||||
readModulesRecursive' ./hectic { inherit flake self inputs; }
|
||||
readModulesRecursive' (flake + "/nixos/module/hectic") { inherit flake self inputs; }
|
||||
);
|
||||
# Read generic modules separately
|
||||
generic = readModulesRecursive'
|
||||
./generic
|
||||
(flake + "/nixos/module/generic")
|
||||
{ inherit flake self inputs; };
|
||||
in generic // {
|
||||
inherit hectic;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
];
|
||||
|
||||
adminNames = [ "yukkop" ];
|
||||
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
|
||||
|
||||
cfg = config.hectic.generic.matrix-cluster;
|
||||
in {
|
||||
@@ -39,7 +40,7 @@ in {
|
||||
value = {
|
||||
key = "matrix/users/${name}/password";
|
||||
owner = "matrix-synapse";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
}) userNames
|
||||
);
|
||||
|
||||
@@ -147,7 +147,7 @@ in {
|
||||
|
||||
(lib.mkIf cfg.watcher.enable {
|
||||
sops.secrets."sentinèlla/watcher/environment" = lib.mkDefault {
|
||||
sopsFile = "${flake}/sus/sentinella-default.yaml";
|
||||
sopsFile = flake + "/sus/sentinella-default.yaml";
|
||||
};
|
||||
|
||||
systemd.services."sentinella-watcher" = {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
jitsiHost = "meet.accord.tube";
|
||||
elementEntryDomain = "element.bfs.band";
|
||||
polandEntryDomain = "bfs.band";
|
||||
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
|
||||
backendProxyConfig = ''
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name ${matrixHost};
|
||||
@@ -203,41 +204,41 @@ in {
|
||||
key = "matrix/signing-key";
|
||||
owner = "matrix-synapse";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
sops.secrets."matrix/postgres-replication-password" = {
|
||||
key = "matrix/postgres-replication-password";
|
||||
owner = "postgres";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
sops.secrets."matrix/object-storage/credentials" = {
|
||||
key = "matrix/object-storage/credentials";
|
||||
owner = "matrix-synapse";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
sops.secrets."matrix/secrets" = {
|
||||
key = "matrix/secrets";
|
||||
owner = "matrix-synapse";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
sops.secrets."matrix/turn-secret" = {
|
||||
key = "matrix/turn-secret";
|
||||
owner = "turnserver";
|
||||
group = "turnserver";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
sops.secrets."matrix/porkbun-api-key" = {
|
||||
key = "matrix/porkbun-api-key";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
sops.secrets."matrix/porkbun-secret-api-key" = {
|
||||
key = "matrix/porkbun-secret-api-key";
|
||||
mode = "0400";
|
||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
||||
sopsFile = matrixClusterSopsFile;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ in {
|
||||
sops = {
|
||||
gnupg.sshKeyPaths = [ ];
|
||||
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
||||
defaultSopsFile = "${flake}/sus/hectic-lab.yaml";
|
||||
defaultSopsFile = flake + "/sus/hectic-lab.yaml";
|
||||
secrets = builtins.listToAttrs (map mkMailPasswordSecret mailUserNames) // {
|
||||
"init-postgresql" = {
|
||||
key = "init-postgresql";
|
||||
|
||||
@@ -25,6 +25,7 @@ func newAdminCommand() *cli.Command {
|
||||
Commands: []*cli.Command{
|
||||
newUserCommand(),
|
||||
newRepoSyncReleasesCommand(),
|
||||
newHeatmapCommand(),
|
||||
newRegenerateCommand(),
|
||||
newAuthCommand(),
|
||||
newSendMailCommand(),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func newHeatmapCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "heatmap",
|
||||
Usage: "Manage heatmap indexes",
|
||||
Commands: []*cli.Command{
|
||||
newHeatmapReindexCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newHeatmapReindexCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reindex",
|
||||
Usage: "Reindex heatmap contributions from repository default branches",
|
||||
Action: runHeatmapReindex,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "all",
|
||||
Usage: "Reindex all repositories",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "repo",
|
||||
Usage: "Repository to reindex as owner/name, or repository name when --owner is set",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "owner",
|
||||
Usage: "Owner name for --repo",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runHeatmapReindex(ctx context.Context, c *cli.Command) error {
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := git.InitSimple(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reindexAll := c.Bool("all")
|
||||
repoName := c.String("repo")
|
||||
ownerName := c.String("owner")
|
||||
if reindexAll {
|
||||
if repoName != "" || ownerName != "" {
|
||||
return fmt.Errorf("--all cannot be combined with --repo or --owner")
|
||||
}
|
||||
return reindexAllHeatmapRepositories(ctx)
|
||||
}
|
||||
if repoName == "" {
|
||||
return fmt.Errorf("either --all or --repo must be provided")
|
||||
}
|
||||
|
||||
ownerName, repoName, err := parseHeatmapRepoSelector(ownerName, repoName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return reindexHeatmapRepository(ctx, repo)
|
||||
}
|
||||
|
||||
func parseHeatmapRepoSelector(ownerName, repoName string) (string, string, error) {
|
||||
if ownerName != "" {
|
||||
if strings.Contains(repoName, "/") {
|
||||
return "", "", fmt.Errorf("--repo must be a repository name when --owner is set")
|
||||
}
|
||||
return ownerName, repoName, nil
|
||||
}
|
||||
|
||||
ownerName, repoName, ok := strings.Cut(repoName, "/")
|
||||
if !ok || ownerName == "" || repoName == "" || strings.Contains(repoName, "/") {
|
||||
return "", "", fmt.Errorf("--repo must be provided as owner/name unless --owner is set")
|
||||
}
|
||||
return ownerName, repoName, nil
|
||||
}
|
||||
|
||||
func reindexAllHeatmapRepositories(ctx context.Context) error {
|
||||
for page := 1; ; page++ {
|
||||
repos, count, err := repo_model.SearchRepositoryByName(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: repo_model.RepositoryListDefaultPageSize,
|
||||
Page: page,
|
||||
},
|
||||
Private: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SearchRepositoryByName: %w", err)
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
break
|
||||
}
|
||||
log.Trace("Processing next %d repos of %d", len(repos), count)
|
||||
for _, repo := range repos {
|
||||
if err := reindexHeatmapRepository(ctx, repo); err != nil {
|
||||
log.Warn("Reindexing heatmap contributions for repo %s failed: %v", repo.FullName(), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reindexHeatmapRepository(ctx context.Context, repo *repo_model.Repository) error {
|
||||
log.Trace("Reindexing heatmap contributions for repo %s", repo.FullName())
|
||||
if err := repo_service.IndexDefaultBranchHeatmapContributions(ctx, repo); err != nil {
|
||||
return fmt.Errorf("IndexDefaultBranchHeatmapContributions[%s]: %w", repo.FullName(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeatmapAdminReindex(t *testing.T) {
|
||||
repo, commits := prepareHeatmapAdminRepo(t, "heatmap-admin-reindex", []heatmapAdminTestCommit{
|
||||
{Branch: "main", Mark: "initial", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-15T12:00:00Z"},
|
||||
})
|
||||
|
||||
timeutil.MockSet(time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
require.NoError(t, reindexHeatmapRepository(t.Context(), repo))
|
||||
require.NoError(t, reindexHeatmapRepository(t.Context(), repo))
|
||||
contributions := loadHeatmapAdminContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 1)
|
||||
assert.Equal(t, commits["initial"], contributions[0].CommitSHA)
|
||||
|
||||
require.NoError(t, gitcmd.NewCommand("update-ref", "-d", "refs/heads/main").WithDir(repo.RepoPath()).Run(t.Context()))
|
||||
newCommits := runHeatmapAdminFastImport(t, repo, []heatmapAdminTestCommit{
|
||||
{Branch: "main", Mark: "forced", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-16T12:00:00Z"},
|
||||
})
|
||||
require.NoError(t, reindexHeatmapRepository(t.Context(), repo))
|
||||
contributions = loadHeatmapAdminContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 1)
|
||||
assert.Equal(t, newCommits["forced"], contributions[0].CommitSHA)
|
||||
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, reindexAllHeatmapRepositories(t.Context()))
|
||||
contributions = loadHeatmapAdminContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 1)
|
||||
assert.Equal(t, newCommits["forced"], contributions[0].CommitSHA)
|
||||
}
|
||||
|
||||
func TestHeatmapAdminRepoSelector(t *testing.T) {
|
||||
owner, repo, err := parseHeatmapRepoSelector("", "user/repo")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user", owner)
|
||||
assert.Equal(t, "repo", repo)
|
||||
|
||||
owner, repo, err = parseHeatmapRepoSelector("user", "repo")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user", owner)
|
||||
assert.Equal(t, "repo", repo)
|
||||
|
||||
_, _, err = parseHeatmapRepoSelector("", "repo")
|
||||
assert.ErrorContains(t, err, "owner/name")
|
||||
_, _, err = parseHeatmapRepoSelector("user", "owner/repo")
|
||||
assert.ErrorContains(t, err, "when --owner is set")
|
||||
}
|
||||
|
||||
type heatmapAdminTestCommit struct {
|
||||
Branch string
|
||||
Mark string
|
||||
Parent string
|
||||
AuthorName string
|
||||
AuthorEmail string
|
||||
CommitterName string
|
||||
CommitterEmail string
|
||||
AuthorDate string
|
||||
}
|
||||
|
||||
func prepareHeatmapAdminRepo(t *testing.T, repoName string, commits []heatmapAdminTestCommit) (*repo_model.Repository, map[string]string) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo, err := repo_service.CreateRepositoryDirectly(t.Context(), owner, owner, repo_service.CreateRepoOptions{Name: repoName, DefaultBranch: "main"}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
marks := runHeatmapAdminFastImport(t, repo, commits)
|
||||
repo.IsEmpty = false
|
||||
require.NoError(t, repo_model.UpdateRepositoryColsWithAutoTime(t.Context(), repo, "is_empty"))
|
||||
return repo, marks
|
||||
}
|
||||
|
||||
func runHeatmapAdminFastImport(t *testing.T, repo *repo_model.Repository, commits []heatmapAdminTestCommit) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
var stream strings.Builder
|
||||
branchTips := make(map[string]string)
|
||||
for i, commit := range commits {
|
||||
mark := fmt.Sprintf(":%d", i+1)
|
||||
branchRef := "refs/heads/" + commit.Branch
|
||||
stream.WriteString("commit " + branchRef + "\n")
|
||||
stream.WriteString("mark " + mark + "\n")
|
||||
stream.WriteString(heatmapAdminSignatureLine("author", commit.AuthorName, commit.AuthorEmail, commit.AuthorDate))
|
||||
stream.WriteString(heatmapAdminSignatureLine("committer", commit.CommitterName, commit.CommitterEmail, commit.AuthorDate))
|
||||
message := "heatmap admin " + commit.Mark
|
||||
stream.WriteString(fmt.Sprintf("data %d\n%s\n", len(message), message))
|
||||
if parentMark := branchTips[commit.Branch]; parentMark != "" {
|
||||
stream.WriteString("from " + parentMark + "\n")
|
||||
} else if commit.Parent != "" {
|
||||
stream.WriteString("from " + commit.Parent + "\n")
|
||||
}
|
||||
content := commit.Mark + "\n"
|
||||
stream.WriteString(fmt.Sprintf("M 100644 inline %s.txt\n", commit.Mark))
|
||||
stream.WriteString(fmt.Sprintf("data %d\n%s", len(content), content))
|
||||
branchTips[commit.Branch] = mark
|
||||
}
|
||||
|
||||
require.NoError(t, gitcmd.NewCommand("fast-import", "--export-marks=-").
|
||||
WithDir(repo.RepoPath()).
|
||||
WithStdinCopy(strings.NewReader(stream.String())).
|
||||
Run(t.Context()))
|
||||
|
||||
commitSHAs := make(map[string]string, len(commits))
|
||||
for _, commit := range commits {
|
||||
stdout, _, err := gitcmd.NewCommand("log", "-1", "--format=%H", "--fixed-strings").
|
||||
AddOptionFormat("--grep=%s", "heatmap admin "+commit.Mark).
|
||||
AddDynamicArguments("refs/heads/" + commit.Branch).
|
||||
WithDir(repo.RepoPath()).
|
||||
RunStdString(t.Context())
|
||||
require.NoError(t, err)
|
||||
commitSHAs[commit.Mark] = strings.TrimSpace(stdout)
|
||||
}
|
||||
return commitSHAs
|
||||
}
|
||||
|
||||
func heatmapAdminSignatureLine(kind, name, email, date string) string {
|
||||
parsed, err := time.Parse(time.RFC3339, date)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s <%s> %d +0000\n", kind, name, email, parsed.Unix())
|
||||
}
|
||||
|
||||
func loadHeatmapAdminContributionsForRepo(t *testing.T, repoID int64) []*activities_model.HeatmapContribution {
|
||||
t.Helper()
|
||||
contributions, err := activities_model.FindHeatmapContributionsByRepo(t.Context(), repoID)
|
||||
require.NoError(t, err)
|
||||
return contributions
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
// HeatmapContribution stores commit contributions counted for profile heatmaps.
|
||||
type HeatmapContribution struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"NOT NULL"`
|
||||
RepoID int64 `xorm:"NOT NULL"`
|
||||
CommitSHA string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
AuthorEmail string `xorm:"VARCHAR(320) NOT NULL"`
|
||||
AuthorUnix timeutil.TimeStamp `xorm:"NOT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// HeatmapContributionIdentity identifies the counted contribution row that remains valid after reindexing.
|
||||
type HeatmapContributionIdentity struct {
|
||||
RepoID int64
|
||||
CommitSHA string
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(HeatmapContribution))
|
||||
}
|
||||
|
||||
// TableIndices implements xorm's TableIndices interface.
|
||||
func (c *HeatmapContribution) TableIndices() []*schemas.Index {
|
||||
uniqueContribution := schemas.NewIndex("repo_commit_user", schemas.UniqueType)
|
||||
uniqueContribution.AddColumn("repo_id", "commit_sha", "user_id")
|
||||
|
||||
userAuthorRepo := schemas.NewIndex("u_a_r", schemas.IndexType)
|
||||
userAuthorRepo.AddColumn("user_id", "author_unix", "repo_id")
|
||||
|
||||
return []*schemas.Index{uniqueContribution, userAuthorRepo}
|
||||
}
|
||||
|
||||
// UpsertHeatmapContribution creates or updates a commit contribution without duplicating counted rows.
|
||||
func UpsertHeatmapContribution(ctx context.Context, contribution *HeatmapContribution) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
rows, err := e.Where("repo_id=? AND commit_sha=? AND user_id=?", contribution.RepoID, contribution.CommitSHA, contribution.UserID).
|
||||
Cols("author_email", "author_unix").
|
||||
Update(contribution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
has, err := e.Exist(&HeatmapContribution{RepoID: contribution.RepoID, CommitSHA: contribution.CommitSHA, UserID: contribution.UserID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = e.Insert(contribution)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// CountHeatmapContributions returns counted heatmap contribution rows for a user.
|
||||
func CountHeatmapContributions(ctx context.Context, userID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("user_id=?", userID).Count(new(HeatmapContribution))
|
||||
}
|
||||
|
||||
// DeleteStaleHeatmapContributions removes indexed rows not found in the current eligible contribution identities.
|
||||
func DeleteStaleHeatmapContributions(ctx context.Context, repoID int64, identities []HeatmapContributionIdentity) error {
|
||||
if len(identities) == 0 {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id=?", repoID).Delete(new(HeatmapContribution))
|
||||
return err
|
||||
}
|
||||
|
||||
current := make(map[HeatmapContributionIdentity]struct{}, len(identities))
|
||||
for _, identity := range identities {
|
||||
current[identity] = struct{}{}
|
||||
}
|
||||
|
||||
contributions, err := FindHeatmapContributionsByRepo(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, contribution := range contributions {
|
||||
identity := HeatmapContributionIdentity{
|
||||
RepoID: contribution.RepoID,
|
||||
CommitSHA: contribution.CommitSHA,
|
||||
UserID: contribution.UserID,
|
||||
}
|
||||
if _, ok := current[identity]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := db.GetEngine(ctx).ID(contribution.ID).Delete(new(HeatmapContribution)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindHeatmapContributionsByRepo returns indexed contribution rows for a repository.
|
||||
func FindHeatmapContributionsByRepo(ctx context.Context, repoID int64) ([]*HeatmapContribution, error) {
|
||||
contributions := make([]*HeatmapContribution, 0)
|
||||
return contributions, db.GetEngine(ctx).
|
||||
Where("repo_id=?", repoID).
|
||||
OrderBy("author_unix ASC, commit_sha ASC, user_id ASC").
|
||||
Find(&contributions)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeatmapContributionModel(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
assert.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
|
||||
const (
|
||||
userID = int64(2)
|
||||
repoID = int64(1)
|
||||
commitSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
authorUnix = timeutil.TimeStamp(1579089600)
|
||||
)
|
||||
|
||||
contribution := &activities_model.HeatmapContribution{
|
||||
UserID: userID,
|
||||
RepoID: repoID,
|
||||
CommitSHA: commitSHA,
|
||||
AuthorEmail: "user2@example.com",
|
||||
AuthorUnix: authorUnix,
|
||||
}
|
||||
require.NoError(t, activities_model.UpsertHeatmapContribution(t.Context(), contribution))
|
||||
|
||||
count, err := activities_model.CountHeatmapContributions(t.Context(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
|
||||
require.NoError(t, activities_model.UpsertHeatmapContribution(t.Context(), &activities_model.HeatmapContribution{
|
||||
UserID: userID,
|
||||
RepoID: repoID,
|
||||
CommitSHA: commitSHA,
|
||||
AuthorEmail: "changed@example.com",
|
||||
AuthorUnix: authorUnix + 900,
|
||||
}))
|
||||
|
||||
count, err = activities_model.CountHeatmapContributions(t.Context(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
|
||||
stored := &activities_model.HeatmapContribution{UserID: userID, RepoID: repoID, CommitSHA: commitSHA}
|
||||
has, err := db.GetEngine(t.Context()).Get(stored)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
assert.Equal(t, "changed@example.com", stored.AuthorEmail)
|
||||
assert.Equal(t, authorUnix+900, stored.AuthorUnix)
|
||||
assert.NotZero(t, stored.CreatedUnix)
|
||||
assert.NotZero(t, stored.UpdatedUnix)
|
||||
|
||||
err = db.Insert(t.Context(), &activities_model.HeatmapContribution{
|
||||
UserID: userID,
|
||||
RepoID: repoID,
|
||||
CommitSHA: commitSHA,
|
||||
AuthorEmail: "duplicate@example.com",
|
||||
AuthorUnix: authorUnix,
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
includePrivate, err := user_model.GetIncludePrivateContributions(t.Context(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, includePrivate)
|
||||
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), userID, true))
|
||||
includePrivate, err = user_model.GetIncludePrivateContributions(t.Context(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, includePrivate)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
@@ -38,35 +39,45 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi
|
||||
|
||||
// Group by 15 minute intervals which will allow the client to accurately shift the timestamp to their timezone.
|
||||
// The interval is based on the fact that there are timezones such as UTC +5:30 and UTC +12:45.
|
||||
groupBy := "created_unix / 900 * 900"
|
||||
groupBy := "author_unix / 900 * 900"
|
||||
groupByName := "timestamp" // We need this extra case because mssql doesn't allow grouping by alias
|
||||
switch {
|
||||
case setting.Database.Type.IsMySQL():
|
||||
groupBy = "created_unix DIV 900 * 900"
|
||||
groupBy = "author_unix DIV 900 * 900"
|
||||
case setting.Database.Type.IsMSSQL():
|
||||
groupByName = groupBy
|
||||
}
|
||||
|
||||
cond, err := ActivityQueryCondition(ctx, GetFeedsOptions{
|
||||
RequestedUser: user,
|
||||
RequestedTeam: team,
|
||||
Actor: doer,
|
||||
IncludePrivate: true, // don't filter by private, as we already filter by repo access
|
||||
IncludeDeleted: true,
|
||||
// * Heatmaps for individual users only include actions that the user themself did.
|
||||
// * For organizations actions by all users that were made in owned
|
||||
// repositories are counted.
|
||||
OnlyPerformedBy: !user.IsOrganization(),
|
||||
})
|
||||
includePrivate, err := user_model.GetIncludePrivateContributions(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hdata, db.GetEngine(ctx).
|
||||
Select(groupBy+" AS timestamp, count(user_id) as contributions").
|
||||
Table("action").
|
||||
Where(cond).
|
||||
And("created_unix > ?", timeutil.TimeStampNow()-(366+7)*86400). // (366+7) days to include the first week for the heatmap
|
||||
sess := db.GetEngine(ctx).
|
||||
Select(groupBy+" AS timestamp, count(heatmap_contribution.user_id) as contributions").
|
||||
Table("heatmap_contribution").
|
||||
Join("INNER", "repository", "repository.id = heatmap_contribution.repo_id").
|
||||
Join("INNER", "`user` AS repo_owner", "repo_owner.id = repository.owner_id").
|
||||
Where("author_unix > ?", timeutil.TimeStampNow()-(366+7)*86400). // (366+7) days to include the first week for the heatmap
|
||||
And("author_unix <= ?", timeutil.TimeStampNow())
|
||||
|
||||
if !includePrivate {
|
||||
sess = sess.And("repository.is_private = ?", false).
|
||||
And("repo_owner.visibility = ?", structs.VisibleTypePublic)
|
||||
}
|
||||
|
||||
if user.IsOrganization() {
|
||||
sess = sess.And("repository.owner_id = ?", user.ID)
|
||||
if team != nil && !team.IncludesAllRepositories {
|
||||
sess = sess.Join("INNER", "team_repo", "team_repo.repo_id = repository.id").
|
||||
And("team_repo.org_id = ?", team.OrgID).
|
||||
And("team_repo.team_id = ?", team.ID)
|
||||
}
|
||||
} else {
|
||||
sess = sess.And("heatmap_contribution.user_id = ?", user.ID)
|
||||
}
|
||||
|
||||
return hdata, sess.
|
||||
GroupBy(groupByName).
|
||||
OrderBy("timestamp").
|
||||
Find(&hdata)
|
||||
|
||||
@@ -4,19 +4,43 @@
|
||||
package activities_test
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetUserHeatmapDataByUser(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 2, false))
|
||||
|
||||
// Mock time
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
insertHeatmapContribution(t, 2, 1, "user2-public-author-date", 1603009800)
|
||||
insertHeatmapContribution(t, 2, 1, "user2-public-same-bucket", 1603009850)
|
||||
insertHeatmapContribution(t, 2, 2, "user2-private-hidden", 1603010700)
|
||||
insertHeatmapContribution(t, 2, 4, "user2-hidden-owner-hidden", 1603010700)
|
||||
insertHeatmapContribution(t, 2, 1, "user2-future-hidden", timeutil.TimeStampNow()+900)
|
||||
insertHeatmapContribution(t, 2, 999999, "user2-deleted-repo-hidden", 1603011600)
|
||||
insertHeatmapContribution(t, 3, 1, "other-author-ignored", 1603010700)
|
||||
setUserVisibility(t, 5, structs.VisibleTypePrivate)
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
userID int64
|
||||
@@ -25,73 +49,211 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
|
||||
JSONResult string
|
||||
}{
|
||||
{
|
||||
"self looks at action in private repo",
|
||||
2, 2, 1, `[{"timestamp":1603227600,"contributions":1}]`,
|
||||
"self sees public author-date contributions only",
|
||||
2, 2, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||
},
|
||||
{
|
||||
"admin looks at action in private repo",
|
||||
2, 1, 1, `[{"timestamp":1603227600,"contributions":1}]`,
|
||||
"admin sees public author-date contributions only",
|
||||
2, 1, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||
},
|
||||
{
|
||||
"other user looks at action in private repo",
|
||||
2, 3, 0, `[]`,
|
||||
"other user sees public author-date contributions only",
|
||||
2, 3, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||
},
|
||||
{
|
||||
"nobody looks at action in private repo",
|
||||
2, 0, 0, `[]`,
|
||||
"anonymous sees public author-date contributions only",
|
||||
2, 0, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||
},
|
||||
{
|
||||
"collaborator looks at action in private repo",
|
||||
16, 15, 1, `[{"timestamp":1603267200,"contributions":1}]`,
|
||||
},
|
||||
{
|
||||
"no action action not performed by target user",
|
||||
"different author is not counted for target user",
|
||||
3, 3, 0, `[]`,
|
||||
},
|
||||
{
|
||||
"multiple actions performed with two grouped together",
|
||||
10, 10, 3, `[{"timestamp":1603009800,"contributions":1},{"timestamp":1603010700,"contributions":2}]`,
|
||||
},
|
||||
}
|
||||
// Prepare
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Mock time
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.userID})
|
||||
|
||||
var doer *user_model.User
|
||||
if tc.doerID != 0 {
|
||||
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
|
||||
}
|
||||
|
||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), user, doer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.CountResult, countHeatmapContributions(heatmap), "testcase '%s'", tc.desc)
|
||||
|
||||
// Test JSON rendering
|
||||
jsonData, err := json.Marshal(heatmap)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tc.JSONResult, string(jsonData))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHeatmapDataByUserUsesCurrentRepoVisibility(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 2, false))
|
||||
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
for _, tc := range testCases {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.userID})
|
||||
insertHeatmapContribution(t, 2, 1, "user2-visibility-flip", 1603009800)
|
||||
|
||||
var doer *user_model.User
|
||||
if tc.doerID != 0 {
|
||||
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
|
||||
}
|
||||
target := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||
require.NoError(t, err)
|
||||
assertHeatmapJSON(t, heatmap, `[{
|
||||
"timestamp": 1603009800,
|
||||
"contributions": 1
|
||||
}]`)
|
||||
|
||||
// get the action for comparison
|
||||
actions, count, err := activities_model.GetFeeds(t.Context(), activities_model.GetFeedsOptions{
|
||||
RequestedUser: user,
|
||||
Actor: doer,
|
||||
IncludePrivate: true,
|
||||
OnlyPerformedBy: true,
|
||||
IncludeDeleted: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
setRepoPrivate(t, 1, true)
|
||||
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, heatmap)
|
||||
|
||||
// Get the heatmap and compare
|
||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), user, doer)
|
||||
var contributions int
|
||||
for _, hm := range heatmap {
|
||||
contributions += int(hm.Contributions)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, contributions, "invalid action count: did the test data became too old?")
|
||||
assert.Equal(t, count, int64(contributions))
|
||||
assert.Equal(t, tc.CountResult, contributions, "testcase '%s'", tc.desc)
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 2, true))
|
||||
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||
require.NoError(t, err)
|
||||
assertHeatmapJSON(t, heatmap, `[{
|
||||
"timestamp": 1603009800,
|
||||
"contributions": 1
|
||||
}]`)
|
||||
}
|
||||
|
||||
// Test JSON rendering
|
||||
jsonData, err := json.Marshal(heatmap)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, tc.JSONResult, string(jsonData))
|
||||
func TestGetUserHeatmapDataByOrgTeam(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 3, false))
|
||||
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
insertHeatmapContribution(t, 2, 32, "org-team-public", 1603009800)
|
||||
insertHeatmapContribution(t, 2, 3, "org-team-private-hidden", 1603010700)
|
||||
insertHeatmapContribution(t, 2, 1, "non-org-repo-ignored", 1603011600)
|
||||
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
heatmap, err := activities_model.GetUserHeatmapDataByOrgTeam(t.Context(), org, team, doer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, countHeatmapContributions(heatmap))
|
||||
|
||||
jsonData, err := json.Marshal(heatmap)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `[{"timestamp":1603009800,"contributions":1}]`, string(jsonData))
|
||||
}
|
||||
|
||||
func TestUserHeatmapPrivateContributionsOptIn(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 16, false))
|
||||
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
insertHeatmapContribution(t, 16, 21, "user16-public", 1603009800)
|
||||
insertHeatmapContribution(t, 16, 22, "user16-private-one", 1603009850)
|
||||
insertHeatmapContribution(t, 16, 22, "user16-private-two", 1603010700)
|
||||
|
||||
target := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
authenticated := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
|
||||
|
||||
for _, doer := range []*user_model.User{nil, target, admin, authenticated, collaborator} {
|
||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), target, doer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, countHeatmapContributions(heatmap), "private contributions should be hidden by default from %s", heatmapDoerName(doer))
|
||||
}
|
||||
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 16, true))
|
||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||
require.NoError(t, err)
|
||||
assertHeatmapJSON(t, heatmap, `[{"timestamp":1603009800,"contributions":2},{"timestamp":1603010700,"contributions":1}]`)
|
||||
|
||||
target.KeepActivityPrivate = true
|
||||
_, err = db.GetEngine(t.Context()).ID(target.ID).Cols("keep_activity_private").Update(target)
|
||||
require.NoError(t, err)
|
||||
|
||||
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, heatmap)
|
||||
|
||||
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, authenticated)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, heatmap)
|
||||
|
||||
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, countHeatmapContributions(heatmap))
|
||||
|
||||
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, admin)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, countHeatmapContributions(heatmap))
|
||||
}
|
||||
|
||||
func insertHeatmapContribution(t *testing.T, userID, repoID int64, commitSHA string, authorUnix timeutil.TimeStamp) {
|
||||
t.Helper()
|
||||
require.NoError(t, activities_model.UpsertHeatmapContribution(t.Context(), &activities_model.HeatmapContribution{
|
||||
UserID: userID,
|
||||
RepoID: repoID,
|
||||
CommitSHA: fmt.Sprintf("%040x", sha1.Sum([]byte(commitSHA))),
|
||||
AuthorEmail: fmt.Sprintf("user%d@example.com", userID),
|
||||
AuthorUnix: authorUnix,
|
||||
}))
|
||||
}
|
||||
|
||||
func setRepoPrivate(t *testing.T, repoID int64, isPrivate bool) {
|
||||
t.Helper()
|
||||
repo := &repo_model.Repository{ID: repoID, IsPrivate: isPrivate}
|
||||
_, err := db.GetEngine(t.Context()).ID(repoID).Cols("is_private").Update(repo)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func setUserVisibility(t *testing.T, userID int64, visibility structs.VisibleType) {
|
||||
t.Helper()
|
||||
user := &user_model.User{ID: userID, Visibility: visibility}
|
||||
_, err := db.GetEngine(t.Context()).ID(userID).Cols("visibility").Update(user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func assertHeatmapJSON(t *testing.T, heatmap []*activities_model.UserHeatmapData, expected string) {
|
||||
t.Helper()
|
||||
jsonData, err := json.Marshal(heatmap)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, expected, string(jsonData))
|
||||
|
||||
var decoded []map[string]any
|
||||
require.NoError(t, json.Unmarshal(jsonData, &decoded))
|
||||
for _, entry := range decoded {
|
||||
assert.ElementsMatch(t, []string{"timestamp", "contributions"}, mapKeys(entry))
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys[K comparable, V any](m map[K]V) []K {
|
||||
keys := make([]K, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func countHeatmapContributions(heatmap []*activities_model.UserHeatmapData) int {
|
||||
var contributions int
|
||||
for _, hm := range heatmap {
|
||||
contributions += int(hm.Contributions)
|
||||
}
|
||||
return contributions
|
||||
}
|
||||
|
||||
func heatmapDoerName(doer *user_model.User) string {
|
||||
if doer == nil {
|
||||
return "anonymous"
|
||||
}
|
||||
return doer.Name
|
||||
}
|
||||
|
||||
@@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
|
||||
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
|
||||
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
|
||||
newMigration(331, "Create heatmap contribution table", v1_26.CreateHeatmapContributionTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
type HeatmapContribution struct { //revive:disable-line:exported
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"NOT NULL"`
|
||||
RepoID int64 `xorm:"NOT NULL"`
|
||||
CommitSHA string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
AuthorEmail string `xorm:"VARCHAR(320) NOT NULL"`
|
||||
AuthorUnix timeutil.TimeStamp `xorm:"NOT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// TableIndices implements xorm's TableIndices interface.
|
||||
func (c *HeatmapContribution) TableIndices() []*schemas.Index {
|
||||
uniqueContribution := schemas.NewIndex("repo_commit_user", schemas.UniqueType)
|
||||
uniqueContribution.AddColumn("repo_id", "commit_sha", "user_id")
|
||||
|
||||
userAuthorRepo := schemas.NewIndex("u_a_r", schemas.IndexType)
|
||||
userAuthorRepo.AddColumn("user_id", "author_unix", "repo_id")
|
||||
|
||||
return []*schemas.Index{uniqueContribution, userAuthorRepo}
|
||||
}
|
||||
|
||||
func CreateHeatmapContributionTable(x *xorm.Engine) error {
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(HeatmapContribution))
|
||||
return err
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@@ -19,6 +20,8 @@ import (
|
||||
"xorm.io/xorm/convert"
|
||||
)
|
||||
|
||||
const SettingsKeyIncludePrivateContributions = "include_private_contributions"
|
||||
|
||||
// Setting is a key value store of user settings
|
||||
type Setting struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
@@ -254,3 +257,17 @@ func SetUserSettingJSON[T any](ctx context.Context, userID int64, key string, va
|
||||
}
|
||||
return SetUserSetting(ctx, userID, key, util.UnsafeBytesToString(bs))
|
||||
}
|
||||
|
||||
// GetIncludePrivateContributions returns whether a user opted in to private heatmap contributions.
|
||||
func GetIncludePrivateContributions(ctx context.Context, userID int64) (bool, error) {
|
||||
value, err := GetUserSetting(ctx, userID, SettingsKeyIncludePrivateContributions, "false")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strconv.ParseBool(value)
|
||||
}
|
||||
|
||||
// SetIncludePrivateContributions stores whether a user opted in to private heatmap contributions.
|
||||
func SetIncludePrivateContributions(ctx context.Context, userID int64, include bool) error {
|
||||
return SetUserSetting(ctx, userID, SettingsKeyIncludePrivateContributions, strconv.FormatBool(include))
|
||||
}
|
||||
|
||||
@@ -80,8 +80,9 @@ type UserSettings struct {
|
||||
Theme string `json:"theme"`
|
||||
DiffViewStyle string `json:"diff_view_style"`
|
||||
// Privacy
|
||||
HideEmail bool `json:"hide_email"`
|
||||
HideActivity bool `json:"hide_activity"`
|
||||
HideEmail bool `json:"hide_email"`
|
||||
HideActivity bool `json:"hide_activity"`
|
||||
IncludePrivateContributions bool `json:"include_private_contributions"`
|
||||
}
|
||||
|
||||
// UserSettingsOptions represents options to change user settings
|
||||
@@ -95,8 +96,9 @@ type UserSettingsOptions struct {
|
||||
Theme *string `json:"theme"`
|
||||
DiffViewStyle *string `json:"diff_view_style"`
|
||||
// Privacy
|
||||
HideEmail *bool `json:"hide_email"`
|
||||
HideActivity *bool `json:"hide_activity"`
|
||||
HideEmail *bool `json:"hide_email"`
|
||||
HideActivity *bool `json:"hide_activity"`
|
||||
IncludePrivateContributions *bool `json:"include_private_contributions"`
|
||||
}
|
||||
|
||||
// RenameUserOption options when renaming a user
|
||||
|
||||
@@ -708,6 +708,8 @@
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.keep_activity_private": "Hide Activity from profile page",
|
||||
"settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins",
|
||||
"settings.include_private_contributions": "Show private contributions on profile heatmap",
|
||||
"settings.include_private_contributions_popup": "Publicly reveals only contribution dates and counts from non-public repositories, never repository or commit details.",
|
||||
"settings.lookup_avatar_by_mail": "Look Up Avatar by Email Address",
|
||||
"settings.federated_avatar_lookup": "Federated Avatar Lookup",
|
||||
"settings.enable_custom_avatar": "Use Custom Avatar",
|
||||
|
||||
@@ -24,7 +24,12 @@ func GetUserSettings(ctx *context.APIContext) {
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserSettings"
|
||||
ctx.JSON(http.StatusOK, convert.User2UserSettings(ctx.Doer))
|
||||
settings, err := convert.User2UserSettings(ctx, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// UpdateUserSettings returns user settings
|
||||
@@ -46,20 +51,26 @@ func UpdateUserSettings(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.UserSettingsOptions)
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.FromPtr(form.FullName),
|
||||
Description: optional.FromPtr(form.Description),
|
||||
Website: optional.FromPtr(form.Website),
|
||||
Location: optional.FromPtr(form.Location),
|
||||
Language: optional.FromPtr(form.Language),
|
||||
Theme: optional.FromPtr(form.Theme),
|
||||
DiffViewStyle: optional.FromPtr(form.DiffViewStyle),
|
||||
KeepEmailPrivate: optional.FromPtr(form.HideEmail),
|
||||
KeepActivityPrivate: optional.FromPtr(form.HideActivity),
|
||||
FullName: optional.FromPtr(form.FullName),
|
||||
Description: optional.FromPtr(form.Description),
|
||||
Website: optional.FromPtr(form.Website),
|
||||
Location: optional.FromPtr(form.Location),
|
||||
Language: optional.FromPtr(form.Language),
|
||||
Theme: optional.FromPtr(form.Theme),
|
||||
DiffViewStyle: optional.FromPtr(form.DiffViewStyle),
|
||||
KeepEmailPrivate: optional.FromPtr(form.HideEmail),
|
||||
KeepActivityPrivate: optional.FromPtr(form.HideActivity),
|
||||
IncludePrivateContributions: optional.FromPtr(form.IncludePrivateContributions),
|
||||
}
|
||||
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.User2UserSettings(ctx.Doer))
|
||||
settings, err := convert.User2UserSettings(ctx, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
@@ -48,16 +48,32 @@ func Profile(ctx *context.Context) {
|
||||
ctx.Data["PageIsSettingsProfile"] = true
|
||||
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
||||
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
||||
if !loadProfilePrivateContributionSetting(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsProfile)
|
||||
}
|
||||
|
||||
func loadProfilePrivateContributionSetting(ctx *context.Context) bool {
|
||||
includePrivateContributions, err := user_model.GetIncludePrivateContributions(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIncludePrivateContributions", err)
|
||||
return false
|
||||
}
|
||||
ctx.Data["IncludePrivateContributions"] = includePrivateContributions
|
||||
return true
|
||||
}
|
||||
|
||||
// ProfilePost response for change user's profile
|
||||
func ProfilePost(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsProfile"] = true
|
||||
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
||||
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
||||
if !loadProfilePrivateContributionSetting(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplSettingsProfile)
|
||||
@@ -94,12 +110,13 @@ func ProfilePost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
||||
Description: optional.Some(form.Description),
|
||||
Website: optional.Some(form.Website),
|
||||
Location: optional.Some(form.Location),
|
||||
Visibility: optional.Some(form.Visibility),
|
||||
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
||||
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
||||
Description: optional.Some(form.Description),
|
||||
Website: optional.Some(form.Website),
|
||||
Location: optional.Some(form.Location),
|
||||
Visibility: optional.Some(form.Visibility),
|
||||
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
||||
IncludePrivateContributions: optional.Some(form.IncludePrivateContributions),
|
||||
}
|
||||
|
||||
if form.FullName != "" {
|
||||
|
||||
@@ -86,18 +86,23 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap
|
||||
}
|
||||
|
||||
// User2UserSettings return UserSettings based on a user
|
||||
func User2UserSettings(user *user_model.User) api.UserSettings {
|
||||
return api.UserSettings{
|
||||
FullName: user.FullName,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
Language: user.Language,
|
||||
Description: user.Description,
|
||||
Theme: user.Theme,
|
||||
HideEmail: user.KeepEmailPrivate,
|
||||
HideActivity: user.KeepActivityPrivate,
|
||||
DiffViewStyle: user.DiffViewStyle,
|
||||
func User2UserSettings(ctx context.Context, user *user_model.User) (api.UserSettings, error) {
|
||||
includePrivateContributions, err := user_model.GetIncludePrivateContributions(ctx, user.ID)
|
||||
if err != nil {
|
||||
return api.UserSettings{}, err
|
||||
}
|
||||
return api.UserSettings{
|
||||
FullName: user.FullName,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
Language: user.Language,
|
||||
Description: user.Description,
|
||||
Theme: user.Theme,
|
||||
HideEmail: user.KeepEmailPrivate,
|
||||
HideActivity: user.KeepActivityPrivate,
|
||||
IncludePrivateContributions: includePrivateContributions,
|
||||
DiffViewStyle: user.DiffViewStyle,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToUserAndPermission return User and its collaboration permission for a repository
|
||||
|
||||
@@ -208,14 +208,15 @@ func (f *IntrospectTokenForm) Validate(req *http.Request, errs binding.Errors) b
|
||||
|
||||
// UpdateProfileForm form for updating profile
|
||||
type UpdateProfileForm struct {
|
||||
Name string `binding:"Username;MaxSize(40)"`
|
||||
FullName string `binding:"MaxSize(100)"`
|
||||
KeepEmailPrivate bool
|
||||
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
|
||||
Location string `binding:"MaxSize(50)"`
|
||||
Description string `binding:"MaxSize(255)"`
|
||||
Visibility structs.VisibleType
|
||||
KeepActivityPrivate bool
|
||||
Name string `binding:"Username;MaxSize(40)"`
|
||||
FullName string `binding:"MaxSize(100)"`
|
||||
KeepEmailPrivate bool
|
||||
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
|
||||
Location string `binding:"MaxSize(50)"`
|
||||
Description string `binding:"MaxSize(255)"`
|
||||
Visibility structs.VisibleType
|
||||
KeepActivityPrivate bool
|
||||
IncludePrivateContributions bool
|
||||
}
|
||||
|
||||
// Validate validates the fields
|
||||
|
||||
@@ -318,6 +318,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
|
||||
defer gitRepo.Close()
|
||||
|
||||
log.Trace("SyncMirrors [repo: %-v]: %d branches updated", m.Repo, len(results))
|
||||
indexHeatmap := false
|
||||
if len(results) > 0 {
|
||||
if ok := checkAndUpdateEmptyRepository(ctx, m, results); !ok {
|
||||
log.Error("SyncMirrors [repo: %-v]: checkAndUpdateEmptyRepository: %v", m.Repo, err)
|
||||
@@ -330,6 +331,9 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
|
||||
if result.RefName.IsPull() {
|
||||
continue
|
||||
}
|
||||
if result.RefName.IsBranch() && result.RefName.BranchName() == m.Repo.DefaultBranch {
|
||||
indexHeatmap = true
|
||||
}
|
||||
|
||||
// Create reference
|
||||
if result.OldCommitID == "" {
|
||||
@@ -391,6 +395,11 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
|
||||
NewCommitID: newCommitID,
|
||||
}, theCommits)
|
||||
}
|
||||
if indexHeatmap {
|
||||
if err := repo_service.IndexDefaultBranchHeatmapContributions(ctx, m.Repo); err != nil {
|
||||
log.Error("SyncMirrors [repo: %-v]: failed to index heatmap contributions: %v", m.Repo, err)
|
||||
}
|
||||
}
|
||||
log.Trace("SyncMirrors [repo: %-v]: done notifying updated branches/tags - now updating last commit time", m.Repo)
|
||||
|
||||
isEmpty, err := gitRepo.IsEmpty()
|
||||
|
||||
@@ -747,6 +747,9 @@ func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, newB
|
||||
}
|
||||
|
||||
notify_service.ChangeDefaultBranch(ctx, repo)
|
||||
if err := IndexDefaultBranchHeatmapContributions(ctx, repo); err != nil {
|
||||
log.Error("IndexDefaultBranchHeatmapContributions[%s]: %v", repo.FullName(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -337,6 +337,10 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User,
|
||||
}
|
||||
}
|
||||
|
||||
if err = IndexDefaultBranchHeatmapContributions(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("IndexDefaultBranchHeatmapContributions: %w", err)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
const heatmapIndexCommitsPageSize = 100
|
||||
|
||||
// IndexDefaultBranchHeatmapContributions indexes commit author-date heatmap contributions reachable from repo's default branch.
|
||||
func IndexDefaultBranchHeatmapContributions(ctx context.Context, repo *repo_model.Repository) error {
|
||||
if repo == nil || repo.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
if repo.DefaultBranch == "" || repo.IsEmpty {
|
||||
return activities_model.DeleteStaleHeatmapContributions(ctx, repo.ID, nil)
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
head, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if git.IsErrNotExist(err) {
|
||||
return activities_model.DeleteStaleHeatmapContributions(ctx, repo.ID, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentIdentities := make([]activities_model.HeatmapContributionIdentity, 0)
|
||||
now := timeutil.TimeStampNow()
|
||||
for page := 1; ; page++ {
|
||||
commits, err := head.CommitsByRange(page, heatmapIndexCommitsPageSize, "", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(commits) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, commit := range commits {
|
||||
commitSHA := commit.ID.String()
|
||||
if commit.Author == nil || commit.Author.Email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
authorUnix := timeutil.TimeStamp(commit.Author.When.Unix())
|
||||
if authorUnix > now {
|
||||
continue
|
||||
}
|
||||
|
||||
userID, ok, err := getHeatmapAuthorUserIDByEmail(ctx, commit.Author.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := activities_model.UpsertHeatmapContribution(ctx, &activities_model.HeatmapContribution{
|
||||
UserID: userID,
|
||||
RepoID: repo.ID,
|
||||
CommitSHA: commitSHA,
|
||||
AuthorEmail: strings.ToLower(commit.Author.Email),
|
||||
AuthorUnix: authorUnix,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
currentIdentities = append(currentIdentities, activities_model.HeatmapContributionIdentity{
|
||||
RepoID: repo.ID,
|
||||
CommitSHA: commitSHA,
|
||||
UserID: userID,
|
||||
})
|
||||
}
|
||||
|
||||
if len(commits) < heatmapIndexCommitsPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return activities_model.DeleteStaleHeatmapContributions(ctx, repo.ID, currentIdentities)
|
||||
}
|
||||
|
||||
func getHeatmapAuthorUserIDByEmail(ctx context.Context, email string) (int64, bool, error) {
|
||||
address, err := user_model.GetEmailAddressByEmail(ctx, email)
|
||||
if user_model.IsErrEmailAddressNotExist(err) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !address.IsActivated {
|
||||
return 0, false, nil
|
||||
}
|
||||
return address.UID, true, nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeatmapIndexDefaultBranchAuthorDates(t *testing.T) {
|
||||
repo, commits := prepareHeatmapIndexRepo(t, "heatmap-author-dates", []heatmapIndexTestCommit{
|
||||
{Branch: "main", Mark: "old", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-15T12:00:00Z"},
|
||||
{Branch: "main", Mark: "unknown", AuthorName: "Unknown", AuthorEmail: "unknown@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-16T12:00:00Z"},
|
||||
{Branch: "main", Mark: "future", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2030-01-15T12:00:00Z"},
|
||||
})
|
||||
|
||||
timeutil.MockSet(time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
require.NoError(t, IndexDefaultBranchHeatmapContributions(t.Context(), repo))
|
||||
require.NoError(t, IndexDefaultBranchHeatmapContributions(t.Context(), repo))
|
||||
|
||||
contributions := loadHeatmapContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 1)
|
||||
assert.Equal(t, int64(2), contributions[0].UserID)
|
||||
assert.Equal(t, commits["old"], contributions[0].CommitSHA)
|
||||
assert.Equal(t, "user2@example.com", contributions[0].AuthorEmail)
|
||||
assert.Equal(t, timeutil.TimeStamp(1579089600), contributions[0].AuthorUnix)
|
||||
|
||||
emailAddress, err := user_model.GetEmailAddressByEmail(t.Context(), "user2@example.com")
|
||||
require.NoError(t, err)
|
||||
emailAddress.UID = 1
|
||||
require.NoError(t, user_model.UpdateEmailAddress(t.Context(), emailAddress))
|
||||
require.NoError(t, IndexDefaultBranchHeatmapContributions(t.Context(), repo))
|
||||
|
||||
contributions = loadHeatmapContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 1)
|
||||
assert.Equal(t, int64(1), contributions[0].UserID)
|
||||
assert.Equal(t, commits["old"], contributions[0].CommitSHA)
|
||||
}
|
||||
|
||||
func TestHeatmapIndexIgnoresPusherAndNonDefaultBranch(t *testing.T) {
|
||||
repo, commits := prepareHeatmapIndexRepo(t, "heatmap-exclusions", []heatmapIndexTestCommit{
|
||||
{Branch: "main", Mark: "author-user2", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-15T12:00:00Z"},
|
||||
{Branch: "main", Mark: "author-user1", AuthorName: "User One", AuthorEmail: "user1@example.com", CommitterName: "User Two", CommitterEmail: "user2@example.com", AuthorDate: "2020-01-16T12:00:00Z"},
|
||||
{Branch: "feature", Mark: "feature-only", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User Two", CommitterEmail: "user2@example.com", AuthorDate: "2020-01-17T12:00:00Z"},
|
||||
})
|
||||
|
||||
timeutil.MockSet(time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
require.NoError(t, IndexDefaultBranchHeatmapContributions(t.Context(), repo))
|
||||
|
||||
contributions := loadHeatmapContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 2)
|
||||
assert.Equal(t, int64(2), contributions[0].UserID)
|
||||
assert.Equal(t, commits["author-user2"], contributions[0].CommitSHA)
|
||||
assert.Equal(t, int64(1), contributions[1].UserID)
|
||||
assert.Equal(t, commits["author-user1"], contributions[1].CommitSHA)
|
||||
for _, contribution := range contributions {
|
||||
assert.NotEqual(t, commits["feature-only"], contribution.CommitSHA)
|
||||
}
|
||||
|
||||
deleteMainRef(t, repo)
|
||||
require.NoError(t, IndexDefaultBranchHeatmapContributions(t.Context(), repo))
|
||||
assert.Empty(t, loadHeatmapContributionsForRepo(t, repo.ID))
|
||||
}
|
||||
|
||||
func TestHeatmapIndexOnPushDefaultBranch(t *testing.T) {
|
||||
repo, commits := prepareHeatmapIndexRepo(t, "heatmap-push-default", []heatmapIndexTestCommit{
|
||||
{Branch: "main", Mark: "initial", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-15T12:00:00Z"},
|
||||
})
|
||||
|
||||
timeutil.MockSet(time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
require.NoError(t, IndexDefaultBranchHeatmapContributions(t.Context(), repo))
|
||||
assert.Len(t, loadHeatmapContributionsForRepo(t, repo.ID), 1)
|
||||
|
||||
newCommits := runFastImport(t, repo, []heatmapIndexTestCommit{
|
||||
{Branch: "main", Mark: "pushed", Parent: commits["initial"], AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-16T12:00:00Z"},
|
||||
})
|
||||
require.NoError(t, pushUpdates([]*repo_module.PushUpdateOptions{
|
||||
{
|
||||
RefFullName: git.RefNameFromBranch("main"),
|
||||
OldCommitID: commits["initial"],
|
||||
NewCommitID: newCommits["pushed"],
|
||||
PusherID: 1,
|
||||
RepoUserName: repo.OwnerName,
|
||||
RepoName: repo.Name,
|
||||
},
|
||||
}))
|
||||
|
||||
contributions := loadHeatmapContributionsForRepo(t, repo.ID)
|
||||
require.Len(t, contributions, 2)
|
||||
assert.Equal(t, newCommits["pushed"], contributions[1].CommitSHA)
|
||||
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
featureCommits := runFastImport(t, repo, []heatmapIndexTestCommit{
|
||||
{Branch: "feature", Mark: "feature-pushed", AuthorName: "User Two", AuthorEmail: "user2@example.com", CommitterName: "User One", CommitterEmail: "user1@example.com", AuthorDate: "2020-01-17T12:00:00Z"},
|
||||
})
|
||||
require.NoError(t, pushUpdates([]*repo_module.PushUpdateOptions{
|
||||
{
|
||||
RefFullName: git.RefNameFromBranch("feature"),
|
||||
OldCommitID: git.Sha1ObjectFormat.EmptyObjectID().String(),
|
||||
NewCommitID: featureCommits["feature-pushed"],
|
||||
PusherID: 1,
|
||||
RepoUserName: repo.OwnerName,
|
||||
RepoName: repo.Name,
|
||||
},
|
||||
}))
|
||||
assert.Empty(t, loadHeatmapContributionsForRepo(t, repo.ID))
|
||||
}
|
||||
|
||||
type heatmapIndexTestCommit struct {
|
||||
Branch string
|
||||
Mark string
|
||||
Parent string
|
||||
AuthorName string
|
||||
AuthorEmail string
|
||||
CommitterName string
|
||||
CommitterEmail string
|
||||
AuthorDate string
|
||||
}
|
||||
|
||||
func prepareHeatmapIndexRepo(t *testing.T, repoName string, commits []heatmapIndexTestCommit) (*repo_model.Repository, map[string]string) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo, err := CreateRepositoryDirectly(t.Context(), owner, owner, CreateRepoOptions{Name: repoName, DefaultBranch: "main"}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
marks := runFastImport(t, repo, commits)
|
||||
repo.IsEmpty = false
|
||||
require.NoError(t, repo_model.UpdateRepositoryColsWithAutoTime(t.Context(), repo, "is_empty"))
|
||||
return repo, marks
|
||||
}
|
||||
|
||||
func runFastImport(t *testing.T, repo *repo_model.Repository, commits []heatmapIndexTestCommit) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
var stream strings.Builder
|
||||
branchTips := make(map[string]string)
|
||||
for i, commit := range commits {
|
||||
mark := fmt.Sprintf(":%d", i+1)
|
||||
branchRef := "refs/heads/" + commit.Branch
|
||||
stream.WriteString("commit " + branchRef + "\n")
|
||||
stream.WriteString("mark " + mark + "\n")
|
||||
stream.WriteString(signatureLine("author", commit.AuthorName, commit.AuthorEmail, commit.AuthorDate))
|
||||
stream.WriteString(signatureLine("committer", commit.CommitterName, commit.CommitterEmail, commit.AuthorDate))
|
||||
message := "heatmap " + commit.Mark
|
||||
stream.WriteString(fmt.Sprintf("data %d\n%s\n", len(message), message))
|
||||
if parentMark := branchTips[commit.Branch]; parentMark != "" {
|
||||
stream.WriteString("from " + parentMark + "\n")
|
||||
} else if commit.Parent != "" {
|
||||
stream.WriteString("from " + commit.Parent + "\n")
|
||||
}
|
||||
content := commit.Mark + "\n"
|
||||
stream.WriteString(fmt.Sprintf("M 100644 inline %s.txt\n", commit.Mark))
|
||||
stream.WriteString(fmt.Sprintf("data %d\n%s", len(content), content))
|
||||
branchTips[commit.Branch] = mark
|
||||
}
|
||||
|
||||
require.NoError(t, gitcmd.NewCommand("fast-import", "--export-marks=-").
|
||||
WithDir(repo.RepoPath()).
|
||||
WithStdinCopy(strings.NewReader(stream.String())).
|
||||
Run(t.Context()))
|
||||
|
||||
commitSHAs := make(map[string]string, len(commits))
|
||||
for _, commit := range commits {
|
||||
stdout, _, err := gitcmd.NewCommand("log", "-1", "--format=%H", "--fixed-strings").
|
||||
AddOptionFormat("--grep=%s", "heatmap "+commit.Mark).
|
||||
AddDynamicArguments("refs/heads/" + commit.Branch).
|
||||
WithDir(repo.RepoPath()).
|
||||
RunStdString(t.Context())
|
||||
require.NoError(t, err)
|
||||
commitSHAs[commit.Mark] = strings.TrimSpace(stdout)
|
||||
}
|
||||
return commitSHAs
|
||||
}
|
||||
|
||||
func signatureLine(kind, name, email, date string) string {
|
||||
parsed, err := time.Parse(time.RFC3339, date)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s <%s> %d +0000\n", kind, name, email, parsed.Unix())
|
||||
}
|
||||
|
||||
func loadHeatmapContributionsForRepo(t *testing.T, repoID int64) []*activities_model.HeatmapContribution {
|
||||
t.Helper()
|
||||
contributions, err := activities_model.FindHeatmapContributionsByRepo(t.Context(), repoID)
|
||||
require.NoError(t, err)
|
||||
return contributions
|
||||
}
|
||||
|
||||
func deleteMainRef(t *testing.T, repo *repo_model.Repository) {
|
||||
t.Helper()
|
||||
require.NoError(t, gitcmd.NewCommand("update-ref", "-d", "refs/heads/main").WithDir(repo.RepoPath()).Run(t.Context()))
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
}
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*repo_model.Repository, error) {
|
||||
repo, err = db.WithTx2(ctx, func(ctx context.Context) (*repo_model.Repository, error) {
|
||||
if opts.Mirror {
|
||||
remoteAddress, err := util.SanitizeURL(opts.CloneAddr)
|
||||
if err != nil {
|
||||
@@ -255,6 +255,13 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
}
|
||||
return repo, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = IndexDefaultBranchHeatmapContributions(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("IndexDefaultBranchHeatmapContributions: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// CleanUpMigrateInfo finishes migrating repository and/or wiki with things that don't need to be done for mirrors.
|
||||
|
||||
@@ -167,6 +167,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
indexHeatmap := false
|
||||
if !opts.IsDelRef() {
|
||||
branch := opts.RefFullName.BranchName()
|
||||
|
||||
@@ -193,6 +194,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil {
|
||||
log.Error("DelRepoDivergenceFromCache: %v", err)
|
||||
}
|
||||
indexHeatmap = true
|
||||
} else {
|
||||
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
|
||||
log.Error("DelDivergenceFromCache: %v", err)
|
||||
@@ -222,6 +224,12 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
pushDeleteBranch(ctx, repo, pusher, opts)
|
||||
}
|
||||
|
||||
if indexHeatmap {
|
||||
if err := IndexDefaultBranchHeatmapContributions(ctx, repo); err != nil {
|
||||
log.Error("IndexDefaultBranchHeatmapContributions[%s]: %v", repo.FullName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Even if user delete a branch on a repository which he didn't watch, he will be watch that.
|
||||
if err = repo_model.WatchIfAuto(ctx, opts.PusherID, repo.ID, true); err != nil {
|
||||
log.Warn("Fail to perform auto watch on user %v for repo %v: %v", opts.PusherID, repo.ID, err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
password_module "code.gitea.io/gitea/modules/auth/password"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
@@ -47,6 +48,7 @@ type UpdateOptions struct {
|
||||
IsRestricted optional.Option[bool]
|
||||
Visibility optional.Option[structs.VisibleType]
|
||||
KeepActivityPrivate optional.Option[bool]
|
||||
IncludePrivateContributions optional.Option[bool]
|
||||
Language optional.Option[string]
|
||||
Theme optional.Option[string]
|
||||
DiffViewStyle optional.Option[string]
|
||||
@@ -182,7 +184,23 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
cols = append(cols, "last_login_unix")
|
||||
}
|
||||
|
||||
return user_model.UpdateUserCols(ctx, u, cols...)
|
||||
if len(cols) > 0 || opts.IncludePrivateContributions.Has() {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if len(cols) > 0 {
|
||||
if err := user_model.UpdateUserCols(ctx, u, cols...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludePrivateContributions.Has() {
|
||||
return user_model.SetIncludePrivateContributions(ctx, u.ID, opts.IncludePrivateContributions.Value())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateAuthOptions struct {
|
||||
|
||||
@@ -44,6 +44,7 @@ func TestUpdateUser(t *testing.T) {
|
||||
IsAdmin: UpdateOptionFieldFromValue(true),
|
||||
Visibility: optional.Some(structs.VisibleTypePrivate),
|
||||
KeepActivityPrivate: optional.Some(true),
|
||||
IncludePrivateContributions: optional.Some(true),
|
||||
Language: optional.Some("lang"),
|
||||
Theme: optional.Some("theme"),
|
||||
DiffViewStyle: optional.Some("split"),
|
||||
@@ -66,6 +67,9 @@ func TestUpdateUser(t *testing.T) {
|
||||
assert.Equal(t, opts.IsAdmin.Value().FieldValue, user.IsAdmin)
|
||||
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
||||
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
|
||||
includePrivateContributions, err := user_model.GetIncludePrivateContributions(t.Context(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, opts.IncludePrivateContributions.Value(), includePrivateContributions)
|
||||
assert.Equal(t, opts.Language.Value(), user.Language)
|
||||
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
||||
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
||||
@@ -86,6 +90,9 @@ func TestUpdateUser(t *testing.T) {
|
||||
assert.Equal(t, opts.IsAdmin.Value().FieldValue, user.IsAdmin)
|
||||
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
||||
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
|
||||
includePrivateContributions, err = user_model.GetIncludePrivateContributions(t.Context(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, opts.IncludePrivateContributions.Value(), includePrivateContributions)
|
||||
assert.Equal(t, opts.Language.Value(), user.Language)
|
||||
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
||||
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
||||
|
||||
+9
-1
@@ -29655,6 +29655,10 @@
|
||||
"type": "boolean",
|
||||
"x-go-name": "HideEmail"
|
||||
},
|
||||
"include_private_contributions": {
|
||||
"type": "boolean",
|
||||
"x-go-name": "IncludePrivateContributions"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"x-go-name": "Language"
|
||||
@@ -29699,6 +29703,10 @@
|
||||
"type": "boolean",
|
||||
"x-go-name": "HideEmail"
|
||||
},
|
||||
"include_private_contributions": {
|
||||
"type": "boolean",
|
||||
"x-go-name": "IncludePrivateContributions"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"x-go-name": "Language"
|
||||
@@ -31073,4 +31081,4 @@
|
||||
"TOTPHeader": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="ui checkbox" id="include-private-contributions">
|
||||
<label data-tooltip-content="{{ctx.Locale.Tr "settings.include_private_contributions_popup"}}"><strong>{{ctx.Locale.Tr "settings.include_private_contributions"}}</strong></label>
|
||||
<input name="include_private_contributions" type="checkbox" {{if .IncludePrivateContributions}}checked{{end}}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserHeatmap(t *testing.T) {
|
||||
@@ -23,17 +25,23 @@ func TestUserHeatmap(t *testing.T) {
|
||||
normalUsername := "user2"
|
||||
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser)
|
||||
|
||||
fakeNow := time.Date(2011, 10, 20, 0, 0, 0, 0, time.Local)
|
||||
fakeNow := time.Date(2011, 10, 21, 0, 0, 0, 0, time.Local)
|
||||
timeutil.MockSet(fakeNow)
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/heatmap", normalUsername)).
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 2, false))
|
||||
testHeatmapSeedContribution(t, 2, 1, "api-user2-public-author-date", 1319068800)
|
||||
testHeatmapSeedContribution(t, 2, 1, "api-user2-public-same-bucket", 1319068850)
|
||||
testHeatmapSeedContribution(t, 2, 2, "api-user2-private-hidden", 1319070600)
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", normalUsername).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var heatmap []*activities_model.UserHeatmapData
|
||||
DecodeJSON(t, resp, &heatmap)
|
||||
var dummyheatmap []*activities_model.UserHeatmapData
|
||||
dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1})
|
||||
|
||||
assert.Equal(t, dummyheatmap, heatmap)
|
||||
assert.Equal(t, []*activities_model.UserHeatmapData{
|
||||
{Timestamp: 1319068800, Contributions: 2},
|
||||
}, heatmap)
|
||||
}
|
||||
|
||||
@@ -4,22 +4,40 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testHeatmapWebResponse struct {
|
||||
HeatmapData [][2]int64 `json:"heatmapData"`
|
||||
TotalContributions int64 `json:"totalContributions"`
|
||||
}
|
||||
|
||||
func TestHeatmapEndpoints(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// Mock time so fixture actions fall within the heatmap's time window
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 2, false))
|
||||
testHeatmapSeedContribution(t, 2, 1, "web-user2-public-one", 1603009800)
|
||||
testHeatmapSeedContribution(t, 2, 1, "web-user2-public-two", 1603009850)
|
||||
testHeatmapSeedContribution(t, 2, 2, "web-user2-private-hidden", 1603010700)
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
@@ -28,11 +46,15 @@ func TestHeatmapEndpoints(t *testing.T) {
|
||||
req := NewRequest(t, "GET", "/user2/-/heatmap")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var result map[string]any
|
||||
DecodeJSON(t, resp, &result)
|
||||
assert.Contains(t, result, "heatmapData")
|
||||
assert.Contains(t, result, "totalContributions")
|
||||
assert.Positive(t, result["totalContributions"])
|
||||
webHeatmap := testHeatmapDecodeWebResponse(t, resp)
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/users/user2/heatmap")
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
var apiHeatmap []*activities_model.UserHeatmapData
|
||||
DecodeJSON(t, resp, &apiHeatmap)
|
||||
|
||||
assert.Equal(t, testHeatmapSumAPIContributions(apiHeatmap), webHeatmap.TotalContributions)
|
||||
assert.Equal(t, int64(2), webHeatmap.TotalContributions)
|
||||
})
|
||||
|
||||
t.Run("OrgDashboard", func(t *testing.T) {
|
||||
@@ -57,3 +79,71 @@ func TestHeatmapEndpoints(t *testing.T) {
|
||||
assert.Contains(t, result, "totalContributions")
|
||||
})
|
||||
}
|
||||
|
||||
func testHeatmapSeedContribution(t *testing.T, userID, repoID int64, label string, authorUnix timeutil.TimeStamp) string {
|
||||
t.Helper()
|
||||
|
||||
commitSHA := fmt.Sprintf("%040x", sha1.Sum([]byte(label)))
|
||||
require.NoError(t, activities_model.UpsertHeatmapContribution(t.Context(), &activities_model.HeatmapContribution{
|
||||
UserID: userID,
|
||||
RepoID: repoID,
|
||||
CommitSHA: commitSHA,
|
||||
AuthorEmail: fmt.Sprintf("user%d@example.com", userID),
|
||||
AuthorUnix: authorUnix,
|
||||
}))
|
||||
return commitSHA
|
||||
}
|
||||
|
||||
func testHeatmapDecodeWebResponse(t *testing.T, resp *httptest.ResponseRecorder) testHeatmapWebResponse {
|
||||
t.Helper()
|
||||
|
||||
var result testHeatmapWebResponse
|
||||
DecodeJSON(t, resp, &result)
|
||||
return result
|
||||
}
|
||||
|
||||
func testHeatmapSumAPIContributions(heatmap []*activities_model.UserHeatmapData) int64 {
|
||||
var total int64
|
||||
for _, item := range heatmap {
|
||||
total += item.Contributions
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func testHeatmapAssertAggregateOnlyAPIResponse(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
var decoded []map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &decoded))
|
||||
for _, entry := range decoded {
|
||||
assert.ElementsMatch(t, []string{"timestamp", "contributions"}, testHeatmapMapKeys(entry))
|
||||
}
|
||||
}
|
||||
|
||||
func testHeatmapAssertAggregateOnlyWebResponse(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &decoded))
|
||||
assert.ElementsMatch(t, []string{"heatmapData", "totalContributions"}, testHeatmapMapKeys(decoded))
|
||||
}
|
||||
|
||||
func testHeatmapAssertNoPrivateMetadata(t *testing.T, body []byte, forbidden ...string) {
|
||||
t.Helper()
|
||||
|
||||
response := string(body)
|
||||
for _, value := range forbidden {
|
||||
assert.NotContains(t, response, value)
|
||||
}
|
||||
for _, value := range []string{"repo_id", "repository_id", "repoID", "commit_sha", "commit_message", "branch", "url", "action_id"} {
|
||||
assert.NotContains(t, response, value)
|
||||
}
|
||||
}
|
||||
|
||||
func testHeatmapMapKeys[K comparable, V any](m map[K]V) []K {
|
||||
keys := make([]K, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -7,16 +7,20 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,6 +28,8 @@ const (
|
||||
privateActivityTestUser = "user2"
|
||||
)
|
||||
|
||||
const privateActivityHeatmapOptInUser = "user16"
|
||||
|
||||
// org3 is an organization so it is not usable here
|
||||
const privateActivityTestOtherUser = "user4"
|
||||
|
||||
@@ -41,6 +47,7 @@ func testPrivateActivityDoSomethingForActionEntries(t *testing.T) {
|
||||
Title: "test",
|
||||
}).AddTokenAuth(token)
|
||||
session.MakeRequest(t, req, http.StatusCreated)
|
||||
testHeatmapSeedContribution(t, 2, 1, "private-activity-action-public", timeutil.TimeStampNow()-900)
|
||||
}
|
||||
|
||||
// private activity helpers
|
||||
@@ -56,6 +63,26 @@ func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) {
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t *testing.T, session *TestSession, username string, include bool) {
|
||||
values := map[string]string{
|
||||
"name": username,
|
||||
"email": username + "@example.com",
|
||||
"language": "en-US",
|
||||
}
|
||||
if include {
|
||||
values["include_private_contributions"] = "1"
|
||||
}
|
||||
req := NewRequestWithValues(t, "POST", "/user/settings", values)
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperAssertIncludePrivateContributions(t *testing.T, username string, expected bool) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
|
||||
includePrivateContributions, err := user_model.GetIncludePrivateContributions(t.Context(), user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, includePrivateContributions)
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool {
|
||||
return htmlDoc.doc.Find("#activity-feed").Find(".flex-item").Length() > 0
|
||||
}
|
||||
@@ -136,6 +163,196 @@ func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session
|
||||
return len(items) != 0
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperGetAPIHeatmapFromPublic(t *testing.T) ([]*activities_model.UserHeatmapData, []byte) {
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityHeatmapOptInUser)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
body := resp.Body.Bytes()
|
||||
|
||||
var items []*activities_model.UserHeatmapData
|
||||
DecodeJSON(t, resp, &items)
|
||||
|
||||
return items, body
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperGetAPIHeatmapFromSession(t *testing.T, session *TestSession) ([]*activities_model.UserHeatmapData, []byte) {
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityHeatmapOptInUser).
|
||||
AddTokenAuth(token)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
body := resp.Body.Bytes()
|
||||
|
||||
var items []*activities_model.UserHeatmapData
|
||||
DecodeJSON(t, resp, &items)
|
||||
|
||||
return items, body
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperGetWebHeatmapFromPublic(t *testing.T) (testHeatmapWebResponse, []byte) {
|
||||
req := NewRequestf(t, "GET", "/%s/-/heatmap", privateActivityHeatmapOptInUser)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
body := resp.Body.Bytes()
|
||||
|
||||
return testHeatmapDecodeWebResponse(t, resp), body
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperSeedHeatmapContributions(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 16, false))
|
||||
|
||||
testHeatmapSeedContribution(t, 16, 21, "private-activity-public", 1603009800)
|
||||
privateSHA := testHeatmapSeedContribution(t, 16, 22, "private-activity-private-one", 1603009850)
|
||||
testHeatmapSeedContribution(t, 16, 22, "private-activity-private-two", 1603010700)
|
||||
|
||||
return privateSHA
|
||||
}
|
||||
|
||||
func testPrivateActivityHelperAssertAPIHeatmapTotal(t *testing.T, heatmap []*activities_model.UserHeatmapData, expected int64) {
|
||||
t.Helper()
|
||||
|
||||
assert.Equal(t, expected, testHeatmapSumAPIContributions(heatmap))
|
||||
}
|
||||
|
||||
// check private contribution opt-in settings persistence and ownership
|
||||
|
||||
func TestPrivateActivityIncludePrivateContributionsWebSettingsPersistence(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, privateActivityTestUser)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
|
||||
|
||||
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, session, privateActivityTestUser, true)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, true)
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: privateActivityTestUser})
|
||||
assert.False(t, user.KeepActivityPrivate, "private contribution opt-in must not hide the whole activity heatmap")
|
||||
|
||||
req := NewRequest(t, "GET", "/user/settings")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
assert.Equal(t, 1, htmlDoc.doc.Find("#include-private-contributions input[name='include_private_contributions']:checked").Length())
|
||||
|
||||
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, session, privateActivityTestUser, false)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
|
||||
}
|
||||
|
||||
func TestPrivateActivityIncludePrivateContributionsAPISettingsPersistence(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, privateActivityTestUser)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
includePrivateContributions := true
|
||||
req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
|
||||
IncludePrivateContributions: &includePrivateContributions,
|
||||
}).AddTokenAuth(token)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
settings := DecodeJSON(t, resp, &api.UserSettings{})
|
||||
assert.True(t, settings.IncludePrivateContributions)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, true)
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: privateActivityTestUser})
|
||||
assert.False(t, user.KeepActivityPrivate, "API opt-in must not set KeepActivityPrivate")
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token)
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
settings = DecodeJSON(t, resp, &api.UserSettings{})
|
||||
assert.True(t, settings.IncludePrivateContributions)
|
||||
|
||||
includePrivateContributions = false
|
||||
req = NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
|
||||
IncludePrivateContributions: &includePrivateContributions,
|
||||
}).AddTokenAuth(token)
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
settings = DecodeJSON(t, resp, &api.UserSettings{})
|
||||
assert.False(t, settings.IncludePrivateContributions)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
|
||||
}
|
||||
|
||||
func TestPrivateActivityPrivateContributionsHiddenByDefault(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
testPrivateActivityHelperSeedHeatmapContributions(t)
|
||||
|
||||
publicHeatmap, _ := testPrivateActivityHelperGetAPIHeatmapFromPublic(t)
|
||||
testPrivateActivityHelperAssertAPIHeatmapTotal(t, publicHeatmap, 1)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
username string
|
||||
}{
|
||||
{"owner", privateActivityHeatmapOptInUser},
|
||||
{"admin", privateActivityTestAdmin},
|
||||
{"collaborator", "user15"},
|
||||
{"unrelated", privateActivityTestOtherUser},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
session := loginUser(t, testCase.username)
|
||||
heatmap, _ := testPrivateActivityHelperGetAPIHeatmapFromSession(t, session)
|
||||
testPrivateActivityHelperAssertAPIHeatmapTotal(t, heatmap, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateActivityIncludePrivateContributionsEndpointAggregatesOnly(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
privateSHA := testPrivateActivityHelperSeedHeatmapContributions(t)
|
||||
|
||||
ownerSession := loginUser(t, privateActivityHeatmapOptInUser)
|
||||
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, ownerSession, privateActivityHeatmapOptInUser, true)
|
||||
|
||||
apiHeatmap, apiBody := testPrivateActivityHelperGetAPIHeatmapFromPublic(t)
|
||||
testPrivateActivityHelperAssertAPIHeatmapTotal(t, apiHeatmap, 3)
|
||||
testHeatmapAssertAggregateOnlyAPIResponse(t, apiBody)
|
||||
testHeatmapAssertNoPrivateMetadata(t, apiBody,
|
||||
"big_test_private_3",
|
||||
"22",
|
||||
privateSHA,
|
||||
"private-activity-private-one",
|
||||
"master",
|
||||
"/user16/big_test_private_3",
|
||||
)
|
||||
|
||||
webHeatmap, webBody := testPrivateActivityHelperGetWebHeatmapFromPublic(t)
|
||||
assert.Equal(t, int64(3), webHeatmap.TotalContributions)
|
||||
testHeatmapAssertAggregateOnlyWebResponse(t, webBody)
|
||||
testHeatmapAssertNoPrivateMetadata(t, webBody,
|
||||
"big_test_private_3",
|
||||
"22",
|
||||
privateSHA,
|
||||
"private-activity-private-one",
|
||||
"master",
|
||||
"/user16/big_test_private_3",
|
||||
)
|
||||
|
||||
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, ownerSession, privateActivityHeatmapOptInUser, false)
|
||||
apiHeatmap, _ = testPrivateActivityHelperGetAPIHeatmapFromPublic(t)
|
||||
testPrivateActivityHelperAssertAPIHeatmapTotal(t, apiHeatmap, 1)
|
||||
}
|
||||
|
||||
func TestPrivateActivityIncludePrivateContributionsWebSettingsAuthBoundary(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
|
||||
|
||||
otherSession := loginUser(t, privateActivityTestOtherUser)
|
||||
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, otherSession, privateActivityTestOtherUser, true)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestOtherUser, true)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
|
||||
|
||||
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
|
||||
"name": privateActivityTestUser,
|
||||
"email": privateActivityTestUser + "@example.com",
|
||||
"language": "en-US",
|
||||
"include_private_contributions": "1",
|
||||
})
|
||||
MakeRequest(t, req, http.StatusSeeOther)
|
||||
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
|
||||
}
|
||||
|
||||
// check activity visibility if the visibility is enabled
|
||||
|
||||
func TestPrivateActivityNoVisibleForPublic(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user