diff --git a/models/activities/notification.go b/models/activities/notification.go index 8a830c5aa26a8..45117bbe2bcfe 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -6,16 +6,20 @@ package activities import ( "context" "fmt" + "html/template" "net/url" "strconv" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "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/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" "xorm.io/xorm/schemas" @@ -46,6 +50,8 @@ const ( NotificationSourceCommit // NotificationSourceRepository is a notification for a repository NotificationSourceRepository + // NotificationSourceRelease is a notification for a release + NotificationSourceRelease ) // Notification represents a notification @@ -60,6 +66,8 @@ type Notification struct { IssueID int64 `xorm:"NOT NULL"` CommitID string CommentID int64 + ReleaseID int64 + UniqueKey *string `xorm:"VARCHAR(255) DEFAULT NULL"` UpdatedBy int64 `xorm:"NOT NULL"` @@ -67,6 +75,8 @@ type Notification struct { Repository *repo_model.Repository `xorm:"-"` Comment *issues_model.Comment `xorm:"-"` User *user_model.User `xorm:"-"` + Release *repo_model.Release `xorm:"-"` + Commit *git.Commit `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"` @@ -74,12 +84,11 @@ type Notification struct { // TableIndices implements xorm's TableIndices interface func (n *Notification) TableIndices() []*schemas.Index { - indices := make([]*schemas.Index, 0, 8) + indices := make([]*schemas.Index, 0, 6) usuuIndex := schemas.NewIndex("u_s_uu", schemas.IndexType) usuuIndex.AddColumn("user_id", "status", "updated_unix") indices = append(indices, usuuIndex) - // Add the individual indices that were previously defined in struct tags userIDIndex := schemas.NewIndex("idx_notification_user_id", schemas.IndexType) userIDIndex.AddColumn("user_id") indices = append(indices, userIDIndex) @@ -92,22 +101,14 @@ func (n *Notification) TableIndices() []*schemas.Index { statusIndex.AddColumn("status") indices = append(indices, statusIndex) - sourceIndex := schemas.NewIndex("idx_notification_source", schemas.IndexType) - sourceIndex.AddColumn("source") - indices = append(indices, sourceIndex) - - issueIDIndex := schemas.NewIndex("idx_notification_issue_id", schemas.IndexType) - issueIDIndex.AddColumn("issue_id") - indices = append(indices, issueIDIndex) - - commitIDIndex := schemas.NewIndex("idx_notification_commit_id", schemas.IndexType) - commitIDIndex.AddColumn("commit_id") - indices = append(indices, commitIDIndex) - updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType) updatedByIndex.AddColumn("updated_by") indices = append(indices, updatedByIndex) + uniqueNotificationKey := schemas.NewIndex("unique_notification_key", schemas.UniqueType) + uniqueNotificationKey.AddColumn("user_id", "unique_key") + indices = append(indices, uniqueNotificationKey) + return indices } @@ -115,46 +116,96 @@ func init() { db.RegisterModel(new(Notification)) } -// CreateRepoTransferNotification creates notification for the user a repository was transferred to -func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error { - return db.WithTx(ctx, func(ctx context.Context) error { - var notify []*Notification +func uniqueKeyForIssueNotification(issueID int64, isPull bool) string { + return fmt.Sprintf("%s-%d", util.Iif(isPull, "pull", "issue"), issueID) +} - if newOwner.IsOrganization() { - users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID) - if err != nil || len(users) == 0 { - return err - } - for i := range users { - notify = append(notify, &Notification{ - UserID: i, - RepoID: repo.ID, - Status: NotificationStatusUnread, - UpdatedBy: doer.ID, - Source: NotificationSourceRepository, - }) - } - } else { - notify = []*Notification{{ - UserID: newOwner.ID, - RepoID: repo.ID, - Status: NotificationStatusUnread, - UpdatedBy: doer.ID, - Source: NotificationSourceRepository, - }} - } +func uniqueKeyForCommitNotification(repoID int64, commitID string) string { + return fmt.Sprintf("commit-%d-%s", repoID, commitID) +} + +// UniqueKeyForReleaseNotification returns the unique_key value for a release notification. +func UniqueKeyForReleaseNotification(releaseID int64) string { + return fmt.Sprintf("release-%d", releaseID) +} + +// CreateRepoTransferNotification creates a notification for the user a repository was transferred to +func CreateRepoTransferNotification(ctx context.Context, doerID, repoID, receiverID int64) error { + notify := &Notification{ + UserID: receiverID, + RepoID: repoID, + Status: NotificationStatusUnread, + UpdatedBy: doerID, + Source: NotificationSourceRepository, + } + return db.Insert(ctx, notify) +} + +func CreateCommitNotifications(ctx context.Context, doerID, repoID int64, commitID string, receiverID int64) error { + uniqueKey := uniqueKeyForCommitNotification(repoID, commitID) + notification := new(Notification) + if _, err := db.GetEngine(ctx). + Where("user_id = ?", receiverID). + And("unique_key = ?", uniqueKey). + Get(notification); err != nil { + return err + } + if notification.ID > 0 { + notification.Status = NotificationStatusUnread + notification.UpdatedBy = doerID + _, err := db.GetEngine(ctx).ID(notification.ID).Cols("status", "updated_by").Update(notification) + return err + } + + notification = &Notification{ + Source: NotificationSourceCommit, + UserID: receiverID, + RepoID: repoID, + CommitID: commitID, + UniqueKey: &uniqueKey, + Status: NotificationStatusUnread, + UpdatedBy: doerID, + } + return db.Insert(ctx, notification) +} + +func CreateOrUpdateReleaseNotifications(ctx context.Context, doerID, repoID, releaseID, receiverID int64) error { + uniqueKey := UniqueKeyForReleaseNotification(releaseID) + notification := new(Notification) + if _, err := db.GetEngine(ctx). + Where("user_id = ?", receiverID). + And("unique_key = ?", uniqueKey). + Get(notification); err != nil { + return err + } + if notification.ID > 0 { + notification.Status = NotificationStatusUnread + notification.UpdatedBy = doerID + _, err := db.GetEngine(ctx).ID(notification.ID).Cols("status", "updated_by").Update(notification) + return err + } - return db.Insert(ctx, notify) - }) + notification = &Notification{ + Source: NotificationSourceRelease, + RepoID: repoID, + UserID: receiverID, + Status: NotificationStatusUnread, + ReleaseID: releaseID, + UniqueKey: &uniqueKey, + UpdatedBy: doerID, + } + return db.Insert(ctx, notification) } func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error { + uniqueKey := uniqueKeyForIssueNotification(issue.ID, issue.IsPull) notification := &Notification{ UserID: userID, RepoID: issue.RepoID, Status: NotificationStatusUnread, IssueID: issue.ID, CommentID: commentID, + UniqueKey: &uniqueKey, UpdatedBy: updatedByID, } @@ -191,10 +242,16 @@ func updateIssueNotification(ctx context.Context, userID, issueID, commentID, up // GetIssueNotification return the notification about an issue func GetIssueNotification(ctx context.Context, userID, issueID int64) (*Notification, error) { + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + return nil, err + } + + uniqueKey := uniqueKeyForIssueNotification(issueID, issue.IsPull) notification := new(Notification) - _, err := db.GetEngine(ctx). + _, err = db.GetEngine(ctx). Where("user_id = ?", userID). - And("issue_id = ?", issueID). + And("unique_key = ?", uniqueKey). Get(notification) return notification, err } @@ -213,6 +270,12 @@ func (n *Notification) LoadAttributes(ctx context.Context) (err error) { if err = n.loadComment(ctx); err != nil { return err } + if err = n.loadCommit(ctx); err != nil { + return err + } + if err = n.loadRelease(ctx); err != nil { + return err + } return err } @@ -253,6 +316,41 @@ func (n *Notification) loadComment(ctx context.Context) (err error) { return nil } +func (n *Notification) loadCommit(ctx context.Context) (err error) { + if n.Source != NotificationSourceCommit || n.CommitID == "" || n.Commit != nil { + return nil + } + + if n.Repository == nil { + _ = n.loadRepo(ctx) + if n.Repository == nil { + return fmt.Errorf("repository not found for notification %d", n.ID) + } + } + + repo, err := gitrepo.OpenRepository(ctx, n.Repository) + if err != nil { + return fmt.Errorf("OpenRepository [%d]: %w", n.Repository.ID, err) + } + defer repo.Close() + + n.Commit, err = repo.GetCommit(n.CommitID) + if err != nil { + return fmt.Errorf("Notification[%d]: Failed to get repo for commit %s: %v", n.ID, n.CommitID, err) + } + return nil +} + +func (n *Notification) loadRelease(ctx context.Context) (err error) { + if n.Release == nil && n.ReleaseID != 0 { + n.Release, err = repo_model.GetReleaseByID(ctx, n.ReleaseID) + if err != nil { + return fmt.Errorf("GetReleaseByID [%d]: %w", n.ReleaseID, err) + } + } + return nil +} + func (n *Notification) loadUser(ctx context.Context) (err error) { if n.User == nil { n.User, err = user_model.GetUserByID(ctx, n.UserID) @@ -285,6 +383,11 @@ func (n *Notification) HTMLURL(ctx context.Context) string { return n.Repository.HTMLURL(ctx) + "/commit/" + url.PathEscape(n.CommitID) case NotificationSourceRepository: return n.Repository.HTMLURL(ctx) + case NotificationSourceRelease: + if n.Release == nil { + return "" + } + return n.Release.HTMLURL() } return "" } @@ -301,25 +404,36 @@ func (n *Notification) Link(ctx context.Context) string { return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID) case NotificationSourceRepository: return n.Repository.Link() + case NotificationSourceRelease: + if n.Release == nil { + return "" + } + return n.Release.Link() } return "" } +func (n *Notification) IconHTML(ctx context.Context) template.HTML { + switch n.Source { + case NotificationSourceIssue, NotificationSourcePullRequest: + // n.Issue should be loaded before calling this method + return n.Issue.IconHTML(ctx) + case NotificationSourceCommit: + return svg.RenderHTML("octicon-commit", 16, "tw-text-grey") + case NotificationSourceRepository: + return svg.RenderHTML("octicon-repo", 16, "tw-text-grey") + case NotificationSourceRelease: + return svg.RenderHTML("octicon-tag", 16, "tw-text-grey") + default: + return "" + } +} + // APIURL formats a URL-string to the notification func (n *Notification) APIURL() string { return setting.AppURL + "api/v1/notifications/threads/" + strconv.FormatInt(n.ID, 10) } -func notificationExists(notifications []*Notification, issueID, userID int64) bool { - for _, notification := range notifications { - if notification.IssueID == issueID && notification.UserID == userID { - return true - } - } - - return false -} - // UserIDCount is a simple coalition of UserID and Count type UserIDCount struct { UserID int64 @@ -373,6 +487,26 @@ func SetRepoReadBy(ctx context.Context, userID, repoID int64) error { return err } +// SetReleaseReadBy sets issue to be read by given user. +func SetReleaseReadBy(ctx context.Context, releaseID, userID int64) error { + _, err := db.GetEngine(ctx).Where(builder.Eq{ + "user_id": userID, + "status": NotificationStatusUnread, + "unique_key": UniqueKeyForReleaseNotification(releaseID), + }).Cols("status").Update(&Notification{Status: NotificationStatusRead}) + return err +} + +// SetCommitReadBy sets commit notification to be read by given user. +func SetCommitReadBy(ctx context.Context, repoID, userID int64, commitID string) error { + _, err := db.GetEngine(ctx).Where(builder.Eq{ + "user_id": userID, + "status": NotificationStatusUnread, + "unique_key": uniqueKeyForCommitNotification(repoID, commitID), + }).Cols("status").Update(&Notification{Status: NotificationStatusRead}) + return err +} + // SetNotificationStatus change the notification status func SetNotificationStatus(ctx context.Context, notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) { notification, err := GetNotificationByID(ctx, notificationID) @@ -385,7 +519,6 @@ func SetNotificationStatus(ctx context.Context, notificationID int64, user *user } notification.Status = status - _, err = db.GetEngine(ctx).ID(notificationID).Cols("status").Update(notification) return notification, err } diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go index d38758bc41aed..d7836268c5a38 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -13,6 +13,8 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" @@ -24,7 +26,7 @@ type FindNotificationOptions struct { db.ListOptions UserID int64 RepoID int64 - IssueID int64 + UniqueKey string Status []NotificationStatus Source []NotificationSource UpdatedAfterUnix int64 @@ -40,8 +42,8 @@ func (opts FindNotificationOptions) ToConds() builder.Cond { if opts.RepoID != 0 { cond = cond.And(builder.Eq{"notification.repo_id": opts.RepoID}) } - if opts.IssueID != 0 { - cond = cond.And(builder.Eq{"notification.issue_id": opts.IssueID}) + if opts.UniqueKey != "" { + cond = cond.And(builder.Eq{"notification.unique_key": opts.UniqueKey}) } if len(opts.Status) > 0 { if len(opts.Status) == 1 { @@ -78,13 +80,6 @@ func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error { // init var toNotify container.Set[int64] - notifications, err := db.Find[Notification](ctx, FindNotificationOptions{ - IssueID: issueID, - }) - if err != nil { - return err - } - issue, err := issues_model.GetIssueByID(ctx, issueID) if err != nil { return err @@ -148,7 +143,11 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n continue } - if notificationExists(notifications, issue.ID, userID) { + existing, err := GetIssueNotification(ctx, userID, issue.ID) + if err != nil { + return err + } + if existing.ID > 0 { if err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); err != nil { return err } @@ -166,18 +165,31 @@ type NotificationList []*Notification // LoadAttributes load Repo Issue User and Comment if not loaded func (nl NotificationList) LoadAttributes(ctx context.Context) error { - if _, _, err := nl.LoadRepos(ctx); err != nil { + repos, _, err := nl.LoadRepos(ctx) + if err != nil { + return err + } + if err := repos.LoadAttributes(ctx); err != nil { return err } if _, err := nl.LoadIssues(ctx); err != nil { return err } + if err = nl.LoadIssuePullRequests(ctx); err != nil { + return err + } if _, err := nl.LoadUsers(ctx); err != nil { return err } if _, err := nl.LoadComments(ctx); err != nil { return err } + if _, err = nl.LoadCommits(ctx); err != nil { + return err + } + if _, err := nl.LoadReleases(ctx); err != nil { + return err + } return nil } @@ -450,6 +462,89 @@ func (nl NotificationList) LoadComments(ctx context.Context) ([]int, error) { return failures, nil } +func (nl NotificationList) getPendingReleaseIDs() []int64 { + ids := make(container.Set[int64], len(nl)) + for _, notification := range nl { + if notification.Release != nil { + continue + } + if notification.ReleaseID > 0 { + ids.Add(notification.ReleaseID) + } + } + return ids.Values() +} + +func (nl NotificationList) LoadReleases(ctx context.Context) ([]int, error) { + if len(nl) == 0 { + return []int{}, nil + } + + releaseIDs := nl.getPendingReleaseIDs() + releases := make(map[int64]*repo_model.Release, len(releaseIDs)) + if err := db.GetEngine(ctx).In("id", releaseIDs).Find(&releases); err != nil { + return nil, err + } + + failures := []int{} + for i, notification := range nl { + if notification.ReleaseID > 0 && notification.Release == nil { + if releases[notification.ReleaseID] == nil { + log.Error("Notification[%d]: ReleaseID[%d] failed to load", notification.ID, notification.ReleaseID) + failures = append(failures, i) + continue + } + notification.Release = releases[notification.ReleaseID] + notification.Release.Repo = notification.Repository + } + } + return failures, nil +} + +func (nl NotificationList) LoadCommits(ctx context.Context) ([]int, error) { + if len(nl) == 0 { + return []int{}, nil + } + + _, _, err := nl.LoadRepos(ctx) + if err != nil { + return nil, err + } + + failures := []int{} + repos := make(map[int64]*git.Repository, len(nl)) + for i, n := range nl { + if n.Source != NotificationSourceCommit || n.CommitID == "" { + continue + } + + repo, ok := repos[n.RepoID] + if !ok { + repo, err = gitrepo.OpenRepository(ctx, n.Repository) + if err != nil { + log.Error("Notification[%d]: Failed to get repo for commit %s: %v", n.ID, n.CommitID, err) + failures = append(failures, i) + continue + } + repos[n.RepoID] = repo + } + n.Commit, err = repo.GetCommit(n.CommitID) + if err != nil { + log.Error("Notification[%d]: Failed to get repo for commit %s: %v", n.ID, n.CommitID, err) + failures = append(failures, i) + continue + } + } + + for _, repo := range repos { + if err := repo.Close(); err != nil { + log.Error("Failed to close repository: %v", err) + } + } + + return failures, nil +} + // LoadIssuePullRequests loads all issues' pull requests if possible func (nl NotificationList) LoadIssuePullRequests(ctx context.Context) error { issues := make(map[int64]*issues_model.Issue, len(nl)) diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go index 6f2253c815ded..588cdb72bc159 100644 --- a/models/activities/notification_test.go +++ b/models/activities/notification_test.go @@ -138,3 +138,121 @@ func TestSetIssueReadBy(t *testing.T) { assert.NoError(t, err) assert.Equal(t, activities_model.NotificationStatusRead, nt.Status) } + +func TestGetIssueNotificationUsesUniqueKeyForPullRequests(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, IsPull: true}) + assert.NoError(t, activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 1, 4)) + + nt, err := activities_model.GetIssueNotification(t.Context(), 4, issue.ID) + assert.NoError(t, err) + assert.Equal(t, issue.ID, nt.IssueID) + assert.Equal(t, activities_model.NotificationSourcePullRequest, nt.Source) +} + +func TestCreateCommitNotificationsDeduplicatesByRepoAndCommit(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const receiverID = int64(2) + const commitID = "0123456789abcdef" + const firstRepoID = int64(1) + const secondRepoID = int64(2) + + assert.NoError(t, activities_model.CreateCommitNotifications(t.Context(), 1, firstRepoID, commitID, receiverID)) + assert.NoError(t, activities_model.CreateCommitNotifications(t.Context(), 3, firstRepoID, commitID, receiverID)) + assert.NoError(t, activities_model.CreateCommitNotifications(t.Context(), 4, secondRepoID, commitID, receiverID)) + + notfs, err := db.Find[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{ + UserID: receiverID, + Source: []activities_model.NotificationSource{activities_model.NotificationSourceCommit}, + }) + assert.NoError(t, err) + if assert.Len(t, notfs, 2) { + assert.Equal(t, commitID, notfs[0].CommitID) + assert.Equal(t, commitID, notfs[1].CommitID) + assert.ElementsMatch(t, []int64{firstRepoID, secondRepoID}, []int64{notfs[0].RepoID, notfs[1].RepoID}) + + var firstRepoNotification *activities_model.Notification + for _, notf := range notfs { + if notf.RepoID == firstRepoID { + firstRepoNotification = notf + break + } + } + if assert.NotNil(t, firstRepoNotification) { + assert.Equal(t, activities_model.NotificationStatusUnread, firstRepoNotification.Status) + assert.EqualValues(t, 3, firstRepoNotification.UpdatedBy) + } + } +} + +func TestCreateOrUpdateReleaseNotificationsDeduplicatesByRelease(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const receiverID = int64(2) + const repoID = int64(1) + const releaseID = int64(1) + + assert.NoError(t, activities_model.CreateOrUpdateReleaseNotifications(t.Context(), 1, repoID, releaseID, receiverID)) + assert.NoError(t, activities_model.CreateOrUpdateReleaseNotifications(t.Context(), 3, repoID, releaseID, receiverID)) + + notfs, err := db.Find[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{ + UserID: receiverID, + UniqueKey: activities_model.UniqueKeyForReleaseNotification(releaseID), + Source: []activities_model.NotificationSource{activities_model.NotificationSourceRelease}, + }) + assert.NoError(t, err) + if assert.Len(t, notfs, 1) { + assert.Equal(t, activities_model.NotificationStatusUnread, notfs[0].Status) + assert.EqualValues(t, 3, notfs[0].UpdatedBy) + assert.Equal(t, releaseID, notfs[0].ReleaseID) + } +} + +func TestSetCommitReadByScopesToRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const receiverID = int64(2) + const commitID = "fedcba9876543210" + const firstRepoID = int64(1) + const secondRepoID = int64(2) + + assert.NoError(t, activities_model.CreateCommitNotifications(t.Context(), 1, firstRepoID, commitID, receiverID)) + assert.NoError(t, activities_model.CreateCommitNotifications(t.Context(), 1, secondRepoID, commitID, receiverID)) + assert.NoError(t, activities_model.SetCommitReadBy(t.Context(), firstRepoID, receiverID, commitID)) + + firstRepoNotification := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ + UserID: receiverID, + RepoID: firstRepoID, + Source: activities_model.NotificationSourceCommit, + CommitID: commitID, + }) + secondRepoNotification := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ + UserID: receiverID, + RepoID: secondRepoID, + Source: activities_model.NotificationSourceCommit, + CommitID: commitID, + }) + + assert.Equal(t, activities_model.NotificationStatusRead, firstRepoNotification.Status) + assert.Equal(t, activities_model.NotificationStatusUnread, secondRepoNotification.Status) +} + +func TestCreateRepoTransferNotificationRemainsAppendOnly(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const receiverID = int64(2) + const repoID = int64(1) + + assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), 1, repoID, receiverID)) + assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), 3, repoID, receiverID)) + + notfs, err := db.Find[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{ + UserID: receiverID, + RepoID: repoID, + Source: []activities_model.NotificationSource{activities_model.NotificationSourceRepository}, + }) + assert.NoError(t, err) + assert.Len(t, notfs, 2) +} diff --git a/models/fixtures/notification.yml b/models/fixtures/notification.yml index bd279d4bb284c..abb7050cb577a 100644 --- a/models/fixtures/notification.yml +++ b/models/fixtures/notification.yml @@ -6,6 +6,7 @@ source: 1 # issue updated_by: 2 issue_id: 1 + unique_key: issue-1 created_unix: 946684800 updated_unix: 946684820 @@ -17,6 +18,7 @@ source: 1 # issue updated_by: 1 issue_id: 2 + unique_key: issue-2 created_unix: 946685800 updated_unix: 946685820 @@ -28,6 +30,7 @@ source: 1 # issue updated_by: 1 issue_id: 3 + unique_key: issue-3 created_unix: 946686800 updated_unix: 946686800 @@ -39,6 +42,7 @@ source: 1 # issue updated_by: 1 issue_id: 5 + unique_key: issue-5 created_unix: 946687800 updated_unix: 946687800 @@ -50,5 +54,6 @@ source: 1 # issue updated_by: 5 issue_id: 4 + unique_key: issue-4 created_unix: 946688800 updated_unix: 946688820 diff --git a/models/issues/issue.go b/models/issues/issue.go index 655cdebdfc6b9..96558b46ca942 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -442,6 +443,31 @@ func (issue *Issue) PatchURL() string { return "" } +// IconHTML returns the HTML for the issue icon. +// the logic should be kept the same as getIssueIcon/getIssueColor in TS code +func (issue *Issue) IconHTML(ctx context.Context) template.HTML { + if !issue.IsPull { + if issue.IsClosed { + return svg.RenderHTML("octicon-issue-closed", 16, "tw-text-red") + } + return svg.RenderHTML("octicon-issue-opened", 16, "tw-text-green") + } + + switch { + case issue.PullRequest == nil: // pull request should be loaded before calling this function + return template.HTML("No PullRequest") + case issue.IsClosed: + if issue.PullRequest.HasMerged { + return svg.RenderHTML("octicon-git-merge", 16, "tw-text-purple") + } + return svg.RenderHTML("octicon-git-pull-request-closed", 16, "tw-text-red") + case issue.PullRequest.IsWorkInProgress(ctx): + return svg.RenderHTML("octicon-git-pull-request-draft", 16, "tw-text-grey") + default: + return svg.RenderHTML("octicon-git-pull-request", 16, "tw-text-green") + } +} + // State returns string representation of issue status. func (issue *Issue) State() api.StateType { if issue.IsClosed { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index db74ff78d5040..b9ccb3a40e3dd 100644 --- a/models/migrations/migrations.go +++ b/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, "Add notification dedupe columns to notification table", v1_26.AddReleaseNotification), } return preparedMigrations } diff --git a/models/migrations/v1_26/v331.go b/models/migrations/v1_26/v331.go new file mode 100644 index 0000000000000..7bd74cc6e30dc --- /dev/null +++ b/models/migrations/v1_26/v331.go @@ -0,0 +1,256 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "fmt" + + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +type NotificationSourceV331 uint8 + +const ( + notificationSourceIssueV331 NotificationSourceV331 = iota + 1 + notificationSourcePullRequestV331 + notificationSourceCommitV331 + notificationSourceRepositoryV331 + notificationSourceReleaseV331 +) + +type notificationStatusV331 uint8 + +const ( + notificationStatusUnreadV331 notificationStatusV331 = iota + 1 + _ // read (unused in merge logic) + notificationStatusPinnedV331 +) + +type NotificationV331 struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + RepoID int64 `xorm:"NOT NULL"` + + Status notificationStatusV331 `xorm:"SMALLINT NOT NULL"` + Source NotificationSourceV331 `xorm:"SMALLINT NOT NULL"` + + IssueID int64 `xorm:"NOT NULL"` + CommitID string + CommentID int64 + ReleaseID int64 + UniqueKey *string `xorm:"VARCHAR(255) DEFAULT NULL"` + + UpdatedBy int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"` +} + +func (n *NotificationV331) TableName() string { + return "notification" +} + +// TableIndices implements xorm's TableIndices interface +func (n *NotificationV331) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 6) + usuuIndex := schemas.NewIndex("u_s_uu", schemas.IndexType) + usuuIndex.AddColumn("user_id", "status", "updated_unix") + indices = append(indices, usuuIndex) + + userIDIndex := schemas.NewIndex("idx_notification_user_id", schemas.IndexType) + userIDIndex.AddColumn("user_id") + indices = append(indices, userIDIndex) + + repoIDIndex := schemas.NewIndex("idx_notification_repo_id", schemas.IndexType) + repoIDIndex.AddColumn("repo_id") + indices = append(indices, repoIDIndex) + + statusIndex := schemas.NewIndex("idx_notification_status", schemas.IndexType) + statusIndex.AddColumn("status") + indices = append(indices, statusIndex) + + updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType) + updatedByIndex.AddColumn("updated_by") + indices = append(indices, updatedByIndex) + + uniqueNotificationKey := schemas.NewIndex("unique_notification_key", schemas.UniqueType) + uniqueNotificationKey.AddColumn("user_id", "unique_key") + indices = append(indices, uniqueNotificationKey) + + return indices +} + +type NotificationV331Backfill struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + RepoID int64 `xorm:"NOT NULL"` + + Status notificationStatusV331 `xorm:"SMALLINT NOT NULL"` + Source NotificationSourceV331 `xorm:"SMALLINT NOT NULL"` + + IssueID int64 `xorm:"NOT NULL"` + CommitID string + CommentID int64 + ReleaseID int64 + UniqueKey *string `xorm:"VARCHAR(255) DEFAULT NULL"` + + UpdatedBy int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"` +} + +func (n *NotificationV331Backfill) TableName() string { + return "notification" +} + +func (n *NotificationV331Backfill) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 3) + usuuIndex := schemas.NewIndex("u_s_uu", schemas.IndexType) + usuuIndex.AddColumn("user_id", "status", "updated_unix") + indices = append(indices, usuuIndex) + + repoIDIndex := schemas.NewIndex("idx_notification_repo_id", schemas.IndexType) + repoIDIndex.AddColumn("repo_id") + indices = append(indices, repoIDIndex) + + updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType) + updatedByIndex.AddColumn("updated_by") + indices = append(indices, updatedByIndex) + + return indices +} + +type notificationV331Duplicate struct { + UserID int64 + UniqueKey string + Cnt int64 +} + +func uniqueKeyV331(source NotificationSourceV331, repoID, issueID, releaseID int64, commitID string) *string { + var key string + switch source { + case notificationSourceIssueV331: + key = fmt.Sprintf("issue-%d", issueID) + case notificationSourcePullRequestV331: + key = fmt.Sprintf("pull-%d", issueID) + case notificationSourceCommitV331: + key = fmt.Sprintf("commit-%d-%s", repoID, commitID) + case notificationSourceReleaseV331: + key = fmt.Sprintf("release-%d", releaseID) + default: + return nil + } + + return &key +} + +func backfillNotificationUniqueKeyV331(x *xorm.Engine) error { + const batchSize = 50 + lastID := int64(0) + + for { + notifications := make([]*NotificationV331Backfill, 0, batchSize) + if err := x.Where("id > ?", lastID).Asc("id").Limit(batchSize).Find(¬ifications); err != nil { + return err + } + if len(notifications) == 0 { + return nil + } + + for _, notification := range notifications { + lastID = notification.ID + + uniqueKey := uniqueKeyV331( + notification.Source, + notification.RepoID, + notification.IssueID, + notification.ReleaseID, + notification.CommitID, + ) + if uniqueKey == nil { + continue + } + + if _, err := x.Exec( + "UPDATE notification SET unique_key = ? WHERE id = ?", + *uniqueKey, + notification.ID, + ); err != nil { + return err + } + } + } +} + +func mergeNotificationStatusV331(notifications []*NotificationV331Backfill) notificationStatusV331 { + mergedStatus := notifications[0].Status + for _, notification := range notifications[1:] { + switch { + case notification.Status == notificationStatusPinnedV331: + return notificationStatusPinnedV331 + case notification.Status == notificationStatusUnreadV331 && mergedStatus != notificationStatusPinnedV331: + mergedStatus = notificationStatusUnreadV331 + } + } + return mergedStatus +} + +func dedupeNotificationRowsV331(x *xorm.Engine) error { + var duplicatedNotifications []notificationV331Duplicate + if err := x.SQL(` + SELECT user_id, unique_key, COUNT(1) AS cnt + FROM notification + WHERE unique_key IS NOT NULL + GROUP BY user_id, unique_key + HAVING COUNT(1) > 1 + `).Find(&duplicatedNotifications); err != nil { + return err + } + + for _, duplicatedNotification := range duplicatedNotifications { + notifications := make([]*NotificationV331Backfill, 0, duplicatedNotification.Cnt) + if err := x.Where("user_id = ?", duplicatedNotification.UserID). + And("unique_key = ?", duplicatedNotification.UniqueKey). + Desc("updated_unix", "id"). + Find(¬ifications); err != nil { + return err + } + if len(notifications) < 2 { + continue + } + + keeper := notifications[0] + keeper.Status = mergeNotificationStatusV331(notifications) + if _, err := x.ID(keeper.ID).Cols("status").NoAutoTime().Update(keeper); err != nil { + return err + } + + idsToDelete := make([]int64, 0, len(notifications)-1) + for _, notification := range notifications[1:] { + idsToDelete = append(idsToDelete, notification.ID) + } + if _, err := x.In("id", idsToDelete).Table("notification").Delete(&NotificationV331Backfill{}); err != nil { + return err + } + } + + return nil +} + +func AddReleaseNotification(x *xorm.Engine) error { + if err := x.Sync(new(NotificationV331Backfill)); err != nil { + return err + } + if err := backfillNotificationUniqueKeyV331(x); err != nil { + return err + } + if err := dedupeNotificationRowsV331(x); err != nil { + return err + } + return x.Sync(new(NotificationV331)) +} diff --git a/models/migrations/v1_26/v331_test.go b/models/migrations/v1_26/v331_test.go new file mode 100644 index 0000000000000..06887349047a5 --- /dev/null +++ b/models/migrations/v1_26/v331_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type NotificationBefore331 struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + RepoID int64 `xorm:"NOT NULL"` + + Status uint8 `xorm:"SMALLINT NOT NULL"` + Source uint8 `xorm:"SMALLINT NOT NULL"` + + IssueID int64 `xorm:"NOT NULL"` + CommitID string + CommentID int64 + ReleaseID int64 + + UpdatedBy int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"` +} + +func (NotificationBefore331) TableName() string { + return "notification" +} + +func TestAddReleaseNotificationBackfillsNotificationDedupe(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, new(NotificationBefore331)) + defer deferable() + if x == nil || t.Failed() { + return + } + + testData := []*NotificationBefore331{ + {UserID: 1, RepoID: 1, Status: 1, Source: 1, IssueID: 42, UpdatedBy: 2}, + {UserID: 1, RepoID: 1, Status: 1, Source: 2, IssueID: 43, UpdatedBy: 2}, + {UserID: 1, RepoID: 2, Status: 1, Source: 3, CommitID: "abc123", UpdatedBy: 2}, + {UserID: 1, RepoID: 3, Status: 1, Source: 5, ReleaseID: 7, UpdatedBy: 2}, + {UserID: 1, RepoID: 4, Status: 1, Source: 4, UpdatedBy: 2}, + } + for _, data := range testData { + _, err := x.Insert(data) + require.NoError(t, err) + } + + require.NoError(t, AddReleaseNotification(x)) + + var notifications []*NotificationV331 + require.NoError(t, x.Table("notification").Asc("id").Find(¬ifications)) + require.Len(t, notifications, len(testData)) + + require.NotNil(t, notifications[0].UniqueKey) + assert.Equal(t, "issue-42", *notifications[0].UniqueKey) + + require.NotNil(t, notifications[1].UniqueKey) + assert.Equal(t, "pull-43", *notifications[1].UniqueKey) + + require.NotNil(t, notifications[2].UniqueKey) + assert.Equal(t, "commit-2-abc123", *notifications[2].UniqueKey) + + require.NotNil(t, notifications[3].UniqueKey) + assert.Equal(t, "release-7", *notifications[3].UniqueKey) + + assert.Nil(t, notifications[4].UniqueKey) +} + +func TestAddReleaseNotificationDeduplicatesLegacyNotificationRows(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, new(NotificationBefore331)) + defer deferable() + if x == nil || t.Failed() { + return + } + + testData := []*NotificationBefore331{ + {UserID: 1, RepoID: 2, Status: 2, Source: 3, CommitID: "abc123", UpdatedBy: 2, UpdatedUnix: 100}, + {UserID: 1, RepoID: 2, Status: 1, Source: 3, CommitID: "abc123", UpdatedBy: 3, UpdatedUnix: 200}, + {UserID: 1, RepoID: 2, Status: 3, Source: 3, CommitID: "abc123", UpdatedBy: 4, UpdatedUnix: 150}, + } + for _, data := range testData { + _, err := x.Insert(data) + require.NoError(t, err) + } + + existingNotifications := make([]*NotificationBefore331, 0, len(testData)) + require.NoError(t, x.Table("notification").Desc("updated_unix", "id").Find(&existingNotifications)) + require.NotEmpty(t, existingNotifications) + expectedKeeper := existingNotifications[0] + + require.NoError(t, AddReleaseNotification(x)) + + var notifications []*NotificationV331 + require.NoError(t, x.Table("notification").Find(¬ifications)) + require.Len(t, notifications, 1) + + require.NotNil(t, notifications[0].UniqueKey) + assert.Equal(t, "commit-2-abc123", *notifications[0].UniqueKey) + assert.Equal(t, notificationStatusPinnedV331, notifications[0].Status) + assert.Equal(t, expectedKeeper.UpdatedBy, notifications[0].UpdatedBy) + assert.Equal(t, expectedKeeper.UpdatedUnix, notifications[0].UpdatedUnix) +} diff --git a/models/repo/release.go b/models/repo/release.go index e2010c8a38d1f..fa158e426e523 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -106,21 +106,29 @@ func (r *Release) LoadRepo(ctx context.Context) (err error) { return err } +func (r *Release) LoadPublisher(ctx context.Context) error { + if r.Publisher != nil { + return nil + } + var err error + r.Publisher, err = user_model.GetPossibleUserByID(ctx, r.PublisherID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + r.Publisher = user_model.NewGhostUser() + } else { + return err + } + } + return nil +} + // LoadAttributes load repo and publisher attributes for a release -func (r *Release) LoadAttributes(ctx context.Context) (err error) { +func (r *Release) LoadAttributes(ctx context.Context) error { if err := r.LoadRepo(ctx); err != nil { return err } - - if r.Publisher == nil { - r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) - if err != nil { - if user_model.IsErrUserNotExist(err) { - r.Publisher = user_model.NewGhostUser() - } else { - return err - } - } + if err := r.LoadPublisher(ctx); err != nil { + return err } return GetReleaseAttachments(ctx, r) } diff --git a/models/user/list.go b/models/user/list.go index aaaa7965c8915..8dbfce8320144 100644 --- a/models/user/list.go +++ b/models/user/list.go @@ -6,6 +6,7 @@ package user import ( "context" "fmt" + "strings" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -81,3 +82,21 @@ func GetUsersByIDs(ctx context.Context, ids []int64) (UserList, error) { Find(&ous) return ous, err } + +// GetUsersByUsernames returns all resolved users from a list of user names. +func GetUsersByUsernames(ctx context.Context, userNames []string) (UserList, error) { + ous := make([]*User, 0, len(userNames)) + if len(userNames) == 0 { + return ous, nil + } + lowerNames := make([]string, len(userNames)) + for i, name := range userNames { + lowerNames[i] = strings.ToLower(name) + } + + err := db.GetEngine(ctx). + Where("`type` = ?", UserTypeIndividual). + In("lower_name", lowerNames). + Find(&ous) + return ous, err +} diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go index d7aa0783dc26b..6f647a00a3c07 100644 --- a/modules/structs/notifications.go +++ b/modules/structs/notifications.go @@ -38,7 +38,7 @@ type NotificationSubject struct { // LatestCommentHTMLURL is the web URL for the latest comment LatestCommentHTMLURL string `json:"latest_comment_html_url"` // Type indicates the type of the notification subject - Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository)"` + Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository,Release)"` // State indicates the current state of the notification subject State NotifySubjectStateType `json:"state"` } @@ -76,4 +76,6 @@ const ( NotifySubjectCommit NotifySubjectType = "Commit" // NotifySubjectRepository an repository is subject of an notification NotifySubjectRepository NotifySubjectType = "Repository" + // NotifySubjectRelease an release is subject of an notification + NotifySubjectRelease NotifySubjectType = "Release" ) diff --git a/routers/api/v1/notify/notifications.go b/routers/api/v1/notify/notifications.go index 4e4c7dc6dddd7..ff6cfc6d74034 100644 --- a/routers/api/v1/notify/notifications.go +++ b/routers/api/v1/notify/notifications.go @@ -71,6 +71,8 @@ func subjectToSource(value []string) (result []activities_model.NotificationSour result = append(result, activities_model.NotificationSourceCommit) case "repository": result = append(result, activities_model.NotificationSourceRepository) + case "release": + result = append(result, activities_model.NotificationSourceRelease) } } return result diff --git a/routers/api/v1/notify/repo.go b/routers/api/v1/notify/repo.go index 5e23e2285da87..82ce7786ab264 100644 --- a/routers/api/v1/notify/repo.go +++ b/routers/api/v1/notify/repo.go @@ -80,7 +80,7 @@ func ListRepoNotifications(ctx *context.APIContext) { // collectionFormat: multi // items: // type: string - // enum: [issue,pull,commit,repository] + // enum: [issue,pull,commit,repository,release] // - name: since // in: query // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format @@ -214,14 +214,20 @@ func ReadRepoNotifications(ctx *context.APIContext) { changed := make([]*structs.NotificationThread, 0, len(nl)) + if err := activities_model.NotificationList(nl).LoadAttributes(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + for _, n := range nl { notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) if err != nil { ctx.APIErrorInternal(err) return } - _ = notif.LoadAttributes(ctx) - changed = append(changed, convert.ToNotificationThread(ctx, notif)) + n.Status = notif.Status + n.UpdatedUnix = notif.UpdatedUnix + changed = append(changed, convert.ToNotificationThread(ctx, n)) } ctx.JSON(http.StatusResetContent, changed) } diff --git a/routers/api/v1/notify/user.go b/routers/api/v1/notify/user.go index 629a5ec228827..1260e117a2db2 100644 --- a/routers/api/v1/notify/user.go +++ b/routers/api/v1/notify/user.go @@ -42,7 +42,7 @@ func ListNotifications(ctx *context.APIContext) { // collectionFormat: multi // items: // type: string - // enum: [issue,pull,commit,repository] + // enum: [issue,pull,commit,repository,release] // - name: since // in: query // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format @@ -162,14 +162,20 @@ func ReadNotifications(ctx *context.APIContext) { changed := make([]*structs.NotificationThread, 0, len(nl)) + if err := activities_model.NotificationList(nl).LoadAttributes(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + for _, n := range nl { notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) if err != nil { ctx.APIErrorInternal(err) return } - _ = notif.LoadAttributes(ctx) - changed = append(changed, convert.ToNotificationThread(ctx, notif)) + n.Status = notif.Status + n.UpdatedUnix = notif.UpdatedUnix + changed = append(changed, convert.ToNotificationThread(ctx, n)) } ctx.JSON(http.StatusResetContent, changed) diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 168d95949407f..1700b99c643d8 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -12,6 +12,7 @@ import ( "path" "strings" + activities_model "code.gitea.io/gitea/models/activities" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" @@ -309,6 +310,14 @@ func Diff(ctx *context.Context) { commitID = commit.ID.String() } + if ctx.IsSigned { + err = activities_model.SetCommitReadBy(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID, commitID) + if err != nil { + ctx.ServerError("SetCommitReadBy", err) + return + } + } + fileOnly := ctx.FormBool("file-only") maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles files := ctx.FormStrings("files") diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 1372022ae4a07..20db0fceffabd 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/renderhelper" @@ -299,6 +300,14 @@ func SingleRelease(ctx *context.Context) { release.Title = release.TagName } + if ctx.IsSigned && !release.IsTag { + err = activities_model.SetReleaseReadBy(ctx, release.ID, ctx.Doer.ID) + if err != nil { + ctx.ServerError("SetReleaseReadBy", err) + return + } + } + ctx.Data["PageIsSingleTag"] = release.IsTag ctx.Data["SingleReleaseTagName"] = release.TagName if release.IsTag { diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 3b7ecd062b3c7..8e6a00b1f1e8d 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -120,6 +120,22 @@ func prepareUserNotificationsData(ctx *context.Context) { notifications = notifications.Without(failures) failCount += len(failures) + failures, err = notifications.LoadCommits(ctx) + if err != nil { + ctx.ServerError("LoadCommits", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + failures, err = notifications.LoadReleases(ctx) + if err != nil { + ctx.ServerError("LoadReleases", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + if failCount > 0 { ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) } diff --git a/services/convert/notification.go b/services/convert/notification.go index 3a1ae09dc5bb2..5cef7d59d1b5f 100644 --- a/services/convert/notification.go +++ b/services/convert/notification.go @@ -9,6 +9,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" ) @@ -75,9 +76,13 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification) } case activities_model.NotificationSourceCommit: url := n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID) + title := n.CommitID + if n.Commit != nil { + title, _ = git.SplitCommitTitleBody(n.Commit.CommitMessage, 255) + } result.Subject = &api.NotificationSubject{ Type: api.NotifySubjectCommit, - Title: n.CommitID, + Title: title, URL: url, HTMLURL: url, } @@ -89,6 +94,13 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification) URL: n.Repository.Link(), HTMLURL: n.Repository.HTMLURL(), } + case activities_model.NotificationSourceRelease: + result.Subject = &api.NotificationSubject{ + Type: api.NotifySubjectRelease, + Title: n.Release.Title, + URL: n.Release.Link(), + HTMLURL: n.Release.HTMLURL(), + } } return result diff --git a/services/markup/renderhelper_issueicontitle.go b/services/markup/renderhelper_issueicontitle.go index 651e2997e0a47..5a0c45965232a 100644 --- a/services/markup/renderhelper_issueicontitle.go +++ b/services/markup/renderhelper_issueicontitle.go @@ -58,10 +58,6 @@ func renderRepoIssueIconTitle(ctx context.Context, opts markup.RenderIssueIconTi } } - htmlIcon, err := webCtx.RenderToHTML("shared/issueicon", issue) - if err != nil { - return "", err - } - - return htmlutil.HTMLFormat(`%s %s %s`, opts.LinkHref, htmlIcon, issue.Title, textIssueIndex), nil + return htmlutil.HTMLFormat(`%s %s %s`, opts.LinkHref, + issue.IconHTML(ctx), issue.Title, textIssueIndex), nil } diff --git a/services/uinotification/notify.go b/services/uinotification/notify.go index dd3f1557c641e..e7246d15f5769 100644 --- a/services/uinotification/notify.go +++ b/services/uinotification/notify.go @@ -5,28 +5,39 @@ package uinotification import ( "context" + "slices" activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" ) type ( notificationService struct { notify_service.NullNotifier - issueQueue *queue.WorkerPoolQueue[issueNotificationOpts] + queue *queue.WorkerPoolQueue[notificationOpts] } - issueNotificationOpts struct { + notificationOpts struct { + Source activities_model.NotificationSource IssueID int64 CommentID int64 + CommitID string // commit ID for commit notifications + RepoID int64 + ReleaseID int64 NotificationAuthorID int64 ReceiverID int64 // 0 -- ALL Watcher } @@ -43,66 +54,81 @@ var _ notify_service.Notifier = ¬ificationService{} // NewNotifier create a new notificationService notifier func NewNotifier() notify_service.Notifier { ns := ¬ificationService{} - ns.issueQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "notification-service", handler) - if ns.issueQueue == nil { + ns.queue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "notification-service", handler) + if ns.queue == nil { log.Fatal("Unable to create notification-service queue") } return ns } -func handler(items ...issueNotificationOpts) []issueNotificationOpts { +func handler(items ...notificationOpts) []notificationOpts { + ctx := graceful.GetManager().ShutdownContext() for _, opts := range items { - if err := activities_model.CreateOrUpdateIssueNotifications(graceful.GetManager().ShutdownContext(), opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID); err != nil { - log.Error("Was unable to create issue notification: %v", err) + switch opts.Source { + case activities_model.NotificationSourceRepository: + if err := activities_model.CreateRepoTransferNotification(ctx, opts.NotificationAuthorID, opts.RepoID, opts.ReceiverID); err != nil { + log.Error("CreateRepoTransferNotification: %v", err) + } + case activities_model.NotificationSourceCommit: + if err := activities_model.CreateCommitNotifications(ctx, opts.NotificationAuthorID, opts.RepoID, opts.CommitID, opts.ReceiverID); err != nil { + log.Error("Was unable to create commit notification: %v", err) + } + case activities_model.NotificationSourceRelease: + if err := activities_model.CreateOrUpdateReleaseNotifications(ctx, opts.NotificationAuthorID, opts.RepoID, opts.ReleaseID, opts.ReceiverID); err != nil { + log.Error("Was unable to create release notification: %v", err) + } + case activities_model.NotificationSourceIssue, activities_model.NotificationSourcePullRequest, 0: + // 0 is for fallback to issue notifications because source is a newly added field + if err := activities_model.CreateOrUpdateIssueNotifications(ctx, opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID); err != nil { + log.Error("Was unable to create issue notification: %v", err) + } + default: + setting.PanicInDevOrTesting("Unknown notification source: %v", opts.Source) } } return nil } func (ns *notificationService) Run() { - go graceful.GetManager().RunWithCancel(ns.issueQueue) // TODO: using "go" here doesn't seem right, just leave it as old code + go graceful.GetManager().RunWithCancel(ns.queue) // TODO: using "go" here doesn't seem right, just leave it as old code } func (ns *notificationService) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, ) { - opts := issueNotificationOpts{ + opts := notificationOpts{ + Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue), IssueID: issue.ID, + RepoID: issue.RepoID, NotificationAuthorID: doer.ID, } if comment != nil { opts.CommentID = comment.ID } - _ = ns.issueQueue.Push(opts) + _ = ns.queue.Push(opts) for _, mention := range mentions { - opts := issueNotificationOpts{ - IssueID: issue.ID, - NotificationAuthorID: doer.ID, - ReceiverID: mention.ID, - } - if comment != nil { - opts.CommentID = comment.ID - } - _ = ns.issueQueue.Push(opts) + opts.ReceiverID = mention.ID + _ = ns.queue.Push(opts) } } func (ns *notificationService) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { - _ = ns.issueQueue.Push(issueNotificationOpts{ + opts := notificationOpts{ + Source: activities_model.NotificationSourceIssue, + RepoID: issue.RepoID, IssueID: issue.ID, NotificationAuthorID: issue.Poster.ID, - }) + } + _ = ns.queue.Push(opts) for _, mention := range mentions { - _ = ns.issueQueue.Push(issueNotificationOpts{ - IssueID: issue.ID, - NotificationAuthorID: issue.Poster.ID, - ReceiverID: mention.ID, - }) + opts.ReceiverID = mention.ID + _ = ns.queue.Push(opts) } } func (ns *notificationService) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { - _ = ns.issueQueue.Push(issueNotificationOpts{ + _ = ns.queue.Push(notificationOpts{ + Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue), IssueID: issue.ID, NotificationAuthorID: doer.ID, CommentID: actionComment.ID, @@ -115,7 +141,8 @@ func (ns *notificationService) IssueChangeTitle(ctx context.Context, doer *user_ return } if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress(ctx) { - _ = ns.issueQueue.Push(issueNotificationOpts{ + _ = ns.queue.Push(notificationOpts{ + Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue), IssueID: issue.ID, NotificationAuthorID: doer.ID, }) @@ -123,7 +150,8 @@ func (ns *notificationService) IssueChangeTitle(ctx context.Context, doer *user_ } func (ns *notificationService) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - _ = ns.issueQueue.Push(issueNotificationOpts{ + _ = ns.queue.Push(notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: pr.Issue.ID, NotificationAuthorID: doer.ID, }) @@ -160,7 +188,8 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo toNotify.Add(mention.ID) } for receiverID := range toNotify { - _ = ns.issueQueue.Push(issueNotificationOpts{ + _ = ns.queue.Push(notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: pr.Issue.ID, NotificationAuthorID: pr.Issue.PosterID, ReceiverID: receiverID, @@ -169,30 +198,25 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo } func (ns *notificationService) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, c *issues_model.Comment, mentions []*user_model.User) { - opts := issueNotificationOpts{ + opts := notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: pr.Issue.ID, NotificationAuthorID: r.Reviewer.ID, } if c != nil { opts.CommentID = c.ID } - _ = ns.issueQueue.Push(opts) + _ = ns.queue.Push(opts) for _, mention := range mentions { - opts := issueNotificationOpts{ - IssueID: pr.Issue.ID, - NotificationAuthorID: r.Reviewer.ID, - ReceiverID: mention.ID, - } - if c != nil { - opts.CommentID = c.ID - } - _ = ns.issueQueue.Push(opts) + opts.ReceiverID = mention.ID + _ = ns.queue.Push(opts) } } func (ns *notificationService) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, c *issues_model.Comment, mentions []*user_model.User) { for _, mention := range mentions { - _ = ns.issueQueue.Push(issueNotificationOpts{ + _ = ns.queue.Push(notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: pr.Issue.ID, NotificationAuthorID: c.Poster.ID, CommentID: c.ID, @@ -202,26 +226,29 @@ func (ns *notificationService) PullRequestCodeComment(ctx context.Context, pr *i } func (ns *notificationService) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { - opts := issueNotificationOpts{ + opts := notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: pr.IssueID, NotificationAuthorID: doer.ID, CommentID: comment.ID, } - _ = ns.issueQueue.Push(opts) + _ = ns.queue.Push(opts) } func (ns *notificationService) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { - opts := issueNotificationOpts{ + opts := notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: review.IssueID, NotificationAuthorID: doer.ID, CommentID: comment.ID, } - _ = ns.issueQueue.Push(opts) + _ = ns.queue.Push(opts) } func (ns *notificationService) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { if !removed && doer.ID != assignee.ID { - opts := issueNotificationOpts{ + opts := notificationOpts{ + Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue), IssueID: issue.ID, NotificationAuthorID: doer.ID, ReceiverID: assignee.ID, @@ -231,13 +258,14 @@ func (ns *notificationService) IssueChangeAssignee(ctx context.Context, doer *us opts.CommentID = comment.ID } - _ = ns.issueQueue.Push(opts) + _ = ns.queue.Push(opts) } } func (ns *notificationService) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { if isRequest { - opts := issueNotificationOpts{ + opts := notificationOpts{ + Source: activities_model.NotificationSourcePullRequest, IssueID: issue.ID, NotificationAuthorID: doer.ID, ReceiverID: reviewer.ID, @@ -247,15 +275,127 @@ func (ns *notificationService) PullRequestReviewRequest(ctx context.Context, doe opts.CommentID = comment.ID } - _ = ns.issueQueue.Push(opts) + _ = ns.queue.Push(opts) } } func (ns *notificationService) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { - err := db.WithTx(ctx, func(ctx context.Context) error { - return activities_model.CreateRepoTransferNotification(ctx, doer, newOwner, repo) - }) + opts := notificationOpts{ + Source: activities_model.NotificationSourceRepository, + RepoID: repo.ID, + NotificationAuthorID: doer.ID, + } + + if newOwner.IsOrganization() { + users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID) + if err != nil { + log.Error("GetUsersWhoCanCreateOrgRepo: %v", err) + return + } + for i := range users { + opts.ReceiverID = users[i].ID + _ = ns.queue.Push(opts) + } + } else { + opts.ReceiverID = newOwner.ID + _ = ns.queue.Push(opts) + } +} + +func (ns *notificationService) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + if len(commits.Commits) == 0 { + return + } + if err := repo.LoadOwner(ctx); err != nil { + log.Error("LoadOwner [%d]: %v", repo.ID, err) + return + } + + for _, commit := range commits.Commits { + mentions := references.FindAllMentionsMarkdown(commit.Message) + receivers, err := user_model.GetUsersByUsernames(ctx, mentions) + if err != nil { + log.Error("GetUserIDsByNames: %v", err) + return + } + + notBlocked := make([]*user_model.User, 0, len(mentions)) + for _, user := range receivers { + if !user_model.IsUserBlockedBy(ctx, repo.Owner, user.ID) { + notBlocked = append(notBlocked, user) + } + } + receivers = notBlocked + + for _, receiver := range receivers { + perm, err := access_model.GetIndividualUserRepoPermission(ctx, repo, receiver) + if err != nil { + log.Error("GetIndividualUserRepoPermission [%d]: %v", receiver.ID, err) + continue + } + if !perm.CanRead(unit.TypeCode) { + continue + } + + opts := notificationOpts{ + Source: activities_model.NotificationSourceCommit, + RepoID: repo.ID, + CommitID: commit.Sha1, + NotificationAuthorID: pusher.ID, + ReceiverID: receiver.ID, + } + if err := ns.queue.Push(opts); err != nil { + log.Error("PushCommits: %v", err) + } + } + } +} + +func (ns *notificationService) NewRelease(ctx context.Context, rel *repo_model.Release) { + if err := rel.LoadPublisher(ctx); err != nil { + log.Error("NewRelease LoadPublisher: %v", err) + return + } + ns.UpdateRelease(ctx, rel.Publisher, rel) +} + +func (ns *notificationService) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + if rel.IsDraft { + return + } + + opts := notificationOpts{ + Source: activities_model.NotificationSourceRelease, + RepoID: rel.RepoID, + ReleaseID: rel.ID, + NotificationAuthorID: rel.PublisherID, + } + + repoWatcherIDs, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID) if err != nil { - log.Error("CreateRepoTransferNotification: %v", err) + log.Error("GetRepoWatchersIDs: %v", err) + return + } + + if err := rel.LoadRepo(ctx); err != nil { + log.Error("LoadRepo: %v", err) + return + } + if err := rel.Repo.LoadOwner(ctx); err != nil { + log.Error("LoadOwner: %v", err) + return + } + if !rel.Repo.Owner.IsOrganization() && !slices.Contains(repoWatcherIDs, rel.Repo.Owner.ID) && rel.Repo.Owner.ID != doer.ID { + repoWatcherIDs = append(repoWatcherIDs, rel.Repo.Owner.ID) + } + + for _, watcherID := range repoWatcherIDs { + if watcherID == doer.ID { + // Do not notify the publisher of the release + continue + } + + opts.ReceiverID = watcherID + _ = ns.queue.Push(opts) } } diff --git a/templates/repo/diff/compare.tmpl b/templates/repo/diff/compare.tmpl index 87c783f44602c..9e68f5251e170 100644 --- a/templates/repo/diff/compare.tmpl +++ b/templates/repo/diff/compare.tmpl @@ -189,7 +189,7 @@ {{end}} {{if .HasPullRequest}}