From 1ed5f379b9f3e38b64cc9de9f418c164ce400be1 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 29 Nov 2024 09:53:49 -0800 Subject: Move GetFeeds to service layer (#32526) Move GetFeeds from models to service layer, no code change. --- models/activities/action.go | 54 +---- models/activities/action_list.go | 52 +++++ models/activities/action_test.go | 149 ------------ models/activities/user_heatmap.go | 2 +- routers/api/v1/org/org.go | 3 +- routers/api/v1/org/team.go | 3 +- routers/api/v1/repo/repo.go | 3 +- routers/api/v1/user/user.go | 3 +- routers/web/feed/profile.go | 3 +- routers/web/feed/repo.go | 3 +- routers/web/user/home.go | 3 +- routers/web/user/profile.go | 3 +- services/feed/action.go | 464 -------------------------------------- services/feed/action_test.go | 53 ----- services/feed/feed.go | 15 ++ services/feed/feed_test.go | 165 ++++++++++++++ services/feed/notifier.go | 464 ++++++++++++++++++++++++++++++++++++++ services/feed/notifier_test.go | 53 +++++ 18 files changed, 767 insertions(+), 728 deletions(-) delete mode 100644 services/feed/action.go delete mode 100644 services/feed/action_test.go create mode 100644 services/feed/feed.go create mode 100644 services/feed/feed_test.go create mode 100644 services/feed/notifier.go create mode 100644 services/feed/notifier_test.go diff --git a/models/activities/action.go b/models/activities/action.go index 546d4340ae..65d95fbe66 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -448,65 +448,13 @@ type GetFeedsOptions struct { Date string // the day we want activity for: YYYY-MM-DD } -// GetFeeds returns actions according to the provided options -func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, int64, error) { - if opts.RequestedUser == nil && opts.RequestedTeam == nil && opts.RequestedRepo == nil { - return nil, 0, fmt.Errorf("need at least one of these filters: RequestedUser, RequestedTeam, RequestedRepo") - } - - cond, err := activityQueryCondition(ctx, opts) - if err != nil { - return nil, 0, err - } - - actions := make([]*Action, 0, opts.PageSize) - var count int64 - opts.SetDefaultValues() - - if opts.Page < 10 { // TODO: why it's 10 but other values? It's an experience value. - sess := db.GetEngine(ctx).Where(cond) - sess = db.SetSessionPagination(sess, &opts) - - count, err = sess.Desc("`action`.created_unix").FindAndCount(&actions) - if err != nil { - return nil, 0, fmt.Errorf("FindAndCount: %w", err) - } - } else { - // First, only query which IDs are necessary, and only then query all actions to speed up the overall query - sess := db.GetEngine(ctx).Where(cond).Select("`action`.id") - sess = db.SetSessionPagination(sess, &opts) - - actionIDs := make([]int64, 0, opts.PageSize) - if err := sess.Table("action").Desc("`action`.created_unix").Find(&actionIDs); err != nil { - return nil, 0, fmt.Errorf("Find(actionsIDs): %w", err) - } - - count, err = db.GetEngine(ctx).Where(cond). - Table("action"). - Cols("`action`.id").Count() - if err != nil { - return nil, 0, fmt.Errorf("Count: %w", err) - } - - if err := db.GetEngine(ctx).In("`action`.id", actionIDs).Desc("`action`.created_unix").Find(&actions); err != nil { - return nil, 0, fmt.Errorf("Find: %w", err) - } - } - - if err := ActionList(actions).LoadAttributes(ctx); err != nil { - return nil, 0, fmt.Errorf("LoadAttributes: %w", err) - } - - return actions, count, nil -} - // ActivityReadable return whether doer can read activities of user func ActivityReadable(user, doer *user_model.User) bool { return !user.KeepActivityPrivate || doer != nil && (doer.IsAdmin || user.ID == doer.ID) } -func activityQueryCondition(ctx context.Context, opts GetFeedsOptions) (builder.Cond, error) { +func ActivityQueryCondition(ctx context.Context, opts GetFeedsOptions) (builder.Cond, error) { cond := builder.NewCond() if opts.RequestedTeam != nil && opts.RequestedUser == nil { diff --git a/models/activities/action_list.go b/models/activities/action_list.go index aafb7f8a26..5f9acb8f2a 100644 --- a/models/activities/action_list.go +++ b/models/activities/action_list.go @@ -201,3 +201,55 @@ func (actions ActionList) LoadIssues(ctx context.Context) error { } return nil } + +// GetFeeds returns actions according to the provided options +func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, int64, error) { + if opts.RequestedUser == nil && opts.RequestedTeam == nil && opts.RequestedRepo == nil { + return nil, 0, fmt.Errorf("need at least one of these filters: RequestedUser, RequestedTeam, RequestedRepo") + } + + cond, err := ActivityQueryCondition(ctx, opts) + if err != nil { + return nil, 0, err + } + + actions := make([]*Action, 0, opts.PageSize) + var count int64 + opts.SetDefaultValues() + + if opts.Page < 10 { // TODO: why it's 10 but other values? It's an experience value. + sess := db.GetEngine(ctx).Where(cond) + sess = db.SetSessionPagination(sess, &opts) + + count, err = sess.Desc("`action`.created_unix").FindAndCount(&actions) + if err != nil { + return nil, 0, fmt.Errorf("FindAndCount: %w", err) + } + } else { + // First, only query which IDs are necessary, and only then query all actions to speed up the overall query + sess := db.GetEngine(ctx).Where(cond).Select("`action`.id") + sess = db.SetSessionPagination(sess, &opts) + + actionIDs := make([]int64, 0, opts.PageSize) + if err := sess.Table("action").Desc("`action`.created_unix").Find(&actionIDs); err != nil { + return nil, 0, fmt.Errorf("Find(actionsIDs): %w", err) + } + + count, err = db.GetEngine(ctx).Where(cond). + Table("action"). + Cols("`action`.id").Count() + if err != nil { + return nil, 0, fmt.Errorf("Count: %w", err) + } + + if err := db.GetEngine(ctx).In("`action`.id", actionIDs).Desc("`action`.created_unix").Find(&actions); err != nil { + return nil, 0, fmt.Errorf("Find: %w", err) + } + } + + if err := ActionList(actions).LoadAttributes(ctx); err != nil { + return nil, 0, fmt.Errorf("LoadAttributes: %w", err) + } + + return actions, count, nil +} diff --git a/models/activities/action_test.go b/models/activities/action_test.go index 64330ebbb3..9cfe981656 100644 --- a/models/activities/action_test.go +++ b/models/activities/action_test.go @@ -42,114 +42,6 @@ func TestAction_GetRepoLink(t *testing.T) { assert.Equal(t, comment.HTMLURL(db.DefaultContext), action.GetCommentHTMLURL(db.DefaultContext)) } -func TestGetFeeds(t *testing.T) { - // test with an individual user - assert.NoError(t, unittest.PrepareTestDatabase()) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - - actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedUser: user, - Actor: user, - IncludePrivate: true, - OnlyPerformedBy: false, - IncludeDeleted: true, - }) - assert.NoError(t, err) - if assert.Len(t, actions, 1) { - assert.EqualValues(t, 1, actions[0].ID) - assert.EqualValues(t, user.ID, actions[0].UserID) - } - assert.Equal(t, int64(1), count) - - actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedUser: user, - Actor: user, - IncludePrivate: false, - OnlyPerformedBy: false, - }) - assert.NoError(t, err) - assert.Len(t, actions, 0) - assert.Equal(t, int64(0), count) -} - -func TestGetFeedsForRepos(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - privRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) - pubRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 8}) - - // private repo & no login - actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedRepo: privRepo, - IncludePrivate: true, - }) - assert.NoError(t, err) - assert.Len(t, actions, 0) - assert.Equal(t, int64(0), count) - - // public repo & no login - actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedRepo: pubRepo, - IncludePrivate: true, - }) - assert.NoError(t, err) - assert.Len(t, actions, 1) - assert.Equal(t, int64(1), count) - - // private repo and login - actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedRepo: privRepo, - IncludePrivate: true, - Actor: user, - }) - assert.NoError(t, err) - assert.Len(t, actions, 1) - assert.Equal(t, int64(1), count) - - // public repo & login - actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedRepo: pubRepo, - IncludePrivate: true, - Actor: user, - }) - assert.NoError(t, err) - assert.Len(t, actions, 1) - assert.Equal(t, int64(1), count) -} - -func TestGetFeeds2(t *testing.T) { - // test with an organization user - assert.NoError(t, unittest.PrepareTestDatabase()) - org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - - actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedUser: org, - Actor: user, - IncludePrivate: true, - OnlyPerformedBy: false, - IncludeDeleted: true, - }) - assert.NoError(t, err) - assert.Len(t, actions, 1) - if assert.Len(t, actions, 1) { - assert.EqualValues(t, 2, actions[0].ID) - assert.EqualValues(t, org.ID, actions[0].UserID) - } - assert.Equal(t, int64(1), count) - - actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedUser: org, - Actor: user, - IncludePrivate: false, - OnlyPerformedBy: false, - IncludeDeleted: true, - }) - assert.NoError(t, err) - assert.Len(t, actions, 0) - assert.Equal(t, int64(0), count) -} - func TestActivityReadable(t *testing.T) { tt := []struct { desc string @@ -227,26 +119,6 @@ func TestNotifyWatchers(t *testing.T) { }) } -func TestGetFeedsCorrupted(t *testing.T) { - // Now we will not check for corrupted data in the feeds - // users should run doctor to fix their data - assert.NoError(t, unittest.PrepareTestDatabase()) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - unittest.AssertExistsAndLoadBean(t, &activities_model.Action{ - ID: 8, - RepoID: 1700, - }) - - actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedUser: user, - Actor: user, - IncludePrivate: true, - }) - assert.NoError(t, err) - assert.Len(t, actions, 1) - assert.Equal(t, int64(1), count) -} - func TestConsistencyUpdateAction(t *testing.T) { if !setting.Database.Type.IsSQLite3() { t.Skip("Test is only for SQLite database.") @@ -322,24 +194,3 @@ func TestDeleteIssueActions(t *testing.T) { assert.NoError(t, activities_model.DeleteIssueActions(db.DefaultContext, issue.RepoID, issue.ID, issue.Index)) unittest.AssertCount(t, &activities_model.Action{}, 0) } - -func TestRepoActions(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - _ = db.TruncateBeans(db.DefaultContext, &activities_model.Action{}) - for i := 0; i < 3; i++ { - _ = db.Insert(db.DefaultContext, &activities_model.Action{ - UserID: 2 + int64(i), - ActUserID: 2, - RepoID: repo.ID, - OpType: activities_model.ActionCommentIssue, - }) - } - count, _ := db.Count[activities_model.Action](db.DefaultContext, &db.ListOptions{}) - assert.EqualValues(t, 3, count) - actions, _, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ - RequestedRepo: repo, - }) - assert.NoError(t, err) - assert.Len(t, actions, 1) -} diff --git a/models/activities/user_heatmap.go b/models/activities/user_heatmap.go index 78fcd76d43..1f8f0f590e 100644 --- a/models/activities/user_heatmap.go +++ b/models/activities/user_heatmap.go @@ -47,7 +47,7 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi groupByName = groupBy } - cond, err := activityQueryCondition(ctx, GetFeedsOptions{ + cond, err := ActivityQueryCondition(ctx, GetFeedsOptions{ RequestedUser: user, RequestedTeam: team, Actor: doer, diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 9e58746272..3fb653bcb6 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + feed_service "code.gitea.io/gitea/services/feed" "code.gitea.io/gitea/services/org" user_service "code.gitea.io/gitea/services/user" ) @@ -447,7 +448,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { ListOptions: listOptions, } - feeds, count, err := activities_model.GetFeeds(ctx, opts) + feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetFeeds", err) return diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 20226b4d6b..bc50960b61 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + feed_service "code.gitea.io/gitea/services/feed" org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" ) @@ -882,7 +883,7 @@ func ListTeamActivityFeeds(ctx *context.APIContext) { ListOptions: listOptions, } - feeds, count, err := activities_model.GetFeeds(ctx, opts) + feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetFeeds", err) return diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 69a95dd5a5..40990a28cb 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -34,6 +34,7 @@ import ( actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + feed_service "code.gitea.io/gitea/services/feed" "code.gitea.io/gitea/services/issue" repo_service "code.gitea.io/gitea/services/repository" ) @@ -1313,7 +1314,7 @@ func ListRepoActivityFeeds(ctx *context.APIContext) { ListOptions: listOptions, } - feeds, count, err := activities_model.GetFeeds(ctx, opts) + feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetFeeds", err) return diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index a9011427fb..e668326861 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + feed_service "code.gitea.io/gitea/services/feed" ) // Search search users @@ -214,7 +215,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) { ListOptions: listOptions, } - feeds, count, err := activities_model.GetFeeds(ctx, opts) + feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetFeeds", err) return diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go index 47de7c089d..4ec46e302a 100644 --- a/routers/web/feed/profile.go +++ b/routers/web/feed/profile.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/services/context" + feed_service "code.gitea.io/gitea/services/feed" "github.com/gorilla/feeds" ) @@ -28,7 +29,7 @@ func ShowUserFeedAtom(ctx *context.Context) { func showUserFeed(ctx *context.Context, formatType string) { includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) - actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ RequestedUser: ctx.ContextUser, Actor: ctx.Doer, IncludePrivate: includePrivate, diff --git a/routers/web/feed/repo.go b/routers/web/feed/repo.go index bfcc3a37d6..2e69fac758 100644 --- a/routers/web/feed/repo.go +++ b/routers/web/feed/repo.go @@ -9,13 +9,14 @@ import ( activities_model "code.gitea.io/gitea/models/activities" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/services/context" + feed_service "code.gitea.io/gitea/services/feed" "github.com/gorilla/feeds" ) // ShowRepoFeed shows user activity on the repo as RSS / Atom feed func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) { - actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ RequestedRepo: repo, Actor: ctx.Doer, IncludePrivate: true, diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 6149ccb08d..0cf932ac03 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -33,6 +33,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/services/context" + feed_service "code.gitea.io/gitea/services/feed" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" @@ -113,7 +114,7 @@ func Dashboard(ctx *context.Context) { ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) } - feeds, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + feeds, count, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ RequestedUser: ctxUser, RequestedTeam: ctx.Org.Team, Actor: ctx.Doer, diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 931af0a283..c41030a5e2 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/routers/web/org" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + feed_service "code.gitea.io/gitea/services/feed" ) const ( @@ -167,7 +168,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb case "activity": date := ctx.FormString("date") pagingNum = setting.UI.FeedPagingNum - items, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + items, count, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ RequestedUser: ctx.ContextUser, Actor: ctx.Doer, IncludePrivate: showPrivate, diff --git a/services/feed/action.go b/services/feed/action.go deleted file mode 100644 index a8820aeb77..0000000000 --- a/services/feed/action.go +++ /dev/null @@ -1,464 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package feed - -import ( - "context" - "fmt" - "path" - "strings" - - activities_model "code.gitea.io/gitea/models/activities" - issues_model "code.gitea.io/gitea/models/issues" - 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/json" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/repository" - "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" -) - -type actionNotifier struct { - notify_service.NullNotifier -} - -var _ notify_service.Notifier = &actionNotifier{} - -func Init() error { - notify_service.RegisterNotifier(NewNotifier()) - - return nil -} - -// NewNotifier create a new actionNotifier notifier -func NewNotifier() notify_service.Notifier { - return &actionNotifier{} -} - -func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { - if err := issue.LoadPoster(ctx); err != nil { - log.Error("issue.LoadPoster: %v", err) - return - } - if err := issue.LoadRepo(ctx); err != nil { - log.Error("issue.LoadRepo: %v", err) - return - } - repo := issue.Repo - - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: issue.Poster.ID, - ActUser: issue.Poster, - OpType: activities_model.ActionCreateIssue, - Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title), - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -// IssueChangeStatus notifies close or reopen issue to notifiers -func (a *actionNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) { - // Compose comment action, could be plain comment, close or reopen issue/pull request. - // This object will be used to notify watchers in the end of function. - act := &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - Content: fmt.Sprintf("%d|%s", issue.Index, ""), - RepoID: issue.Repo.ID, - Repo: issue.Repo, - Comment: actionComment, - CommentID: actionComment.ID, - IsPrivate: issue.Repo.IsPrivate, - } - // Check comment type. - if closeOrReopen { - act.OpType = activities_model.ActionCloseIssue - if issue.IsPull { - act.OpType = activities_model.ActionClosePullRequest - } - } else { - act.OpType = activities_model.ActionReopenIssue - if issue.IsPull { - act.OpType = activities_model.ActionReopenPullRequest - } - } - - // Notify watchers for whatever action comes in, ignore if no action type. - if err := activities_model.NotifyWatchers(ctx, act); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -// CreateIssueComment notifies comment on an issue to notifiers -func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, - issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, -) { - act := &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - RepoID: issue.Repo.ID, - Repo: issue.Repo, - Comment: comment, - CommentID: comment.ID, - IsPrivate: issue.Repo.IsPrivate, - } - - truncatedContent, truncatedRight := util.SplitStringAtByteN(comment.Content, 200) - if truncatedRight != "" { - // in case the content is in a Latin family language, we remove the last broken word. - lastSpaceIdx := strings.LastIndex(truncatedContent, " ") - if lastSpaceIdx != -1 && (len(truncatedContent)-lastSpaceIdx < 15) { - truncatedContent = truncatedContent[:lastSpaceIdx] + "…" - } - } - act.Content = fmt.Sprintf("%d|%s", issue.Index, truncatedContent) - - if issue.IsPull { - act.OpType = activities_model.ActionCommentPull - } else { - act.OpType = activities_model.ActionCommentIssue - } - - // Notify watchers for whatever action comes in, ignore if no action type. - if err := activities_model.NotifyWatchers(ctx, act); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) { - if err := pull.LoadIssue(ctx); err != nil { - log.Error("pull.LoadIssue: %v", err) - return - } - if err := pull.Issue.LoadRepo(ctx); err != nil { - log.Error("pull.Issue.LoadRepo: %v", err) - return - } - if err := pull.Issue.LoadPoster(ctx); err != nil { - log.Error("pull.Issue.LoadPoster: %v", err) - return - } - - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: pull.Issue.Poster.ID, - ActUser: pull.Issue.Poster, - OpType: activities_model.ActionCreatePullRequest, - Content: fmt.Sprintf("%d|%s", pull.Issue.Index, pull.Issue.Title), - RepoID: pull.Issue.Repo.ID, - Repo: pull.Issue.Repo, - IsPrivate: pull.Issue.Repo.IsPrivate, - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionRenameRepo, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - Content: oldRepoName, - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionTransferRepo, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - Content: path.Join(oldOwnerName, repo.Name), - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionCreateRepo, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - }); err != nil { - log.Error("notify watchers '%d/%d': %v", doer.ID, repo.ID, err) - } -} - -func (a *actionNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionCreateRepo, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - }); err != nil { - log.Error("notify watchers '%d/%d': %v", doer.ID, repo.ID, err) - } -} - -func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { - if err := review.LoadReviewer(ctx); err != nil { - log.Error("LoadReviewer '%d/%d': %v", review.ID, review.ReviewerID, err) - return - } - if err := review.LoadCodeComments(ctx); err != nil { - log.Error("LoadCodeComments '%d/%d': %v", review.Reviewer.ID, review.ID, err) - return - } - - actions := make([]*activities_model.Action, 0, 10) - for _, lines := range review.CodeComments { - for _, comments := range lines { - for _, comm := range comments { - actions = append(actions, &activities_model.Action{ - ActUserID: review.Reviewer.ID, - ActUser: review.Reviewer, - Content: fmt.Sprintf("%d|%s", review.Issue.Index, strings.Split(comm.Content, "\n")[0]), - OpType: activities_model.ActionCommentPull, - RepoID: review.Issue.RepoID, - Repo: review.Issue.Repo, - IsPrivate: review.Issue.Repo.IsPrivate, - Comment: comm, - CommentID: comm.ID, - }) - } - } - } - - if review.Type != issues_model.ReviewTypeComment || strings.TrimSpace(comment.Content) != "" { - action := &activities_model.Action{ - ActUserID: review.Reviewer.ID, - ActUser: review.Reviewer, - Content: fmt.Sprintf("%d|%s", review.Issue.Index, strings.Split(comment.Content, "\n")[0]), - RepoID: review.Issue.RepoID, - Repo: review.Issue.Repo, - IsPrivate: review.Issue.Repo.IsPrivate, - Comment: comment, - CommentID: comment.ID, - } - - switch review.Type { - case issues_model.ReviewTypeApprove: - action.OpType = activities_model.ActionApprovePullRequest - case issues_model.ReviewTypeReject: - action.OpType = activities_model.ActionRejectPullRequest - default: - action.OpType = activities_model.ActionCommentPull - } - - actions = append(actions, action) - } - - if err := activities_model.NotifyWatchersActions(ctx, actions); err != nil { - log.Error("notify watchers '%d/%d': %v", review.Reviewer.ID, review.Issue.RepoID, err) - } -} - -func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionMergePullRequest, - Content: fmt.Sprintf("%d|%s", pr.Issue.Index, pr.Issue.Title), - RepoID: pr.Issue.Repo.ID, - Repo: pr.Issue.Repo, - IsPrivate: pr.Issue.Repo.IsPrivate, - }); err != nil { - log.Error("NotifyWatchers [%d]: %v", pr.ID, err) - } -} - -func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionAutoMergePullRequest, - Content: fmt.Sprintf("%d|%s", pr.Issue.Index, pr.Issue.Title), - RepoID: pr.Issue.Repo.ID, - Repo: pr.Issue.Repo, - IsPrivate: pr.Issue.Repo.IsPrivate, - }); err != nil { - log.Error("NotifyWatchers [%d]: %v", pr.ID, err) - } -} - -func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { - reviewerName := review.Reviewer.Name - if len(review.OriginalAuthor) > 0 { - reviewerName = review.OriginalAuthor - } - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: activities_model.ActionPullReviewDismissed, - Content: fmt.Sprintf("%d|%s|%s", review.Issue.Index, reviewerName, comment.Content), - RepoID: review.Issue.Repo.ID, - Repo: review.Issue.Repo, - IsPrivate: review.Issue.Repo.IsPrivate, - CommentID: comment.ID, - Comment: comment, - }); err != nil { - log.Error("NotifyWatchers [%d]: %v", review.Issue.ID, err) - } -} - -func (a *actionNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { - data, err := json.Marshal(commits) - if err != nil { - log.Error("Marshal: %v", err) - return - } - - opType := activities_model.ActionCommitRepo - - // Check it's tag push or branch. - if opts.RefFullName.IsTag() { - opType = activities_model.ActionPushTag - if opts.IsDelRef() { - opType = activities_model.ActionDeleteTag - } - } else if opts.IsDelRef() { - opType = activities_model.ActionDeleteBranch - } - - if err = activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: pusher.ID, - ActUser: pusher, - OpType: opType, - Content: string(data), - RepoID: repo.ID, - Repo: repo, - RefName: opts.RefFullName.String(), - IsPrivate: repo.IsPrivate, - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) CreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { - opType := activities_model.ActionCommitRepo - if refFullName.IsTag() { - // has sent same action in `PushCommits`, so skip it. - return - } - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: opType, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - RefName: refFullName.String(), - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { - opType := activities_model.ActionDeleteBranch - if refFullName.IsTag() { - // has sent same action in `PushCommits`, so skip it. - return - } - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: doer.ID, - ActUser: doer, - OpType: opType, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - RefName: refFullName.String(), - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { - // ignore pull sync message for pull requests refs - // TODO: it's better to have a UI to let users chose - if opts.RefFullName.IsPull() { - return - } - - data, err := json.Marshal(commits) - if err != nil { - log.Error("json.Marshal: %v", err) - return - } - - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: repo.OwnerID, - ActUser: repo.MustOwner(ctx), - OpType: activities_model.ActionMirrorSyncPush, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - RefName: opts.RefFullName.String(), - Content: string(data), - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: repo.OwnerID, - ActUser: repo.MustOwner(ctx), - OpType: activities_model.ActionMirrorSyncCreate, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - RefName: refFullName.String(), - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: repo.OwnerID, - ActUser: repo.MustOwner(ctx), - OpType: activities_model.ActionMirrorSyncDelete, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - RefName: refFullName.String(), - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} - -func (a *actionNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { - if err := rel.LoadAttributes(ctx); err != nil { - log.Error("LoadAttributes: %v", err) - return - } - if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ - ActUserID: rel.PublisherID, - ActUser: rel.Publisher, - OpType: activities_model.ActionPublishRelease, - RepoID: rel.RepoID, - Repo: rel.Repo, - IsPrivate: rel.Repo.IsPrivate, - Content: rel.Title, - RefName: rel.TagName, // FIXME: use a full ref name? - }); err != nil { - log.Error("NotifyWatchers: %v", err) - } -} diff --git a/services/feed/action_test.go b/services/feed/action_test.go deleted file mode 100644 index 60cf7fbb49..0000000000 --- a/services/feed/action_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package feed - -import ( - "strings" - "testing" - - activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/models/db" - repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unittest" - user_model "code.gitea.io/gitea/models/user" - - _ "code.gitea.io/gitea/models" - _ "code.gitea.io/gitea/models/actions" - - "github.com/stretchr/testify/assert" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} - -func TestRenameRepoAction(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID}) - repo.Owner = user - - oldRepoName := repo.Name - const newRepoName = "newRepoName" - repo.Name = newRepoName - repo.LowerName = strings.ToLower(newRepoName) - - actionBean := &activities_model.Action{ - OpType: activities_model.ActionRenameRepo, - ActUserID: user.ID, - ActUser: user, - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, - Content: oldRepoName, - } - unittest.AssertNotExistsBean(t, actionBean) - - NewNotifier().RenameRepository(db.DefaultContext, user, repo, oldRepoName) - - unittest.AssertExistsAndLoadBean(t, actionBean) - unittest.CheckConsistencyFor(t, &activities_model.Action{}) -} diff --git a/services/feed/feed.go b/services/feed/feed.go new file mode 100644 index 0000000000..93bf875fd0 --- /dev/null +++ b/services/feed/feed.go @@ -0,0 +1,15 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "context" + + activities_model "code.gitea.io/gitea/models/activities" +) + +// GetFeeds returns actions according to the provided options +func GetFeeds(ctx context.Context, opts activities_model.GetFeedsOptions) (activities_model.ActionList, int64, error) { + return activities_model.GetFeeds(ctx, opts) +} diff --git a/services/feed/feed_test.go b/services/feed/feed_test.go new file mode 100644 index 0000000000..6f1cb9a969 --- /dev/null +++ b/services/feed/feed_test.go @@ -0,0 +1,165 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestGetFeeds(t *testing.T) { + // test with an individual user + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, + }) + assert.NoError(t, err) + if assert.Len(t, actions, 1) { + assert.EqualValues(t, 1, actions[0].ID) + assert.EqualValues(t, user.ID, actions[0].UserID) + } + assert.Equal(t, int64(1), count) + + actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: user, + IncludePrivate: false, + OnlyPerformedBy: false, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) + assert.Equal(t, int64(0), count) +} + +func TestGetFeedsForRepos(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + privRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + pubRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 8}) + + // private repo & no login + actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: privRepo, + IncludePrivate: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) + assert.Equal(t, int64(0), count) + + // public repo & no login + actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: pubRepo, + IncludePrivate: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + assert.Equal(t, int64(1), count) + + // private repo and login + actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: privRepo, + IncludePrivate: true, + Actor: user, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + assert.Equal(t, int64(1), count) + + // public repo & login + actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: pubRepo, + IncludePrivate: true, + Actor: user, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + assert.Equal(t, int64(1), count) +} + +func TestGetFeeds2(t *testing.T) { + // test with an organization user + assert.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: org, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + if assert.Len(t, actions, 1) { + assert.EqualValues(t, 2, actions[0].ID) + assert.EqualValues(t, org.ID, actions[0].UserID) + } + assert.Equal(t, int64(1), count) + + actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: org, + Actor: user, + IncludePrivate: false, + OnlyPerformedBy: false, + IncludeDeleted: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) + assert.Equal(t, int64(0), count) +} + +func TestGetFeedsCorrupted(t *testing.T) { + // Now we will not check for corrupted data in the feeds + // users should run doctor to fix their data + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + unittest.AssertExistsAndLoadBean(t, &activities_model.Action{ + ID: 8, + RepoID: 1700, + }) + + actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: user, + IncludePrivate: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + assert.Equal(t, int64(1), count) +} + +func TestRepoActions(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + _ = db.TruncateBeans(db.DefaultContext, &activities_model.Action{}) + for i := 0; i < 3; i++ { + _ = db.Insert(db.DefaultContext, &activities_model.Action{ + UserID: 2 + int64(i), + ActUserID: 2, + RepoID: repo.ID, + OpType: activities_model.ActionCommentIssue, + }) + } + count, _ := db.Count[activities_model.Action](db.DefaultContext, &db.ListOptions{}) + assert.EqualValues(t, 3, count) + actions, _, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: repo, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) +} diff --git a/services/feed/notifier.go b/services/feed/notifier.go new file mode 100644 index 0000000000..a8820aeb77 --- /dev/null +++ b/services/feed/notifier.go @@ -0,0 +1,464 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "context" + "fmt" + "path" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + issues_model "code.gitea.io/gitea/models/issues" + 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/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" +) + +type actionNotifier struct { + notify_service.NullNotifier +} + +var _ notify_service.Notifier = &actionNotifier{} + +func Init() error { + notify_service.RegisterNotifier(NewNotifier()) + + return nil +} + +// NewNotifier create a new actionNotifier notifier +func NewNotifier() notify_service.Notifier { + return &actionNotifier{} +} + +func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { + if err := issue.LoadPoster(ctx); err != nil { + log.Error("issue.LoadPoster: %v", err) + return + } + if err := issue.LoadRepo(ctx); err != nil { + log.Error("issue.LoadRepo: %v", err) + return + } + repo := issue.Repo + + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: issue.Poster.ID, + ActUser: issue.Poster, + OpType: activities_model.ActionCreateIssue, + Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title), + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +// IssueChangeStatus notifies close or reopen issue to notifiers +func (a *actionNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) { + // Compose comment action, could be plain comment, close or reopen issue/pull request. + // This object will be used to notify watchers in the end of function. + act := &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + Content: fmt.Sprintf("%d|%s", issue.Index, ""), + RepoID: issue.Repo.ID, + Repo: issue.Repo, + Comment: actionComment, + CommentID: actionComment.ID, + IsPrivate: issue.Repo.IsPrivate, + } + // Check comment type. + if closeOrReopen { + act.OpType = activities_model.ActionCloseIssue + if issue.IsPull { + act.OpType = activities_model.ActionClosePullRequest + } + } else { + act.OpType = activities_model.ActionReopenIssue + if issue.IsPull { + act.OpType = activities_model.ActionReopenPullRequest + } + } + + // Notify watchers for whatever action comes in, ignore if no action type. + if err := activities_model.NotifyWatchers(ctx, act); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +// CreateIssueComment notifies comment on an issue to notifiers +func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, +) { + act := &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + RepoID: issue.Repo.ID, + Repo: issue.Repo, + Comment: comment, + CommentID: comment.ID, + IsPrivate: issue.Repo.IsPrivate, + } + + truncatedContent, truncatedRight := util.SplitStringAtByteN(comment.Content, 200) + if truncatedRight != "" { + // in case the content is in a Latin family language, we remove the last broken word. + lastSpaceIdx := strings.LastIndex(truncatedContent, " ") + if lastSpaceIdx != -1 && (len(truncatedContent)-lastSpaceIdx < 15) { + truncatedContent = truncatedContent[:lastSpaceIdx] + "…" + } + } + act.Content = fmt.Sprintf("%d|%s", issue.Index, truncatedContent) + + if issue.IsPull { + act.OpType = activities_model.ActionCommentPull + } else { + act.OpType = activities_model.ActionCommentIssue + } + + // Notify watchers for whatever action comes in, ignore if no action type. + if err := activities_model.NotifyWatchers(ctx, act); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) { + if err := pull.LoadIssue(ctx); err != nil { + log.Error("pull.LoadIssue: %v", err) + return + } + if err := pull.Issue.LoadRepo(ctx); err != nil { + log.Error("pull.Issue.LoadRepo: %v", err) + return + } + if err := pull.Issue.LoadPoster(ctx); err != nil { + log.Error("pull.Issue.LoadPoster: %v", err) + return + } + + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: pull.Issue.Poster.ID, + ActUser: pull.Issue.Poster, + OpType: activities_model.ActionCreatePullRequest, + Content: fmt.Sprintf("%d|%s", pull.Issue.Index, pull.Issue.Title), + RepoID: pull.Issue.Repo.ID, + Repo: pull.Issue.Repo, + IsPrivate: pull.Issue.Repo.IsPrivate, + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionRenameRepo, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + Content: oldRepoName, + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionTransferRepo, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + Content: path.Join(oldOwnerName, repo.Name), + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionCreateRepo, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + }); err != nil { + log.Error("notify watchers '%d/%d': %v", doer.ID, repo.ID, err) + } +} + +func (a *actionNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionCreateRepo, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + }); err != nil { + log.Error("notify watchers '%d/%d': %v", doer.ID, repo.ID, err) + } +} + +func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { + if err := review.LoadReviewer(ctx); err != nil { + log.Error("LoadReviewer '%d/%d': %v", review.ID, review.ReviewerID, err) + return + } + if err := review.LoadCodeComments(ctx); err != nil { + log.Error("LoadCodeComments '%d/%d': %v", review.Reviewer.ID, review.ID, err) + return + } + + actions := make([]*activities_model.Action, 0, 10) + for _, lines := range review.CodeComments { + for _, comments := range lines { + for _, comm := range comments { + actions = append(actions, &activities_model.Action{ + ActUserID: review.Reviewer.ID, + ActUser: review.Reviewer, + Content: fmt.Sprintf("%d|%s", review.Issue.Index, strings.Split(comm.Content, "\n")[0]), + OpType: activities_model.ActionCommentPull, + RepoID: review.Issue.RepoID, + Repo: review.Issue.Repo, + IsPrivate: review.Issue.Repo.IsPrivate, + Comment: comm, + CommentID: comm.ID, + }) + } + } + } + + if review.Type != issues_model.ReviewTypeComment || strings.TrimSpace(comment.Content) != "" { + action := &activities_model.Action{ + ActUserID: review.Reviewer.ID, + ActUser: review.Reviewer, + Content: fmt.Sprintf("%d|%s", review.Issue.Index, strings.Split(comment.Content, "\n")[0]), + RepoID: review.Issue.RepoID, + Repo: review.Issue.Repo, + IsPrivate: review.Issue.Repo.IsPrivate, + Comment: comment, + CommentID: comment.ID, + } + + switch review.Type { + case issues_model.ReviewTypeApprove: + action.OpType = activities_model.ActionApprovePullRequest + case issues_model.ReviewTypeReject: + action.OpType = activities_model.ActionRejectPullRequest + default: + action.OpType = activities_model.ActionCommentPull + } + + actions = append(actions, action) + } + + if err := activities_model.NotifyWatchersActions(ctx, actions); err != nil { + log.Error("notify watchers '%d/%d': %v", review.Reviewer.ID, review.Issue.RepoID, err) + } +} + +func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionMergePullRequest, + Content: fmt.Sprintf("%d|%s", pr.Issue.Index, pr.Issue.Title), + RepoID: pr.Issue.Repo.ID, + Repo: pr.Issue.Repo, + IsPrivate: pr.Issue.Repo.IsPrivate, + }); err != nil { + log.Error("NotifyWatchers [%d]: %v", pr.ID, err) + } +} + +func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionAutoMergePullRequest, + Content: fmt.Sprintf("%d|%s", pr.Issue.Index, pr.Issue.Title), + RepoID: pr.Issue.Repo.ID, + Repo: pr.Issue.Repo, + IsPrivate: pr.Issue.Repo.IsPrivate, + }); err != nil { + log.Error("NotifyWatchers [%d]: %v", pr.ID, err) + } +} + +func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { + reviewerName := review.Reviewer.Name + if len(review.OriginalAuthor) > 0 { + reviewerName = review.OriginalAuthor + } + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: activities_model.ActionPullReviewDismissed, + Content: fmt.Sprintf("%d|%s|%s", review.Issue.Index, reviewerName, comment.Content), + RepoID: review.Issue.Repo.ID, + Repo: review.Issue.Repo, + IsPrivate: review.Issue.Repo.IsPrivate, + CommentID: comment.ID, + Comment: comment, + }); err != nil { + log.Error("NotifyWatchers [%d]: %v", review.Issue.ID, err) + } +} + +func (a *actionNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + data, err := json.Marshal(commits) + if err != nil { + log.Error("Marshal: %v", err) + return + } + + opType := activities_model.ActionCommitRepo + + // Check it's tag push or branch. + if opts.RefFullName.IsTag() { + opType = activities_model.ActionPushTag + if opts.IsDelRef() { + opType = activities_model.ActionDeleteTag + } + } else if opts.IsDelRef() { + opType = activities_model.ActionDeleteBranch + } + + if err = activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: pusher.ID, + ActUser: pusher, + OpType: opType, + Content: string(data), + RepoID: repo.ID, + Repo: repo, + RefName: opts.RefFullName.String(), + IsPrivate: repo.IsPrivate, + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) CreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + opType := activities_model.ActionCommitRepo + if refFullName.IsTag() { + // has sent same action in `PushCommits`, so skip it. + return + } + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: opType, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + RefName: refFullName.String(), + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + opType := activities_model.ActionDeleteBranch + if refFullName.IsTag() { + // has sent same action in `PushCommits`, so skip it. + return + } + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: doer.ID, + ActUser: doer, + OpType: opType, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + RefName: refFullName.String(), + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + // ignore pull sync message for pull requests refs + // TODO: it's better to have a UI to let users chose + if opts.RefFullName.IsPull() { + return + } + + data, err := json.Marshal(commits) + if err != nil { + log.Error("json.Marshal: %v", err) + return + } + + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: repo.OwnerID, + ActUser: repo.MustOwner(ctx), + OpType: activities_model.ActionMirrorSyncPush, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + RefName: opts.RefFullName.String(), + Content: string(data), + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: repo.OwnerID, + ActUser: repo.MustOwner(ctx), + OpType: activities_model.ActionMirrorSyncCreate, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + RefName: refFullName.String(), + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: repo.OwnerID, + ActUser: repo.MustOwner(ctx), + OpType: activities_model.ActionMirrorSyncDelete, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + RefName: refFullName.String(), + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} + +func (a *actionNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { + if err := rel.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ + ActUserID: rel.PublisherID, + ActUser: rel.Publisher, + OpType: activities_model.ActionPublishRelease, + RepoID: rel.RepoID, + Repo: rel.Repo, + IsPrivate: rel.Repo.IsPrivate, + Content: rel.Title, + RefName: rel.TagName, // FIXME: use a full ref name? + }); err != nil { + log.Error("NotifyWatchers: %v", err) + } +} diff --git a/services/feed/notifier_test.go b/services/feed/notifier_test.go new file mode 100644 index 0000000000..60cf7fbb49 --- /dev/null +++ b/services/feed/notifier_test.go @@ -0,0 +1,53 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "strings" + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func TestRenameRepoAction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID}) + repo.Owner = user + + oldRepoName := repo.Name + const newRepoName = "newRepoName" + repo.Name = newRepoName + repo.LowerName = strings.ToLower(newRepoName) + + actionBean := &activities_model.Action{ + OpType: activities_model.ActionRenameRepo, + ActUserID: user.ID, + ActUser: user, + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + Content: oldRepoName, + } + unittest.AssertNotExistsBean(t, actionBean) + + NewNotifier().RenameRepository(db.DefaultContext, user, repo, oldRepoName) + + unittest.AssertExistsAndLoadBean(t, actionBean) + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} -- cgit v1.2.3