summaryrefslogtreecommitdiffstats
path: root/models/activities
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /models/activities
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--models/activities/action.go777
-rw-r--r--models/activities/action_list.go203
-rw-r--r--models/activities/action_test.go320
-rw-r--r--models/activities/main_test.go17
-rw-r--r--models/activities/notification.go407
-rw-r--r--models/activities/notification_list.go476
-rw-r--r--models/activities/notification_test.go141
-rw-r--r--models/activities/repo_activity.go391
-rw-r--r--models/activities/repo_activity_test.go30
-rw-r--r--models/activities/statistic.go120
-rw-r--r--models/activities/user_heatmap.go78
-rw-r--r--models/activities/user_heatmap_test.go101
12 files changed, 3061 insertions, 0 deletions
diff --git a/models/activities/action.go b/models/activities/action.go
new file mode 100644
index 0000000..dd67b98
--- /dev/null
+++ b/models/activities/action.go
@@ -0,0 +1,777 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "path"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "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/base"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/builder"
+ "xorm.io/xorm/schemas"
+)
+
+// ActionType represents the type of an action.
+type ActionType int
+
+// Possible action types.
+const (
+ ActionCreateRepo ActionType = iota + 1 // 1
+ ActionRenameRepo // 2
+ ActionStarRepo // 3
+ ActionWatchRepo // 4
+ ActionCommitRepo // 5
+ ActionCreateIssue // 6
+ ActionCreatePullRequest // 7
+ ActionTransferRepo // 8
+ ActionPushTag // 9
+ ActionCommentIssue // 10
+ ActionMergePullRequest // 11
+ ActionCloseIssue // 12
+ ActionReopenIssue // 13
+ ActionClosePullRequest // 14
+ ActionReopenPullRequest // 15
+ ActionDeleteTag // 16
+ ActionDeleteBranch // 17
+ ActionMirrorSyncPush // 18
+ ActionMirrorSyncCreate // 19
+ ActionMirrorSyncDelete // 20
+ ActionApprovePullRequest // 21
+ ActionRejectPullRequest // 22
+ ActionCommentPull // 23
+ ActionPublishRelease // 24
+ ActionPullReviewDismissed // 25
+ ActionPullRequestReadyForReview // 26
+ ActionAutoMergePullRequest // 27
+)
+
+func (at ActionType) String() string {
+ switch at {
+ case ActionCreateRepo:
+ return "create_repo"
+ case ActionRenameRepo:
+ return "rename_repo"
+ case ActionStarRepo:
+ return "star_repo"
+ case ActionWatchRepo:
+ return "watch_repo"
+ case ActionCommitRepo:
+ return "commit_repo"
+ case ActionCreateIssue:
+ return "create_issue"
+ case ActionCreatePullRequest:
+ return "create_pull_request"
+ case ActionTransferRepo:
+ return "transfer_repo"
+ case ActionPushTag:
+ return "push_tag"
+ case ActionCommentIssue:
+ return "comment_issue"
+ case ActionMergePullRequest:
+ return "merge_pull_request"
+ case ActionCloseIssue:
+ return "close_issue"
+ case ActionReopenIssue:
+ return "reopen_issue"
+ case ActionClosePullRequest:
+ return "close_pull_request"
+ case ActionReopenPullRequest:
+ return "reopen_pull_request"
+ case ActionDeleteTag:
+ return "delete_tag"
+ case ActionDeleteBranch:
+ return "delete_branch"
+ case ActionMirrorSyncPush:
+ return "mirror_sync_push"
+ case ActionMirrorSyncCreate:
+ return "mirror_sync_create"
+ case ActionMirrorSyncDelete:
+ return "mirror_sync_delete"
+ case ActionApprovePullRequest:
+ return "approve_pull_request"
+ case ActionRejectPullRequest:
+ return "reject_pull_request"
+ case ActionCommentPull:
+ return "comment_pull"
+ case ActionPublishRelease:
+ return "publish_release"
+ case ActionPullReviewDismissed:
+ return "pull_review_dismissed"
+ case ActionPullRequestReadyForReview:
+ return "pull_request_ready_for_review"
+ case ActionAutoMergePullRequest:
+ return "auto_merge_pull_request"
+ default:
+ return "action-" + strconv.Itoa(int(at))
+ }
+}
+
+func (at ActionType) InActions(actions ...string) bool {
+ for _, action := range actions {
+ if action == at.String() {
+ return true
+ }
+ }
+ return false
+}
+
+// Action represents user operation type and other information to
+// repository. It implemented interface base.Actioner so that can be
+// used in template render.
+type Action struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"INDEX"` // Receiver user id.
+ OpType ActionType
+ ActUserID int64 // Action user id.
+ ActUser *user_model.User `xorm:"-"`
+ RepoID int64
+ Repo *repo_model.Repository `xorm:"-"`
+ CommentID int64 `xorm:"INDEX"`
+ Comment *issues_model.Comment `xorm:"-"`
+ Issue *issues_model.Issue `xorm:"-"` // get the issue id from content
+ IsDeleted bool `xorm:"NOT NULL DEFAULT false"`
+ RefName string
+ IsPrivate bool `xorm:"NOT NULL DEFAULT false"`
+ Content string `xorm:"TEXT"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+}
+
+func init() {
+ db.RegisterModel(new(Action))
+}
+
+// TableIndices implements xorm's TableIndices interface
+func (a *Action) TableIndices() []*schemas.Index {
+ repoIndex := schemas.NewIndex("r_u_d", schemas.IndexType)
+ repoIndex.AddColumn("repo_id", "user_id", "is_deleted")
+
+ actUserIndex := schemas.NewIndex("au_r_c_u_d", schemas.IndexType)
+ actUserIndex.AddColumn("act_user_id", "repo_id", "created_unix", "user_id", "is_deleted")
+
+ cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
+ cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
+
+ indices := []*schemas.Index{actUserIndex, repoIndex, cudIndex}
+
+ return indices
+}
+
+// GetOpType gets the ActionType of this action.
+func (a *Action) GetOpType() ActionType {
+ return a.OpType
+}
+
+// LoadActUser loads a.ActUser
+func (a *Action) LoadActUser(ctx context.Context) {
+ if a.ActUser != nil {
+ return
+ }
+ var err error
+ a.ActUser, err = user_model.GetUserByID(ctx, a.ActUserID)
+ if err == nil {
+ return
+ } else if user_model.IsErrUserNotExist(err) {
+ a.ActUser = user_model.NewGhostUser()
+ } else {
+ log.Error("GetUserByID(%d): %v", a.ActUserID, err)
+ }
+}
+
+func (a *Action) loadRepo(ctx context.Context) {
+ if a.Repo != nil {
+ return
+ }
+ var err error
+ a.Repo, err = repo_model.GetRepositoryByID(ctx, a.RepoID)
+ if err != nil {
+ log.Error("repo_model.GetRepositoryByID(%d): %v", a.RepoID, err)
+ }
+}
+
+// GetActFullName gets the action's user full name.
+func (a *Action) GetActFullName(ctx context.Context) string {
+ a.LoadActUser(ctx)
+ return a.ActUser.FullName
+}
+
+// GetActUserName gets the action's user name.
+func (a *Action) GetActUserName(ctx context.Context) string {
+ a.LoadActUser(ctx)
+ return a.ActUser.Name
+}
+
+// ShortActUserName gets the action's user name trimmed to max 20
+// chars.
+func (a *Action) ShortActUserName(ctx context.Context) string {
+ return base.EllipsisString(a.GetActUserName(ctx), 20)
+}
+
+// GetActDisplayName gets the action's display name based on DEFAULT_SHOW_FULL_NAME, or falls back to the username if it is blank.
+func (a *Action) GetActDisplayName(ctx context.Context) string {
+ if setting.UI.DefaultShowFullName {
+ trimmedFullName := strings.TrimSpace(a.GetActFullName(ctx))
+ if len(trimmedFullName) > 0 {
+ return trimmedFullName
+ }
+ }
+ return a.ShortActUserName(ctx)
+}
+
+// GetActDisplayNameTitle gets the action's display name used for the title (tooltip) based on DEFAULT_SHOW_FULL_NAME
+func (a *Action) GetActDisplayNameTitle(ctx context.Context) string {
+ if setting.UI.DefaultShowFullName {
+ return a.ShortActUserName(ctx)
+ }
+ return a.GetActFullName(ctx)
+}
+
+// GetRepoUserName returns the name of the action repository owner.
+func (a *Action) GetRepoUserName(ctx context.Context) string {
+ a.loadRepo(ctx)
+ if a.Repo == nil {
+ return "(non-existing-repo)"
+ }
+ return a.Repo.OwnerName
+}
+
+// ShortRepoUserName returns the name of the action repository owner
+// trimmed to max 20 chars.
+func (a *Action) ShortRepoUserName(ctx context.Context) string {
+ return base.EllipsisString(a.GetRepoUserName(ctx), 20)
+}
+
+// GetRepoName returns the name of the action repository.
+func (a *Action) GetRepoName(ctx context.Context) string {
+ a.loadRepo(ctx)
+ if a.Repo == nil {
+ return "(non-existing-repo)"
+ }
+ return a.Repo.Name
+}
+
+// ShortRepoName returns the name of the action repository
+// trimmed to max 33 chars.
+func (a *Action) ShortRepoName(ctx context.Context) string {
+ return base.EllipsisString(a.GetRepoName(ctx), 33)
+}
+
+// GetRepoPath returns the virtual path to the action repository.
+func (a *Action) GetRepoPath(ctx context.Context) string {
+ return path.Join(a.GetRepoUserName(ctx), a.GetRepoName(ctx))
+}
+
+// ShortRepoPath returns the virtual path to the action repository
+// trimmed to max 20 + 1 + 33 chars.
+func (a *Action) ShortRepoPath(ctx context.Context) string {
+ return path.Join(a.ShortRepoUserName(ctx), a.ShortRepoName(ctx))
+}
+
+// GetRepoLink returns relative link to action repository.
+func (a *Action) GetRepoLink(ctx context.Context) string {
+ // path.Join will skip empty strings
+ return path.Join(setting.AppSubURL, "/", url.PathEscape(a.GetRepoUserName(ctx)), url.PathEscape(a.GetRepoName(ctx)))
+}
+
+// GetRepoAbsoluteLink returns the absolute link to action repository.
+func (a *Action) GetRepoAbsoluteLink(ctx context.Context) string {
+ return setting.AppURL + url.PathEscape(a.GetRepoUserName(ctx)) + "/" + url.PathEscape(a.GetRepoName(ctx))
+}
+
+func (a *Action) loadComment(ctx context.Context) (err error) {
+ if a.CommentID == 0 || a.Comment != nil {
+ return nil
+ }
+ a.Comment, err = issues_model.GetCommentByID(ctx, a.CommentID)
+ return err
+}
+
+// GetCommentHTMLURL returns link to action comment.
+func (a *Action) GetCommentHTMLURL(ctx context.Context) string {
+ if a == nil {
+ return "#"
+ }
+ _ = a.loadComment(ctx)
+ if a.Comment != nil {
+ return a.Comment.HTMLURL(ctx)
+ }
+
+ if err := a.LoadIssue(ctx); err != nil || a.Issue == nil {
+ return "#"
+ }
+ if err := a.Issue.LoadRepo(ctx); err != nil {
+ return "#"
+ }
+
+ return a.Issue.HTMLURL()
+}
+
+// GetCommentLink returns link to action comment.
+func (a *Action) GetCommentLink(ctx context.Context) string {
+ if a == nil {
+ return "#"
+ }
+ _ = a.loadComment(ctx)
+ if a.Comment != nil {
+ return a.Comment.Link(ctx)
+ }
+
+ if err := a.LoadIssue(ctx); err != nil || a.Issue == nil {
+ return "#"
+ }
+ if err := a.Issue.LoadRepo(ctx); err != nil {
+ return "#"
+ }
+
+ return a.Issue.Link()
+}
+
+// GetBranch returns the action's repository branch.
+func (a *Action) GetBranch() string {
+ return strings.TrimPrefix(a.RefName, git.BranchPrefix)
+}
+
+// GetRefLink returns the action's ref link.
+func (a *Action) GetRefLink(ctx context.Context) string {
+ return git.RefURL(a.GetRepoLink(ctx), a.RefName)
+}
+
+// GetTag returns the action's repository tag.
+func (a *Action) GetTag() string {
+ return strings.TrimPrefix(a.RefName, git.TagPrefix)
+}
+
+// GetContent returns the action's content.
+func (a *Action) GetContent() string {
+ return a.Content
+}
+
+// GetCreate returns the action creation time.
+func (a *Action) GetCreate() time.Time {
+ return a.CreatedUnix.AsTime()
+}
+
+func (a *Action) IsIssueEvent() bool {
+ return a.OpType.InActions("comment_issue", "approve_pull_request", "reject_pull_request", "comment_pull", "merge_pull_request")
+}
+
+// GetIssueInfos returns a list of associated information with the action.
+func (a *Action) GetIssueInfos() []string {
+ // make sure it always returns 3 elements, because there are some access to the a[1] and a[2] without checking the length
+ ret := strings.SplitN(a.Content, "|", 3)
+ for len(ret) < 3 {
+ ret = append(ret, "")
+ }
+ return ret
+}
+
+func (a *Action) getIssueIndex() int64 {
+ infos := a.GetIssueInfos()
+ if len(infos) == 0 {
+ return 0
+ }
+ index, _ := strconv.ParseInt(infos[0], 10, 64)
+ return index
+}
+
+func (a *Action) LoadIssue(ctx context.Context) error {
+ if a.Issue != nil {
+ return nil
+ }
+ if index := a.getIssueIndex(); index > 0 {
+ issue, err := issues_model.GetIssueByIndex(ctx, a.RepoID, index)
+ if err != nil {
+ return err
+ }
+ a.Issue = issue
+ a.Issue.Repo = a.Repo
+ }
+ return nil
+}
+
+// GetIssueTitle returns the title of first issue associated with the action.
+func (a *Action) GetIssueTitle(ctx context.Context) string {
+ if err := a.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return "<500 when get issue>"
+ }
+ if a.Issue == nil {
+ return "<Issue not found>"
+ }
+ return a.Issue.Title
+}
+
+// GetIssueContent returns the content of first issue associated with this action.
+func (a *Action) GetIssueContent(ctx context.Context) string {
+ if err := a.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return "<500 when get issue>"
+ }
+ if a.Issue == nil {
+ return "<Content not found>"
+ }
+ return a.Issue.Content
+}
+
+// GetFeedsOptions options for retrieving feeds
+type GetFeedsOptions struct {
+ db.ListOptions
+ RequestedUser *user_model.User // the user we want activity for
+ RequestedTeam *organization.Team // the team we want activity for
+ RequestedRepo *repo_model.Repository // the repo we want activity for
+ Actor *user_model.User // the user viewing the activity
+ IncludePrivate bool // include private actions
+ OnlyPerformedBy bool // only actions performed by requested user
+ OnlyPerformedByActor bool // only actions performed by the original actor
+ IncludeDeleted bool // include deleted actions
+ 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
+ }
+
+ sess := db.GetEngine(ctx).Where(cond).
+ Select("`action`.*"). // this line will avoid select other joined table's columns
+ Join("INNER", "repository", "`repository`.id = `action`.repo_id")
+
+ opts.SetDefaultValues()
+ sess = db.SetSessionPagination(sess, &opts)
+
+ actions := make([]*Action, 0, opts.PageSize)
+ count, err := sess.Desc("`action`.created_unix").FindAndCount(&actions)
+ if err != nil {
+ return nil, 0, fmt.Errorf("FindAndCount: %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) {
+ cond := builder.NewCond()
+
+ if opts.OnlyPerformedByActor {
+ cond = cond.And(builder.Expr("`action`.user_id = `action`.act_user_id"))
+ }
+
+ if opts.RequestedTeam != nil && opts.RequestedUser == nil {
+ org, err := user_model.GetUserByID(ctx, opts.RequestedTeam.OrgID)
+ if err != nil {
+ return nil, err
+ }
+ opts.RequestedUser = org
+ }
+
+ // check activity visibility for actor ( similar to activityReadable() )
+ if opts.Actor == nil {
+ cond = cond.And(builder.In("act_user_id",
+ builder.Select("`user`.id").Where(
+ builder.Eq{"keep_activity_private": false, "visibility": structs.VisibleTypePublic},
+ ).From("`user`"),
+ ))
+ } else if !opts.Actor.IsAdmin {
+ uidCond := builder.Select("`user`.id").From("`user`").Where(
+ builder.Eq{"keep_activity_private": false}.
+ And(builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited))).
+ Or(builder.Eq{"id": opts.Actor.ID})
+
+ if opts.RequestedUser != nil {
+ if opts.RequestedUser.IsOrganization() {
+ // An organization can always see the activities whose `act_user_id` is the same as its id.
+ uidCond = uidCond.Or(builder.Eq{"id": opts.RequestedUser.ID})
+ } else {
+ // A user can always see the activities of the organizations to which the user belongs.
+ uidCond = uidCond.Or(
+ builder.Eq{"type": user_model.UserTypeOrganization}.
+ And(builder.In("`user`.id", builder.Select("org_id").
+ Where(builder.Eq{"uid": opts.RequestedUser.ID}).
+ From("team_user"))),
+ )
+ }
+ }
+
+ cond = cond.And(builder.In("act_user_id", uidCond))
+ }
+
+ // check readable repositories by doer/actor
+ if opts.Actor == nil || !opts.Actor.IsAdmin {
+ cond = cond.And(builder.In("repo_id", repo_model.AccessibleRepoIDsQuery(opts.Actor)))
+ }
+
+ if opts.RequestedRepo != nil {
+ cond = cond.And(builder.Eq{"repo_id": opts.RequestedRepo.ID})
+ }
+
+ if opts.RequestedTeam != nil {
+ env := organization.OrgFromUser(opts.RequestedUser).AccessibleTeamReposEnv(ctx, opts.RequestedTeam)
+ teamRepoIDs, err := env.RepoIDs(1, opts.RequestedUser.NumRepos)
+ if err != nil {
+ return nil, fmt.Errorf("GetTeamRepositories: %w", err)
+ }
+ cond = cond.And(builder.In("repo_id", teamRepoIDs))
+ }
+
+ if opts.RequestedUser != nil {
+ cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})
+
+ if opts.OnlyPerformedBy {
+ cond = cond.And(builder.Eq{"act_user_id": opts.RequestedUser.ID})
+ }
+ }
+
+ if !opts.IncludePrivate {
+ cond = cond.And(builder.Eq{"`action`.is_private": false})
+ }
+ if !opts.IncludeDeleted {
+ cond = cond.And(builder.Eq{"is_deleted": false})
+ }
+
+ if opts.Date != "" {
+ dateLow, err := time.ParseInLocation("2006-01-02", opts.Date, setting.DefaultUILocation)
+ if err != nil {
+ log.Warn("Unable to parse %s, filter not applied: %v", opts.Date, err)
+ } else {
+ dateHigh := dateLow.Add(86399000000000) // 23h59m59s
+
+ cond = cond.And(builder.Gte{"`action`.created_unix": dateLow.Unix()})
+ cond = cond.And(builder.Lte{"`action`.created_unix": dateHigh.Unix()})
+ }
+ }
+
+ return cond, nil
+}
+
+// DeleteOldActions deletes all old actions from database.
+func DeleteOldActions(ctx context.Context, olderThan time.Duration) (err error) {
+ if olderThan <= 0 {
+ return nil
+ }
+
+ _, err = db.GetEngine(ctx).Where("created_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Action{})
+ return err
+}
+
+// NotifyWatchers creates batch of actions for every watcher.
+func NotifyWatchers(ctx context.Context, actions ...*Action) error {
+ var watchers []*repo_model.Watch
+ var repo *repo_model.Repository
+ var err error
+ var permCode []bool
+ var permIssue []bool
+ var permPR []bool
+
+ e := db.GetEngine(ctx)
+
+ for _, act := range actions {
+ repoChanged := repo == nil || repo.ID != act.RepoID
+
+ if repoChanged {
+ // Add feeds for user self and all watchers.
+ watchers, err = repo_model.GetWatchers(ctx, act.RepoID)
+ if err != nil {
+ return fmt.Errorf("get watchers: %w", err)
+ }
+
+ // Be aware that optimizing this correctly into the `GetWatchers` SQL
+ // query is for most cases less performant than doing this.
+ blockedDoerUserIDs, err := user_model.ListBlockedByUsersID(ctx, act.ActUserID)
+ if err != nil {
+ return fmt.Errorf("user_model.ListBlockedByUsersID: %w", err)
+ }
+
+ if len(blockedDoerUserIDs) > 0 {
+ excludeWatcherIDs := make(container.Set[int64], len(blockedDoerUserIDs))
+ excludeWatcherIDs.AddMultiple(blockedDoerUserIDs...)
+ watchers = slices.DeleteFunc(watchers, func(v *repo_model.Watch) bool {
+ return excludeWatcherIDs.Contains(v.UserID)
+ })
+ }
+ }
+
+ // Add feed for actioner.
+ act.UserID = act.ActUserID
+ if _, err = e.Insert(act); err != nil {
+ return fmt.Errorf("insert new actioner: %w", err)
+ }
+
+ if repoChanged {
+ act.loadRepo(ctx)
+ repo = act.Repo
+
+ // check repo owner exist.
+ if err := act.Repo.LoadOwner(ctx); err != nil {
+ return fmt.Errorf("can't get repo owner: %w", err)
+ }
+ } else if act.Repo == nil {
+ act.Repo = repo
+ }
+
+ // Add feed for organization
+ if act.Repo.Owner.IsOrganization() && act.ActUserID != act.Repo.Owner.ID {
+ act.ID = 0
+ act.UserID = act.Repo.Owner.ID
+ if err = db.Insert(ctx, act); err != nil {
+ return fmt.Errorf("insert new actioner: %w", err)
+ }
+ }
+
+ if repoChanged {
+ permCode = make([]bool, len(watchers))
+ permIssue = make([]bool, len(watchers))
+ permPR = make([]bool, len(watchers))
+ for i, watcher := range watchers {
+ user, err := user_model.GetUserByID(ctx, watcher.UserID)
+ if err != nil {
+ permCode[i] = false
+ permIssue[i] = false
+ permPR[i] = false
+ continue
+ }
+ perm, err := access_model.GetUserRepoPermission(ctx, repo, user)
+ if err != nil {
+ permCode[i] = false
+ permIssue[i] = false
+ permPR[i] = false
+ continue
+ }
+ permCode[i] = perm.CanRead(unit.TypeCode)
+ permIssue[i] = perm.CanRead(unit.TypeIssues)
+ permPR[i] = perm.CanRead(unit.TypePullRequests)
+ }
+ }
+
+ for i, watcher := range watchers {
+ if act.ActUserID == watcher.UserID {
+ continue
+ }
+ act.ID = 0
+ act.UserID = watcher.UserID
+ act.Repo.Units = nil
+
+ switch act.OpType {
+ case ActionCommitRepo, ActionPushTag, ActionDeleteTag, ActionPublishRelease, ActionDeleteBranch:
+ if !permCode[i] {
+ continue
+ }
+ case ActionCreateIssue, ActionCommentIssue, ActionCloseIssue, ActionReopenIssue:
+ if !permIssue[i] {
+ continue
+ }
+ case ActionCreatePullRequest, ActionCommentPull, ActionMergePullRequest, ActionClosePullRequest, ActionReopenPullRequest, ActionAutoMergePullRequest:
+ if !permPR[i] {
+ continue
+ }
+ }
+
+ if err = db.Insert(ctx, act); err != nil {
+ return fmt.Errorf("insert new action: %w", err)
+ }
+ }
+ }
+ return nil
+}
+
+// NotifyWatchersActions creates batch of actions for every watcher.
+func NotifyWatchersActions(ctx context.Context, acts []*Action) error {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ for _, act := range acts {
+ if err := NotifyWatchers(ctx, act); err != nil {
+ return err
+ }
+ }
+ return committer.Commit()
+}
+
+// DeleteIssueActions delete all actions related with issueID
+func DeleteIssueActions(ctx context.Context, repoID, issueID, issueIndex int64) error {
+ // delete actions assigned to this issue
+ e := db.GetEngine(ctx)
+
+ // MariaDB has a performance bug: https://jira.mariadb.org/browse/MDEV-16289
+ // so here it uses "DELETE ... WHERE IN" with pre-queried IDs.
+ var lastCommentID int64
+ commentIDs := make([]int64, 0, db.DefaultMaxInSize)
+ for {
+ commentIDs = commentIDs[:0]
+ err := e.Select("`id`").Table(&issues_model.Comment{}).
+ Where(builder.Eq{"issue_id": issueID}).And("`id` > ?", lastCommentID).
+ OrderBy("`id`").Limit(db.DefaultMaxInSize).
+ Find(&commentIDs)
+ if err != nil {
+ return err
+ } else if len(commentIDs) == 0 {
+ break
+ } else if _, err = db.GetEngine(ctx).In("comment_id", commentIDs).Delete(&Action{}); err != nil {
+ return err
+ }
+ lastCommentID = commentIDs[len(commentIDs)-1]
+ }
+
+ _, err := e.Where("repo_id = ?", repoID).
+ In("op_type", ActionCreateIssue, ActionCreatePullRequest).
+ Where("content LIKE ?", strconv.FormatInt(issueIndex, 10)+"|%"). // "IssueIndex|content..."
+ Delete(&Action{})
+ return err
+}
+
+// CountActionCreatedUnixString count actions where created_unix is an empty string
+func CountActionCreatedUnixString(ctx context.Context) (int64, error) {
+ if setting.Database.Type.IsSQLite3() {
+ return db.GetEngine(ctx).Where(`created_unix = ""`).Count(new(Action))
+ }
+ return 0, nil
+}
+
+// FixActionCreatedUnixString set created_unix to zero if it is an empty string
+func FixActionCreatedUnixString(ctx context.Context) (int64, error) {
+ if setting.Database.Type.IsSQLite3() {
+ res, err := db.GetEngine(ctx).Exec(`UPDATE action SET created_unix = 0 WHERE created_unix = ""`)
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+ }
+ return 0, nil
+}
diff --git a/models/activities/action_list.go b/models/activities/action_list.go
new file mode 100644
index 0000000..aafb7f8
--- /dev/null
+++ b/models/activities/action_list.go
@@ -0,0 +1,203 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "code.gitea.io/gitea/models/db"
+ 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/container"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+// ActionList defines a list of actions
+type ActionList []*Action
+
+func (actions ActionList) getUserIDs() []int64 {
+ return container.FilterSlice(actions, func(action *Action) (int64, bool) {
+ return action.ActUserID, true
+ })
+}
+
+func (actions ActionList) LoadActUsers(ctx context.Context) (map[int64]*user_model.User, error) {
+ if len(actions) == 0 {
+ return nil, nil
+ }
+
+ userIDs := actions.getUserIDs()
+ userMaps := make(map[int64]*user_model.User, len(userIDs))
+ err := db.GetEngine(ctx).
+ In("id", userIDs).
+ Find(&userMaps)
+ if err != nil {
+ return nil, fmt.Errorf("find user: %w", err)
+ }
+
+ for _, action := range actions {
+ action.ActUser = userMaps[action.ActUserID]
+ }
+ return userMaps, nil
+}
+
+func (actions ActionList) getRepoIDs() []int64 {
+ return container.FilterSlice(actions, func(action *Action) (int64, bool) {
+ return action.RepoID, true
+ })
+}
+
+func (actions ActionList) LoadRepositories(ctx context.Context) error {
+ if len(actions) == 0 {
+ return nil
+ }
+
+ repoIDs := actions.getRepoIDs()
+ repoMaps := make(map[int64]*repo_model.Repository, len(repoIDs))
+ err := db.GetEngine(ctx).In("id", repoIDs).Find(&repoMaps)
+ if err != nil {
+ return fmt.Errorf("find repository: %w", err)
+ }
+ for _, action := range actions {
+ action.Repo = repoMaps[action.RepoID]
+ }
+ repos := repo_model.RepositoryList(util.ValuesOfMap(repoMaps))
+ return repos.LoadUnits(ctx)
+}
+
+func (actions ActionList) loadRepoOwner(ctx context.Context, userMap map[int64]*user_model.User) (err error) {
+ if userMap == nil {
+ userMap = make(map[int64]*user_model.User)
+ }
+
+ missingUserIDs := container.FilterSlice(actions, func(action *Action) (int64, bool) {
+ if action.Repo == nil {
+ return 0, false
+ }
+ _, alreadyLoaded := userMap[action.Repo.OwnerID]
+ return action.Repo.OwnerID, !alreadyLoaded
+ })
+ if len(missingUserIDs) == 0 {
+ return nil
+ }
+
+ if err := db.GetEngine(ctx).
+ In("id", missingUserIDs).
+ Find(&userMap); err != nil {
+ return fmt.Errorf("find user: %w", err)
+ }
+
+ for _, action := range actions {
+ if action.Repo != nil {
+ action.Repo.Owner = userMap[action.Repo.OwnerID]
+ }
+ }
+
+ return nil
+}
+
+// LoadAttributes loads all attributes
+func (actions ActionList) LoadAttributes(ctx context.Context) error {
+ // the load sequence cannot be changed because of the dependencies
+ userMap, err := actions.LoadActUsers(ctx)
+ if err != nil {
+ return err
+ }
+ if err := actions.LoadRepositories(ctx); err != nil {
+ return err
+ }
+ if err := actions.loadRepoOwner(ctx, userMap); err != nil {
+ return err
+ }
+ if err := actions.LoadIssues(ctx); err != nil {
+ return err
+ }
+ return actions.LoadComments(ctx)
+}
+
+func (actions ActionList) LoadComments(ctx context.Context) error {
+ if len(actions) == 0 {
+ return nil
+ }
+
+ commentIDs := make([]int64, 0, len(actions))
+ for _, action := range actions {
+ if action.CommentID > 0 {
+ commentIDs = append(commentIDs, action.CommentID)
+ }
+ }
+ if len(commentIDs) == 0 {
+ return nil
+ }
+
+ commentsMap := make(map[int64]*issues_model.Comment, len(commentIDs))
+ if err := db.GetEngine(ctx).In("id", commentIDs).Find(&commentsMap); err != nil {
+ return fmt.Errorf("find comment: %w", err)
+ }
+
+ for _, action := range actions {
+ if action.CommentID > 0 {
+ action.Comment = commentsMap[action.CommentID]
+ if action.Comment != nil {
+ action.Comment.Issue = action.Issue
+ }
+ }
+ }
+ return nil
+}
+
+func (actions ActionList) LoadIssues(ctx context.Context) error {
+ if len(actions) == 0 {
+ return nil
+ }
+
+ conditions := builder.NewCond()
+ issueNum := 0
+ for _, action := range actions {
+ if action.IsIssueEvent() {
+ infos := action.GetIssueInfos()
+ if len(infos) == 0 {
+ continue
+ }
+ index, _ := strconv.ParseInt(infos[0], 10, 64)
+ if index > 0 {
+ conditions = conditions.Or(builder.Eq{
+ "repo_id": action.RepoID,
+ "`index`": index,
+ })
+ issueNum++
+ }
+ }
+ }
+ if !conditions.IsValid() {
+ return nil
+ }
+
+ issuesMap := make(map[string]*issues_model.Issue, issueNum)
+ issues := make([]*issues_model.Issue, 0, issueNum)
+ if err := db.GetEngine(ctx).Where(conditions).Find(&issues); err != nil {
+ return fmt.Errorf("find issue: %w", err)
+ }
+ for _, issue := range issues {
+ issuesMap[fmt.Sprintf("%d-%d", issue.RepoID, issue.Index)] = issue
+ }
+
+ for _, action := range actions {
+ if !action.IsIssueEvent() {
+ continue
+ }
+ if index := action.getIssueIndex(); index > 0 {
+ if issue, ok := issuesMap[fmt.Sprintf("%d-%d", action.RepoID, index)]; ok {
+ action.Issue = issue
+ action.Issue.Repo = action.Repo
+ }
+ }
+ }
+ return nil
+}
diff --git a/models/activities/action_test.go b/models/activities/action_test.go
new file mode 100644
index 0000000..4ce030d
--- /dev/null
+++ b/models/activities/action_test.go
@@ -0,0 +1,320 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities_test
+
+import (
+ "fmt"
+ "path"
+ "testing"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ issue_model "code.gitea.io/gitea/models/issues"
+ 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/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAction_GetRepoPath(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ action := &activities_model.Action{RepoID: repo.ID}
+ assert.Equal(t, path.Join(owner.Name, repo.Name), action.GetRepoPath(db.DefaultContext))
+}
+
+func TestAction_GetRepoLink(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 2})
+ action := &activities_model.Action{RepoID: repo.ID, CommentID: comment.ID}
+ setting.AppSubURL = "/suburl"
+ expected := path.Join(setting.AppSubURL, owner.Name, repo.Name)
+ assert.Equal(t, expected, action.GetRepoLink(db.DefaultContext))
+ assert.Equal(t, repo.HTMLURL(), action.GetRepoAbsoluteLink(db.DefaultContext))
+ assert.Equal(t, comment.HTMLURL(db.DefaultContext), action.GetCommentHTMLURL(db.DefaultContext))
+}
+
+func TestGetFeeds(t *testing.T) {
+ // test with an individual user
+ require.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,
+ })
+ require.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,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, actions)
+ assert.Equal(t, int64(0), count)
+}
+
+func TestGetFeedsForRepos(t *testing.T) {
+ require.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,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, actions)
+ 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,
+ })
+ require.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,
+ })
+ require.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,
+ })
+ require.NoError(t, err)
+ assert.Len(t, actions, 1)
+ assert.Equal(t, int64(1), count)
+}
+
+func TestGetFeeds2(t *testing.T) {
+ // test with an organization user
+ require.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,
+ })
+ require.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,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, actions)
+ assert.Equal(t, int64(0), count)
+}
+
+func TestActivityReadable(t *testing.T) {
+ tt := []struct {
+ desc string
+ user *user_model.User
+ doer *user_model.User
+ result bool
+ }{{
+ desc: "user should see own activity",
+ user: &user_model.User{ID: 1},
+ doer: &user_model.User{ID: 1},
+ result: true,
+ }, {
+ desc: "anon should see activity if public",
+ user: &user_model.User{ID: 1},
+ result: true,
+ }, {
+ desc: "anon should NOT see activity",
+ user: &user_model.User{ID: 1, KeepActivityPrivate: true},
+ result: false,
+ }, {
+ desc: "user should see own activity if private too",
+ user: &user_model.User{ID: 1, KeepActivityPrivate: true},
+ doer: &user_model.User{ID: 1},
+ result: true,
+ }, {
+ desc: "other user should NOT see activity",
+ user: &user_model.User{ID: 1, KeepActivityPrivate: true},
+ doer: &user_model.User{ID: 2},
+ result: false,
+ }, {
+ desc: "admin should see activity",
+ user: &user_model.User{ID: 1, KeepActivityPrivate: true},
+ doer: &user_model.User{ID: 2, IsAdmin: true},
+ result: true,
+ }}
+ for _, test := range tt {
+ assert.Equal(t, test.result, activities_model.ActivityReadable(test.user, test.doer), test.desc)
+ }
+}
+
+func TestNotifyWatchers(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ action := &activities_model.Action{
+ ActUserID: 8,
+ RepoID: 1,
+ OpType: activities_model.ActionStarRepo,
+ }
+ require.NoError(t, activities_model.NotifyWatchers(db.DefaultContext, action))
+
+ // One watchers are inactive, thus action is only created for user 8, 1, 4, 11
+ unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
+ ActUserID: action.ActUserID,
+ UserID: 8,
+ RepoID: action.RepoID,
+ OpType: action.OpType,
+ })
+ unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
+ ActUserID: action.ActUserID,
+ UserID: 1,
+ RepoID: action.RepoID,
+ OpType: action.OpType,
+ })
+ unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
+ ActUserID: action.ActUserID,
+ UserID: 4,
+ RepoID: action.RepoID,
+ OpType: action.OpType,
+ })
+ unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
+ ActUserID: action.ActUserID,
+ UserID: 11,
+ RepoID: action.RepoID,
+ OpType: action.OpType,
+ })
+}
+
+func TestGetFeedsCorrupted(t *testing.T) {
+ require.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,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, actions)
+ assert.Equal(t, int64(0), count)
+}
+
+func TestConsistencyUpdateAction(t *testing.T) {
+ if !setting.Database.Type.IsSQLite3() {
+ t.Skip("Test is only for SQLite database.")
+ }
+ require.NoError(t, unittest.PrepareTestDatabase())
+ id := 8
+ unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
+ ID: int64(id),
+ })
+ _, err := db.GetEngine(db.DefaultContext).Exec(`UPDATE action SET created_unix = "" WHERE id = ?`, id)
+ require.NoError(t, err)
+ actions := make([]*activities_model.Action, 0, 1)
+ //
+ // XORM returns an error when created_unix is a string
+ //
+ err = db.GetEngine(db.DefaultContext).Where("id = ?", id).Find(&actions)
+ require.ErrorContains(t, err, "type string to a int64: invalid syntax")
+
+ //
+ // Get rid of incorrectly set created_unix
+ //
+ count, err := activities_model.CountActionCreatedUnixString(db.DefaultContext)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, count)
+ count, err = activities_model.FixActionCreatedUnixString(db.DefaultContext)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, count)
+
+ count, err = activities_model.CountActionCreatedUnixString(db.DefaultContext)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, count)
+ count, err = activities_model.FixActionCreatedUnixString(db.DefaultContext)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, count)
+
+ //
+ // XORM must be happy now
+ //
+ require.NoError(t, db.GetEngine(db.DefaultContext).Where("id = ?", id).Find(&actions))
+ unittest.CheckConsistencyFor(t, &activities_model.Action{})
+}
+
+func TestDeleteIssueActions(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // load an issue
+ issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4})
+ assert.NotEqualValues(t, issue.ID, issue.Index) // it needs to use different ID/Index to test the DeleteIssueActions to delete some actions by IssueIndex
+
+ // insert a comment
+ err := db.Insert(db.DefaultContext, &issue_model.Comment{Type: issue_model.CommentTypeComment, IssueID: issue.ID})
+ require.NoError(t, err)
+ comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{Type: issue_model.CommentTypeComment, IssueID: issue.ID})
+
+ // truncate action table and insert some actions
+ err = db.TruncateBeans(db.DefaultContext, &activities_model.Action{})
+ require.NoError(t, err)
+ err = db.Insert(db.DefaultContext, &activities_model.Action{
+ OpType: activities_model.ActionCommentIssue,
+ CommentID: comment.ID,
+ })
+ require.NoError(t, err)
+ err = db.Insert(db.DefaultContext, &activities_model.Action{
+ OpType: activities_model.ActionCreateIssue,
+ RepoID: issue.RepoID,
+ Content: fmt.Sprintf("%d|content...", issue.Index),
+ })
+ require.NoError(t, err)
+
+ // assert that the actions exist, then delete them
+ unittest.AssertCount(t, &activities_model.Action{}, 2)
+ require.NoError(t, activities_model.DeleteIssueActions(db.DefaultContext, issue.RepoID, issue.ID, issue.Index))
+ unittest.AssertCount(t, &activities_model.Action{}, 0)
+}
diff --git a/models/activities/main_test.go b/models/activities/main_test.go
new file mode 100644
index 0000000..43afb84
--- /dev/null
+++ b/models/activities/main_test.go
@@ -0,0 +1,17 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/models/activities/notification.go b/models/activities/notification.go
new file mode 100644
index 0000000..09cc640
--- /dev/null
+++ b/models/activities/notification.go
@@ -0,0 +1,407 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+ "fmt"
+ "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/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+type (
+ // NotificationStatus is the status of the notification (read or unread)
+ NotificationStatus uint8
+ // NotificationSource is the source of the notification (issue, PR, commit, etc)
+ NotificationSource uint8
+)
+
+const (
+ // NotificationStatusUnread represents an unread notification
+ NotificationStatusUnread NotificationStatus = iota + 1
+ // NotificationStatusRead represents a read notification
+ NotificationStatusRead
+ // NotificationStatusPinned represents a pinned notification
+ NotificationStatusPinned
+)
+
+const (
+ // NotificationSourceIssue is a notification of an issue
+ NotificationSourceIssue NotificationSource = iota + 1
+ // NotificationSourcePullRequest is a notification of a pull request
+ NotificationSourcePullRequest
+ // NotificationSourceCommit is a notification of a commit
+ NotificationSourceCommit
+ // NotificationSourceRepository is a notification for a repository
+ NotificationSourceRepository
+)
+
+// Notification represents a notification
+type Notification struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"INDEX NOT NULL"`
+ RepoID int64 `xorm:"INDEX NOT NULL"`
+
+ Status NotificationStatus `xorm:"SMALLINT INDEX NOT NULL"`
+ Source NotificationSource `xorm:"SMALLINT INDEX NOT NULL"`
+
+ IssueID int64 `xorm:"INDEX NOT NULL"`
+ CommitID string `xorm:"INDEX"`
+ CommentID int64
+
+ UpdatedBy int64 `xorm:"INDEX NOT NULL"`
+
+ Issue *issues_model.Issue `xorm:"-"`
+ Repository *repo_model.Repository `xorm:"-"`
+ Comment *issues_model.Comment `xorm:"-"`
+ User *user_model.User `xorm:"-"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"created INDEX NOT NULL"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated INDEX NOT NULL"`
+}
+
+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
+
+ 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,
+ }}
+ }
+
+ return db.Insert(ctx, notify)
+ })
+}
+
+func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error {
+ notification := &Notification{
+ UserID: userID,
+ RepoID: issue.RepoID,
+ Status: NotificationStatusUnread,
+ IssueID: issue.ID,
+ CommentID: commentID,
+ UpdatedBy: updatedByID,
+ }
+
+ if issue.IsPull {
+ notification.Source = NotificationSourcePullRequest
+ } else {
+ notification.Source = NotificationSourceIssue
+ }
+
+ return db.Insert(ctx, notification)
+}
+
+func updateIssueNotification(ctx context.Context, userID, issueID, commentID, updatedByID int64) error {
+ notification, err := GetIssueNotification(ctx, userID, issueID)
+ if err != nil {
+ return err
+ }
+
+ // NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments.
+ // But we need update update_by so that the notification will be reorder
+ var cols []string
+ if notification.Status == NotificationStatusRead {
+ notification.Status = NotificationStatusUnread
+ notification.CommentID = commentID
+ cols = []string{"status", "update_by", "comment_id"}
+ } else {
+ notification.UpdatedBy = updatedByID
+ cols = []string{"update_by"}
+ }
+
+ _, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification)
+ return err
+}
+
+// GetIssueNotification return the notification about an issue
+func GetIssueNotification(ctx context.Context, userID, issueID int64) (*Notification, error) {
+ notification := new(Notification)
+ _, err := db.GetEngine(ctx).
+ Where("user_id = ?", userID).
+ And("issue_id = ?", issueID).
+ Get(notification)
+ return notification, err
+}
+
+// LoadAttributes load Repo Issue User and Comment if not loaded
+func (n *Notification) LoadAttributes(ctx context.Context) (err error) {
+ if err = n.loadRepo(ctx); err != nil {
+ return err
+ }
+ if err = n.loadIssue(ctx); err != nil {
+ return err
+ }
+ if err = n.loadUser(ctx); err != nil {
+ return err
+ }
+ if err = n.loadComment(ctx); err != nil {
+ return err
+ }
+ return err
+}
+
+func (n *Notification) loadRepo(ctx context.Context) (err error) {
+ if n.Repository == nil {
+ n.Repository, err = repo_model.GetRepositoryByID(ctx, n.RepoID)
+ if err != nil {
+ return fmt.Errorf("getRepositoryByID [%d]: %w", n.RepoID, err)
+ }
+ }
+ return nil
+}
+
+func (n *Notification) loadIssue(ctx context.Context) (err error) {
+ if n.Issue == nil && n.IssueID != 0 {
+ n.Issue, err = issues_model.GetIssueByID(ctx, n.IssueID)
+ if err != nil {
+ return fmt.Errorf("getIssueByID [%d]: %w", n.IssueID, err)
+ }
+ return n.Issue.LoadAttributes(ctx)
+ }
+ return nil
+}
+
+func (n *Notification) loadComment(ctx context.Context) (err error) {
+ if n.Comment == nil && n.CommentID != 0 {
+ n.Comment, err = issues_model.GetCommentByID(ctx, n.CommentID)
+ if err != nil {
+ if issues_model.IsErrCommentNotExist(err) {
+ return issues_model.ErrCommentNotExist{
+ ID: n.CommentID,
+ IssueID: n.IssueID,
+ }
+ }
+ return 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)
+ if err != nil {
+ return fmt.Errorf("getUserByID [%d]: %w", n.UserID, err)
+ }
+ }
+ return nil
+}
+
+// GetRepo returns the repo of the notification
+func (n *Notification) GetRepo(ctx context.Context) (*repo_model.Repository, error) {
+ return n.Repository, n.loadRepo(ctx)
+}
+
+// GetIssue returns the issue of the notification
+func (n *Notification) GetIssue(ctx context.Context) (*issues_model.Issue, error) {
+ return n.Issue, n.loadIssue(ctx)
+}
+
+// HTMLURL formats a URL-string to the notification
+func (n *Notification) HTMLURL(ctx context.Context) string {
+ switch n.Source {
+ case NotificationSourceIssue, NotificationSourcePullRequest:
+ if n.Comment != nil {
+ return n.Comment.HTMLURL(ctx)
+ }
+ return n.Issue.HTMLURL()
+ case NotificationSourceCommit:
+ return n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID)
+ case NotificationSourceRepository:
+ return n.Repository.HTMLURL()
+ }
+ return ""
+}
+
+// Link formats a relative URL-string to the notification
+func (n *Notification) Link(ctx context.Context) string {
+ switch n.Source {
+ case NotificationSourceIssue, NotificationSourcePullRequest:
+ if n.Comment != nil {
+ return n.Comment.Link(ctx)
+ }
+ return n.Issue.Link()
+ case NotificationSourceCommit:
+ return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID)
+ case NotificationSourceRepository:
+ return n.Repository.Link()
+ }
+ 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
+ Count int64
+}
+
+// GetUIDsAndNotificationCounts returns the unread counts for every user between the two provided times.
+// It must return all user IDs which appear during the period, including count=0 for users who have read all.
+func GetUIDsAndNotificationCounts(ctx context.Context, since, until timeutil.TimeStamp) ([]UserIDCount, error) {
+ sql := `SELECT user_id, sum(case when status= ? then 1 else 0 end) AS count FROM notification ` +
+ `WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` +
+ `updated_unix < ?) GROUP BY user_id`
+ var res []UserIDCount
+ return res, db.GetEngine(ctx).SQL(sql, NotificationStatusUnread, since, until).Find(&res)
+}
+
+// SetIssueReadBy sets issue to be read by given user.
+func SetIssueReadBy(ctx context.Context, issueID, userID int64) error {
+ if err := issues_model.UpdateIssueUserByRead(ctx, userID, issueID); err != nil {
+ return err
+ }
+
+ return setIssueNotificationStatusReadIfUnread(ctx, userID, issueID)
+}
+
+func setIssueNotificationStatusReadIfUnread(ctx context.Context, userID, issueID int64) error {
+ notification, err := GetIssueNotification(ctx, userID, issueID)
+ // ignore if not exists
+ if err != nil {
+ return nil
+ }
+
+ if notification.Status != NotificationStatusUnread {
+ return nil
+ }
+
+ notification.Status = NotificationStatusRead
+
+ _, err = db.GetEngine(ctx).ID(notification.ID).Cols("status").Update(notification)
+ return err
+}
+
+// SetRepoReadBy sets repo to be visited by given user.
+func SetRepoReadBy(ctx context.Context, userID, repoID int64) error {
+ _, err := db.GetEngine(ctx).Where(builder.Eq{
+ "user_id": userID,
+ "status": NotificationStatusUnread,
+ "source": NotificationSourceRepository,
+ "repo_id": repoID,
+ }).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)
+ if err != nil {
+ return notification, err
+ }
+
+ if notification.UserID != user.ID {
+ return nil, fmt.Errorf("Can't change notification of another user: %d, %d", notification.UserID, user.ID)
+ }
+
+ notification.Status = status
+
+ _, err = db.GetEngine(ctx).ID(notificationID).Update(notification)
+ return notification, err
+}
+
+// GetNotificationByID return notification by ID
+func GetNotificationByID(ctx context.Context, notificationID int64) (*Notification, error) {
+ notification := new(Notification)
+ ok, err := db.GetEngine(ctx).
+ Where("id = ?", notificationID).
+ Get(notification)
+ if err != nil {
+ return nil, err
+ }
+
+ if !ok {
+ return nil, db.ErrNotExist{Resource: "notification", ID: notificationID}
+ }
+
+ return notification, nil
+}
+
+// UpdateNotificationStatuses updates the statuses of all of a user's notifications that are of the currentStatus type to the desiredStatus
+func UpdateNotificationStatuses(ctx context.Context, user *user_model.User, currentStatus, desiredStatus NotificationStatus) error {
+ n := &Notification{Status: desiredStatus, UpdatedBy: user.ID}
+ _, err := db.GetEngine(ctx).
+ Where("user_id = ? AND status = ?", user.ID, currentStatus).
+ Cols("status", "updated_by", "updated_unix").
+ Update(n)
+ return err
+}
+
+// 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))
+ for _, notification := range nl {
+ if notification.Issue != nil && notification.Issue.IsPull && notification.Issue.PullRequest == nil {
+ issues[notification.Issue.ID] = notification.Issue
+ }
+ }
+
+ if len(issues) == 0 {
+ return nil
+ }
+
+ pulls, err := issues_model.GetPullRequestByIssueIDs(ctx, util.KeysOfMap(issues))
+ if err != nil {
+ return err
+ }
+
+ for _, pull := range pulls {
+ if issue := issues[pull.IssueID]; issue != nil {
+ issue.PullRequest = pull
+ issue.PullRequest.Issue = issue
+ }
+ }
+
+ return nil
+}
diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go
new file mode 100644
index 0000000..32d2a5c
--- /dev/null
+++ b/models/activities/notification_list.go
@@ -0,0 +1,476 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ 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/log"
+
+ "xorm.io/builder"
+)
+
+// FindNotificationOptions represent the filters for notifications. If an ID is 0 it will be ignored.
+type FindNotificationOptions struct {
+ db.ListOptions
+ UserID int64
+ RepoID int64
+ IssueID int64
+ Status []NotificationStatus
+ Source []NotificationSource
+ UpdatedAfterUnix int64
+ UpdatedBeforeUnix int64
+}
+
+// ToCond will convert each condition into a xorm-Cond
+func (opts FindNotificationOptions) ToConds() builder.Cond {
+ cond := builder.NewCond()
+ if opts.UserID != 0 {
+ cond = cond.And(builder.Eq{"notification.user_id": opts.UserID})
+ }
+ 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 len(opts.Status) > 0 {
+ if len(opts.Status) == 1 {
+ cond = cond.And(builder.Eq{"notification.status": opts.Status[0]})
+ } else {
+ cond = cond.And(builder.In("notification.status", opts.Status))
+ }
+ }
+ if len(opts.Source) > 0 {
+ cond = cond.And(builder.In("notification.source", opts.Source))
+ }
+ if opts.UpdatedAfterUnix != 0 {
+ cond = cond.And(builder.Gte{"notification.updated_unix": opts.UpdatedAfterUnix})
+ }
+ if opts.UpdatedBeforeUnix != 0 {
+ cond = cond.And(builder.Lte{"notification.updated_unix": opts.UpdatedBeforeUnix})
+ }
+ return cond
+}
+
+func (opts FindNotificationOptions) ToOrders() string {
+ return "notification.updated_unix DESC"
+}
+
+// CreateOrUpdateIssueNotifications creates an issue notification
+// for each watcher, or updates it if already exists
+// receiverID > 0 just send to receiver, else send to all watcher
+func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+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
+ }
+
+ if receiverID > 0 {
+ toNotify = make(container.Set[int64], 1)
+ toNotify.Add(receiverID)
+ } else {
+ toNotify = make(container.Set[int64], 32)
+ issueWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, true)
+ if err != nil {
+ return err
+ }
+ toNotify.AddMultiple(issueWatches...)
+ if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {
+ repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID)
+ if err != nil {
+ return err
+ }
+ toNotify.AddMultiple(repoWatches...)
+ }
+ issueParticipants, err := issue.GetParticipantIDsByIssue(ctx)
+ if err != nil {
+ return err
+ }
+ toNotify.AddMultiple(issueParticipants...)
+
+ // dont notify user who cause notification
+ delete(toNotify, notificationAuthorID)
+ // explicit unwatch on issue
+ issueUnWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, false)
+ if err != nil {
+ return err
+ }
+ for _, id := range issueUnWatches {
+ toNotify.Remove(id)
+ }
+ // Remove users who have the notification author blocked.
+ blockedAuthorIDs, err := user_model.ListBlockedByUsersID(ctx, notificationAuthorID)
+ if err != nil {
+ return err
+ }
+ for _, id := range blockedAuthorIDs {
+ toNotify.Remove(id)
+ }
+ }
+
+ err = issue.LoadRepo(ctx)
+ if err != nil {
+ return err
+ }
+
+ // notify
+ for userID := range toNotify {
+ issue.Repo.Units = nil
+ user, err := user_model.GetUserByID(ctx, userID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ continue
+ }
+
+ return err
+ }
+ if issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypePullRequests) {
+ continue
+ }
+ if !issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypeIssues) {
+ continue
+ }
+
+ if notificationExists(notifications, issue.ID, userID) {
+ if err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); err != nil {
+ return err
+ }
+ continue
+ }
+ if err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// NotificationList contains a list of notifications
+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 {
+ return err
+ }
+ if _, err := nl.LoadIssues(ctx); err != nil {
+ return err
+ }
+ if _, err := nl.LoadUsers(ctx); err != nil {
+ return err
+ }
+ if _, err := nl.LoadComments(ctx); err != nil {
+ return err
+ }
+ return nil
+}
+
+// getPendingRepoIDs returns all the repositoty ids which haven't been loaded
+func (nl NotificationList) getPendingRepoIDs() []int64 {
+ return container.FilterSlice(nl, func(n *Notification) (int64, bool) {
+ return n.RepoID, n.Repository == nil
+ })
+}
+
+// LoadRepos loads repositories from database
+func (nl NotificationList) LoadRepos(ctx context.Context) (repo_model.RepositoryList, []int, error) {
+ if len(nl) == 0 {
+ return repo_model.RepositoryList{}, []int{}, nil
+ }
+
+ repoIDs := nl.getPendingRepoIDs()
+ repos := make(map[int64]*repo_model.Repository, len(repoIDs))
+ left := len(repoIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).
+ In("id", repoIDs[:limit]).
+ Rows(new(repo_model.Repository))
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for rows.Next() {
+ var repo repo_model.Repository
+ err = rows.Scan(&repo)
+ if err != nil {
+ rows.Close()
+ return nil, nil, err
+ }
+
+ repos[repo.ID] = &repo
+ }
+ _ = rows.Close()
+
+ left -= limit
+ repoIDs = repoIDs[limit:]
+ }
+
+ failed := []int{}
+
+ reposList := make(repo_model.RepositoryList, 0, len(repoIDs))
+ for i, notification := range nl {
+ if notification.Repository == nil {
+ notification.Repository = repos[notification.RepoID]
+ }
+ if notification.Repository == nil {
+ log.Error("Notification[%d]: RepoID: %d not found", notification.ID, notification.RepoID)
+ failed = append(failed, i)
+ continue
+ }
+ var found bool
+ for _, r := range reposList {
+ if r.ID == notification.RepoID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ reposList = append(reposList, notification.Repository)
+ }
+ }
+ return reposList, failed, nil
+}
+
+func (nl NotificationList) getPendingIssueIDs() []int64 {
+ ids := make(container.Set[int64], len(nl))
+ for _, notification := range nl {
+ if notification.Issue != nil {
+ continue
+ }
+ ids.Add(notification.IssueID)
+ }
+ return ids.Values()
+}
+
+// LoadIssues loads issues from database
+func (nl NotificationList) LoadIssues(ctx context.Context) ([]int, error) {
+ if len(nl) == 0 {
+ return []int{}, nil
+ }
+
+ issueIDs := nl.getPendingIssueIDs()
+ issues := make(map[int64]*issues_model.Issue, len(issueIDs))
+ left := len(issueIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).
+ In("id", issueIDs[:limit]).
+ Rows(new(issues_model.Issue))
+ if err != nil {
+ return nil, err
+ }
+
+ for rows.Next() {
+ var issue issues_model.Issue
+ err = rows.Scan(&issue)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+
+ issues[issue.ID] = &issue
+ }
+ _ = rows.Close()
+
+ left -= limit
+ issueIDs = issueIDs[limit:]
+ }
+
+ failures := []int{}
+
+ for i, notification := range nl {
+ if notification.Issue == nil {
+ notification.Issue = issues[notification.IssueID]
+ if notification.Issue == nil {
+ if notification.IssueID != 0 {
+ log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID)
+ failures = append(failures, i)
+ }
+ continue
+ }
+ notification.Issue.Repo = notification.Repository
+ }
+ }
+ return failures, nil
+}
+
+// Without returns the notification list without the failures
+func (nl NotificationList) Without(failures []int) NotificationList {
+ if len(failures) == 0 {
+ return nl
+ }
+ remaining := make([]*Notification, 0, len(nl))
+ last := -1
+ var i int
+ for _, i = range failures {
+ remaining = append(remaining, nl[last+1:i]...)
+ last = i
+ }
+ if len(nl) > i {
+ remaining = append(remaining, nl[i+1:]...)
+ }
+ return remaining
+}
+
+func (nl NotificationList) getPendingCommentIDs() []int64 {
+ ids := make(container.Set[int64], len(nl))
+ for _, notification := range nl {
+ if notification.CommentID == 0 || notification.Comment != nil {
+ continue
+ }
+ ids.Add(notification.CommentID)
+ }
+ return ids.Values()
+}
+
+func (nl NotificationList) getUserIDs() []int64 {
+ ids := make(container.Set[int64], len(nl))
+ for _, notification := range nl {
+ if notification.UserID == 0 || notification.User != nil {
+ continue
+ }
+ ids.Add(notification.UserID)
+ }
+ return ids.Values()
+}
+
+// LoadUsers loads users from database
+func (nl NotificationList) LoadUsers(ctx context.Context) ([]int, error) {
+ if len(nl) == 0 {
+ return []int{}, nil
+ }
+
+ userIDs := nl.getUserIDs()
+ users := make(map[int64]*user_model.User, len(userIDs))
+ left := len(userIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).
+ In("id", userIDs[:limit]).
+ Rows(new(user_model.User))
+ if err != nil {
+ return nil, err
+ }
+
+ for rows.Next() {
+ var user user_model.User
+ err = rows.Scan(&user)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+
+ users[user.ID] = &user
+ }
+ _ = rows.Close()
+
+ left -= limit
+ userIDs = userIDs[limit:]
+ }
+
+ failures := []int{}
+ for i, notification := range nl {
+ if notification.UserID > 0 && notification.User == nil && users[notification.UserID] != nil {
+ notification.User = users[notification.UserID]
+ if notification.User == nil {
+ log.Error("Notification[%d]: UserID[%d] failed to load", notification.ID, notification.UserID)
+ failures = append(failures, i)
+ continue
+ }
+ }
+ }
+ return failures, nil
+}
+
+// LoadComments loads comments from database
+func (nl NotificationList) LoadComments(ctx context.Context) ([]int, error) {
+ if len(nl) == 0 {
+ return []int{}, nil
+ }
+
+ commentIDs := nl.getPendingCommentIDs()
+ comments := make(map[int64]*issues_model.Comment, len(commentIDs))
+ left := len(commentIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).
+ In("id", commentIDs[:limit]).
+ Rows(new(issues_model.Comment))
+ if err != nil {
+ return nil, err
+ }
+
+ for rows.Next() {
+ var comment issues_model.Comment
+ err = rows.Scan(&comment)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+
+ comments[comment.ID] = &comment
+ }
+ _ = rows.Close()
+
+ left -= limit
+ commentIDs = commentIDs[limit:]
+ }
+
+ failures := []int{}
+ for i, notification := range nl {
+ if notification.CommentID > 0 && notification.Comment == nil && comments[notification.CommentID] != nil {
+ notification.Comment = comments[notification.CommentID]
+ if notification.Comment == nil {
+ log.Error("Notification[%d]: CommentID[%d] failed to load", notification.ID, notification.CommentID)
+ failures = append(failures, i)
+ continue
+ }
+ notification.Comment.Issue = notification.Issue
+ }
+ }
+ return failures, nil
+}
diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go
new file mode 100644
index 0000000..3ff223d
--- /dev/null
+++ b/models/activities/notification_test.go
@@ -0,0 +1,141 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities_test
+
+import (
+ "context"
+ "testing"
+
+ 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/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateOrUpdateIssueNotifications(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+
+ require.NoError(t, activities_model.CreateOrUpdateIssueNotifications(db.DefaultContext, issue.ID, 0, 2, 0))
+
+ // User 9 is inactive, thus notifications for user 1 and 4 are created
+ notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: issue.ID})
+ assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
+ unittest.CheckConsistencyFor(t, &issues_model.Issue{ID: issue.ID})
+
+ notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 4, IssueID: issue.ID})
+ assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
+}
+
+func TestNotificationsForUser(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ notfs, err := db.Find[activities_model.Notification](db.DefaultContext, activities_model.FindNotificationOptions{
+ UserID: user.ID,
+ Status: []activities_model.NotificationStatus{
+ activities_model.NotificationStatusRead,
+ activities_model.NotificationStatusUnread,
+ },
+ })
+ require.NoError(t, err)
+ if assert.Len(t, notfs, 3) {
+ assert.EqualValues(t, 5, notfs[0].ID)
+ assert.EqualValues(t, user.ID, notfs[0].UserID)
+ assert.EqualValues(t, 4, notfs[1].ID)
+ assert.EqualValues(t, user.ID, notfs[1].UserID)
+ assert.EqualValues(t, 2, notfs[2].ID)
+ assert.EqualValues(t, user.ID, notfs[2].UserID)
+ }
+}
+
+func TestNotification_GetRepo(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{RepoID: 1})
+ repo, err := notf.GetRepo(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Equal(t, repo, notf.Repository)
+ assert.EqualValues(t, notf.RepoID, repo.ID)
+}
+
+func TestNotification_GetIssue(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{RepoID: 1})
+ issue, err := notf.GetIssue(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Equal(t, issue, notf.Issue)
+ assert.EqualValues(t, notf.IssueID, issue.ID)
+}
+
+func TestGetNotificationCount(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ cnt, err := db.Count[activities_model.Notification](db.DefaultContext, activities_model.FindNotificationOptions{
+ UserID: user.ID,
+ Status: []activities_model.NotificationStatus{
+ activities_model.NotificationStatusRead,
+ },
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, cnt)
+
+ cnt, err = db.Count[activities_model.Notification](db.DefaultContext, activities_model.FindNotificationOptions{
+ UserID: user.ID,
+ Status: []activities_model.NotificationStatus{
+ activities_model.NotificationStatusUnread,
+ },
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, cnt)
+}
+
+func TestSetNotificationStatus(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ notf := unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusRead})
+ _, err := activities_model.SetNotificationStatus(db.DefaultContext, notf.ID, user, activities_model.NotificationStatusPinned)
+ require.NoError(t, err)
+ unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{ID: notf.ID, Status: activities_model.NotificationStatusPinned})
+
+ _, err = activities_model.SetNotificationStatus(db.DefaultContext, 1, user, activities_model.NotificationStatusRead)
+ require.Error(t, err)
+ _, err = activities_model.SetNotificationStatus(db.DefaultContext, unittest.NonexistentID, user, activities_model.NotificationStatusRead)
+ require.Error(t, err)
+}
+
+func TestUpdateNotificationStatuses(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ notfUnread := unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusUnread})
+ notfRead := unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusRead})
+ notfPinned := unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusPinned})
+ require.NoError(t, activities_model.UpdateNotificationStatuses(db.DefaultContext, user, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead))
+ unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{ID: notfUnread.ID, Status: activities_model.NotificationStatusRead})
+ unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{ID: notfRead.ID, Status: activities_model.NotificationStatusRead})
+ unittest.AssertExistsAndLoadBean(t,
+ &activities_model.Notification{ID: notfPinned.ID, Status: activities_model.NotificationStatusPinned})
+}
+
+func TestSetIssueReadBy(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ require.NoError(t, db.WithTx(db.DefaultContext, func(ctx context.Context) error {
+ return activities_model.SetIssueReadBy(ctx, issue.ID, user.ID)
+ }))
+
+ nt, err := activities_model.GetIssueNotification(db.DefaultContext, user.ID, issue.ID)
+ require.NoError(t, err)
+ assert.EqualValues(t, activities_model.NotificationStatusRead, nt.Status)
+}
diff --git a/models/activities/repo_activity.go b/models/activities/repo_activity.go
new file mode 100644
index 0000000..ffa709a
--- /dev/null
+++ b/models/activities/repo_activity.go
@@ -0,0 +1,391 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ 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/gitrepo"
+
+ "xorm.io/xorm"
+)
+
+// ActivityAuthorData represents statistical git commit count data
+type ActivityAuthorData struct {
+ Name string `json:"name"`
+ Login string `json:"login"`
+ AvatarLink string `json:"avatar_link"`
+ HomeLink string `json:"home_link"`
+ Commits int64 `json:"commits"`
+}
+
+// ActivityStats represents issue and pull request information.
+type ActivityStats struct {
+ OpenedPRs issues_model.PullRequestList
+ OpenedPRAuthorCount int64
+ MergedPRs issues_model.PullRequestList
+ MergedPRAuthorCount int64
+ ActiveIssues issues_model.IssueList
+ OpenedIssues issues_model.IssueList
+ OpenedIssueAuthorCount int64
+ ClosedIssues issues_model.IssueList
+ ClosedIssueAuthorCount int64
+ UnresolvedIssues issues_model.IssueList
+ PublishedReleases []*repo_model.Release
+ PublishedReleaseAuthorCount int64
+ Code *git.CodeActivityStats
+}
+
+// GetActivityStats return stats for repository at given time range
+func GetActivityStats(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) {
+ stats := &ActivityStats{Code: &git.CodeActivityStats{}}
+ if releases {
+ if err := stats.FillReleases(ctx, repo.ID, timeFrom); err != nil {
+ return nil, fmt.Errorf("FillReleases: %w", err)
+ }
+ }
+ if prs {
+ if err := stats.FillPullRequests(ctx, repo.ID, timeFrom); err != nil {
+ return nil, fmt.Errorf("FillPullRequests: %w", err)
+ }
+ }
+ if issues {
+ if err := stats.FillIssues(ctx, repo.ID, timeFrom); err != nil {
+ return nil, fmt.Errorf("FillIssues: %w", err)
+ }
+ }
+ if err := stats.FillUnresolvedIssues(ctx, repo.ID, timeFrom, issues, prs); err != nil {
+ return nil, fmt.Errorf("FillUnresolvedIssues: %w", err)
+ }
+ if code {
+ gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
+ if err != nil {
+ return nil, fmt.Errorf("OpenRepository: %w", err)
+ }
+ defer closer.Close()
+
+ code, err := gitRepo.GetCodeActivityStats(timeFrom, repo.DefaultBranch)
+ if err != nil {
+ return nil, fmt.Errorf("FillFromGit: %w", err)
+ }
+ stats.Code = code
+ }
+ return stats, nil
+}
+
+// GetActivityStatsTopAuthors returns top author stats for git commits for all branches
+func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, count int) ([]*ActivityAuthorData, error) {
+ gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
+ if err != nil {
+ return nil, fmt.Errorf("OpenRepository: %w", err)
+ }
+ defer closer.Close()
+
+ code, err := gitRepo.GetCodeActivityStats(timeFrom, "")
+ if err != nil {
+ return nil, fmt.Errorf("FillFromGit: %w", err)
+ }
+ if code.Authors == nil {
+ return nil, nil
+ }
+ users := make(map[int64]*ActivityAuthorData)
+ var unknownUserID int64
+ unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink(ctx)
+ for _, v := range code.Authors {
+ if len(v.Email) == 0 {
+ continue
+ }
+ u, err := user_model.GetUserByEmail(ctx, v.Email)
+ if u == nil || user_model.IsErrUserNotExist(err) {
+ unknownUserID--
+ users[unknownUserID] = &ActivityAuthorData{
+ Name: v.Name,
+ AvatarLink: unknownUserAvatarLink,
+ Commits: v.Commits,
+ }
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ if user, ok := users[u.ID]; !ok {
+ users[u.ID] = &ActivityAuthorData{
+ Name: u.DisplayName(),
+ Login: u.LowerName,
+ AvatarLink: u.AvatarLink(ctx),
+ HomeLink: u.HomeLink(),
+ Commits: v.Commits,
+ }
+ } else {
+ user.Commits += v.Commits
+ }
+ }
+ v := make([]*ActivityAuthorData, 0, len(users))
+ for _, u := range users {
+ v = append(v, u)
+ }
+
+ sort.Slice(v, func(i, j int) bool {
+ return v[i].Commits > v[j].Commits
+ })
+
+ cnt := count
+ if cnt > len(v) {
+ cnt = len(v)
+ }
+
+ return v[:cnt], nil
+}
+
+// ActivePRCount returns total active pull request count
+func (stats *ActivityStats) ActivePRCount() int {
+ return stats.OpenedPRCount() + stats.MergedPRCount()
+}
+
+// OpenedPRCount returns opened pull request count
+func (stats *ActivityStats) OpenedPRCount() int {
+ return len(stats.OpenedPRs)
+}
+
+// OpenedPRPerc returns opened pull request percents from total active
+func (stats *ActivityStats) OpenedPRPerc() int {
+ return int(float32(stats.OpenedPRCount()) / float32(stats.ActivePRCount()) * 100.0)
+}
+
+// MergedPRCount returns merged pull request count
+func (stats *ActivityStats) MergedPRCount() int {
+ return len(stats.MergedPRs)
+}
+
+// MergedPRPerc returns merged pull request percent from total active
+func (stats *ActivityStats) MergedPRPerc() int {
+ return int(float32(stats.MergedPRCount()) / float32(stats.ActivePRCount()) * 100.0)
+}
+
+// ActiveIssueCount returns total active issue count
+func (stats *ActivityStats) ActiveIssueCount() int {
+ return len(stats.ActiveIssues)
+}
+
+// OpenedIssueCount returns open issue count
+func (stats *ActivityStats) OpenedIssueCount() int {
+ return len(stats.OpenedIssues)
+}
+
+// OpenedIssuePerc returns open issue count percent from total active
+func (stats *ActivityStats) OpenedIssuePerc() int {
+ return int(float32(stats.OpenedIssueCount()) / float32(stats.ActiveIssueCount()) * 100.0)
+}
+
+// ClosedIssueCount returns closed issue count
+func (stats *ActivityStats) ClosedIssueCount() int {
+ return len(stats.ClosedIssues)
+}
+
+// ClosedIssuePerc returns closed issue count percent from total active
+func (stats *ActivityStats) ClosedIssuePerc() int {
+ return int(float32(stats.ClosedIssueCount()) / float32(stats.ActiveIssueCount()) * 100.0)
+}
+
+// UnresolvedIssueCount returns unresolved issue and pull request count
+func (stats *ActivityStats) UnresolvedIssueCount() int {
+ return len(stats.UnresolvedIssues)
+}
+
+// PublishedReleaseCount returns published release count
+func (stats *ActivityStats) PublishedReleaseCount() int {
+ return len(stats.PublishedReleases)
+}
+
+// FillPullRequests returns pull request information for activity page
+func (stats *ActivityStats) FillPullRequests(ctx context.Context, repoID int64, fromTime time.Time) error {
+ var err error
+ var count int64
+
+ // Merged pull requests
+ sess := pullRequestsForActivityStatement(ctx, repoID, fromTime, true)
+ sess.OrderBy("pull_request.merged_unix DESC")
+ stats.MergedPRs = make(issues_model.PullRequestList, 0)
+ if err = sess.Find(&stats.MergedPRs); err != nil {
+ return err
+ }
+ if err = stats.MergedPRs.LoadAttributes(ctx); err != nil {
+ return err
+ }
+
+ // Merged pull request authors
+ sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, true)
+ if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("pull_request").Get(&count); err != nil {
+ return err
+ }
+ stats.MergedPRAuthorCount = count
+
+ // Opened pull requests
+ sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, false)
+ sess.OrderBy("issue.created_unix ASC")
+ stats.OpenedPRs = make(issues_model.PullRequestList, 0)
+ if err = sess.Find(&stats.OpenedPRs); err != nil {
+ return err
+ }
+ if err = stats.OpenedPRs.LoadAttributes(ctx); err != nil {
+ return err
+ }
+
+ // Opened pull request authors
+ sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, false)
+ if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("pull_request").Get(&count); err != nil {
+ return err
+ }
+ stats.OpenedPRAuthorCount = count
+
+ return nil
+}
+
+func pullRequestsForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time, merged bool) *xorm.Session {
+ sess := db.GetEngine(ctx).Where("pull_request.base_repo_id=?", repoID).
+ Join("INNER", "issue", "pull_request.issue_id = issue.id")
+
+ if merged {
+ sess.And("pull_request.has_merged = ?", true)
+ sess.And("pull_request.merged_unix >= ?", fromTime.Unix())
+ } else {
+ sess.And("issue.is_closed = ?", false)
+ sess.And("issue.created_unix >= ?", fromTime.Unix())
+ }
+
+ return sess
+}
+
+// FillIssues returns issue information for activity page
+func (stats *ActivityStats) FillIssues(ctx context.Context, repoID int64, fromTime time.Time) error {
+ var err error
+ var count int64
+
+ // Closed issues
+ sess := issuesForActivityStatement(ctx, repoID, fromTime, true, false)
+ sess.OrderBy("issue.closed_unix DESC")
+ stats.ClosedIssues = make(issues_model.IssueList, 0)
+ if err = sess.Find(&stats.ClosedIssues); err != nil {
+ return err
+ }
+
+ // Closed issue authors
+ sess = issuesForActivityStatement(ctx, repoID, fromTime, true, false)
+ if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil {
+ return err
+ }
+ stats.ClosedIssueAuthorCount = count
+
+ // New issues
+ sess = newlyCreatedIssues(ctx, repoID, fromTime)
+ sess.OrderBy("issue.created_unix ASC")
+ stats.OpenedIssues = make(issues_model.IssueList, 0)
+ if err = sess.Find(&stats.OpenedIssues); err != nil {
+ return err
+ }
+
+ // Active issues
+ sess = activeIssues(ctx, repoID, fromTime)
+ sess.OrderBy("issue.created_unix ASC")
+ stats.ActiveIssues = make(issues_model.IssueList, 0)
+ if err = sess.Find(&stats.ActiveIssues); err != nil {
+ return err
+ }
+
+ // Opened issue authors
+ sess = issuesForActivityStatement(ctx, repoID, fromTime, false, false)
+ if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil {
+ return err
+ }
+ stats.OpenedIssueAuthorCount = count
+
+ return nil
+}
+
+// FillUnresolvedIssues returns unresolved issue and pull request information for activity page
+func (stats *ActivityStats) FillUnresolvedIssues(ctx context.Context, repoID int64, fromTime time.Time, issues, prs bool) error {
+ // Check if we need to select anything
+ if !issues && !prs {
+ return nil
+ }
+ sess := issuesForActivityStatement(ctx, repoID, fromTime, false, true)
+ if !issues || !prs {
+ sess.And("issue.is_pull = ?", prs)
+ }
+ sess.OrderBy("issue.updated_unix DESC")
+ stats.UnresolvedIssues = make(issues_model.IssueList, 0)
+ return sess.Find(&stats.UnresolvedIssues)
+}
+
+func newlyCreatedIssues(ctx context.Context, repoID int64, fromTime time.Time) *xorm.Session {
+ sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
+ And("issue.is_pull = ?", false). // Retain the is_pull check to exclude pull requests
+ And("issue.created_unix >= ?", fromTime.Unix()) // Include all issues created after fromTime
+
+ return sess
+}
+
+func activeIssues(ctx context.Context, repoID int64, fromTime time.Time) *xorm.Session {
+ sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
+ And("issue.is_pull = ?", false).
+ And("issue.created_unix >= ? OR issue.closed_unix >= ?", fromTime.Unix(), fromTime.Unix())
+
+ return sess
+}
+
+func issuesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time, closed, unresolved bool) *xorm.Session {
+ sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
+ And("issue.is_closed = ?", closed)
+
+ if !unresolved {
+ sess.And("issue.is_pull = ?", false)
+ if closed {
+ sess.And("issue.closed_unix >= ?", fromTime.Unix())
+ } else {
+ sess.And("issue.created_unix >= ?", fromTime.Unix())
+ }
+ } else {
+ sess.And("issue.created_unix < ?", fromTime.Unix())
+ sess.And("issue.updated_unix >= ?", fromTime.Unix())
+ }
+
+ return sess
+}
+
+// FillReleases returns release information for activity page
+func (stats *ActivityStats) FillReleases(ctx context.Context, repoID int64, fromTime time.Time) error {
+ var err error
+ var count int64
+
+ // Published releases list
+ sess := releasesForActivityStatement(ctx, repoID, fromTime)
+ sess.OrderBy("`release`.created_unix DESC")
+ stats.PublishedReleases = make([]*repo_model.Release, 0)
+ if err = sess.Find(&stats.PublishedReleases); err != nil {
+ return err
+ }
+
+ // Published releases authors
+ sess = releasesForActivityStatement(ctx, repoID, fromTime)
+ if _, err = sess.Select("count(distinct `release`.publisher_id) as `count`").Table("release").Get(&count); err != nil {
+ return err
+ }
+ stats.PublishedReleaseAuthorCount = count
+
+ return nil
+}
+
+func releasesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time) *xorm.Session {
+ return db.GetEngine(ctx).Where("`release`.repo_id = ?", repoID).
+ And("`release`.is_draft = ?", false).
+ And("`release`.created_unix >= ?", fromTime.Unix())
+}
diff --git a/models/activities/repo_activity_test.go b/models/activities/repo_activity_test.go
new file mode 100644
index 0000000..06cd0e1
--- /dev/null
+++ b/models/activities/repo_activity_test.go
@@ -0,0 +1,30 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetActivityStats(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ stats, err := GetActivityStats(db.DefaultContext, repo, time.Unix(0, 0), true, true, true, true)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, 2, stats.ActiveIssueCount())
+ assert.EqualValues(t, 2, stats.OpenedIssueCount())
+ assert.EqualValues(t, 0, stats.ClosedIssueCount())
+ assert.EqualValues(t, 3, stats.ActivePRCount())
+}
diff --git a/models/activities/statistic.go b/models/activities/statistic.go
new file mode 100644
index 0000000..ff81ad7
--- /dev/null
+++ b/models/activities/statistic.go
@@ -0,0 +1,120 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ project_model "code.gitea.io/gitea/models/project"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Statistic contains the database statistics
+type Statistic struct {
+ Counter struct {
+ User, Org, PublicKey,
+ Repo, Watch, Star, Access,
+ Issue, IssueClosed, IssueOpen,
+ Comment, Oauth, Follow,
+ Mirror, Release, AuthSource, Webhook,
+ Milestone, Label, HookTask,
+ Team, UpdateTask, Project,
+ ProjectColumn, Attachment,
+ Branches, Tags, CommitStatus int64
+ IssueByLabel []IssueByLabelCount
+ IssueByRepository []IssueByRepositoryCount
+ }
+}
+
+// IssueByLabelCount contains the number of issue group by label
+type IssueByLabelCount struct {
+ Count int64
+ Label string
+}
+
+// IssueByRepositoryCount contains the number of issue group by repository
+type IssueByRepositoryCount struct {
+ Count int64
+ OwnerName string
+ Repository string
+}
+
+// GetStatistic returns the database statistics
+func GetStatistic(ctx context.Context) (stats Statistic) {
+ e := db.GetEngine(ctx)
+ stats.Counter.User = user_model.CountUsers(ctx, nil)
+ stats.Counter.Org, _ = db.Count[organization.Organization](ctx, organization.FindOrgOptions{IncludePrivate: true})
+ stats.Counter.PublicKey, _ = e.Count(new(asymkey_model.PublicKey))
+ stats.Counter.Repo, _ = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{})
+ stats.Counter.Watch, _ = e.Count(new(repo_model.Watch))
+ stats.Counter.Star, _ = e.Count(new(repo_model.Star))
+ stats.Counter.Access, _ = e.Count(new(access_model.Access))
+ stats.Counter.Branches, _ = e.Count(new(git_model.Branch))
+ stats.Counter.Tags, _ = e.Where("is_draft=?", false).Count(new(repo_model.Release))
+ stats.Counter.CommitStatus, _ = e.Count(new(git_model.CommitStatus))
+
+ type IssueCount struct {
+ Count int64
+ IsClosed bool
+ }
+
+ if setting.Metrics.EnabledIssueByLabel {
+ stats.Counter.IssueByLabel = []IssueByLabelCount{}
+
+ _ = e.Select("COUNT(*) AS count, l.name AS label").
+ Join("LEFT", "label l", "l.id=il.label_id").
+ Table("issue_label il").
+ GroupBy("l.name").
+ Find(&stats.Counter.IssueByLabel)
+ }
+
+ if setting.Metrics.EnabledIssueByRepository {
+ stats.Counter.IssueByRepository = []IssueByRepositoryCount{}
+
+ _ = e.Select("COUNT(*) AS count, r.owner_name, r.name AS repository").
+ Join("LEFT", "repository r", "r.id=i.repo_id").
+ Table("issue i").
+ GroupBy("r.owner_name, r.name").
+ Find(&stats.Counter.IssueByRepository)
+ }
+
+ var issueCounts []IssueCount
+
+ _ = e.Select("COUNT(*) AS count, is_closed").Table("issue").GroupBy("is_closed").Find(&issueCounts)
+ for _, c := range issueCounts {
+ if c.IsClosed {
+ stats.Counter.IssueClosed = c.Count
+ } else {
+ stats.Counter.IssueOpen = c.Count
+ }
+ }
+
+ stats.Counter.Issue = stats.Counter.IssueClosed + stats.Counter.IssueOpen
+
+ stats.Counter.Comment, _ = e.Count(new(issues_model.Comment))
+ stats.Counter.Oauth = 0
+ stats.Counter.Follow, _ = e.Count(new(user_model.Follow))
+ stats.Counter.Mirror, _ = e.Count(new(repo_model.Mirror))
+ stats.Counter.Release, _ = e.Count(new(repo_model.Release))
+ stats.Counter.AuthSource, _ = db.Count[auth.Source](ctx, auth.FindSourcesOptions{})
+ stats.Counter.Webhook, _ = e.Count(new(webhook.Webhook))
+ stats.Counter.Milestone, _ = e.Count(new(issues_model.Milestone))
+ stats.Counter.Label, _ = e.Count(new(issues_model.Label))
+ stats.Counter.HookTask, _ = e.Count(new(webhook.HookTask))
+ stats.Counter.Team, _ = e.Count(new(organization.Team))
+ stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment))
+ stats.Counter.Project, _ = e.Count(new(project_model.Project))
+ stats.Counter.ProjectColumn, _ = e.Count(new(project_model.Column))
+ return stats
+}
diff --git a/models/activities/user_heatmap.go b/models/activities/user_heatmap.go
new file mode 100644
index 0000000..080075d
--- /dev/null
+++ b/models/activities/user_heatmap.go
@@ -0,0 +1,78 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// UserHeatmapData represents the data needed to create a heatmap
+type UserHeatmapData struct {
+ Timestamp timeutil.TimeStamp `json:"timestamp"`
+ Contributions int64 `json:"contributions"`
+}
+
+// GetUserHeatmapDataByUser returns an array of UserHeatmapData
+func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) {
+ return getUserHeatmapData(ctx, user, nil, doer)
+}
+
+// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData
+func GetUserHeatmapDataByUserTeam(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
+ return getUserHeatmapData(ctx, user, team, doer)
+}
+
+func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
+ hdata := make([]*UserHeatmapData, 0)
+
+ if !ActivityReadable(user, doer) {
+ return hdata, nil
+ }
+
+ // Group by 15 minute intervals which will allow the client to accurately shift the timestamp to their timezone.
+ // The interval is based on the fact that there are timezones such as UTC +5:30 and UTC +12:45.
+ groupBy := "created_unix / 900 * 900"
+ if setting.Database.Type.IsMySQL() {
+ groupBy = "created_unix DIV 900 * 900"
+ }
+
+ cond, err := activityQueryCondition(ctx, GetFeedsOptions{
+ RequestedUser: user,
+ RequestedTeam: team,
+ Actor: doer,
+ IncludePrivate: true, // don't filter by private, as we already filter by repo access
+ IncludeDeleted: true,
+ // * Heatmaps for individual users only include actions that the user themself did.
+ // * For organizations actions by all users that were made in owned
+ // repositories are counted.
+ OnlyPerformedBy: !user.IsOrganization(),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return hdata, db.GetEngine(ctx).
+ Select(groupBy+" AS timestamp, count(user_id) as contributions").
+ Table("action").
+ Where(cond).
+ And("created_unix > ?", timeutil.TimeStampNow()-31536000).
+ GroupBy("timestamp").
+ OrderBy("timestamp").
+ Find(&hdata)
+}
+
+// GetTotalContributionsInHeatmap returns the total number of contributions in a heatmap
+func GetTotalContributionsInHeatmap(hdata []*UserHeatmapData) int64 {
+ var total int64
+ for _, v := range hdata {
+ total += v.Contributions
+ }
+ return total
+}
diff --git a/models/activities/user_heatmap_test.go b/models/activities/user_heatmap_test.go
new file mode 100644
index 0000000..316ea7d
--- /dev/null
+++ b/models/activities/user_heatmap_test.go
@@ -0,0 +1,101 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities_test
+
+import (
+ "testing"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetUserHeatmapDataByUser(t *testing.T) {
+ testCases := []struct {
+ desc string
+ userID int64
+ doerID int64
+ CountResult int
+ JSONResult string
+ }{
+ {
+ "self looks at action in private repo",
+ 2, 2, 1, `[{"timestamp":1603227600,"contributions":1}]`,
+ },
+ {
+ "admin looks at action in private repo",
+ 2, 1, 1, `[{"timestamp":1603227600,"contributions":1}]`,
+ },
+ {
+ "other user looks at action in private repo",
+ 2, 3, 0, `[]`,
+ },
+ {
+ "nobody looks at action in private repo",
+ 2, 0, 0, `[]`,
+ },
+ {
+ "collaborator looks at action in private repo",
+ 16, 15, 1, `[{"timestamp":1603267200,"contributions":1}]`,
+ },
+ {
+ "no action action not performed by target user",
+ 3, 3, 0, `[]`,
+ },
+ {
+ "multiple actions performed with two grouped together",
+ 10, 10, 3, `[{"timestamp":1603009800,"contributions":1},{"timestamp":1603010700,"contributions":2}]`,
+ },
+ }
+ // Prepare
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // Mock time
+ timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
+ defer timeutil.MockUnset()
+
+ for _, tc := range testCases {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.userID})
+
+ doer := &user_model.User{ID: tc.doerID}
+ _, err := unittest.LoadBeanIfExists(doer)
+ require.NoError(t, err)
+ if tc.doerID == 0 {
+ doer = nil
+ }
+
+ // get the action for comparison
+ actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+ RequestedUser: user,
+ Actor: doer,
+ IncludePrivate: true,
+ OnlyPerformedBy: true,
+ IncludeDeleted: true,
+ })
+ require.NoError(t, err)
+
+ // Get the heatmap and compare
+ heatmap, err := activities_model.GetUserHeatmapDataByUser(db.DefaultContext, user, doer)
+ var contributions int
+ for _, hm := range heatmap {
+ contributions += int(hm.Contributions)
+ }
+ require.NoError(t, err)
+ assert.Len(t, actions, contributions, "invalid action count: did the test data became too old?")
+ assert.Equal(t, count, int64(contributions))
+ assert.Equal(t, tc.CountResult, contributions, tc.desc)
+
+ // Test JSON rendering
+ jsonData, err := json.Marshal(heatmap)
+ require.NoError(t, err)
+ assert.Equal(t, tc.JSONResult, string(jsonData))
+ }
+}