Compare commits

...

8 Commits

36 changed files with 1618 additions and 140 deletions
+15 -7
View File
@@ -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
+2 -2
View File
@@ -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
); );
+1 -1
View File
@@ -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;
}; };
} }
+1 -1
View File
@@ -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";
+1
View File
@@ -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(),
+131
View File
@@ -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))
}
+6 -4
View File
@@ -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 != "" {
+16 -11
View File
@@ -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)
+19 -1
View File
@@ -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)
+8
View File
@@ -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) {