diff --git a/package/gitea/source/models/activities/heatmap_contribution.go b/package/gitea/source/models/activities/heatmap_contribution.go new file mode 100644 index 00000000..0c91fafb --- /dev/null +++ b/package/gitea/source/models/activities/heatmap_contribution.go @@ -0,0 +1,73 @@ +// 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"` +} + +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)) +} diff --git a/package/gitea/source/models/activities/heatmap_contribution_test.go b/package/gitea/source/models/activities/heatmap_contribution_test.go new file mode 100644 index 00000000..28c62d3f --- /dev/null +++ b/package/gitea/source/models/activities/heatmap_contribution_test.go @@ -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) +} diff --git a/package/gitea/source/models/migrations/migrations.go b/package/gitea/source/models/migrations/migrations.go index db74ff78..fefae40d 100644 --- a/package/gitea/source/models/migrations/migrations.go +++ b/package/gitea/source/models/migrations/migrations.go @@ -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 } diff --git a/package/gitea/source/models/migrations/v1_26/v331.go b/package/gitea/source/models/migrations/v1_26/v331.go new file mode 100644 index 00000000..be96ee0c --- /dev/null +++ b/package/gitea/source/models/migrations/v1_26/v331.go @@ -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 +} diff --git a/package/gitea/source/models/user/setting.go b/package/gitea/source/models/user/setting.go index a16fc86e..f91d7d4a 100644 --- a/package/gitea/source/models/user/setting.go +++ b/package/gitea/source/models/user/setting.go @@ -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)) +}