Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
974f890
Use commit author date for heatmap instead of push date
fjamesprice Jan 28, 2026
6ce5dba
Group commits by date for accurate heatmap display
fjamesprice Jan 28, 2026
113ff5e
Address review feedback: fix date filter, deduplicate, sort, add test
fjamesprice Jan 30, 2026
815d964
Fix feed test expectations for new action fixture
fjamesprice Jan 30, 2026
bccce90
Fix integration test for new heatmap fixture
fjamesprice Jan 30, 2026
7120778
Merge branch 'main' into feature/heatmap-commit-dates
silverwind Feb 12, 2026
94e70ea
Use auxiliary table for heatmap commit dates to fix feeds and perform…
fjamesprice Feb 12, 2026
96aff42
Fix linting issues in heatmap auxiliary table implementation
fjamesprice Feb 12, 2026
d2519b1
Fix integration test expectations and gofumpt formatting
fjamesprice Feb 12, 2026
2628038
Remove unnecessary whitespace changes in Action struct
fjamesprice Feb 13, 2026
4b67e43
Fix struct field alignment formatting
fjamesprice Feb 13, 2026
832e032
Remove struct field alignment to fix gofmt
fjamesprice Feb 13, 2026
c05b452
Fix struct formatting to match gofmt style
fjamesprice Feb 13, 2026
040879f
Fix critical bugs in auxiliary table implementation
fjamesprice Feb 14, 2026
f299e1d
Remove cutoff filter from backfill migration
fjamesprice Feb 14, 2026
23f6f62
Deduplicate commit date construction and filter invalid timestamps
fjamesprice Feb 14, 2026
e79abec
Decouple heatmap from action table with user_heatmap_commit
fjamesprice Feb 14, 2026
c051b6b
Fix gofmt and test fixtures for decoupled heatmap
fjamesprice Feb 14, 2026
14c8c95
Fix gofmt spacing in heatmap query string concatenation
fjamesprice Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions models/activities/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,10 @@ func DeleteOldActions(ctx context.Context, olderThan time.Duration) (err error)
return nil
}

_, err = db.GetEngine(ctx).Where("created_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Action{})
e := db.GetEngine(ctx)
cutoff := time.Now().Add(-olderThan).Unix()

_, err = e.Where("created_unix < ?", cutoff).Delete(&Action{})
return err
}

Expand All @@ -582,9 +585,12 @@ func DeleteIssueActions(ctx context.Context, repoID, issueID, issueIndex int64)
return err
} else if len(commentIDs) == 0 {
break
} else if _, err = db.GetEngine(ctx).In("comment_id", commentIDs).Delete(&Action{}); err != nil {
}

if _, err = e.In("comment_id", commentIDs).Delete(&Action{}); err != nil {
return err
}

lastCommentID = commentIDs[len(commentIDs)-1]
}

Expand Down
59 changes: 59 additions & 0 deletions models/activities/action_commit_date.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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"
)

// UserHeatmapCommit stores an individual commit's author timestamp for heatmap display.
// Decoupled from the action table — keyed by user and repo instead of action ID.
type UserHeatmapCommit struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"INDEX"`
RepoID int64 `xorm:"INDEX"`
CommitSha1 string `xorm:"VARCHAR(64)"`
CommitTimestamp timeutil.TimeStamp `xorm:"INDEX"`
}

func init() {
db.RegisterModel(new(UserHeatmapCommit))
}

// InsertUserHeatmapCommits inserts commit date records for heatmap display,
// filtering out commits with zero or negative timestamps.
func InsertUserHeatmapCommits(ctx context.Context, userID, repoID int64, commits []UserHeatmapCommit) error {
if len(commits) == 0 {
return nil
}

records := make([]*UserHeatmapCommit, 0, len(commits))
for i := range commits {
if commits[i].CommitTimestamp <= 0 {
continue
}
records = append(records, &UserHeatmapCommit{
UserID: userID,
RepoID: repoID,
CommitSha1: commits[i].CommitSha1,
CommitTimestamp: commits[i].CommitTimestamp,
})
}

if len(records) == 0 {
return nil
}

_, err := db.GetEngine(ctx).Insert(&records)
return err
}

// DeleteUserHeatmapCommitsByRepo removes all heatmap commit records for a repo.
func DeleteUserHeatmapCommitsByRepo(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(UserHeatmapCommit))
return err
}
44 changes: 26 additions & 18 deletions models/activities/user_heatmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"

"xorm.io/builder"
)

// UserHeatmapData represents the data needed to create a heatmap
Expand Down Expand Up @@ -38,35 +41,40 @@ 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 := "commit_timestamp / 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 = "commit_timestamp 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(),
})
if err != nil {
return nil, err
cutoff := timeutil.TimeStampNow() - (366+7)*86400 // (366+7) days to include the first week for the heatmap

cond := builder.NewCond()
cond = cond.And(builder.Eq{"user_id": user.ID})
cond = cond.And(builder.Gt{"commit_timestamp": cutoff})

// Filter by accessible repos for the viewer
if doer == nil || !doer.IsAdmin {
cond = cond.And(builder.In("repo_id", repo_model.AccessibleRepoIDsQuery(doer)))
}

// Filter by team repos if applicable
if team != nil {
env := repo_model.AccessibleTeamReposEnv(organization.OrgFromUser(user), team)
teamRepoIDs, err := env.RepoIDs(ctx)
if err != nil {
return nil, err
}
cond = cond.And(builder.In("repo_id", teamRepoIDs))
}

return hdata, db.GetEngine(ctx).
Select(groupBy+" AS timestamp, count(user_id) as contributions").
Table("action").
Table("user_heatmap_commit").
Select(groupBy + " AS timestamp, count(*) as contributions").
Where(cond).
And("created_unix > ?", timeutil.TimeStampNow()-(366+7)*86400). // (366+7) days to include the first week for the heatmap
GroupBy(groupByName).
OrderBy("timestamp").
Find(&hdata)
Expand Down
18 changes: 10 additions & 8 deletions models/activities/user_heatmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
JSONResult string
}{
{
"self looks at action in private repo",
2, 2, 1, `[{"timestamp":1603227600,"contributions":1}]`,
"self looks at action in private repo (includes commits from user_heatmap_commit)",
2, 2, 3, `[{"timestamp":1602622800,"contributions":1},{"timestamp":1603227600,"contributions":2}]`,
},
{
"admin looks at action in private repo",
2, 1, 1, `[{"timestamp":1603227600,"contributions":1}]`,
"admin looks at action in private repo (includes commits from user_heatmap_commit)",
2, 1, 3, `[{"timestamp":1602622800,"contributions":1},{"timestamp":1603227600,"contributions":2}]`,
},
{
"other user looks at action in private repo",
Expand Down Expand Up @@ -68,8 +68,10 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
}

// get the action for comparison
actions, count, err := activities_model.GetFeeds(t.Context(), activities_model.GetFeedsOptions{
// With auxiliary table approach, feed entries (actions) and heatmap contributions
// are decoupled - one push action can have multiple commit dates in the heatmap.
// We no longer compare action count to contribution count.
_, _, err := activities_model.GetFeeds(t.Context(), activities_model.GetFeedsOptions{
RequestedUser: user,
Actor: doer,
IncludePrivate: true,
Expand All @@ -85,8 +87,8 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
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))
// Note: With the auxiliary table approach, one action (push) can have multiple commits,
// so total contributions can be >= number of actions. We only verify the expected count.
assert.Equal(t, tc.CountResult, contributions, "testcase '%s'", tc.desc)

// Test JSON rendering
Expand Down
8 changes: 8 additions & 0 deletions models/fixtures/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,11 @@
is_private: false
created_unix: 1680454039
content: '4|' # issueId 5

- id: 10
user_id: 2
op_type: 5 # commit repo (push)
act_user_id: 2
repo_id: 2 # private
is_private: true
created_unix: 1603228283 # Oct 20, 2020 (push date)
57 changes: 57 additions & 0 deletions models/fixtures/user_heatmap_commit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# User heatmap commit records for heatmap testing
# These store individual commit timestamps per user/repo for accurate heatmap display

# For user 2 repo 2: push with 3 commits (2 unique 900s buckets)
# Bucket 1602622800: 1 commit, Bucket 1603227600: 2 commits
-
id: 1
user_id: 2
repo_id: 2
commit_sha1: "abcdef1234567890abcdef1234567890abcdef12"
commit_timestamp: 1602622800

-
id: 2
user_id: 2
repo_id: 2
commit_sha1: "1234567890abcdef1234567890abcdef12345678"
commit_timestamp: 1603227600

-
id: 3
user_id: 2
repo_id: 2
commit_sha1: "fedcba0987654321fedcba0987654321fedcba09"
commit_timestamp: 1603228283

# For user 16 repo 22: 1 commit (collaborator in private repo)
# Bucket 1603267200: 1 commit
-
id: 4
user_id: 16
repo_id: 22
commit_sha1: "aabb112233445566778899aabb112233445566cc"
commit_timestamp: 1603267200

# For user 10: commits across repos 6 (private), 7 (private), 8 (public)
# Bucket 1603009800: 1 commit, Bucket 1603010700: 2 commits
-
id: 5
user_id: 10
repo_id: 6
commit_sha1: "1111111111111111111111111111111111111111"
commit_timestamp: 1603010100

-
id: 6
user_id: 10
repo_id: 7
commit_sha1: "2222222222222222222222222222222222222222"
commit_timestamp: 1603011300

-
id: 7
user_id: 10
repo_id: 8
commit_sha1: "3333333333333333333333333333333333333333"
commit_timestamp: 1603011540
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ func prepareMigrationTasks() []*migration {
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness),
newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments),
newMigration(326, "Create user_heatmap_commit table", v1_26.CreateUserHeatmapCommitTable),
newMigration(327, "Backfill user_heatmap_commit from existing push actions", v1_26.BackfillUserHeatmapCommits),
}
return preparedMigrations
}
Expand Down
18 changes: 18 additions & 0 deletions models/migrations/v1_26/v326.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import "xorm.io/xorm"

type UserHeatmapCommit struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"INDEX"`
RepoID int64 `xorm:"INDEX"`
CommitSha1 string `xorm:"VARCHAR(64)"`
CommitTimestamp int64 `xorm:"INDEX"`
}

func CreateUserHeatmapCommitTable(x *xorm.Engine) error {
return x.Sync(new(UserHeatmapCommit))
}
94 changes: 94 additions & 0 deletions models/migrations/v1_26/v327.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import (
"time"

"code.gitea.io/gitea/modules/json"

"xorm.io/xorm"
)

// pushCommitsMigration represents commits in a push action (simplified for migration)
type pushCommitsMigration struct {
Commits []*pushCommitMigration `json:"commits"`
}

// pushCommitMigration represents a commit in a push (simplified for migration)
type pushCommitMigration struct {
Sha1 string `json:"sha1"`
Timestamp time.Time `json:"timestamp"`
}

func BackfillUserHeatmapCommits(x *xorm.Engine) error {
const batchSize = 100
const actionCommitRepo = 5 // ActionCommitRepo operation type
const actionMirrorSyncPush = 16 // ActionMirrorSyncPush operation type

// Process actions in batches
var lastID int64
for {
type ActionRow struct {
ID int64 `xorm:"id"`
ActUserID int64 `xorm:"act_user_id"`
RepoID int64 `xorm:"repo_id"`
Content string `xorm:"content"`
}

actions := make([]*ActionRow, 0, batchSize)
err := x.Table("action").
Select("id, act_user_id, repo_id, content").
Where("op_type = ? OR op_type = ?", actionCommitRepo, actionMirrorSyncPush).
And("id > ?", lastID).
And("content != ''").
OrderBy("id").
Limit(batchSize).
Find(&actions)
if err != nil {
return err
}

if len(actions) == 0 {
break
}

for _, action := range actions {
var pushCommits pushCommitsMigration
if err := json.Unmarshal([]byte(action.Content), &pushCommits); err != nil {
// Skip actions with invalid JSON
continue
}

if len(pushCommits.Commits) == 0 {
continue
}

records := make([]map[string]any, 0, len(pushCommits.Commits))
for _, commit := range pushCommits.Commits {
timestamp := commit.Timestamp.Unix()
if timestamp <= 0 {
continue
}

records = append(records, map[string]any{
"user_id": action.ActUserID,
"repo_id": action.RepoID,
"commit_sha1": commit.Sha1,
"commit_timestamp": timestamp,
})
}

if len(records) > 0 {
if _, err := x.Table("user_heatmap_commit").Insert(&records); err != nil {
return err
}
}
}

lastID = actions[len(actions)-1].ID
}

return nil
}
Loading
Loading