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