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}}
- {{template "shared/issueicon" .}} + {{.IconHTML ctx}}
{{ctx.RenderUtils.RenderIssueTitle .PullRequest.Issue.Title $.Repository}} #{{.PullRequest.Issue.Index}} diff --git a/templates/repo/issue/card.tmpl b/templates/repo/issue/card.tmpl index 9600752036ad6..4670d752c7025 100644 --- a/templates/repo/issue/card.tmpl +++ b/templates/repo/issue/card.tmpl @@ -12,7 +12,7 @@
- {{template "shared/issueicon" .}} + {{.IconHTML ctx}}
{{.Title | ctx.RenderUtils.RenderIssueSimpleTitle}} {{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}} diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index aaf80a1ec94d4..4fab64e600a57 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -9,7 +9,7 @@ {{if $.CanWriteIssuesOrPulls}} {{end}} - {{template "shared/issueicon" .}} + {{.IconHTML ctx}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 703a25336f24f..9606e345096d6 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1543,7 +1543,8 @@ "issue", "pull", "commit", - "repository" + "repository", + "release" ], "type": "string" }, @@ -13709,7 +13710,8 @@ "issue", "pull", "commit", - "repository" + "repository", + "release" ], "type": "string" }, @@ -27264,15 +27266,16 @@ "x-go-name": "Title" }, "type": { - "description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification", + "description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification\nRelease NotifySubjectRelease NotifySubjectRelease an release is subject of an notification", "type": "string", "enum": [ "Issue", "Pull", "Commit", - "Repository" + "Repository", + "Release" ], - "x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification", + "x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification\nRelease NotifySubjectRelease NotifySubjectRelease an release is subject of an notification", "x-go-name": "Type" }, "url": { diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl index 993975169b5d4..e70d8f8e4218c 100644 --- a/templates/user/notification/notification_div.tmpl +++ b/templates/user/notification/notification_div.tmpl @@ -25,11 +25,7 @@ {{range $one := .Notifications}}
- {{if $one.Issue}} - {{template "shared/issueicon" $one.Issue}} - {{else}} - {{svg "octicon-repo" 16 "tw-text-text-light"}} - {{end}} + {{$one.IconHTML ctx}}
@@ -41,6 +37,10 @@
{{if $one.Issue}} {{$one.Issue.Title | ctx.RenderUtils.RenderIssueSimpleTitle}} + {{else if $one.Release}} + {{$one.Release.Title}} + {{else if $one.Commit}} + {{$one.Commit.Summary}} {{else}} {{$one.Repository.FullName}} {{end}} @@ -49,6 +49,10 @@
{{if $one.Issue}} {{DateUtils.TimeSince $one.Issue.UpdatedUnix}} + {{else if $one.Release}} + {{DateUtils.TimeSince $one.Release.CreatedUnix}} + {{else if $one.Commit}} + {{DateUtils.TimeSince $one.Commit.Committer.When}} {{else}} {{DateUtils.TimeSince $one.UpdatedUnix}} {{end}} diff --git a/tests/integration/api_notification_test.go b/tests/integration/api_notification_test.go index 0c17ece55d6d1..3ad692f1759cf 100644 --- a/tests/integration/api_notification_test.go +++ b/tests/integration/api_notification_test.go @@ -4,8 +4,10 @@ package integration import ( + "encoding/base64" "fmt" "net/http" + "net/url" "testing" activities_model "code.gitea.io/gitea/models/activities" @@ -14,6 +16,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -212,3 +215,124 @@ func TestAPINotificationPUT(t *testing.T) { assert.True(t, apiNL[0].Unread) assert.False(t, apiNL[0].Pinned) } + +func TestAPICommitNotification(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // user1 (admin) pushes a commit to user2's repo that mentions @user2; + // user2 should receive a commit notification. + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + session1 := loginUser(t, user1.Name) + token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeWriteRepository) + + contentEncoded := base64.StdEncoding.EncodeToString([]byte("notification test content")) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/new_commit_notification.txt", user2.Name, repo1.Name), &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "Test commit to mention @user2", + }, + ContentBase64: contentEncoded, + }).AddTokenAuth(token1) + MakeRequest(t, req, http.StatusCreated) + + // Check that user2 received a commit notification + session2 := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteNotification) + req = NewRequest(t, "GET", "/api/v1/notifications?all=true"). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var apiNL []api.NotificationThread + DecodeJSON(t, resp, &apiNL) + + if assert.NotEmpty(t, apiNL) { + assert.Equal(t, api.NotifySubjectCommit, apiNL[0].Subject.Type) + assert.Equal(t, "Test commit to mention @user2", apiNL[0].Subject.Title) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + } + }) +} + +func TestAPIReleaseNotification(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + session1 := loginUser(t, user1.Name) + token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeWriteRepository) + + // user1 create a release, it's expected to create a notification + createNewReleaseUsingAPI(t, token1, user2, repo1, "v0.0.2", "", "v0.0.2 is released", "test notification release") + + // user2 login to check notifications + session2 := loginUser(t, user2.Name) + + // Check notifications are as expected + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteNotification) + req := NewRequest(t, "GET", "/api/v1/notifications?all=true"). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var apiNL []api.NotificationThread + DecodeJSON(t, resp, &apiNL) + + if assert.NotEmpty(t, apiNL) { + assert.Equal(t, api.NotifySubjectRelease, apiNL[0].Subject.Type) + assert.Equal(t, "v0.0.2 is released", apiNL[0].Subject.Title) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + } + }) +} + +func TestAPIRepoTransferNotification(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + // create a repo to transfer + repoName := "moveME" + apiRepo := new(api.Repository) + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: repoName, + Description: "repo move around", + Private: false, + Readme: "Default", + AutoInit: true, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiRepo) + + defer func() { + _ = repo_service.DeleteRepositoryDirectly(t.Context(), apiRepo.ID) + }() + + // transfer user2/moveME to org6; user5 is a member of org6's Owners team and should be notified + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", repo.OwnerName, repo.Name), &api.TransferRepoOption{ + NewOwner: "org6", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + session5 := loginUser(t, user5.Name) + token5 := getTokenForLoggedInUser(t, session5, auth_model.AccessTokenScopeWriteNotification) + req = NewRequest(t, "GET", "/api/v1/notifications?all=true"). + AddTokenAuth(token5) + resp = MakeRequest(t, req, http.StatusOK) + var apiNL []api.NotificationThread + DecodeJSON(t, resp, &apiNL) + + if assert.NotEmpty(t, apiNL) { + assert.Equal(t, api.NotifySubjectRepository, apiNL[0].Subject.Type) + assert.Equal(t, "user2/moveME", apiNL[0].Subject.Title) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + } + }) +}