diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2022-08-25 10:31:57 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-25 10:31:57 +0800 |
commit | 1d8543e7db58d7c4973758e47f005c4d8bd7d7a3 (patch) | |
tree | b60c99e2dfd69ccb998f8a0829d98d7cadcf0d6b /models/activities | |
parent | 4a4bfafa238bf48851f8c11fa3701bd42b912475 (diff) | |
download | gitea-1d8543e7db58d7c4973758e47f005c4d8bd7d7a3.tar.gz gitea-1d8543e7db58d7c4973758e47f005c4d8bd7d7a3.zip |
Move some files into models' sub packages (#20262)
* Move some files into models' sub packages
* Move functions
* merge main branch
* Fix check
* fix check
* Fix some tests
* Fix lint
* Fix lint
* Revert lint changes
* Fix error comments
* Fix lint
Co-authored-by: 6543 <6543@obermui.de>
Diffstat (limited to 'models/activities')
-rw-r--r-- | models/activities/action.go | 610 | ||||
-rw-r--r-- | models/activities/action_list.go | 116 | ||||
-rw-r--r-- | models/activities/action_test.go | 274 | ||||
-rw-r--r-- | models/activities/main_test.go | 20 | ||||
-rw-r--r-- | models/activities/notification.go | 835 | ||||
-rw-r--r-- | models/activities/notification_test.go | 112 | ||||
-rw-r--r-- | models/activities/repo_activity.go | 366 | ||||
-rw-r--r-- | models/activities/statistic.go | 115 | ||||
-rw-r--r-- | models/activities/user_heatmap.go | 72 | ||||
-rw-r--r-- | models/activities/user_heatmap_test.go | 101 |
10 files changed, 2621 insertions, 0 deletions
diff --git a/models/activities/action.go b/models/activities/action.go new file mode 100644 index 0000000000..78a519e9a7 --- /dev/null +++ b/models/activities/action.go @@ -0,0 +1,610 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activities + +import ( + "context" + "fmt" + "net/url" + "path" + "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/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" + "code.gitea.io/gitea/modules/util" + + "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 +) + +// 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 // 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:"-"` + 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") + + return []*schemas.Index{actUserIndex, repoIndex} +} + +// GetOpType gets the ActionType of this action. +func (a *Action) GetOpType() ActionType { + return a.OpType +} + +// LoadActUser loads a.ActUser +func (a *Action) LoadActUser() { + if a.ActUser != nil { + return + } + var err error + a.ActUser, err = user_model.GetUserByID(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() { + if a.Repo != nil { + return + } + var err error + a.Repo, err = repo_model.GetRepositoryByID(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() string { + a.LoadActUser() + return a.ActUser.FullName +} + +// GetActUserName gets the action's user name. +func (a *Action) GetActUserName() string { + a.LoadActUser() + return a.ActUser.Name +} + +// ShortActUserName gets the action's user name trimmed to max 20 +// chars. +func (a *Action) ShortActUserName() string { + return base.EllipsisString(a.GetActUserName(), 20) +} + +// GetDisplayName 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) GetDisplayName() string { + if setting.UI.DefaultShowFullName { + trimmedFullName := strings.TrimSpace(a.GetActFullName()) + if len(trimmedFullName) > 0 { + return trimmedFullName + } + } + return a.ShortActUserName() +} + +// GetDisplayNameTitle gets the action's display name used for the title (tooltip) based on DEFAULT_SHOW_FULL_NAME +func (a *Action) GetDisplayNameTitle() string { + if setting.UI.DefaultShowFullName { + return a.ShortActUserName() + } + return a.GetActFullName() +} + +// GetRepoUserName returns the name of the action repository owner. +func (a *Action) GetRepoUserName() string { + a.loadRepo() + return a.Repo.OwnerName +} + +// ShortRepoUserName returns the name of the action repository owner +// trimmed to max 20 chars. +func (a *Action) ShortRepoUserName() string { + return base.EllipsisString(a.GetRepoUserName(), 20) +} + +// GetRepoName returns the name of the action repository. +func (a *Action) GetRepoName() string { + a.loadRepo() + return a.Repo.Name +} + +// ShortRepoName returns the name of the action repository +// trimmed to max 33 chars. +func (a *Action) ShortRepoName() string { + return base.EllipsisString(a.GetRepoName(), 33) +} + +// GetRepoPath returns the virtual path to the action repository. +func (a *Action) GetRepoPath() string { + return path.Join(a.GetRepoUserName(), a.GetRepoName()) +} + +// ShortRepoPath returns the virtual path to the action repository +// trimmed to max 20 + 1 + 33 chars. +func (a *Action) ShortRepoPath() string { + return path.Join(a.ShortRepoUserName(), a.ShortRepoName()) +} + +// GetRepoLink returns relative link to action repository. +func (a *Action) GetRepoLink() string { + // path.Join will skip empty strings + return path.Join(setting.AppSubURL, "/", url.PathEscape(a.GetRepoUserName()), url.PathEscape(a.GetRepoName())) +} + +// GetCommentLink returns link to action comment. +func (a *Action) GetCommentLink() string { + return a.getCommentLink(db.DefaultContext) +} + +func (a *Action) getCommentLink(ctx context.Context) string { + if a == nil { + return "#" + } + if a.Comment == nil && a.CommentID != 0 { + a.Comment, _ = issues_model.GetCommentByID(ctx, a.CommentID) + } + if a.Comment != nil { + return a.Comment.HTMLURL() + } + if len(a.GetIssueInfos()) == 0 { + return "#" + } + // Return link to issue + issueIDString := a.GetIssueInfos()[0] + issueID, err := strconv.ParseInt(issueIDString, 10, 64) + if err != nil { + return "#" + } + + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + return "#" + } + + if err = issue.LoadRepo(ctx); err != nil { + return "#" + } + + return issue.HTMLURL() +} + +// 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() string { + switch { + case strings.HasPrefix(a.RefName, git.BranchPrefix): + return a.GetRepoLink() + "/src/branch/" + util.PathEscapeSegments(strings.TrimPrefix(a.RefName, git.BranchPrefix)) + case strings.HasPrefix(a.RefName, git.TagPrefix): + return a.GetRepoLink() + "/src/tag/" + util.PathEscapeSegments(strings.TrimPrefix(a.RefName, git.TagPrefix)) + case len(a.RefName) == 40 && git.SHAPattern.MatchString(a.RefName): + return a.GetRepoLink() + "/src/commit/" + a.RefName + default: + // FIXME: we will just assume it's a branch - this was the old way - at some point we may want to enforce that there is always a ref here. + return a.GetRepoLink() + "/src/branch/" + util.PathEscapeSegments(strings.TrimPrefix(a.RefName, git.BranchPrefix)) + } +} + +// 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() +} + +// GetIssueInfos returns a list of issues associated with +// the action. +func (a *Action) GetIssueInfos() []string { + return strings.SplitN(a.Content, "|", 3) +} + +// GetIssueTitle returns the title of first issue associated +// with the action. +func (a *Action) GetIssueTitle() string { + index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64) + issue, err := issues_model.GetIssueByIndex(a.RepoID, index) + if err != nil { + log.Error("GetIssueByIndex: %v", err) + return "500 when get issue" + } + return issue.Title +} + +// GetIssueContent returns the content of first issue associated with +// this action. +func (a *Action) GetIssueContent() string { + index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64) + issue, err := issues_model.GetIssueByIndex(a.RepoID, index) + if err != nil { + log.Error("GetIssueByIndex: %v", err) + return "500 when get issue" + } + return 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 + 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, error) { + if opts.RequestedUser == nil && opts.RequestedTeam == nil && opts.RequestedRepo == nil { + return nil, fmt.Errorf("need at least one of these filters: RequestedUser, RequestedTeam, RequestedRepo") + } + + cond, err := activityQueryCondition(opts) + if err != nil { + return nil, 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) + + if err := sess.Desc("`action`.created_unix").Find(&actions); err != nil { + return nil, fmt.Errorf("Find: %v", err) + } + + if err := ActionList(actions).loadAttributes(ctx); err != nil { + return nil, fmt.Errorf("LoadAttributes: %v", err) + } + + return actions, 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(opts GetFeedsOptions) (builder.Cond, error) { + cond := builder.NewCond() + + if opts.RequestedTeam != nil && opts.RequestedUser == nil { + org, err := user_model.GetUserByID(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 { + cond = cond.And(builder.In("act_user_id", + builder.Select("`user`.id").Where( + builder.Eq{"keep_activity_private": false}. + And(builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited))). + Or(builder.Eq{"id": opts.Actor.ID}).From("`user`"), + )) + } + + // 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(opts.RequestedTeam) + teamRepoIDs, err := env.RepoIDs(1, opts.RequestedUser.NumRepos) + if err != nil { + return nil, fmt.Errorf("GetTeamRepositories: %v", 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(olderThan time.Duration) (err error) { + if olderThan <= 0 { + return nil + } + + _, err = db.GetEngine(db.DefaultContext).Where("created_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Action{}) + return err +} + +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: %v", err) + } + } + + // Add feed for actioner. + act.UserID = act.ActUserID + if _, err = e.Insert(act); err != nil { + return fmt.Errorf("insert new actioner: %v", err) + } + + if repoChanged { + act.loadRepo() + repo = act.Repo + + // check repo owner exist. + if err := act.Repo.GetOwner(ctx); err != nil { + return fmt.Errorf("can't get repo owner: %v", 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: %v", 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.GetUserByIDCtx(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: + if !permPR[i] { + continue + } + } + + if err = db.Insert(ctx, act); err != nil { + return fmt.Errorf("insert new action: %v", err) + } + } + } + return nil +} + +// NotifyWatchers creates batch of actions for every watcher. +func NotifyWatchers(actions ...*Action) error { + return notifyWatchers(db.DefaultContext, actions...) +} + +// NotifyWatchersActions creates batch of actions for every watcher. +func NotifyWatchersActions(acts []*Action) error { + ctx, committer, err := db.TxContext() + 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 int64) error { + // delete actions assigned to this issue + subQuery := builder.Select("`id`"). + From("`comment`"). + Where(builder.Eq{"`issue_id`": issueID}) + if _, err := db.GetEngine(ctx).In("comment_id", subQuery).Delete(&Action{}); err != nil { + return err + } + + _, err := db.GetEngine(ctx).Table("action").Where("repo_id = ?", repoID). + In("op_type", ActionCreateIssue, ActionCreatePullRequest). + Where("content LIKE ?", strconv.FormatInt(issueID, 10)+"|%"). + Delete(&Action{}) + return err +} + +// CountActionCreatedUnixString count actions where created_unix is an empty string +func CountActionCreatedUnixString() (int64, error) { + if setting.Database.UseSQLite3 { + return db.GetEngine(db.DefaultContext).Where(`created_unix = ""`).Count(new(Action)) + } + return 0, nil +} + +// FixActionCreatedUnixString set created_unix to zero if it is an empty string +func FixActionCreatedUnixString() (int64, error) { + if setting.Database.UseSQLite3 { + res, err := db.GetEngine(db.DefaultContext).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 0000000000..16fb4bac8c --- /dev/null +++ b/models/activities/action_list.go @@ -0,0 +1,116 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activities + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" +) + +// ActionList defines a list of actions +type ActionList []*Action + +func (actions ActionList) getUserIDs() []int64 { + userIDs := make(map[int64]struct{}, len(actions)) + for _, action := range actions { + if _, ok := userIDs[action.ActUserID]; !ok { + userIDs[action.ActUserID] = struct{}{} + } + } + return container.KeysInt64(userIDs) +} + +func (actions ActionList) loadUsers(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: %v", err) + } + + for _, action := range actions { + action.ActUser = userMaps[action.ActUserID] + } + return userMaps, nil +} + +func (actions ActionList) getRepoIDs() []int64 { + repoIDs := make(map[int64]struct{}, len(actions)) + for _, action := range actions { + if _, ok := repoIDs[action.RepoID]; !ok { + repoIDs[action.RepoID] = struct{}{} + } + } + return container.KeysInt64(repoIDs) +} + +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: %v", err) + } + + for _, action := range actions { + action.Repo = repoMaps[action.RepoID] + } + return nil +} + +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) + } + + for _, action := range actions { + if action.Repo == nil { + continue + } + repoOwner, ok := userMap[action.Repo.OwnerID] + if !ok { + repoOwner, err = user_model.GetUserByIDCtx(ctx, action.Repo.OwnerID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + continue + } + return err + } + userMap[repoOwner.ID] = repoOwner + } + action.Repo.Owner = repoOwner + } + + return nil +} + +// loadAttributes loads all attributes +func (actions ActionList) loadAttributes(ctx context.Context) error { + userMap, err := actions.loadUsers(ctx) + if err != nil { + return err + } + + if err := actions.loadRepositories(ctx); err != nil { + return err + } + + return actions.loadRepoOwner(ctx, userMap) +} diff --git a/models/activities/action_test.go b/models/activities/action_test.go new file mode 100644 index 0000000000..83fd9ee38d --- /dev/null +++ b/models/activities/action_test.go @@ -0,0 +1,274 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activities_test + +import ( + "path" + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +func TestAction_GetRepoPath(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{}) + 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()) +} + +func TestAction_GetRepoLink(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + action := &activities_model.Action{RepoID: repo.ID} + setting.AppSubURL = "/suburl" + expected := path.Join(setting.AppSubURL, owner.Name, repo.Name) + assert.Equal(t, expected, action.GetRepoLink()) +} + +func TestGetFeeds(t *testing.T) { + // test with an individual user + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, + }) + assert.NoError(t, err) + if assert.Len(t, actions, 1) { + assert.EqualValues(t, 1, actions[0].ID) + assert.EqualValues(t, user.ID, actions[0].UserID) + } + + actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: user, + IncludePrivate: false, + OnlyPerformedBy: false, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) +} + +func TestGetFeedsForRepos(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + privRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + pubRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 8}) + + // private repo & no login + actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: privRepo, + IncludePrivate: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) + + // public repo & no login + actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: pubRepo, + IncludePrivate: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + + // private repo and login + actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: privRepo, + IncludePrivate: true, + Actor: user, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + + // public repo & login + actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: pubRepo, + IncludePrivate: true, + Actor: user, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) +} + +func TestGetFeeds2(t *testing.T) { + // test with an organization user + assert.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: org, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) + if assert.Len(t, actions, 1) { + assert.EqualValues(t, 2, actions[0].ID) + assert.EqualValues(t, org.ID, actions[0].UserID) + } + + actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: org, + Actor: user, + IncludePrivate: false, + OnlyPerformedBy: false, + IncludeDeleted: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) +} + +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) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + action := &activities_model.Action{ + ActUserID: 8, + RepoID: 1, + OpType: activities_model.ActionStarRepo, + } + assert.NoError(t, activities_model.NotifyWatchers(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) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + unittest.AssertExistsAndLoadBean(t, &activities_model.Action{ + ID: 8, + RepoID: 1700, + }) + + actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: user, + IncludePrivate: true, + }) + assert.NoError(t, err) + assert.Len(t, actions, 0) +} + +func TestConsistencyUpdateAction(t *testing.T) { + if !setting.Database.UseSQLite3 { + t.Skip("Test is only for SQLite database.") + } + assert.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) + assert.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) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "type string to a int64: invalid syntax") + } + // + // Get rid of incorrectly set created_unix + // + count, err := activities_model.CountActionCreatedUnixString() + assert.NoError(t, err) + assert.EqualValues(t, 1, count) + count, err = activities_model.FixActionCreatedUnixString() + assert.NoError(t, err) + assert.EqualValues(t, 1, count) + + count, err = activities_model.CountActionCreatedUnixString() + assert.NoError(t, err) + assert.EqualValues(t, 0, count) + count, err = activities_model.FixActionCreatedUnixString() + assert.NoError(t, err) + assert.EqualValues(t, 0, count) + + // + // XORM must be happy now + // + assert.NoError(t, db.GetEngine(db.DefaultContext).Where("id = ?", id).Find(&actions)) + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} diff --git a/models/activities/main_test.go b/models/activities/main_test.go new file mode 100644 index 0000000000..0a87f47600 --- /dev/null +++ b/models/activities/main_test.go @@ -0,0 +1,20 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activities_test + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", ".."), + }) +} diff --git a/models/activities/notification.go b/models/activities/notification.go new file mode 100644 index 0000000000..88776db42b --- /dev/null +++ b/models/activities/notification.go @@ -0,0 +1,835 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +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" + 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" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" + "xorm.io/xorm" +) + +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)) +} + +// 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) ToCond() 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 { + 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 +} + +// ToSession will convert the given options to a xorm Session by using the conditions from ToCond and joining with issue table if required +func (opts *FindNotificationOptions) ToSession(ctx context.Context) *xorm.Session { + sess := db.GetEngine(ctx).Where(opts.ToCond()) + if opts.Page != 0 { + sess = db.SetSessionPagination(sess, opts) + } + return sess +} + +// GetNotifications returns all notifications that fit to the given options. +func GetNotifications(ctx context.Context, options *FindNotificationOptions) (nl NotificationList, err error) { + err = options.ToSession(ctx).OrderBy("notification.updated_unix DESC").Find(&nl) + return nl, err +} + +// CountNotifications count all notifications that fit to the given options and ignore pagination. +func CountNotifications(opts *FindNotificationOptions) (int64, error) { + return db.GetEngine(db.DefaultContext).Where(opts.ToCond()).Count(&Notification{}) +} + +// CreateRepoTransferNotification creates notification for the user a repository was transferred to +func CreateRepoTransferNotification(doer, newOwner *user_model.User, repo *repo_model.Repository) error { + ctx, committer, err := db.TxContext() + if err != nil { + return err + } + defer committer.Close() + + 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: users[i].ID, + 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, + }} + } + + if err := db.Insert(ctx, notify); err != nil { + return err + } + + return committer.Commit() +} + +// 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(issueID, commentID, notificationAuthorID, receiverID int64) error { + ctx, committer, err := db.TxContext() + 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 map[int64]struct{} + notifications, err := getNotificationsByIssueID(ctx, issueID) + if err != nil { + return err + } + + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + return err + } + + if receiverID > 0 { + toNotify = make(map[int64]struct{}, 1) + toNotify[receiverID] = struct{}{} + } else { + toNotify = make(map[int64]struct{}, 32) + issueWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, true) + if err != nil { + return err + } + for _, id := range issueWatches { + toNotify[id] = struct{}{} + } + if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) { + repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID) + if err != nil { + return err + } + for _, id := range repoWatches { + toNotify[id] = struct{}{} + } + } + issueParticipants, err := issue.GetParticipantIDsByIssue(ctx) + if err != nil { + return err + } + for _, id := range issueParticipants { + toNotify[id] = struct{}{} + } + + // 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 { + delete(toNotify, id) + } + } + + err = issue.LoadRepo(ctx) + if err != nil { + return err + } + + // notify + for userID := range toNotify { + issue.Repo.Units = nil + user, err := user_model.GetUserByIDCtx(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 +} + +func getNotificationsByIssueID(ctx context.Context, issueID int64) (notifications []*Notification, err error) { + err = db.GetEngine(ctx). + Where("issue_id = ?", issueID). + Find(¬ifications) + return notifications, err +} + +func notificationExists(notifications []*Notification, issueID, userID int64) bool { + for _, notification := range notifications { + if notification.IssueID == issueID && notification.UserID == userID { + return true + } + } + + return false +} + +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 +} + +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 +} + +// NotificationsForUser returns notifications for a given user and status +func NotificationsForUser(ctx context.Context, user *user_model.User, statuses []NotificationStatus, page, perPage int) (notifications NotificationList, err error) { + if len(statuses) == 0 { + return + } + + sess := db.GetEngine(ctx). + Where("user_id = ?", user.ID). + In("status", statuses). + OrderBy("updated_unix DESC") + + if page > 0 && perPage > 0 { + sess.Limit(perPage, (page-1)*perPage) + } + + err = sess.Find(¬ifications) + return notifications, err +} + +// CountUnread count unread notifications for a user +func CountUnread(ctx context.Context, userID int64) int64 { + exist, err := db.GetEngine(ctx).Where("user_id = ?", userID).And("status = ?", NotificationStatusUnread).Count(new(Notification)) + if err != nil { + log.Error("countUnread", err) + return 0 + } + return exist +} + +// LoadAttributes load Repo Issue User and Comment if not loaded +func (n *Notification) LoadAttributes() (err error) { + return n.loadAttributes(db.DefaultContext) +} + +func (n *Notification) loadAttributes(ctx context.Context) (err error) { + if err = n.loadRepo(ctx); err != nil { + return + } + if err = n.loadIssue(ctx); err != nil { + return + } + if err = n.loadUser(ctx); err != nil { + return + } + if err = n.loadComment(ctx); err != nil { + return + } + return err +} + +func (n *Notification) loadRepo(ctx context.Context) (err error) { + if n.Repository == nil { + n.Repository, err = repo_model.GetRepositoryByIDCtx(ctx, n.RepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %v", 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]: %v", 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.GetUserByIDCtx(ctx, n.UserID) + if err != nil { + return fmt.Errorf("getUserByID [%d]: %v", n.UserID, err) + } + } + return nil +} + +// GetRepo returns the repo of the notification +func (n *Notification) GetRepo() (*repo_model.Repository, error) { + return n.Repository, n.loadRepo(db.DefaultContext) +} + +// GetIssue returns the issue of the notification +func (n *Notification) GetIssue() (*issues_model.Issue, error) { + return n.Issue, n.loadIssue(db.DefaultContext) +} + +// HTMLURL formats a URL-string to the notification +func (n *Notification) HTMLURL() string { + switch n.Source { + case NotificationSourceIssue, NotificationSourcePullRequest: + if n.Comment != nil { + return n.Comment.HTMLURL() + } + return n.Issue.HTMLURL() + case NotificationSourceCommit: + return n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID) + case NotificationSourceRepository: + return n.Repository.HTMLURL() + } + 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) +} + +// NotificationList contains a list of notifications +type NotificationList []*Notification + +// LoadAttributes load Repo Issue User and Comment if not loaded +func (nl NotificationList) LoadAttributes() error { + var err error + for i := 0; i < len(nl); i++ { + err = nl[i].LoadAttributes() + if err != nil && !issues_model.IsErrCommentNotExist(err) { + return err + } + } + return nil +} + +func (nl NotificationList) getPendingRepoIDs() []int64 { + ids := make(map[int64]struct{}, len(nl)) + for _, notification := range nl { + if notification.Repository != nil { + continue + } + if _, ok := ids[notification.RepoID]; !ok { + ids[notification.RepoID] = struct{}{} + } + } + return container.KeysInt64(ids) +} + +// LoadRepos loads repositories from database +func (nl NotificationList) LoadRepos() (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(db.DefaultContext). + 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(map[int64]struct{}, len(nl)) + for _, notification := range nl { + if notification.Issue != nil { + continue + } + if _, ok := ids[notification.IssueID]; !ok { + ids[notification.IssueID] = struct{}{} + } + } + return container.KeysInt64(ids) +} + +// LoadIssues loads issues from database +func (nl NotificationList) LoadIssues() ([]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(db.DefaultContext). + 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(map[int64]struct{}, len(nl)) + for _, notification := range nl { + if notification.CommentID == 0 || notification.Comment != nil { + continue + } + if _, ok := ids[notification.CommentID]; !ok { + ids[notification.CommentID] = struct{}{} + } + } + return container.KeysInt64(ids) +} + +// LoadComments loads comments from database +func (nl NotificationList) LoadComments() ([]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(db.DefaultContext). + 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 +} + +// GetNotificationCount returns the notification count for user +func GetNotificationCount(ctx context.Context, user *user_model.User, status NotificationStatus) (count int64, err error) { + count, err = db.GetEngine(ctx). + Where("user_id = ?", user.ID). + And("status = ?", status). + Count(&Notification{}) + return count, err +} + +// UserIDCount is a simple coalition of UserID and Count +type UserIDCount struct { + UserID int64 + Count int64 +} + +// GetUIDsAndNotificationCounts between the two provided times +func GetUIDsAndNotificationCounts(since, until timeutil.TimeStamp) ([]UserIDCount, error) { + sql := `SELECT user_id, count(*) AS count FROM notification ` + + `WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` + + `updated_unix < ?) AND status = ? GROUP BY user_id` + var res []UserIDCount + return res, db.GetEngine(db.DefaultContext).SQL(sql, since, until, NotificationStatusUnread).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(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).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(notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) { + notification, err := getNotificationByID(db.DefaultContext, 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(db.DefaultContext).ID(notificationID).Update(notification) + return notification, err +} + +// GetNotificationByID return notification by ID +func GetNotificationByID(notificationID int64) (*Notification, error) { + return getNotificationByID(db.DefaultContext, notificationID) +} + +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{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(user *user_model.User, currentStatus, desiredStatus NotificationStatus) error { + n := &Notification{Status: desiredStatus, UpdatedBy: user.ID} + _, err := db.GetEngine(db.DefaultContext). + Where("user_id = ? AND status = ?", user.ID, currentStatus). + Cols("status", "updated_by", "updated_unix"). + Update(n) + return err +} diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go new file mode 100644 index 0000000000..4ee16af076 --- /dev/null +++ b/models/activities/notification_test.go @@ -0,0 +1,112 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activities_test + +import ( + "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" +) + +func TestCreateOrUpdateIssueNotifications(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + + assert.NoError(t, activities_model.CreateOrUpdateIssueNotifications(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) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + statuses := []activities_model.NotificationStatus{activities_model.NotificationStatusRead, activities_model.NotificationStatusUnread} + notfs, err := activities_model.NotificationsForUser(db.DefaultContext, user, statuses, 1, 10) + assert.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) { + assert.NoError(t, unittest.PrepareTestDatabase()) + notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{RepoID: 1}) + repo, err := notf.GetRepo() + assert.NoError(t, err) + assert.Equal(t, repo, notf.Repository) + assert.EqualValues(t, notf.RepoID, repo.ID) +} + +func TestNotification_GetIssue(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{RepoID: 1}) + issue, err := notf.GetIssue() + assert.NoError(t, err) + assert.Equal(t, issue, notf.Issue) + assert.EqualValues(t, notf.IssueID, issue.ID) +} + +func TestGetNotificationCount(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + cnt, err := activities_model.GetNotificationCount(db.DefaultContext, user, activities_model.NotificationStatusRead) + assert.NoError(t, err) + assert.EqualValues(t, 0, cnt) + + cnt, err = activities_model.GetNotificationCount(db.DefaultContext, user, activities_model.NotificationStatusUnread) + assert.NoError(t, err) + assert.EqualValues(t, 1, cnt) +} + +func TestSetNotificationStatus(t *testing.T) { + assert.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(notf.ID, user, activities_model.NotificationStatusPinned) + assert.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, + &activities_model.Notification{ID: notf.ID, Status: activities_model.NotificationStatusPinned}) + + _, err = activities_model.SetNotificationStatus(1, user, activities_model.NotificationStatusRead) + assert.Error(t, err) + _, err = activities_model.SetNotificationStatus(unittest.NonexistentID, user, activities_model.NotificationStatusRead) + assert.Error(t, err) +} + +func TestUpdateNotificationStatuses(t *testing.T) { + assert.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}) + assert.NoError(t, activities_model.UpdateNotificationStatuses(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}) +} diff --git a/models/activities/repo_activity.go b/models/activities/repo_activity.go new file mode 100644 index 0000000000..684ceee272 --- /dev/null +++ b/models/activities/repo_activity.go @@ -0,0 +1,366 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +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" + + "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 represets issue and pull request information. +type ActivityStats struct { + OpenedPRs issues_model.PullRequestList + OpenedPRAuthorCount int64 + MergedPRs issues_model.PullRequestList + MergedPRAuthorCount int64 + 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(repo.ID, timeFrom); err != nil { + return nil, fmt.Errorf("FillReleases: %v", err) + } + } + if prs { + if err := stats.FillPullRequests(repo.ID, timeFrom); err != nil { + return nil, fmt.Errorf("FillPullRequests: %v", err) + } + } + if issues { + if err := stats.FillIssues(repo.ID, timeFrom); err != nil { + return nil, fmt.Errorf("FillIssues: %v", err) + } + } + if err := stats.FillUnresolvedIssues(repo.ID, timeFrom, issues, prs); err != nil { + return nil, fmt.Errorf("FillUnresolvedIssues: %v", err) + } + if code { + gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + if err != nil { + return nil, fmt.Errorf("OpenRepository: %v", err) + } + defer closer.Close() + + code, err := gitRepo.GetCodeActivityStats(timeFrom, repo.DefaultBranch) + if err != nil { + return nil, fmt.Errorf("FillFromGit: %v", 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 := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + if err != nil { + return nil, fmt.Errorf("OpenRepository: %v", err) + } + defer closer.Close() + + code, err := gitRepo.GetCodeActivityStats(timeFrom, "") + if err != nil { + return nil, fmt.Errorf("FillFromGit: %v", err) + } + if code.Authors == nil { + return nil, nil + } + users := make(map[int64]*ActivityAuthorData) + var unknownUserID int64 + unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink() + for _, v := range code.Authors { + if len(v.Email) == 0 { + continue + } + u, err := user_model.GetUserByEmail(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(), + 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 stats.OpenedIssueCount() + stats.ClosedIssueCount() +} + +// 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(repoID int64, fromTime time.Time) error { + var err error + var count int64 + + // Merged pull requests + sess := pullRequestsForActivityStatement(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(); err != nil { + return err + } + + // Merged pull request authors + sess = pullRequestsForActivityStatement(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(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(); err != nil { + return err + } + + // Opened pull request authors + sess = pullRequestsForActivityStatement(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(repoID int64, fromTime time.Time, merged bool) *xorm.Session { + sess := db.GetEngine(db.DefaultContext).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(repoID int64, fromTime time.Time) error { + var err error + var count int64 + + // Closed issues + sess := issuesForActivityStatement(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(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 = issuesForActivityStatement(repoID, fromTime, false, false) + sess.OrderBy("issue.created_unix ASC") + stats.OpenedIssues = make(issues_model.IssueList, 0) + if err = sess.Find(&stats.OpenedIssues); err != nil { + return err + } + + // Opened issue authors + sess = issuesForActivityStatement(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(repoID int64, fromTime time.Time, issues, prs bool) error { + // Check if we need to select anything + if !issues && !prs { + return nil + } + sess := issuesForActivityStatement(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 issuesForActivityStatement(repoID int64, fromTime time.Time, closed, unresolved bool) *xorm.Session { + sess := db.GetEngine(db.DefaultContext).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(repoID int64, fromTime time.Time) error { + var err error + var count int64 + + // Published releases list + sess := releasesForActivityStatement(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(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(repoID int64, fromTime time.Time) *xorm.Session { + return db.GetEngine(db.DefaultContext).Where("release.repo_id = ?", repoID). + And("release.is_draft = ?", false). + And("release.created_unix >= ?", fromTime.Unix()) +} diff --git a/models/activities/statistic.go b/models/activities/statistic.go new file mode 100644 index 0000000000..ea785a3ee2 --- /dev/null +++ b/models/activities/statistic.go @@ -0,0 +1,115 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activities + +import ( + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/auth" + "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" + 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, Action, Access, + Issue, IssueClosed, IssueOpen, + Comment, Oauth, Follow, + Mirror, Release, AuthSource, Webhook, + Milestone, Label, HookTask, + Team, UpdateTask, Project, + ProjectBoard, Attachment 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() (stats Statistic) { + e := db.GetEngine(db.DefaultContext) + stats.Counter.User = user_model.CountUsers(nil) + stats.Counter.Org, _ = organization.CountOrgs(organization.FindOrgOptions{IncludePrivate: true}) + stats.Counter.PublicKey, _ = e.Count(new(asymkey_model.PublicKey)) + stats.Counter.Repo, _ = repo_model.CountRepositories(db.DefaultContext, repo_model.CountRepositoryOptions{}) + stats.Counter.Watch, _ = e.Count(new(repo_model.Watch)) + stats.Counter.Star, _ = e.Count(new(repo_model.Star)) + stats.Counter.Action, _ = db.EstimateCount(db.DefaultContext, new(Action)) + stats.Counter.Access, _ = e.Count(new(access_model.Access)) + + 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) + } + + 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 = auth.CountSources() + 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.ProjectBoard, _ = e.Count(new(project_model.Board)) + return stats +} diff --git a/models/activities/user_heatmap.go b/models/activities/user_heatmap.go new file mode 100644 index 0000000000..6e76be6c6b --- /dev/null +++ b/models/activities/user_heatmap.go @@ -0,0 +1,72 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file.package models + +package activities + +import ( + "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(user, doer *user_model.User) ([]*UserHeatmapData, error) { + return getUserHeatmapData(user, nil, doer) +} + +// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData +func GetUserHeatmapDataByUserTeam(user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) { + return getUserHeatmapData(user, team, doer) +} + +func getUserHeatmapData(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" + groupByName := "timestamp" // We need this extra case because mssql doesn't allow grouping by alias + switch { + case setting.Database.UseMySQL: + groupBy = "created_unix DIV 900 * 900" + case setting.Database.UseMSSQL: + groupByName = groupBy + } + + cond, err := activityQueryCondition(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(db.DefaultContext). + Select(groupBy+" AS timestamp, count(user_id) as contributions"). + Table("action"). + Where(cond). + And("created_unix > ?", timeutil.TimeStampNow()-31536000). + GroupBy(groupByName). + OrderBy("timestamp"). + Find(&hdata) +} diff --git a/models/activities/user_heatmap_test.go b/models/activities/user_heatmap_test.go new file mode 100644 index 0000000000..a8a240f790 --- /dev/null +++ b/models/activities/user_heatmap_test.go @@ -0,0 +1,101 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file.package models + +package activities_test + +import ( + "fmt" + "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" +) + +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 + assert.NoError(t, unittest.PrepareTestDatabase()) + + // Mock time + timeutil.Set(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + defer timeutil.Unset() + + 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) + assert.NoError(t, err) + if tc.doerID == 0 { + doer = nil + } + + // get the action for comparison + actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedUser: user, + Actor: doer, + IncludePrivate: true, + OnlyPerformedBy: true, + IncludeDeleted: true, + }) + assert.NoError(t, err) + + // Get the heatmap and compare + heatmap, err := activities_model.GetUserHeatmapDataByUser(user, doer) + var contributions int + for _, hm := range heatmap { + contributions += int(hm.Contributions) + } + assert.NoError(t, err) + assert.Len(t, actions, contributions, "invalid action count: did the test data became too old?") + assert.Equal(t, tc.CountResult, contributions, fmt.Sprintf("testcase '%s'", tc.desc)) + + // Test JSON rendering + jsonData, err := json.Marshal(heatmap) + assert.NoError(t, err) + assert.Equal(t, tc.JSONResult, string(jsonData)) + } +} |