From 2eb23ea7ea415389df5c0dded9b3cabb4cb60a4e Mon Sep 17 00:00:00 2001 From: yukkop Date: Sat, 6 Jun 2026 20:22:12 +0000 Subject: [PATCH] feat(`package`): `gitea`: query heatmap by commit author date --- .../source/models/activities/user_heatmap.go | 47 +++-- .../models/activities/user_heatmap_test.go | 193 +++++++++++++----- 2 files changed, 171 insertions(+), 69 deletions(-) diff --git a/package/gitea/source/models/activities/user_heatmap.go b/package/gitea/source/models/activities/user_heatmap.go index e24d44c5..f714b4f6 100644 --- a/package/gitea/source/models/activities/user_heatmap.go +++ b/package/gitea/source/models/activities/user_heatmap.go @@ -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) diff --git a/package/gitea/source/models/activities/user_heatmap_test.go b/package/gitea/source/models/activities/user_heatmap_test.go index 66087325..8378714b 100644 --- a/package/gitea/source/models/activities/user_heatmap_test.go +++ b/package/gitea/source/models/activities/user_heatmap_test.go @@ -4,19 +4,37 @@ 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" "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/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, 3, 1, "other-author-ignored", 1603010700) + testCases := []struct { desc string userID int64 @@ -25,73 +43,146 @@ 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 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() - for _, tc := range testCases { - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.userID}) + 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) - var doer *user_model.User - if tc.doerID != 0 { - doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID}) - } + 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}) - // 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) + heatmap, err := activities_model.GetUserHeatmapDataByOrgTeam(t.Context(), org, team, doer) + require.NoError(t, err) + assert.Equal(t, 1, countHeatmapContributions(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) - - // Test JSON rendering - jsonData, err := json.Marshal(heatmap) - assert.NoError(t, err) - assert.JSONEq(t, tc.JSONResult, string(jsonData)) - } + 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) + jsonData, err := json.Marshal(heatmap) + require.NoError(t, err) + assert.JSONEq(t, `[{"timestamp":1603009800,"contributions":2},{"timestamp":1603010700,"contributions":1}]`, string(jsonData)) + + 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 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 }