diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /models/activities | |
parent | Initial commit. (diff) | |
download | forgejo-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.go | 777 | ||||
-rw-r--r-- | models/activities/action_list.go | 203 | ||||
-rw-r--r-- | models/activities/action_test.go | 320 | ||||
-rw-r--r-- | models/activities/main_test.go | 17 | ||||
-rw-r--r-- | models/activities/notification.go | 407 | ||||
-rw-r--r-- | models/activities/notification_list.go | 476 | ||||
-rw-r--r-- | models/activities/notification_test.go | 141 | ||||
-rw-r--r-- | models/activities/repo_activity.go | 391 | ||||
-rw-r--r-- | models/activities/repo_activity_test.go | 30 | ||||
-rw-r--r-- | models/activities/statistic.go | 120 | ||||
-rw-r--r-- | models/activities/user_heatmap.go | 78 | ||||
-rw-r--r-- | models/activities/user_heatmap_test.go | 101 |
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)) + } +} |