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:
|
readModulesRecursive' = path: extraArgs:
|
||||||
with lib;
|
with lib;
|
||||||
with builtins; let
|
with builtins; let
|
||||||
paths = pipe "${path}" [
|
collectPaths = dir: prefix:
|
||||||
(filesystem.listFilesRecursive)
|
concatLists (mapAttrsToList (name: type: let
|
||||||
(filter (hasSuffix ".nix"))
|
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 [
|
pathToName = flip pipe [
|
||||||
(removePrefix "${path}/")
|
|
||||||
(replaceStrings ["/" ".nix"] ["." ""])
|
(replaceStrings ["/" ".nix"] ["." ""])
|
||||||
(removeSuffix ".nix")
|
(removeSuffix ".nix")
|
||||||
];
|
];
|
||||||
attrList =
|
attrList =
|
||||||
map (path': {
|
map (path': {
|
||||||
name = pathToName (unsafeDiscardStringContext path');
|
name = pathToName path'.name;
|
||||||
value = import path' extraArgs;
|
value = import path'.path' extraArgs;
|
||||||
})
|
})
|
||||||
paths;
|
paths;
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ with self.lib;
|
|||||||
let
|
let
|
||||||
# Combine hectic modules into one
|
# Combine hectic modules into one
|
||||||
hectic.imports = attrValues (
|
hectic.imports = attrValues (
|
||||||
readModulesRecursive' ./hectic { inherit flake self inputs; }
|
readModulesRecursive' (flake + "/nixos/module/hectic") { inherit flake self inputs; }
|
||||||
);
|
);
|
||||||
# Read generic modules separately
|
# Read generic modules separately
|
||||||
generic = readModulesRecursive'
|
generic = readModulesRecursive'
|
||||||
./generic
|
(flake + "/nixos/module/generic")
|
||||||
{ inherit flake self inputs; };
|
{ inherit flake self inputs; };
|
||||||
in generic // {
|
in generic // {
|
||||||
inherit hectic;
|
inherit hectic;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
adminNames = [ "yukkop" ];
|
adminNames = [ "yukkop" ];
|
||||||
|
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
|
||||||
|
|
||||||
cfg = config.hectic.generic.matrix-cluster;
|
cfg = config.hectic.generic.matrix-cluster;
|
||||||
in {
|
in {
|
||||||
@@ -39,7 +40,7 @@ in {
|
|||||||
value = {
|
value = {
|
||||||
key = "matrix/users/${name}/password";
|
key = "matrix/users/${name}/password";
|
||||||
owner = "matrix-synapse";
|
owner = "matrix-synapse";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
}) userNames
|
}) userNames
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ in {
|
|||||||
|
|
||||||
(lib.mkIf cfg.watcher.enable {
|
(lib.mkIf cfg.watcher.enable {
|
||||||
sops.secrets."sentinèlla/watcher/environment" = lib.mkDefault {
|
sops.secrets."sentinèlla/watcher/environment" = lib.mkDefault {
|
||||||
sopsFile = "${flake}/sus/sentinella-default.yaml";
|
sopsFile = flake + "/sus/sentinella-default.yaml";
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services."sentinella-watcher" = {
|
systemd.services."sentinella-watcher" = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
jitsiHost = "meet.accord.tube";
|
jitsiHost = "meet.accord.tube";
|
||||||
elementEntryDomain = "element.bfs.band";
|
elementEntryDomain = "element.bfs.band";
|
||||||
polandEntryDomain = "bfs.band";
|
polandEntryDomain = "bfs.band";
|
||||||
|
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
|
||||||
backendProxyConfig = ''
|
backendProxyConfig = ''
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_ssl_name ${matrixHost};
|
proxy_ssl_name ${matrixHost};
|
||||||
@@ -203,41 +204,41 @@ in {
|
|||||||
key = "matrix/signing-key";
|
key = "matrix/signing-key";
|
||||||
owner = "matrix-synapse";
|
owner = "matrix-synapse";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
sops.secrets."matrix/postgres-replication-password" = {
|
sops.secrets."matrix/postgres-replication-password" = {
|
||||||
key = "matrix/postgres-replication-password";
|
key = "matrix/postgres-replication-password";
|
||||||
owner = "postgres";
|
owner = "postgres";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
sops.secrets."matrix/object-storage/credentials" = {
|
sops.secrets."matrix/object-storage/credentials" = {
|
||||||
key = "matrix/object-storage/credentials";
|
key = "matrix/object-storage/credentials";
|
||||||
owner = "matrix-synapse";
|
owner = "matrix-synapse";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
sops.secrets."matrix/secrets" = {
|
sops.secrets."matrix/secrets" = {
|
||||||
key = "matrix/secrets";
|
key = "matrix/secrets";
|
||||||
owner = "matrix-synapse";
|
owner = "matrix-synapse";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
sops.secrets."matrix/turn-secret" = {
|
sops.secrets."matrix/turn-secret" = {
|
||||||
key = "matrix/turn-secret";
|
key = "matrix/turn-secret";
|
||||||
owner = "turnserver";
|
owner = "turnserver";
|
||||||
group = "turnserver";
|
group = "turnserver";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
sops.secrets."matrix/porkbun-api-key" = {
|
sops.secrets."matrix/porkbun-api-key" = {
|
||||||
key = "matrix/porkbun-api-key";
|
key = "matrix/porkbun-api-key";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
sops.secrets."matrix/porkbun-secret-api-key" = {
|
sops.secrets."matrix/porkbun-secret-api-key" = {
|
||||||
key = "matrix/porkbun-secret-api-key";
|
key = "matrix/porkbun-secret-api-key";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
sopsFile = "${flake}/sus/matrix-cluster.yaml";
|
sopsFile = matrixClusterSopsFile;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ in {
|
|||||||
sops = {
|
sops = {
|
||||||
gnupg.sshKeyPaths = [ ];
|
gnupg.sshKeyPaths = [ ];
|
||||||
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
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) // {
|
secrets = builtins.listToAttrs (map mkMailPasswordSecret mailUserNames) // {
|
||||||
"init-postgresql" = {
|
"init-postgresql" = {
|
||||||
key = "init-postgresql";
|
key = "init-postgresql";
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func newAdminCommand() *cli.Command {
|
|||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
newUserCommand(),
|
newUserCommand(),
|
||||||
newRepoSyncReleasesCommand(),
|
newRepoSyncReleasesCommand(),
|
||||||
|
newHeatmapCommand(),
|
||||||
newRegenerateCommand(),
|
newRegenerateCommand(),
|
||||||
newAuthCommand(),
|
newAuthCommand(),
|
||||||
newSendMailCommand(),
|
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"
|
"code.gitea.io/gitea/models/organization"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"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.
|
// 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.
|
// 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
|
groupByName := "timestamp" // We need this extra case because mssql doesn't allow grouping by alias
|
||||||
switch {
|
switch {
|
||||||
case setting.Database.Type.IsMySQL():
|
case setting.Database.Type.IsMySQL():
|
||||||
groupBy = "created_unix DIV 900 * 900"
|
groupBy = "author_unix DIV 900 * 900"
|
||||||
case setting.Database.Type.IsMSSQL():
|
case setting.Database.Type.IsMSSQL():
|
||||||
groupByName = groupBy
|
groupByName = groupBy
|
||||||
}
|
}
|
||||||
|
|
||||||
cond, err := ActivityQueryCondition(ctx, GetFeedsOptions{
|
includePrivate, err := user_model.GetIncludePrivateContributions(ctx, user.ID)
|
||||||
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(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return hdata, db.GetEngine(ctx).
|
sess := db.GetEngine(ctx).
|
||||||
Select(groupBy+" AS timestamp, count(user_id) as contributions").
|
Select(groupBy+" AS timestamp, count(heatmap_contribution.user_id) as contributions").
|
||||||
Table("action").
|
Table("heatmap_contribution").
|
||||||
Where(cond).
|
Join("INNER", "repository", "repository.id = heatmap_contribution.repo_id").
|
||||||
And("created_unix > ?", timeutil.TimeStampNow()-(366+7)*86400). // (366+7) days to include the first week for the heatmap
|
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).
|
GroupBy(groupByName).
|
||||||
OrderBy("timestamp").
|
OrderBy("timestamp").
|
||||||
Find(&hdata)
|
Find(&hdata)
|
||||||
|
|||||||
@@ -4,19 +4,43 @@
|
|||||||
package activities_test
|
package activities_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
activities_model "code.gitea.io/gitea/models/activities"
|
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"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
"code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetUserHeatmapDataByUser(t *testing.T) {
|
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 {
|
testCases := []struct {
|
||||||
desc string
|
desc string
|
||||||
userID int64
|
userID int64
|
||||||
@@ -25,73 +49,211 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
|
|||||||
JSONResult string
|
JSONResult string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"self looks at action in private repo",
|
"self sees public author-date contributions only",
|
||||||
2, 2, 1, `[{"timestamp":1603227600,"contributions":1}]`,
|
2, 2, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"admin looks at action in private repo",
|
"admin sees public author-date contributions only",
|
||||||
2, 1, 1, `[{"timestamp":1603227600,"contributions":1}]`,
|
2, 1, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"other user looks at action in private repo",
|
"other user sees public author-date contributions only",
|
||||||
2, 3, 0, `[]`,
|
2, 3, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nobody looks at action in private repo",
|
"anonymous sees public author-date contributions only",
|
||||||
2, 0, 0, `[]`,
|
2, 0, 2, `[{"timestamp":1603009800,"contributions":2}]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"collaborator looks at action in private repo",
|
"different author is not counted for target user",
|
||||||
16, 15, 1, `[{"timestamp":1603267200,"contributions":1}]`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"no action action not performed by target user",
|
|
||||||
3, 3, 0, `[]`,
|
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))
|
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
defer timeutil.MockUnset()
|
defer timeutil.MockUnset()
|
||||||
|
|
||||||
for _, tc := range testCases {
|
insertHeatmapContribution(t, 2, 1, "user2-visibility-flip", 1603009800)
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.userID})
|
|
||||||
|
|
||||||
var doer *user_model.User
|
target := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
if tc.doerID != 0 {
|
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||||
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
|
require.NoError(t, err)
|
||||||
}
|
assertHeatmapJSON(t, heatmap, `[{
|
||||||
|
"timestamp": 1603009800,
|
||||||
|
"contributions": 1
|
||||||
|
}]`)
|
||||||
|
|
||||||
// get the action for comparison
|
setRepoPrivate(t, 1, true)
|
||||||
actions, count, err := activities_model.GetFeeds(t.Context(), activities_model.GetFeedsOptions{
|
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||||
RequestedUser: user,
|
require.NoError(t, err)
|
||||||
Actor: doer,
|
assert.Empty(t, heatmap)
|
||||||
IncludePrivate: true,
|
|
||||||
OnlyPerformedBy: true,
|
|
||||||
IncludeDeleted: true,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get the heatmap and compare
|
require.NoError(t, user_model.SetIncludePrivateContributions(t.Context(), 2, true))
|
||||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(t.Context(), user, doer)
|
heatmap, err = activities_model.GetUserHeatmapDataByUser(t.Context(), target, nil)
|
||||||
var contributions int
|
require.NoError(t, err)
|
||||||
for _, hm := range heatmap {
|
assertHeatmapJSON(t, heatmap, `[{
|
||||||
contributions += int(hm.Contributions)
|
"timestamp": 1603009800,
|
||||||
}
|
"contributions": 1
|
||||||
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)
|
|
||||||
|
|
||||||
// Test JSON rendering
|
func TestGetUserHeatmapDataByOrgTeam(t *testing.T) {
|
||||||
jsonData, err := json.Marshal(heatmap)
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
assert.NoError(t, err)
|
require.NoError(t, db.TruncateBeans(t.Context(), &activities_model.HeatmapContribution{}))
|
||||||
assert.JSONEq(t, tc.JSONResult, string(jsonData))
|
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(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
|
||||||
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
|
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
|
||||||
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
|
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
|
||||||
|
newMigration(331, "Create heatmap contribution table", v1_26.CreateHeatmapContributionTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
@@ -19,6 +20,8 @@ import (
|
|||||||
"xorm.io/xorm/convert"
|
"xorm.io/xorm/convert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const SettingsKeyIncludePrivateContributions = "include_private_contributions"
|
||||||
|
|
||||||
// Setting is a key value store of user settings
|
// Setting is a key value store of user settings
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
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))
|
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"`
|
Theme string `json:"theme"`
|
||||||
DiffViewStyle string `json:"diff_view_style"`
|
DiffViewStyle string `json:"diff_view_style"`
|
||||||
// Privacy
|
// Privacy
|
||||||
HideEmail bool `json:"hide_email"`
|
HideEmail bool `json:"hide_email"`
|
||||||
HideActivity bool `json:"hide_activity"`
|
HideActivity bool `json:"hide_activity"`
|
||||||
|
IncludePrivateContributions bool `json:"include_private_contributions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSettingsOptions represents options to change user settings
|
// UserSettingsOptions represents options to change user settings
|
||||||
@@ -95,8 +96,9 @@ type UserSettingsOptions struct {
|
|||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
DiffViewStyle *string `json:"diff_view_style"`
|
DiffViewStyle *string `json:"diff_view_style"`
|
||||||
// Privacy
|
// Privacy
|
||||||
HideEmail *bool `json:"hide_email"`
|
HideEmail *bool `json:"hide_email"`
|
||||||
HideActivity *bool `json:"hide_activity"`
|
HideActivity *bool `json:"hide_activity"`
|
||||||
|
IncludePrivateContributions *bool `json:"include_private_contributions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameUserOption options when renaming a user
|
// RenameUserOption options when renaming a user
|
||||||
|
|||||||
@@ -708,6 +708,8 @@
|
|||||||
"settings.privacy": "Privacy",
|
"settings.privacy": "Privacy",
|
||||||
"settings.keep_activity_private": "Hide Activity from profile page",
|
"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.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.lookup_avatar_by_mail": "Look Up Avatar by Email Address",
|
||||||
"settings.federated_avatar_lookup": "Federated Avatar Lookup",
|
"settings.federated_avatar_lookup": "Federated Avatar Lookup",
|
||||||
"settings.enable_custom_avatar": "Use Custom Avatar",
|
"settings.enable_custom_avatar": "Use Custom Avatar",
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ func GetUserSettings(ctx *context.APIContext) {
|
|||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/UserSettings"
|
// "$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
|
// UpdateUserSettings returns user settings
|
||||||
@@ -46,20 +51,26 @@ func UpdateUserSettings(ctx *context.APIContext) {
|
|||||||
form := web.GetForm(ctx).(*api.UserSettingsOptions)
|
form := web.GetForm(ctx).(*api.UserSettingsOptions)
|
||||||
|
|
||||||
opts := &user_service.UpdateOptions{
|
opts := &user_service.UpdateOptions{
|
||||||
FullName: optional.FromPtr(form.FullName),
|
FullName: optional.FromPtr(form.FullName),
|
||||||
Description: optional.FromPtr(form.Description),
|
Description: optional.FromPtr(form.Description),
|
||||||
Website: optional.FromPtr(form.Website),
|
Website: optional.FromPtr(form.Website),
|
||||||
Location: optional.FromPtr(form.Location),
|
Location: optional.FromPtr(form.Location),
|
||||||
Language: optional.FromPtr(form.Language),
|
Language: optional.FromPtr(form.Language),
|
||||||
Theme: optional.FromPtr(form.Theme),
|
Theme: optional.FromPtr(form.Theme),
|
||||||
DiffViewStyle: optional.FromPtr(form.DiffViewStyle),
|
DiffViewStyle: optional.FromPtr(form.DiffViewStyle),
|
||||||
KeepEmailPrivate: optional.FromPtr(form.HideEmail),
|
KeepEmailPrivate: optional.FromPtr(form.HideEmail),
|
||||||
KeepActivityPrivate: optional.FromPtr(form.HideActivity),
|
KeepActivityPrivate: optional.FromPtr(form.HideActivity),
|
||||||
|
IncludePrivateContributions: optional.FromPtr(form.IncludePrivateContributions),
|
||||||
}
|
}
|
||||||
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
|
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
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["PageIsSettingsProfile"] = true
|
||||||
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
||||||
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
||||||
|
if !loadProfilePrivateContributionSetting(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, tplSettingsProfile)
|
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
|
// ProfilePost response for change user's profile
|
||||||
func ProfilePost(ctx *context.Context) {
|
func ProfilePost(ctx *context.Context) {
|
||||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||||
ctx.Data["PageIsSettingsProfile"] = true
|
ctx.Data["PageIsSettingsProfile"] = true
|
||||||
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
||||||
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
||||||
|
if !loadProfilePrivateContributionSetting(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if ctx.HasError() {
|
if ctx.HasError() {
|
||||||
ctx.HTML(http.StatusOK, tplSettingsProfile)
|
ctx.HTML(http.StatusOK, tplSettingsProfile)
|
||||||
@@ -94,12 +110,13 @@ func ProfilePost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
opts := &user_service.UpdateOptions{
|
opts := &user_service.UpdateOptions{
|
||||||
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
||||||
Description: optional.Some(form.Description),
|
Description: optional.Some(form.Description),
|
||||||
Website: optional.Some(form.Website),
|
Website: optional.Some(form.Website),
|
||||||
Location: optional.Some(form.Location),
|
Location: optional.Some(form.Location),
|
||||||
Visibility: optional.Some(form.Visibility),
|
Visibility: optional.Some(form.Visibility),
|
||||||
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
||||||
|
IncludePrivateContributions: optional.Some(form.IncludePrivateContributions),
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.FullName != "" {
|
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
|
// User2UserSettings return UserSettings based on a user
|
||||||
func User2UserSettings(user *user_model.User) api.UserSettings {
|
func User2UserSettings(ctx context.Context, user *user_model.User) (api.UserSettings, error) {
|
||||||
return api.UserSettings{
|
includePrivateContributions, err := user_model.GetIncludePrivateContributions(ctx, user.ID)
|
||||||
FullName: user.FullName,
|
if err != nil {
|
||||||
Website: user.Website,
|
return api.UserSettings{}, err
|
||||||
Location: user.Location,
|
|
||||||
Language: user.Language,
|
|
||||||
Description: user.Description,
|
|
||||||
Theme: user.Theme,
|
|
||||||
HideEmail: user.KeepEmailPrivate,
|
|
||||||
HideActivity: user.KeepActivityPrivate,
|
|
||||||
DiffViewStyle: user.DiffViewStyle,
|
|
||||||
}
|
}
|
||||||
|
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
|
// 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
|
// UpdateProfileForm form for updating profile
|
||||||
type UpdateProfileForm struct {
|
type UpdateProfileForm struct {
|
||||||
Name string `binding:"Username;MaxSize(40)"`
|
Name string `binding:"Username;MaxSize(40)"`
|
||||||
FullName string `binding:"MaxSize(100)"`
|
FullName string `binding:"MaxSize(100)"`
|
||||||
KeepEmailPrivate bool
|
KeepEmailPrivate bool
|
||||||
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
|
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
|
||||||
Location string `binding:"MaxSize(50)"`
|
Location string `binding:"MaxSize(50)"`
|
||||||
Description string `binding:"MaxSize(255)"`
|
Description string `binding:"MaxSize(255)"`
|
||||||
Visibility structs.VisibleType
|
Visibility structs.VisibleType
|
||||||
KeepActivityPrivate bool
|
KeepActivityPrivate bool
|
||||||
|
IncludePrivateContributions bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the fields
|
// Validate validates the fields
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
|
|||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
log.Trace("SyncMirrors [repo: %-v]: %d branches updated", m.Repo, len(results))
|
log.Trace("SyncMirrors [repo: %-v]: %d branches updated", m.Repo, len(results))
|
||||||
|
indexHeatmap := false
|
||||||
if len(results) > 0 {
|
if len(results) > 0 {
|
||||||
if ok := checkAndUpdateEmptyRepository(ctx, m, results); !ok {
|
if ok := checkAndUpdateEmptyRepository(ctx, m, results); !ok {
|
||||||
log.Error("SyncMirrors [repo: %-v]: checkAndUpdateEmptyRepository: %v", m.Repo, err)
|
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() {
|
if result.RefName.IsPull() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if result.RefName.IsBranch() && result.RefName.BranchName() == m.Repo.DefaultBranch {
|
||||||
|
indexHeatmap = true
|
||||||
|
}
|
||||||
|
|
||||||
// Create reference
|
// Create reference
|
||||||
if result.OldCommitID == "" {
|
if result.OldCommitID == "" {
|
||||||
@@ -391,6 +395,11 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
|
|||||||
NewCommitID: newCommitID,
|
NewCommitID: newCommitID,
|
||||||
}, theCommits)
|
}, 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)
|
log.Trace("SyncMirrors [repo: %-v]: done notifying updated branches/tags - now updating last commit time", m.Repo)
|
||||||
|
|
||||||
isEmpty, err := gitRepo.IsEmpty()
|
isEmpty, err := gitRepo.IsEmpty()
|
||||||
|
|||||||
@@ -747,6 +747,9 @@ func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, newB
|
|||||||
}
|
}
|
||||||
|
|
||||||
notify_service.ChangeDefaultBranch(ctx, repo)
|
notify_service.ChangeDefaultBranch(ctx, repo)
|
||||||
|
if err := IndexDefaultBranchHeatmapContributions(ctx, repo); err != nil {
|
||||||
|
log.Error("IndexDefaultBranchHeatmapContributions[%s]: %v", repo.FullName(), err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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
|
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 {
|
if opts.Mirror {
|
||||||
remoteAddress, err := util.SanitizeURL(opts.CloneAddr)
|
remoteAddress, err := util.SanitizeURL(opts.CloneAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -255,6 +255,13 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
|||||||
}
|
}
|
||||||
return repo, nil
|
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.
|
// 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() {
|
if !opts.IsDelRef() {
|
||||||
branch := opts.RefFullName.BranchName()
|
branch := opts.RefFullName.BranchName()
|
||||||
|
|
||||||
@@ -193,6 +194,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
|||||||
if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil {
|
if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil {
|
||||||
log.Error("DelRepoDivergenceFromCache: %v", err)
|
log.Error("DelRepoDivergenceFromCache: %v", err)
|
||||||
}
|
}
|
||||||
|
indexHeatmap = true
|
||||||
} else {
|
} else {
|
||||||
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
|
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
|
||||||
log.Error("DelDivergenceFromCache: %v", err)
|
log.Error("DelDivergenceFromCache: %v", err)
|
||||||
@@ -222,6 +224,12 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
|||||||
pushDeleteBranch(ctx, repo, pusher, opts)
|
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.
|
// 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 {
|
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)
|
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"
|
"fmt"
|
||||||
|
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
password_module "code.gitea.io/gitea/modules/auth/password"
|
password_module "code.gitea.io/gitea/modules/auth/password"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
@@ -47,6 +48,7 @@ type UpdateOptions struct {
|
|||||||
IsRestricted optional.Option[bool]
|
IsRestricted optional.Option[bool]
|
||||||
Visibility optional.Option[structs.VisibleType]
|
Visibility optional.Option[structs.VisibleType]
|
||||||
KeepActivityPrivate optional.Option[bool]
|
KeepActivityPrivate optional.Option[bool]
|
||||||
|
IncludePrivateContributions optional.Option[bool]
|
||||||
Language optional.Option[string]
|
Language optional.Option[string]
|
||||||
Theme optional.Option[string]
|
Theme optional.Option[string]
|
||||||
DiffViewStyle 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")
|
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 {
|
type UpdateAuthOptions struct {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func TestUpdateUser(t *testing.T) {
|
|||||||
IsAdmin: UpdateOptionFieldFromValue(true),
|
IsAdmin: UpdateOptionFieldFromValue(true),
|
||||||
Visibility: optional.Some(structs.VisibleTypePrivate),
|
Visibility: optional.Some(structs.VisibleTypePrivate),
|
||||||
KeepActivityPrivate: optional.Some(true),
|
KeepActivityPrivate: optional.Some(true),
|
||||||
|
IncludePrivateContributions: optional.Some(true),
|
||||||
Language: optional.Some("lang"),
|
Language: optional.Some("lang"),
|
||||||
Theme: optional.Some("theme"),
|
Theme: optional.Some("theme"),
|
||||||
DiffViewStyle: optional.Some("split"),
|
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.IsAdmin.Value().FieldValue, user.IsAdmin)
|
||||||
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
||||||
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
|
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.Language.Value(), user.Language)
|
||||||
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
||||||
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
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.IsAdmin.Value().FieldValue, user.IsAdmin)
|
||||||
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
||||||
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
|
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.Language.Value(), user.Language)
|
||||||
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
||||||
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
||||||
|
|||||||
@@ -29655,6 +29655,10 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"x-go-name": "HideEmail"
|
"x-go-name": "HideEmail"
|
||||||
},
|
},
|
||||||
|
"include_private_contributions": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "IncludePrivateContributions"
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Language"
|
"x-go-name": "Language"
|
||||||
@@ -29699,6 +29703,10 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"x-go-name": "HideEmail"
|
"x-go-name": "HideEmail"
|
||||||
},
|
},
|
||||||
|
"include_private_contributions": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "IncludePrivateContributions"
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Language"
|
"x-go-name": "Language"
|
||||||
|
|||||||
@@ -88,6 +88,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="divider"></div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -4,17 +4,19 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
activities_model "code.gitea.io/gitea/models/activities"
|
activities_model "code.gitea.io/gitea/models/activities"
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
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/modules/timeutil"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserHeatmap(t *testing.T) {
|
func TestUserHeatmap(t *testing.T) {
|
||||||
@@ -23,17 +25,23 @@ func TestUserHeatmap(t *testing.T) {
|
|||||||
normalUsername := "user2"
|
normalUsername := "user2"
|
||||||
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser)
|
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)
|
timeutil.MockSet(fakeNow)
|
||||||
defer timeutil.MockUnset()
|
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)
|
AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
var heatmap []*activities_model.UserHeatmapData
|
var heatmap []*activities_model.UserHeatmapData
|
||||||
DecodeJSON(t, resp, &heatmap)
|
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
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/modules/timeutil"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestHeatmapEndpoints(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
// Mock time so fixture actions fall within the heatmap's time window
|
// 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))
|
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
defer timeutil.MockUnset()
|
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")
|
session := loginUser(t, "user2")
|
||||||
|
|
||||||
@@ -28,11 +46,15 @@ func TestHeatmapEndpoints(t *testing.T) {
|
|||||||
req := NewRequest(t, "GET", "/user2/-/heatmap")
|
req := NewRequest(t, "GET", "/user2/-/heatmap")
|
||||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
var result map[string]any
|
webHeatmap := testHeatmapDecodeWebResponse(t, resp)
|
||||||
DecodeJSON(t, resp, &result)
|
|
||||||
assert.Contains(t, result, "heatmapData")
|
req = NewRequest(t, "GET", "/api/v1/users/user2/heatmap")
|
||||||
assert.Contains(t, result, "totalContributions")
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
assert.Positive(t, result["totalContributions"])
|
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) {
|
t.Run("OrgDashboard", func(t *testing.T) {
|
||||||
@@ -57,3 +79,71 @@ func TestHeatmapEndpoints(t *testing.T) {
|
|||||||
assert.Contains(t, result, "totalContributions")
|
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"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
activities_model "code.gitea.io/gitea/models/activities"
|
activities_model "code.gitea.io/gitea/models/activities"
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,6 +28,8 @@ const (
|
|||||||
privateActivityTestUser = "user2"
|
privateActivityTestUser = "user2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const privateActivityHeatmapOptInUser = "user16"
|
||||||
|
|
||||||
// org3 is an organization so it is not usable here
|
// org3 is an organization so it is not usable here
|
||||||
const privateActivityTestOtherUser = "user4"
|
const privateActivityTestOtherUser = "user4"
|
||||||
|
|
||||||
@@ -41,6 +47,7 @@ func testPrivateActivityDoSomethingForActionEntries(t *testing.T) {
|
|||||||
Title: "test",
|
Title: "test",
|
||||||
}).AddTokenAuth(token)
|
}).AddTokenAuth(token)
|
||||||
session.MakeRequest(t, req, http.StatusCreated)
|
session.MakeRequest(t, req, http.StatusCreated)
|
||||||
|
testHeatmapSeedContribution(t, 2, 1, "private-activity-action-public", timeutil.TimeStampNow()-900)
|
||||||
}
|
}
|
||||||
|
|
||||||
// private activity helpers
|
// private activity helpers
|
||||||
@@ -56,6 +63,26 @@ func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) {
|
|||||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
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 {
|
func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool {
|
||||||
return htmlDoc.doc.Find("#activity-feed").Find(".flex-item").Length() > 0
|
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
|
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
|
// check activity visibility if the visibility is enabled
|
||||||
|
|
||||||
func TestPrivateActivityNoVisibleForPublic(t *testing.T) {
|
func TestPrivateActivityNoVisibleForPublic(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user