summaryrefslogtreecommitdiffstats
path: root/models/activities
diff options
context:
space:
mode:
authorLunny Xiao <xiaolunwen@gmail.com>2022-08-25 10:31:57 +0800
committerGitHub <noreply@github.com>2022-08-25 10:31:57 +0800
commit1d8543e7db58d7c4973758e47f005c4d8bd7d7a3 (patch)
treeb60c99e2dfd69ccb998f8a0829d98d7cadcf0d6b /models/activities
parent4a4bfafa238bf48851f8c11fa3701bd42b912475 (diff)
downloadgitea-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.go610
-rw-r--r--models/activities/action_list.go116
-rw-r--r--models/activities/action_test.go274
-rw-r--r--models/activities/main_test.go20
-rw-r--r--models/activities/notification.go835
-rw-r--r--models/activities/notification_test.go112
-rw-r--r--models/activities/repo_activity.go366
-rw-r--r--models/activities/statistic.go115
-rw-r--r--models/activities/user_heatmap.go72
-rw-r--r--models/activities/user_heatmap_test.go101
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(&notifications)
+ 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(&notifications)
+ 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))
+ }
+}