初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/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
|
||||
ActionAutoMergePullRequest // 27
|
||||
)
|
||||
|
||||
func (at ActionType) String() string {
|
||||
switch at {
|
||||
case ActionCreateRepo:
|
||||
return "create_repo"
|
||||
case ActionRenameRepo:
|
||||
return "rename_repo"
|
||||
case ActionStarRepo:
|
||||
return "star_repo" // will not displayed in feeds.tmpl
|
||||
case ActionWatchRepo:
|
||||
return "watch_repo" // will not displayed in feeds.tmpl
|
||||
case ActionCommitRepo:
|
||||
return "commit_repo"
|
||||
case ActionCreateIssue:
|
||||
return "create_issue"
|
||||
case ActionCreatePullRequest:
|
||||
return "create_pull_request"
|
||||
case ActionTransferRepo:
|
||||
return "transfer_repo"
|
||||
case ActionPushTag:
|
||||
return "push_tag"
|
||||
case ActionCommentIssue:
|
||||
return "comment_issue"
|
||||
case ActionMergePullRequest:
|
||||
return "merge_pull_request"
|
||||
case ActionCloseIssue:
|
||||
return "close_issue"
|
||||
case ActionReopenIssue:
|
||||
return "reopen_issue"
|
||||
case ActionClosePullRequest:
|
||||
return "close_pull_request"
|
||||
case ActionReopenPullRequest:
|
||||
return "reopen_pull_request"
|
||||
case ActionDeleteTag:
|
||||
return "delete_tag"
|
||||
case ActionDeleteBranch:
|
||||
return "delete_branch"
|
||||
case ActionMirrorSyncPush:
|
||||
return "mirror_sync_push"
|
||||
case ActionMirrorSyncCreate:
|
||||
return "mirror_sync_create"
|
||||
case ActionMirrorSyncDelete:
|
||||
return "mirror_sync_delete"
|
||||
case ActionApprovePullRequest:
|
||||
return "approve_pull_request"
|
||||
case ActionRejectPullRequest:
|
||||
return "reject_pull_request"
|
||||
case ActionCommentPull:
|
||||
return "comment_pull"
|
||||
case ActionPublishRelease:
|
||||
return "publish_release"
|
||||
case ActionPullReviewDismissed:
|
||||
return "pull_review_dismissed"
|
||||
case ActionPullRequestReadyForReview:
|
||||
return "pull_request_ready_for_review"
|
||||
case ActionAutoMergePullRequest:
|
||||
return "auto_merge_pull_request"
|
||||
default:
|
||||
return "action-" + strconv.Itoa(int(at))
|
||||
}
|
||||
}
|
||||
|
||||
func (at ActionType) InActions(actions ...string) bool {
|
||||
return slices.Contains(actions, at.String())
|
||||
}
|
||||
|
||||
// Action represents user operation type and other information to
|
||||
// repository. It implemented interface base.Actioner so that can be
|
||||
// used in template render.
|
||||
type Action struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"INDEX"` // Receiver user id.
|
||||
OpType ActionType
|
||||
ActUserID int64 // Action user id.
|
||||
ActUser *user_model.User `xorm:"-"`
|
||||
RepoID int64
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
CommentID int64 `xorm:"INDEX"`
|
||||
Comment *issues_model.Comment `xorm:"-"`
|
||||
Issue *issues_model.Issue `xorm:"-"` // get the issue id from content
|
||||
IsDeleted bool `xorm:"NOT NULL DEFAULT false"`
|
||||
RefName string
|
||||
IsPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Content string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Action))
|
||||
}
|
||||
|
||||
// TableIndices implements xorm's TableIndices interface
|
||||
func (a *Action) TableIndices() []*schemas.Index {
|
||||
repoIndex := schemas.NewIndex("r_u_d", schemas.IndexType)
|
||||
repoIndex.AddColumn("repo_id", "user_id", "is_deleted")
|
||||
|
||||
actUserIndex := schemas.NewIndex("au_r_c_u_d", schemas.IndexType)
|
||||
actUserIndex.AddColumn("act_user_id", "repo_id", "created_unix", "user_id", "is_deleted")
|
||||
|
||||
cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
|
||||
cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
|
||||
|
||||
cuIndex := schemas.NewIndex("c_u", schemas.IndexType)
|
||||
cuIndex.AddColumn("user_id", "is_deleted")
|
||||
|
||||
actUserUserIndex := schemas.NewIndex("au_c_u", schemas.IndexType)
|
||||
actUserUserIndex.AddColumn("act_user_id", "created_unix", "user_id")
|
||||
|
||||
indices := []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex, actUserUserIndex}
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
// GetOpType gets the ActionType of this action.
|
||||
func (a *Action) GetOpType() ActionType {
|
||||
return a.OpType
|
||||
}
|
||||
|
||||
// LoadActUser loads a.ActUser
|
||||
func (a *Action) LoadActUser(ctx context.Context) {
|
||||
if a.ActUser != nil {
|
||||
return
|
||||
}
|
||||
a.ActUserID, a.ActUser, _ = user_model.GetPossibleUserByID(ctx, a.ActUserID)
|
||||
}
|
||||
|
||||
func (a *Action) LoadRepo(ctx context.Context) error {
|
||||
if a.Repo != nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
a.Repo, err = repo_model.GetRepositoryByID(ctx, a.RepoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetActFullName gets the action's user full name.
|
||||
func (a *Action) GetActFullName(ctx context.Context) string {
|
||||
a.LoadActUser(ctx)
|
||||
return a.ActUser.FullName
|
||||
}
|
||||
|
||||
// GetActUserName gets the action's user name.
|
||||
func (a *Action) GetActUserName(ctx context.Context) string {
|
||||
a.LoadActUser(ctx)
|
||||
return a.ActUser.Name
|
||||
}
|
||||
|
||||
// ShortActUserName gets the action's user name trimmed to max 20
|
||||
// chars.
|
||||
func (a *Action) ShortActUserName(ctx context.Context) string {
|
||||
return util.EllipsisDisplayString(a.GetActUserName(ctx), 20)
|
||||
}
|
||||
|
||||
// GetActDisplayName gets the action's display name based on DEFAULT_SHOW_FULL_NAME, or falls back to the username if it is blank.
|
||||
func (a *Action) GetActDisplayName(ctx context.Context) string {
|
||||
if setting.UI.DefaultShowFullName {
|
||||
trimmedFullName := strings.TrimSpace(a.GetActFullName(ctx))
|
||||
if len(trimmedFullName) > 0 {
|
||||
return trimmedFullName
|
||||
}
|
||||
}
|
||||
return a.ShortActUserName(ctx)
|
||||
}
|
||||
|
||||
// GetActDisplayNameTitle gets the action's display name used for the title (tooltip) based on DEFAULT_SHOW_FULL_NAME
|
||||
func (a *Action) GetActDisplayNameTitle(ctx context.Context) string {
|
||||
if setting.UI.DefaultShowFullName {
|
||||
return a.ShortActUserName(ctx)
|
||||
}
|
||||
return a.GetActFullName(ctx)
|
||||
}
|
||||
|
||||
// GetRepoUserName returns the name of the action repository owner.
|
||||
func (a *Action) GetRepoUserName(ctx context.Context) string {
|
||||
_ = a.LoadRepo(ctx)
|
||||
if a.Repo == nil {
|
||||
return "(non-existing-repo)"
|
||||
}
|
||||
return a.Repo.OwnerName
|
||||
}
|
||||
|
||||
// ShortRepoUserName returns the name of the action repository owner
|
||||
// trimmed to max 20 chars.
|
||||
func (a *Action) ShortRepoUserName(ctx context.Context) string {
|
||||
return util.EllipsisDisplayString(a.GetRepoUserName(ctx), 20)
|
||||
}
|
||||
|
||||
// GetRepoName returns the name of the action repository.
|
||||
func (a *Action) GetRepoName(ctx context.Context) string {
|
||||
_ = a.LoadRepo(ctx)
|
||||
if a.Repo == nil {
|
||||
return "(non-existing-repo)"
|
||||
}
|
||||
return a.Repo.Name
|
||||
}
|
||||
|
||||
// ShortRepoName returns the name of the action repository
|
||||
// trimmed to max 33 chars.
|
||||
func (a *Action) ShortRepoName(ctx context.Context) string {
|
||||
return util.EllipsisDisplayString(a.GetRepoName(ctx), 33)
|
||||
}
|
||||
|
||||
// GetRepoPath returns the virtual path to the action repository.
|
||||
func (a *Action) GetRepoPath(ctx context.Context) string {
|
||||
return path.Join(a.GetRepoUserName(ctx), a.GetRepoName(ctx))
|
||||
}
|
||||
|
||||
// ShortRepoPath returns the virtual path to the action repository
|
||||
// trimmed to max 20 + 1 + 33 chars.
|
||||
func (a *Action) ShortRepoPath(ctx context.Context) string {
|
||||
return path.Join(a.ShortRepoUserName(ctx), a.ShortRepoName(ctx))
|
||||
}
|
||||
|
||||
// GetRepoLink returns relative link to action repository.
|
||||
func (a *Action) GetRepoLink(ctx context.Context) string {
|
||||
// path.Join will skip empty strings
|
||||
return path.Join(setting.AppSubURL, "/", url.PathEscape(a.GetRepoUserName(ctx)), url.PathEscape(a.GetRepoName(ctx)))
|
||||
}
|
||||
|
||||
// GetRepoAbsoluteLink returns the absolute link to action repository.
|
||||
func (a *Action) GetRepoAbsoluteLink(ctx context.Context) string {
|
||||
return setting.AppURL + url.PathEscape(a.GetRepoUserName(ctx)) + "/" + url.PathEscape(a.GetRepoName(ctx))
|
||||
}
|
||||
|
||||
func (a *Action) loadComment(ctx context.Context) (err error) {
|
||||
if a.CommentID == 0 || a.Comment != nil {
|
||||
return nil
|
||||
}
|
||||
a.Comment, err = issues_model.GetCommentByID(ctx, a.CommentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCommentHTMLURL returns link to action comment.
|
||||
func (a *Action) GetCommentHTMLURL(ctx context.Context) string {
|
||||
if a == nil {
|
||||
return "#"
|
||||
}
|
||||
_ = a.loadComment(ctx)
|
||||
if a.Comment != nil {
|
||||
return a.Comment.HTMLURL(ctx)
|
||||
}
|
||||
|
||||
if err := a.LoadIssue(ctx); err != nil || a.Issue == nil {
|
||||
return "#"
|
||||
}
|
||||
if err := a.Issue.LoadRepo(ctx); err != nil {
|
||||
return "#"
|
||||
}
|
||||
|
||||
return a.Issue.HTMLURL(ctx)
|
||||
}
|
||||
|
||||
// GetCommentLink returns link to action comment.
|
||||
func (a *Action) GetCommentLink(ctx context.Context) string {
|
||||
if a == nil {
|
||||
return "#"
|
||||
}
|
||||
_ = a.loadComment(ctx)
|
||||
if a.Comment != nil {
|
||||
return a.Comment.Link(ctx)
|
||||
}
|
||||
|
||||
if err := a.LoadIssue(ctx); err != nil || a.Issue == nil {
|
||||
return "#"
|
||||
}
|
||||
if err := a.Issue.LoadRepo(ctx); err != nil {
|
||||
return "#"
|
||||
}
|
||||
|
||||
return a.Issue.Link()
|
||||
}
|
||||
|
||||
// GetBranch returns the action's repository branch.
|
||||
func (a *Action) GetBranch() string {
|
||||
return strings.TrimPrefix(a.RefName, git.BranchPrefix)
|
||||
}
|
||||
|
||||
// GetRefLink returns the action's ref link.
|
||||
func (a *Action) GetRefLink(ctx context.Context) string {
|
||||
return a.GetRepoLink(ctx) + "/src/" + git.RefName(a.RefName).RefWebLinkPath()
|
||||
}
|
||||
|
||||
// GetTag returns the action's repository tag.
|
||||
func (a *Action) GetTag() string {
|
||||
return strings.TrimPrefix(a.RefName, git.TagPrefix)
|
||||
}
|
||||
|
||||
// GetContent returns the action's content.
|
||||
func (a *Action) GetContent() string {
|
||||
return a.Content
|
||||
}
|
||||
|
||||
// GetCreate returns the action creation time.
|
||||
func (a *Action) GetCreate() time.Time {
|
||||
return a.CreatedUnix.AsTime()
|
||||
}
|
||||
|
||||
func (a *Action) IsIssueEvent() bool {
|
||||
return a.OpType.InActions("comment_issue", "approve_pull_request", "reject_pull_request", "comment_pull", "merge_pull_request")
|
||||
}
|
||||
|
||||
// GetIssueInfos returns a list of associated information with the action.
|
||||
func (a *Action) GetIssueInfos() []string {
|
||||
// make sure it always returns 3 elements, because there are some access to the a[1] and a[2] without checking the length
|
||||
ret := strings.SplitN(a.Content, "|", 3)
|
||||
for len(ret) < 3 {
|
||||
ret = append(ret, "")
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (a *Action) getIssueIndex() int64 {
|
||||
infos := a.GetIssueInfos()
|
||||
if len(infos) == 0 {
|
||||
return 0
|
||||
}
|
||||
index, _ := strconv.ParseInt(infos[0], 10, 64)
|
||||
return index
|
||||
}
|
||||
|
||||
func (a *Action) LoadIssue(ctx context.Context) error {
|
||||
if a.Issue != nil {
|
||||
return nil
|
||||
}
|
||||
if index := a.getIssueIndex(); index > 0 {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, a.RepoID, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Issue = issue
|
||||
a.Issue.Repo = a.Repo
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIssueTitle returns the title of first issue associated with the action.
|
||||
func (a *Action) GetIssueTitle(ctx context.Context) string {
|
||||
if err := a.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadIssue: %v", err)
|
||||
return "<500 when get issue>"
|
||||
}
|
||||
if a.Issue == nil {
|
||||
return "<Issue not found>"
|
||||
}
|
||||
return a.Issue.Title
|
||||
}
|
||||
|
||||
// GetIssueContent returns the content of first issue associated with this action.
|
||||
func (a *Action) GetIssueContent(ctx context.Context) string {
|
||||
if err := a.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadIssue: %v", err)
|
||||
return "<500 when get issue>"
|
||||
}
|
||||
if a.Issue == nil {
|
||||
return "<Content not found>"
|
||||
}
|
||||
return a.Issue.Content
|
||||
}
|
||||
|
||||
// GetFeedsOptions options for retrieving feeds
|
||||
type GetFeedsOptions struct {
|
||||
db.ListOptions
|
||||
RequestedUser *user_model.User // the user we want activity for
|
||||
RequestedTeam *organization.Team // the team we want activity for
|
||||
RequestedRepo *repo_model.Repository // the repo we want activity for
|
||||
Actor *user_model.User // the user viewing the activity
|
||||
IncludePrivate bool // include private actions
|
||||
OnlyPerformedBy bool // only actions performed by requested user
|
||||
IncludeDeleted bool // include deleted actions
|
||||
Date string // the day we want activity for: YYYY-MM-DD
|
||||
DontCount bool // do counting in GetFeeds
|
||||
}
|
||||
|
||||
func (opts *GetFeedsOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
if publicOnly {
|
||||
opts.IncludePrivate = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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 FeedDateCond(opts GetFeedsOptions) builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.Date == "" {
|
||||
return cond
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func ActivityQueryCondition(ctx context.Context, opts GetFeedsOptions) (builder.Cond, error) {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if opts.RequestedTeam != nil && opts.RequestedUser == nil {
|
||||
org, err := user_model.GetUserByID(ctx, opts.RequestedTeam.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.RequestedUser = org
|
||||
}
|
||||
|
||||
// check activity visibility for actor ( similar to activityReadable() )
|
||||
if opts.Actor == nil {
|
||||
cond = cond.And(builder.In("act_user_id",
|
||||
builder.Select("`user`.id").Where(
|
||||
builder.Eq{"keep_activity_private": false, "visibility": structs.VisibleTypePublic},
|
||||
).From("`user`"),
|
||||
))
|
||||
} else if !opts.Actor.IsAdmin {
|
||||
uidCond := builder.Select("`user`.id").From("`user`").Where(
|
||||
builder.Eq{"keep_activity_private": false}.
|
||||
And(builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited))).
|
||||
Or(builder.Eq{"id": opts.Actor.ID})
|
||||
|
||||
if opts.RequestedUser != nil {
|
||||
if opts.RequestedUser.IsOrganization() {
|
||||
// An organization can always see the activities whose `act_user_id` is the same as its id.
|
||||
uidCond = uidCond.Or(builder.Eq{"id": opts.RequestedUser.ID})
|
||||
} else {
|
||||
// A user can always see the activities of the organizations to which the user belongs.
|
||||
uidCond = uidCond.Or(
|
||||
builder.Eq{"type": user_model.UserTypeOrganization}.
|
||||
And(builder.In("`user`.id", builder.Select("org_id").
|
||||
Where(builder.Eq{"uid": opts.RequestedUser.ID}).
|
||||
From("team_user"))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cond = cond.And(builder.In("act_user_id", uidCond))
|
||||
}
|
||||
|
||||
// check readable repositories by doer/actor
|
||||
if opts.Actor == nil || !opts.Actor.IsAdmin {
|
||||
cond = cond.And(builder.In("repo_id", repo_model.AccessibleRepoIDsQuery(opts.Actor)))
|
||||
}
|
||||
|
||||
if opts.RequestedRepo != nil {
|
||||
// repo's actions could have duplicate items, see the comment of NotifyWatchers
|
||||
// so here we only filter the "original items", aka: user_id == act_user_id
|
||||
cond = cond.And(
|
||||
builder.Eq{"`action`.repo_id": opts.RequestedRepo.ID},
|
||||
builder.Expr("`action`.user_id = `action`.act_user_id"),
|
||||
)
|
||||
}
|
||||
|
||||
if opts.RequestedTeam != nil {
|
||||
env := repo_model.AccessibleTeamReposEnv(organization.OrgFromUser(opts.RequestedUser), opts.RequestedTeam)
|
||||
teamRepoIDs, err := env.RepoIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetTeamRepositories: %w", err)
|
||||
}
|
||||
cond = cond.And(builder.In("repo_id", teamRepoIDs))
|
||||
}
|
||||
|
||||
if opts.RequestedUser != nil {
|
||||
cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})
|
||||
|
||||
if opts.OnlyPerformedBy {
|
||||
cond = cond.And(builder.Eq{"act_user_id": opts.RequestedUser.ID})
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{"`action`.is_private": false})
|
||||
}
|
||||
if !opts.IncludeDeleted {
|
||||
cond = cond.And(builder.Eq{"is_deleted": false})
|
||||
}
|
||||
|
||||
cond = cond.And(FeedDateCond(opts))
|
||||
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// DeleteOldActions deletes all old actions from database.
|
||||
func DeleteOldActions(ctx context.Context, olderThan time.Duration) (err error) {
|
||||
if olderThan <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Where("created_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Action{})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteIssueActions delete all actions related with issueID
|
||||
func DeleteIssueActions(ctx context.Context, repoID, issueID, issueIndex int64) error {
|
||||
// delete actions assigned to this issue
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
// MariaDB has a performance bug: https://jira.mariadb.org/browse/MDEV-16289
|
||||
// so here it uses "DELETE ... WHERE IN" with pre-queried IDs.
|
||||
var lastCommentID int64
|
||||
commentIDs := make([]int64, 0, db.DefaultMaxInSize)
|
||||
for {
|
||||
commentIDs = commentIDs[:0]
|
||||
err := e.Select("`id`").Table(&issues_model.Comment{}).
|
||||
Where(builder.Eq{"issue_id": issueID}).And("`id` > ?", lastCommentID).
|
||||
OrderBy("`id`").Limit(db.DefaultMaxInSize).
|
||||
Find(&commentIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(commentIDs) == 0 {
|
||||
break
|
||||
} else if _, err = db.GetEngine(ctx).In("comment_id", commentIDs).Delete(&Action{}); err != nil {
|
||||
return err
|
||||
}
|
||||
lastCommentID = commentIDs[len(commentIDs)-1]
|
||||
}
|
||||
|
||||
_, err := e.Where("repo_id = ?", repoID).
|
||||
In("op_type", ActionCreateIssue, ActionCreatePullRequest).
|
||||
Where("content LIKE ?", strconv.FormatInt(issueIndex, 10)+"|%"). // "IssueIndex|content..."
|
||||
Delete(&Action{})
|
||||
return err
|
||||
}
|
||||
|
||||
// CountActionCreatedUnixString count actions where created_unix is an empty string
|
||||
func CountActionCreatedUnixString(ctx context.Context) (int64, error) {
|
||||
if setting.Database.Type.IsSQLite3() {
|
||||
return db.GetEngine(ctx).Where(`created_unix = ''`).Count(new(Action))
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// FixActionCreatedUnixString set created_unix to zero if it is an empty string
|
||||
func FixActionCreatedUnixString(ctx context.Context) (int64, error) {
|
||||
if setting.Database.Type.IsSQLite3() {
|
||||
res, err := db.GetEngine(ctx).Exec(`UPDATE action SET created_unix = 0 WHERE created_unix = ''`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ActionList defines a list of actions
|
||||
type ActionList []*Action
|
||||
|
||||
func (actions ActionList) getUserIDs() []int64 {
|
||||
return container.FilterSlice(actions, func(action *Action) (int64, bool) {
|
||||
return action.ActUserID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (actions ActionList) LoadActUsers(ctx context.Context) (map[int64]*user_model.User, error) {
|
||||
if len(actions) == 0 {
|
||||
return nil, nil //nolint:nilnil // returns nil when there are no actions
|
||||
}
|
||||
|
||||
userIDs := actions.getUserIDs()
|
||||
userMaps := make(map[int64]*user_model.User, len(userIDs))
|
||||
err := db.GetEngine(ctx).
|
||||
In("id", userIDs).
|
||||
Find(&userMaps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
action.ActUser = userMaps[action.ActUserID]
|
||||
}
|
||||
return userMaps, nil
|
||||
}
|
||||
|
||||
func (actions ActionList) getRepoIDs() []int64 {
|
||||
return container.FilterSlice(actions, func(action *Action) (int64, bool) {
|
||||
return action.RepoID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (actions ActionList) LoadRepositories(ctx context.Context) error {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
repoIDs := actions.getRepoIDs()
|
||||
repoMaps := make(map[int64]*repo_model.Repository, len(repoIDs))
|
||||
err := db.GetEngine(ctx).In("id", repoIDs).Find(&repoMaps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find repository: %w", err)
|
||||
}
|
||||
for _, action := range actions {
|
||||
action.Repo = repoMaps[action.RepoID]
|
||||
}
|
||||
repos := repo_model.RepositoryList(util.ValuesOfMap(repoMaps))
|
||||
return repos.LoadUnits(ctx)
|
||||
}
|
||||
|
||||
func (actions ActionList) loadRepoOwner(ctx context.Context, userMap map[int64]*user_model.User) (err error) {
|
||||
if userMap == nil {
|
||||
userMap = make(map[int64]*user_model.User)
|
||||
}
|
||||
|
||||
missingUserIDs := container.FilterSlice(actions, func(action *Action) (int64, bool) {
|
||||
if action.Repo == nil {
|
||||
return 0, false
|
||||
}
|
||||
_, alreadyLoaded := userMap[action.Repo.OwnerID]
|
||||
return action.Repo.OwnerID, !alreadyLoaded
|
||||
})
|
||||
if len(missingUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.GetEngine(ctx).
|
||||
In("id", missingUserIDs).
|
||||
Find(&userMap); err != nil {
|
||||
return fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
if action.Repo != nil {
|
||||
action.Repo.Owner = userMap[action.Repo.OwnerID]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAttributes loads all attributes
|
||||
func (actions ActionList) LoadAttributes(ctx context.Context) error {
|
||||
// the load sequence cannot be changed because of the dependencies
|
||||
userMap, err := actions.LoadActUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := actions.LoadRepositories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := actions.loadRepoOwner(ctx, userMap); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := actions.LoadIssues(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return actions.LoadComments(ctx)
|
||||
}
|
||||
|
||||
func (actions ActionList) LoadComments(ctx context.Context) error {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commentIDs := make([]int64, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action.CommentID > 0 {
|
||||
commentIDs = append(commentIDs, action.CommentID)
|
||||
}
|
||||
}
|
||||
if len(commentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commentsMap := make(map[int64]*issues_model.Comment, len(commentIDs))
|
||||
if err := db.GetEngine(ctx).In("id", commentIDs).Find(&commentsMap); err != nil {
|
||||
return fmt.Errorf("find comment: %w", err)
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
if action.CommentID > 0 {
|
||||
action.Comment = commentsMap[action.CommentID]
|
||||
if action.Comment != nil {
|
||||
action.Comment.Issue = action.Issue
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (actions ActionList) LoadIssues(ctx context.Context) error {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
conditions := builder.NewCond()
|
||||
issueNum := 0
|
||||
for _, action := range actions {
|
||||
if action.IsIssueEvent() {
|
||||
infos := action.GetIssueInfos()
|
||||
if len(infos) == 0 {
|
||||
continue
|
||||
}
|
||||
index, _ := strconv.ParseInt(infos[0], 10, 64)
|
||||
if index > 0 {
|
||||
conditions = conditions.Or(builder.Eq{
|
||||
"repo_id": action.RepoID,
|
||||
"`index`": index,
|
||||
})
|
||||
issueNum++
|
||||
}
|
||||
}
|
||||
}
|
||||
if !conditions.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
issuesMap := make(map[string]*issues_model.Issue, issueNum)
|
||||
issues := make([]*issues_model.Issue, 0, issueNum)
|
||||
if err := db.GetEngine(ctx).Where(conditions).Find(&issues); err != nil {
|
||||
return fmt.Errorf("find issue: %w", err)
|
||||
}
|
||||
for _, issue := range issues {
|
||||
issuesMap[fmt.Sprintf("%d-%d", issue.RepoID, issue.Index)] = issue
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
if !action.IsIssueEvent() {
|
||||
continue
|
||||
}
|
||||
if index := action.getIssueIndex(); index > 0 {
|
||||
if issue, ok := issuesMap[fmt.Sprintf("%d-%d", action.RepoID, index)]; ok {
|
||||
action.Issue = issue
|
||||
action.Issue.Repo = action.Repo
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFeeds returns actions according to the provided options
|
||||
func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, int64, error) {
|
||||
if opts.RequestedUser == nil && opts.RequestedTeam == nil && opts.RequestedRepo == nil {
|
||||
return nil, 0, errors.New("need at least one of these filters: RequestedUser, RequestedTeam, RequestedRepo")
|
||||
}
|
||||
|
||||
var err error
|
||||
var cond builder.Cond
|
||||
// if the actor is the requested user or is an administrator, we can skip the ActivityQueryCondition
|
||||
if opts.Actor != nil && opts.RequestedUser != nil && (opts.Actor.IsAdmin || opts.Actor.ID == opts.RequestedUser.ID) {
|
||||
cond = builder.Eq{
|
||||
"user_id": opts.RequestedUser.ID,
|
||||
}.And(
|
||||
FeedDateCond(opts),
|
||||
)
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
cond = cond.And(builder.Eq{"is_deleted": false})
|
||||
}
|
||||
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{"is_private": false})
|
||||
}
|
||||
if opts.OnlyPerformedBy {
|
||||
cond = cond.And(builder.Eq{"act_user_id": opts.RequestedUser.ID})
|
||||
}
|
||||
} else {
|
||||
cond, err = ActivityQueryCondition(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
actions := make([]*Action, 0, opts.PageSize)
|
||||
var count int64
|
||||
opts.SetDefaultValues()
|
||||
|
||||
if opts.Page < 10 { // TODO: why it's 10 but other values? It's an experience value.
|
||||
sess := db.GetEngine(ctx).Where(cond)
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
|
||||
if opts.DontCount {
|
||||
err = sess.Desc("`action`.created_unix").Find(&actions)
|
||||
} else {
|
||||
count, err = sess.Desc("`action`.created_unix").FindAndCount(&actions)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("FindAndCount: %w", err)
|
||||
}
|
||||
} else {
|
||||
// First, only query which IDs are necessary, and only then query all actions to speed up the overall query
|
||||
sess := db.GetEngine(ctx).Where(cond).Select("`action`.id")
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
|
||||
actionIDs := make([]int64, 0, opts.PageSize)
|
||||
if err := sess.Table("action").Desc("`action`.created_unix").Find(&actionIDs); err != nil {
|
||||
return nil, 0, fmt.Errorf("Find(actionsIDs): %w", err)
|
||||
}
|
||||
|
||||
if !opts.DontCount {
|
||||
count, err = db.GetEngine(ctx).Where(cond).
|
||||
Table("action").
|
||||
Cols("`action`.id").Count()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("Count: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.GetEngine(ctx).In("`action`.id", actionIDs).Desc("`action`.created_unix").Find(&actions); err != nil {
|
||||
return nil, 0, fmt.Errorf("Find: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ActionList(actions).LoadAttributes(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
return actions, count, nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
issue_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAction_GetRepoPath(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
action := &activities_model.Action{RepoID: repo.ID}
|
||||
assert.Equal(t, path.Join(owner.Name, repo.Name), action.GetRepoPath(t.Context()))
|
||||
}
|
||||
|
||||
func TestAction_GetRepoLink(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 2})
|
||||
action := &activities_model.Action{RepoID: repo.ID, CommentID: comment.ID}
|
||||
defer test.MockVariableValue(&setting.AppURL, "https://try.gitea.io/suburl/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/suburl")()
|
||||
expected := path.Join(setting.AppSubURL, owner.Name, repo.Name)
|
||||
assert.Equal(t, expected, action.GetRepoLink(t.Context()))
|
||||
assert.Equal(t, repo.HTMLURL(), action.GetRepoAbsoluteLink(t.Context()))
|
||||
assert.Equal(t, comment.HTMLURL(t.Context()), action.GetCommentHTMLURL(t.Context()))
|
||||
}
|
||||
|
||||
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 TestConsistencyUpdateAction(t *testing.T) {
|
||||
if !setting.Database.Type.IsSQLite3() {
|
||||
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(t.Context()).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(t.Context()).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(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
count, err = activities_model.FixActionCreatedUnixString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
|
||||
count, err = activities_model.CountActionCreatedUnixString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0, count)
|
||||
count, err = activities_model.FixActionCreatedUnixString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0, count)
|
||||
|
||||
//
|
||||
// XORM must be happy now
|
||||
//
|
||||
assert.NoError(t, db.GetEngine(t.Context()).Where("id = ?", id).Find(&actions))
|
||||
unittest.CheckConsistencyFor(t, &activities_model.Action{})
|
||||
}
|
||||
|
||||
func TestDeleteIssueActions(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// load an issue
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4})
|
||||
assert.NotEqual(t, issue.ID, issue.Index) // it needs to use different ID/Index to test the DeleteIssueActions to delete some actions by IssueIndex
|
||||
|
||||
// insert a comment
|
||||
err := db.Insert(t.Context(), &issue_model.Comment{Type: issue_model.CommentTypeComment, IssueID: issue.ID})
|
||||
assert.NoError(t, err)
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{Type: issue_model.CommentTypeComment, IssueID: issue.ID})
|
||||
|
||||
// truncate action table and insert some actions
|
||||
err = db.TruncateBeans(t.Context(), &activities_model.Action{})
|
||||
assert.NoError(t, err)
|
||||
err = db.Insert(t.Context(), &activities_model.Action{
|
||||
OpType: activities_model.ActionCommentIssue,
|
||||
CommentID: comment.ID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
err = db.Insert(t.Context(), &activities_model.Action{
|
||||
OpType: activities_model.ActionCreateIssue,
|
||||
RepoID: issue.RepoID,
|
||||
Content: fmt.Sprintf("%d|content...", issue.Index),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// assert that the actions exist, then delete them
|
||||
unittest.AssertCount(t, &activities_model.Action{}, 2)
|
||||
assert.NoError(t, activities_model.DeleteIssueActions(t.Context(), issue.RepoID, issue.ID, issue.Index))
|
||||
unittest.AssertCount(t, &activities_model.Action{}, 0)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models"
|
||||
_ "gitea.dev/models/actions"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
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:"NOT NULL"`
|
||||
RepoID int64 `xorm:"NOT NULL"`
|
||||
|
||||
Status NotificationStatus `xorm:"SMALLINT NOT NULL"`
|
||||
Source NotificationSource `xorm:"SMALLINT NOT NULL"`
|
||||
|
||||
IssueID int64 `xorm:"NOT NULL"`
|
||||
CommitID string
|
||||
CommentID int64
|
||||
|
||||
UpdatedBy int64 `xorm:"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 NOT NULL"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
|
||||
}
|
||||
|
||||
// TableIndices implements xorm's TableIndices interface
|
||||
func (n *Notification) TableIndices() []*schemas.Index {
|
||||
indices := make([]*schemas.Index, 0, 8)
|
||||
usuuIndex := schemas.NewIndex("u_s_uu", schemas.IndexType)
|
||||
usuuIndex.AddColumn("user_id", "status", "updated_unix")
|
||||
indices = append(indices, usuuIndex)
|
||||
|
||||
// Add the individual indices that were previously defined in struct tags
|
||||
userIDIndex := schemas.NewIndex("idx_notification_user_id", schemas.IndexType)
|
||||
userIDIndex.AddColumn("user_id")
|
||||
indices = append(indices, userIDIndex)
|
||||
|
||||
repoIDIndex := schemas.NewIndex("idx_notification_repo_id", schemas.IndexType)
|
||||
repoIDIndex.AddColumn("repo_id")
|
||||
indices = append(indices, repoIDIndex)
|
||||
|
||||
statusIndex := schemas.NewIndex("idx_notification_status", schemas.IndexType)
|
||||
statusIndex.AddColumn("status")
|
||||
indices = append(indices, statusIndex)
|
||||
|
||||
sourceIndex := schemas.NewIndex("idx_notification_source", schemas.IndexType)
|
||||
sourceIndex.AddColumn("source")
|
||||
indices = append(indices, sourceIndex)
|
||||
|
||||
issueIDIndex := schemas.NewIndex("idx_notification_issue_id", schemas.IndexType)
|
||||
issueIDIndex.AddColumn("issue_id")
|
||||
indices = append(indices, issueIDIndex)
|
||||
|
||||
commitIDIndex := schemas.NewIndex("idx_notification_commit_id", schemas.IndexType)
|
||||
commitIDIndex.AddColumn("commit_id")
|
||||
indices = append(indices, commitIDIndex)
|
||||
|
||||
updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType)
|
||||
updatedByIndex.AddColumn("updated_by")
|
||||
indices = append(indices, updatedByIndex)
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Notification))
|
||||
}
|
||||
|
||||
// CreateRepoTransferNotification creates notification for the user a repository was transferred to
|
||||
func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
var notify []*Notification
|
||||
|
||||
if newOwner.IsOrganization() {
|
||||
users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID)
|
||||
if err != nil || len(users) == 0 {
|
||||
return err
|
||||
}
|
||||
for i := range users {
|
||||
notify = append(notify, &Notification{
|
||||
UserID: i,
|
||||
RepoID: repo.ID,
|
||||
Status: NotificationStatusUnread,
|
||||
UpdatedBy: doer.ID,
|
||||
Source: NotificationSourceRepository,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
notify = []*Notification{{
|
||||
UserID: newOwner.ID,
|
||||
RepoID: repo.ID,
|
||||
Status: NotificationStatusUnread,
|
||||
UpdatedBy: doer.ID,
|
||||
Source: NotificationSourceRepository,
|
||||
}}
|
||||
}
|
||||
|
||||
return db.Insert(ctx, notify)
|
||||
})
|
||||
}
|
||||
|
||||
func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error {
|
||||
notification := &Notification{
|
||||
UserID: userID,
|
||||
RepoID: issue.RepoID,
|
||||
Status: NotificationStatusUnread,
|
||||
IssueID: issue.ID,
|
||||
CommentID: commentID,
|
||||
UpdatedBy: updatedByID,
|
||||
}
|
||||
|
||||
if issue.IsPull {
|
||||
notification.Source = NotificationSourcePullRequest
|
||||
} else {
|
||||
notification.Source = NotificationSourceIssue
|
||||
}
|
||||
|
||||
return db.Insert(ctx, notification)
|
||||
}
|
||||
|
||||
func updateIssueNotification(ctx context.Context, userID, issueID, commentID, updatedByID int64) error {
|
||||
notification, err := GetIssueNotification(ctx, userID, issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments.
|
||||
// But we need update update_by so that the notification will be reorder
|
||||
var cols []string
|
||||
if notification.Status == NotificationStatusRead {
|
||||
notification.Status = NotificationStatusUnread
|
||||
notification.CommentID = commentID
|
||||
cols = []string{"status", "update_by", "comment_id"}
|
||||
} else {
|
||||
notification.UpdatedBy = updatedByID
|
||||
cols = []string{"update_by"}
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetIssueNotification return the notification about an issue
|
||||
func GetIssueNotification(ctx context.Context, userID, issueID int64) (*Notification, error) {
|
||||
notification := new(Notification)
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
And("issue_id = ?", issueID).
|
||||
Get(notification)
|
||||
return notification, err
|
||||
}
|
||||
|
||||
// LoadAttributes load Repo Issue User and Comment if not loaded
|
||||
func (n *Notification) LoadAttributes(ctx context.Context) (err error) {
|
||||
if err = n.loadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = n.loadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = n.loadUser(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = n.loadComment(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *Notification) loadRepo(ctx context.Context) (err error) {
|
||||
if n.Repository == nil {
|
||||
n.Repository, err = repo_model.GetRepositoryByID(ctx, n.RepoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getRepositoryByID [%d]: %w", n.RepoID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notification) loadIssue(ctx context.Context) (err error) {
|
||||
if n.Issue == nil && n.IssueID != 0 {
|
||||
n.Issue, err = issues_model.GetIssueByID(ctx, n.IssueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getIssueByID [%d]: %w", n.IssueID, err)
|
||||
}
|
||||
return n.Issue.LoadAttributes(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notification) loadComment(ctx context.Context) (err error) {
|
||||
if n.Comment == nil && n.CommentID != 0 {
|
||||
n.Comment, err = issues_model.GetCommentByID(ctx, n.CommentID)
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
return issues_model.ErrCommentNotExist{
|
||||
ID: n.CommentID,
|
||||
IssueID: n.IssueID,
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notification) loadUser(ctx context.Context) (err error) {
|
||||
if n.User == nil {
|
||||
n.User, err = user_model.GetUserByID(ctx, n.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getUserByID [%d]: %w", n.UserID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepo returns the repo of the notification
|
||||
func (n *Notification) GetRepo(ctx context.Context) (*repo_model.Repository, error) {
|
||||
return n.Repository, n.loadRepo(ctx)
|
||||
}
|
||||
|
||||
// GetIssue returns the issue of the notification
|
||||
func (n *Notification) GetIssue(ctx context.Context) (*issues_model.Issue, error) {
|
||||
return n.Issue, n.loadIssue(ctx)
|
||||
}
|
||||
|
||||
// HTMLURL formats a URL-string to the notification
|
||||
func (n *Notification) HTMLURL(ctx context.Context) string {
|
||||
switch n.Source {
|
||||
case NotificationSourceIssue, NotificationSourcePullRequest:
|
||||
if n.Comment != nil {
|
||||
return n.Comment.HTMLURL(ctx)
|
||||
}
|
||||
return n.Issue.HTMLURL(ctx)
|
||||
case NotificationSourceCommit:
|
||||
return n.Repository.HTMLURL(ctx) + "/commit/" + url.PathEscape(n.CommitID)
|
||||
case NotificationSourceRepository:
|
||||
return n.Repository.HTMLURL(ctx)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Link formats a relative URL-string to the notification
|
||||
func (n *Notification) Link(ctx context.Context) string {
|
||||
switch n.Source {
|
||||
case NotificationSourceIssue, NotificationSourcePullRequest:
|
||||
if n.Comment != nil {
|
||||
return n.Comment.Link(ctx)
|
||||
}
|
||||
return n.Issue.Link()
|
||||
case NotificationSourceCommit:
|
||||
return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID)
|
||||
case NotificationSourceRepository:
|
||||
return n.Repository.Link()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIURL formats a URL-string to the notification
|
||||
func (n *Notification) APIURL() string {
|
||||
return setting.AppURL + "api/v1/notifications/threads/" + strconv.FormatInt(n.ID, 10)
|
||||
}
|
||||
|
||||
func notificationExists(notifications []*Notification, issueID, userID int64) bool {
|
||||
for _, notification := range notifications {
|
||||
if notification.IssueID == issueID && notification.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UserIDCount is a simple coalition of UserID and Count
|
||||
type UserIDCount struct {
|
||||
UserID int64
|
||||
Count int64
|
||||
}
|
||||
|
||||
// GetUIDsAndNotificationCounts returns the unread counts for every user between the two provided times.
|
||||
// It must return all user IDs which appear during the period, including count=0 for users who have read all.
|
||||
func GetUIDsAndNotificationCounts(ctx context.Context, since, until timeutil.TimeStamp) ([]UserIDCount, error) {
|
||||
sql := `SELECT user_id, sum(case when status= ? then 1 else 0 end) AS count FROM notification ` +
|
||||
`WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` +
|
||||
`updated_unix < ?) GROUP BY user_id`
|
||||
var res []UserIDCount
|
||||
return res, db.GetEngine(ctx).SQL(sql, NotificationStatusUnread, since, until).Find(&res)
|
||||
}
|
||||
|
||||
// SetIssueReadBy sets issue to be read by given user.
|
||||
func SetIssueReadBy(ctx context.Context, issueID, userID int64) error {
|
||||
if err := issues_model.UpdateIssueUserByRead(ctx, userID, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return setIssueNotificationStatusReadIfUnread(ctx, userID, issueID)
|
||||
}
|
||||
|
||||
func setIssueNotificationStatusReadIfUnread(ctx context.Context, userID, issueID int64) error {
|
||||
notification, err := GetIssueNotification(ctx, userID, issueID)
|
||||
// ignore if not exists
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if notification.Status != NotificationStatusUnread {
|
||||
return nil
|
||||
}
|
||||
|
||||
notification.Status = NotificationStatusRead
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(notification.ID).Cols("status").Update(notification)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRepoReadBy sets repo to be visited by given user.
|
||||
func SetRepoReadBy(ctx context.Context, userID, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where(builder.Eq{
|
||||
"user_id": userID,
|
||||
"status": NotificationStatusUnread,
|
||||
"source": NotificationSourceRepository,
|
||||
"repo_id": repoID,
|
||||
}).Cols("status").Update(&Notification{Status: NotificationStatusRead})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNotificationStatus change the notification status
|
||||
func SetNotificationStatus(ctx context.Context, notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) {
|
||||
notification, err := GetNotificationByID(ctx, notificationID)
|
||||
if err != nil {
|
||||
return notification, err
|
||||
}
|
||||
|
||||
if notification.UserID != user.ID {
|
||||
return nil, fmt.Errorf("Can't change notification of another user: %d, %d", notification.UserID, user.ID)
|
||||
}
|
||||
|
||||
notification.Status = status
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(notificationID).Cols("status").Update(notification)
|
||||
return notification, err
|
||||
}
|
||||
|
||||
// GetNotificationByID return notification by ID
|
||||
func GetNotificationByID(ctx context.Context, notificationID int64) (*Notification, error) {
|
||||
notification := new(Notification)
|
||||
ok, err := db.GetEngine(ctx).
|
||||
Where("id = ?", notificationID).
|
||||
Get(notification)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return nil, db.ErrNotExist{Resource: "notification", ID: notificationID}
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
// UpdateNotificationStatuses updates the statuses of all of a user's notifications that are of the currentStatus type to the desiredStatus
|
||||
func UpdateNotificationStatuses(ctx context.Context, user *user_model.User, currentStatus, desiredStatus NotificationStatus) error {
|
||||
n := &Notification{Status: desiredStatus, UpdatedBy: user.ID}
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("user_id = ? AND status = ?", user.ID, currentStatus).
|
||||
Cols("status", "updated_by", "updated_unix").
|
||||
Update(n)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// FindNotificationOptions represent the filters for notifications. If an ID is 0 it will be ignored.
|
||||
type FindNotificationOptions struct {
|
||||
db.ListOptions
|
||||
UserID int64
|
||||
RepoID int64
|
||||
IssueID int64
|
||||
Status []NotificationStatus
|
||||
Source []NotificationSource
|
||||
UpdatedAfterUnix int64
|
||||
UpdatedBeforeUnix int64
|
||||
}
|
||||
|
||||
// ToCond will convert each condition into a xorm-Cond
|
||||
func (opts FindNotificationOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.UserID != 0 {
|
||||
cond = cond.And(builder.Eq{"notification.user_id": opts.UserID})
|
||||
}
|
||||
if opts.RepoID != 0 {
|
||||
cond = cond.And(builder.Eq{"notification.repo_id": opts.RepoID})
|
||||
}
|
||||
if opts.IssueID != 0 {
|
||||
cond = cond.And(builder.Eq{"notification.issue_id": opts.IssueID})
|
||||
}
|
||||
if len(opts.Status) > 0 {
|
||||
if len(opts.Status) == 1 {
|
||||
cond = cond.And(builder.Eq{"notification.status": opts.Status[0]})
|
||||
} else {
|
||||
cond = cond.And(builder.In("notification.status", opts.Status))
|
||||
}
|
||||
}
|
||||
if len(opts.Source) > 0 {
|
||||
cond = cond.And(builder.In("notification.source", opts.Source))
|
||||
}
|
||||
if opts.UpdatedAfterUnix != 0 {
|
||||
cond = cond.And(builder.Gte{"notification.updated_unix": opts.UpdatedAfterUnix})
|
||||
}
|
||||
if opts.UpdatedBeforeUnix != 0 {
|
||||
cond = cond.And(builder.Lte{"notification.updated_unix": opts.UpdatedBeforeUnix})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts FindNotificationOptions) ToOrders() string {
|
||||
return "notification.updated_unix DESC"
|
||||
}
|
||||
|
||||
// CreateOrUpdateIssueNotifications creates an issue notification
|
||||
// for each watcher, or updates it if already exists
|
||||
// receiverID > 0 just send to receiver, else send to all watcher
|
||||
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
|
||||
})
|
||||
}
|
||||
|
||||
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
|
||||
// init
|
||||
var toNotify container.Set[int64]
|
||||
notifications, err := db.Find[Notification](ctx, FindNotificationOptions{
|
||||
IssueID: issueID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if receiverID > 0 {
|
||||
toNotify = make(container.Set[int64], 1)
|
||||
toNotify.Add(receiverID)
|
||||
} else {
|
||||
toNotify = make(container.Set[int64], 32)
|
||||
issueWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toNotify.AddMultiple(issueWatches...)
|
||||
if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {
|
||||
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toNotify.AddMultiple(repoWatches...)
|
||||
}
|
||||
issueParticipants, err := issue.GetParticipantIDsByIssue(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toNotify.AddMultiple(issueParticipants...)
|
||||
|
||||
// don't notify user who cause notification
|
||||
delete(toNotify, notificationAuthorID)
|
||||
// explicit unwatch on issue
|
||||
issueUnWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range issueUnWatches {
|
||||
toNotify.Remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
err = issue.LoadRepo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// notify
|
||||
for userID := range toNotify {
|
||||
issue.Repo.Units = nil
|
||||
user, err := user_model.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
if issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypePullRequests) {
|
||||
continue
|
||||
}
|
||||
if !issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypeIssues) {
|
||||
continue
|
||||
}
|
||||
|
||||
if notificationExists(notifications, issue.ID, userID) {
|
||||
if err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationList contains a list of notifications
|
||||
type NotificationList []*Notification
|
||||
|
||||
// LoadAttributes load Repo Issue User and Comment if not loaded
|
||||
func (nl NotificationList) LoadAttributes(ctx context.Context) error {
|
||||
if _, _, err := nl.LoadRepos(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := nl.LoadIssues(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := nl.LoadUsers(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := nl.LoadComments(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (nl NotificationList) getPendingRepoIDs() []int64 {
|
||||
return container.FilterSlice(nl, func(n *Notification) (int64, bool) {
|
||||
if n.Repository != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n.RepoID, true
|
||||
})
|
||||
}
|
||||
|
||||
// LoadRepos loads repositories from database
|
||||
func (nl NotificationList) LoadRepos(ctx context.Context) (repo_model.RepositoryList, []int, error) {
|
||||
if len(nl) == 0 {
|
||||
return repo_model.RepositoryList{}, []int{}, nil
|
||||
}
|
||||
|
||||
repoIDs := nl.getPendingRepoIDs()
|
||||
repos := make(map[int64]*repo_model.Repository, len(repoIDs))
|
||||
left := len(repoIDs)
|
||||
for left > 0 {
|
||||
limit := min(left, db.DefaultMaxInSize)
|
||||
rows, err := db.GetEngine(ctx).
|
||||
In("id", repoIDs[:limit]).
|
||||
Rows(new(repo_model.Repository))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var repo repo_model.Repository
|
||||
err = rows.Scan(&repo)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
repos[repo.ID] = &repo
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
left -= limit
|
||||
repoIDs = repoIDs[limit:]
|
||||
}
|
||||
|
||||
failed := []int{}
|
||||
|
||||
reposList := make(repo_model.RepositoryList, 0, len(repoIDs))
|
||||
for i, notification := range nl {
|
||||
if notification.Repository == nil {
|
||||
notification.Repository = repos[notification.RepoID]
|
||||
}
|
||||
if notification.Repository == nil {
|
||||
log.Error("Notification[%d]: RepoID: %d not found", notification.ID, notification.RepoID)
|
||||
failed = append(failed, i)
|
||||
continue
|
||||
}
|
||||
var found bool
|
||||
for _, r := range reposList {
|
||||
if r.ID == notification.RepoID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
reposList = append(reposList, notification.Repository)
|
||||
}
|
||||
}
|
||||
return reposList, failed, nil
|
||||
}
|
||||
|
||||
func (nl NotificationList) getPendingIssueIDs() []int64 {
|
||||
ids := make(container.Set[int64], len(nl))
|
||||
for _, notification := range nl {
|
||||
if notification.Issue != nil {
|
||||
continue
|
||||
}
|
||||
ids.Add(notification.IssueID)
|
||||
}
|
||||
return ids.Values()
|
||||
}
|
||||
|
||||
// LoadIssues loads issues from database
|
||||
func (nl NotificationList) LoadIssues(ctx context.Context) ([]int, error) {
|
||||
if len(nl) == 0 {
|
||||
return []int{}, nil
|
||||
}
|
||||
|
||||
issueIDs := nl.getPendingIssueIDs()
|
||||
issues := make(map[int64]*issues_model.Issue, len(issueIDs))
|
||||
left := len(issueIDs)
|
||||
for left > 0 {
|
||||
limit := min(left, db.DefaultMaxInSize)
|
||||
rows, err := db.GetEngine(ctx).
|
||||
In("id", issueIDs[:limit]).
|
||||
Rows(new(issues_model.Issue))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var issue issues_model.Issue
|
||||
err = rows.Scan(&issue)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issues[issue.ID] = &issue
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
left -= limit
|
||||
issueIDs = issueIDs[limit:]
|
||||
}
|
||||
|
||||
failures := []int{}
|
||||
|
||||
for i, notification := range nl {
|
||||
if notification.Issue == nil {
|
||||
notification.Issue = issues[notification.IssueID]
|
||||
if notification.Issue == nil {
|
||||
if notification.IssueID != 0 {
|
||||
log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID)
|
||||
failures = append(failures, i)
|
||||
}
|
||||
continue
|
||||
}
|
||||
notification.Issue.Repo = notification.Repository
|
||||
}
|
||||
}
|
||||
return failures, nil
|
||||
}
|
||||
|
||||
// Without returns the notification list without the failures
|
||||
func (nl NotificationList) Without(failures []int) NotificationList {
|
||||
if len(failures) == 0 {
|
||||
return nl
|
||||
}
|
||||
remaining := make([]*Notification, 0, len(nl))
|
||||
last := -1
|
||||
var i int
|
||||
for _, i = range failures {
|
||||
remaining = append(remaining, nl[last+1:i]...)
|
||||
last = i
|
||||
}
|
||||
if len(nl) > i {
|
||||
remaining = append(remaining, nl[i+1:]...)
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (nl NotificationList) getPendingCommentIDs() []int64 {
|
||||
ids := make(container.Set[int64], len(nl))
|
||||
for _, notification := range nl {
|
||||
if notification.CommentID == 0 || notification.Comment != nil {
|
||||
continue
|
||||
}
|
||||
ids.Add(notification.CommentID)
|
||||
}
|
||||
return ids.Values()
|
||||
}
|
||||
|
||||
func (nl NotificationList) getUserIDs() []int64 {
|
||||
ids := make(container.Set[int64], len(nl))
|
||||
for _, notification := range nl {
|
||||
if notification.UserID == 0 || notification.User != nil {
|
||||
continue
|
||||
}
|
||||
ids.Add(notification.UserID)
|
||||
}
|
||||
return ids.Values()
|
||||
}
|
||||
|
||||
// LoadUsers loads users from database
|
||||
func (nl NotificationList) LoadUsers(ctx context.Context) ([]int, error) {
|
||||
if len(nl) == 0 {
|
||||
return []int{}, nil
|
||||
}
|
||||
|
||||
userIDs := nl.getUserIDs()
|
||||
users := make(map[int64]*user_model.User, len(userIDs))
|
||||
left := len(userIDs)
|
||||
for left > 0 {
|
||||
limit := min(left, db.DefaultMaxInSize)
|
||||
rows, err := db.GetEngine(ctx).
|
||||
In("id", userIDs[:limit]).
|
||||
Rows(new(user_model.User))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var user user_model.User
|
||||
err = rows.Scan(&user)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users[user.ID] = &user
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
left -= limit
|
||||
userIDs = userIDs[limit:]
|
||||
}
|
||||
|
||||
failures := []int{}
|
||||
for i, notification := range nl {
|
||||
if notification.UserID > 0 && notification.User == nil && users[notification.UserID] != nil {
|
||||
notification.User = users[notification.UserID]
|
||||
if notification.User == nil {
|
||||
log.Error("Notification[%d]: UserID[%d] failed to load", notification.ID, notification.UserID)
|
||||
failures = append(failures, i)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return failures, nil
|
||||
}
|
||||
|
||||
// LoadComments loads comments from database
|
||||
func (nl NotificationList) LoadComments(ctx context.Context) ([]int, error) {
|
||||
if len(nl) == 0 {
|
||||
return []int{}, nil
|
||||
}
|
||||
|
||||
commentIDs := nl.getPendingCommentIDs()
|
||||
comments := make(map[int64]*issues_model.Comment, len(commentIDs))
|
||||
left := len(commentIDs)
|
||||
for left > 0 {
|
||||
limit := min(left, db.DefaultMaxInSize)
|
||||
rows, err := db.GetEngine(ctx).
|
||||
In("id", commentIDs[:limit]).
|
||||
Rows(new(issues_model.Comment))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var comment issues_model.Comment
|
||||
err = rows.Scan(&comment)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comments[comment.ID] = &comment
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
left -= limit
|
||||
commentIDs = commentIDs[limit:]
|
||||
}
|
||||
|
||||
failures := []int{}
|
||||
for i, notification := range nl {
|
||||
if notification.CommentID > 0 && notification.Comment == nil && comments[notification.CommentID] != nil {
|
||||
notification.Comment = comments[notification.CommentID]
|
||||
if notification.Comment == nil {
|
||||
log.Error("Notification[%d]: CommentID[%d] failed to load", notification.ID, notification.CommentID)
|
||||
failures = append(failures, i)
|
||||
continue
|
||||
}
|
||||
notification.Comment.Issue = notification.Issue
|
||||
}
|
||||
}
|
||||
return failures, nil
|
||||
}
|
||||
|
||||
// LoadIssuePullRequests loads all issues' pull requests if possible
|
||||
func (nl NotificationList) LoadIssuePullRequests(ctx context.Context) error {
|
||||
issues := make(map[int64]*issues_model.Issue, len(nl))
|
||||
for _, notification := range nl {
|
||||
if notification.Issue != nil && notification.Issue.IsPull && notification.Issue.PullRequest == nil {
|
||||
issues[notification.Issue.ID] = notification.Issue
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pulls, err := issues_model.GetPullRequestByIssueIDs(ctx, util.KeysOfMap(issues))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pull := range pulls {
|
||||
if issue := issues[pull.IssueID]; issue != nil {
|
||||
issue.PullRequest = pull
|
||||
issue.PullRequest.Issue = issue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/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(t.Context(), 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})
|
||||
notfs, err := db.Find[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{
|
||||
UserID: user.ID,
|
||||
Status: []activities_model.NotificationStatus{
|
||||
activities_model.NotificationStatusRead,
|
||||
activities_model.NotificationStatusUnread,
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, notfs, 3) {
|
||||
assert.EqualValues(t, 5, notfs[0].ID)
|
||||
assert.Equal(t, user.ID, notfs[0].UserID)
|
||||
assert.EqualValues(t, 4, notfs[1].ID)
|
||||
assert.Equal(t, user.ID, notfs[1].UserID)
|
||||
assert.EqualValues(t, 2, notfs[2].ID)
|
||||
assert.Equal(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(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, repo, notf.Repository)
|
||||
assert.Equal(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(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, issue, notf.Issue)
|
||||
assert.Equal(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 := db.Count[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{
|
||||
UserID: user.ID,
|
||||
Status: []activities_model.NotificationStatus{
|
||||
activities_model.NotificationStatusRead,
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0, cnt)
|
||||
|
||||
cnt, err = db.Count[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{
|
||||
UserID: user.ID,
|
||||
Status: []activities_model.NotificationStatus{
|
||||
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(t.Context(), 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(t.Context(), 1, user, activities_model.NotificationStatusRead)
|
||||
assert.Error(t, err)
|
||||
_, err = activities_model.SetNotificationStatus(t.Context(), 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(t.Context(), user, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead))
|
||||
unittest.AssertExistsAndLoadBean(t,
|
||||
&activities_model.Notification{ID: notfUnread.ID, Status: activities_model.NotificationStatusRead})
|
||||
unittest.AssertExistsAndLoadBean(t,
|
||||
&activities_model.Notification{ID: notfRead.ID, Status: activities_model.NotificationStatusRead})
|
||||
unittest.AssertExistsAndLoadBean(t,
|
||||
&activities_model.Notification{ID: notfPinned.ID, Status: activities_model.NotificationStatusPinned})
|
||||
}
|
||||
|
||||
func TestSetIssueReadBy(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
assert.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
|
||||
return activities_model.SetIssueReadBy(ctx, issue.ID, user.ID)
|
||||
}))
|
||||
|
||||
nt, err := activities_model.GetIssueNotification(t.Context(), user.ID, issue.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, activities_model.NotificationStatusRead, nt.Status)
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ActivityAuthorData represents statistical git commit count data
|
||||
type ActivityAuthorData struct {
|
||||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
HomeLink string `json:"home_link"`
|
||||
Commits int64 `json:"commits"`
|
||||
}
|
||||
|
||||
// ActivityStats represents issue and pull request information.
|
||||
type ActivityStats struct {
|
||||
OpenedPRs issues_model.PullRequestList
|
||||
OpenedPRAuthorCount int64
|
||||
MergedPRs issues_model.PullRequestList
|
||||
MergedPRAuthorCount int64
|
||||
ActiveIssues issues_model.IssueList
|
||||
OpenedIssues issues_model.IssueList
|
||||
OpenedIssueAuthorCount int64
|
||||
ClosedIssues issues_model.IssueList
|
||||
ClosedIssueAuthorCount int64
|
||||
UnresolvedIssues issues_model.IssueList
|
||||
PublishedReleases []*repo_model.Release
|
||||
PublishedReleaseAuthorCount int64
|
||||
Code *git.CodeActivityStats
|
||||
}
|
||||
|
||||
// GetActivityStats return stats for repository at given time range
|
||||
func GetActivityStats(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) {
|
||||
stats := &ActivityStats{Code: &git.CodeActivityStats{}}
|
||||
if releases {
|
||||
if err := stats.FillReleases(ctx, repo.ID, timeFrom); err != nil {
|
||||
return nil, fmt.Errorf("FillReleases: %w", err)
|
||||
}
|
||||
}
|
||||
if prs {
|
||||
if err := stats.FillPullRequests(ctx, repo.ID, timeFrom); err != nil {
|
||||
return nil, fmt.Errorf("FillPullRequests: %w", err)
|
||||
}
|
||||
}
|
||||
if issues {
|
||||
if err := stats.FillIssues(ctx, repo.ID, timeFrom); err != nil {
|
||||
return nil, fmt.Errorf("FillIssues: %w", err)
|
||||
}
|
||||
}
|
||||
if err := stats.FillUnresolvedIssues(ctx, repo.ID, timeFrom, issues, prs); err != nil {
|
||||
return nil, fmt.Errorf("FillUnresolvedIssues: %w", err)
|
||||
}
|
||||
if code {
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
code, err := gitRepo.GetCodeActivityStats(timeFrom, repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FillFromGit: %w", err)
|
||||
}
|
||||
stats.Code = code
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetActivityStatsTopAuthors returns top author stats for git commits for all branches
|
||||
func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, count int) ([]*ActivityAuthorData, error) {
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
code, err := gitRepo.GetCodeActivityStats(timeFrom, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FillFromGit: %w", err)
|
||||
}
|
||||
if code.Authors == nil {
|
||||
return nil, nil
|
||||
}
|
||||
users := make(map[int64]*ActivityAuthorData)
|
||||
var unknownUserID int64
|
||||
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink(ctx)
|
||||
for _, v := range code.Authors {
|
||||
if len(v.Email) == 0 {
|
||||
continue
|
||||
}
|
||||
u, err := user_model.GetUserByEmail(ctx, v.Email)
|
||||
if u == nil || user_model.IsErrUserNotExist(err) {
|
||||
unknownUserID--
|
||||
users[unknownUserID] = &ActivityAuthorData{
|
||||
Name: v.Name,
|
||||
AvatarLink: unknownUserAvatarLink,
|
||||
Commits: v.Commits,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user, ok := users[u.ID]; !ok {
|
||||
users[u.ID] = &ActivityAuthorData{
|
||||
Name: u.DisplayName(),
|
||||
Login: u.LowerName,
|
||||
AvatarLink: u.AvatarLink(ctx),
|
||||
HomeLink: u.HomeLink(),
|
||||
Commits: v.Commits,
|
||||
}
|
||||
} else {
|
||||
user.Commits += v.Commits
|
||||
}
|
||||
}
|
||||
v := make([]*ActivityAuthorData, 0, len(users))
|
||||
for _, u := range users {
|
||||
v = append(v, u)
|
||||
}
|
||||
|
||||
sort.Slice(v, func(i, j int) bool {
|
||||
return v[i].Commits > v[j].Commits
|
||||
})
|
||||
|
||||
cnt := min(count, len(v))
|
||||
|
||||
return v[:cnt], nil
|
||||
}
|
||||
|
||||
// ActivePRCount returns total active pull request count
|
||||
func (stats *ActivityStats) ActivePRCount() int {
|
||||
return stats.OpenedPRCount() + stats.MergedPRCount()
|
||||
}
|
||||
|
||||
// OpenedPRCount returns opened pull request count
|
||||
func (stats *ActivityStats) OpenedPRCount() int {
|
||||
return len(stats.OpenedPRs)
|
||||
}
|
||||
|
||||
// OpenedPRPerc returns opened pull request percents from total active
|
||||
func (stats *ActivityStats) OpenedPRPerc() int {
|
||||
return int(float32(stats.OpenedPRCount()) / float32(stats.ActivePRCount()) * 100.0)
|
||||
}
|
||||
|
||||
// MergedPRCount returns merged pull request count
|
||||
func (stats *ActivityStats) MergedPRCount() int {
|
||||
return len(stats.MergedPRs)
|
||||
}
|
||||
|
||||
// MergedPRPerc returns merged pull request percent from total active
|
||||
func (stats *ActivityStats) MergedPRPerc() int {
|
||||
return int(float32(stats.MergedPRCount()) / float32(stats.ActivePRCount()) * 100.0)
|
||||
}
|
||||
|
||||
// ActiveIssueCount returns total active issue count
|
||||
func (stats *ActivityStats) ActiveIssueCount() int {
|
||||
return len(stats.ActiveIssues)
|
||||
}
|
||||
|
||||
// OpenedIssueCount returns open issue count
|
||||
func (stats *ActivityStats) OpenedIssueCount() int {
|
||||
return len(stats.OpenedIssues)
|
||||
}
|
||||
|
||||
// OpenedIssuePerc returns open issue count percent from total active
|
||||
func (stats *ActivityStats) OpenedIssuePerc() int {
|
||||
return int(float32(stats.OpenedIssueCount()) / float32(stats.ActiveIssueCount()) * 100.0)
|
||||
}
|
||||
|
||||
// ClosedIssueCount returns closed issue count
|
||||
func (stats *ActivityStats) ClosedIssueCount() int {
|
||||
return len(stats.ClosedIssues)
|
||||
}
|
||||
|
||||
// ClosedIssuePerc returns closed issue count percent from total active
|
||||
func (stats *ActivityStats) ClosedIssuePerc() int {
|
||||
return int(float32(stats.ClosedIssueCount()) / float32(stats.ActiveIssueCount()) * 100.0)
|
||||
}
|
||||
|
||||
// UnresolvedIssueCount returns unresolved issue and pull request count
|
||||
func (stats *ActivityStats) UnresolvedIssueCount() int {
|
||||
return len(stats.UnresolvedIssues)
|
||||
}
|
||||
|
||||
// PublishedReleaseCount returns published release count
|
||||
func (stats *ActivityStats) PublishedReleaseCount() int {
|
||||
return len(stats.PublishedReleases)
|
||||
}
|
||||
|
||||
// FillPullRequests returns pull request information for activity page
|
||||
func (stats *ActivityStats) FillPullRequests(ctx context.Context, repoID int64, fromTime time.Time) error {
|
||||
var err error
|
||||
var count int64
|
||||
|
||||
// Merged pull requests
|
||||
sess := pullRequestsForActivityStatement(ctx, repoID, fromTime, true)
|
||||
sess.OrderBy("pull_request.merged_unix DESC")
|
||||
stats.MergedPRs = make(issues_model.PullRequestList, 0)
|
||||
if err = sess.Find(&stats.MergedPRs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = stats.MergedPRs.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merged pull request authors
|
||||
sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, true)
|
||||
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("pull_request").Get(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.MergedPRAuthorCount = count
|
||||
|
||||
// Opened pull requests
|
||||
sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, false)
|
||||
sess.OrderBy("issue.created_unix ASC")
|
||||
stats.OpenedPRs = make(issues_model.PullRequestList, 0)
|
||||
if err = sess.Find(&stats.OpenedPRs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = stats.OpenedPRs.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Opened pull request authors
|
||||
sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, false)
|
||||
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("pull_request").Get(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.OpenedPRAuthorCount = count
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pullRequestsForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time, merged bool) db.Session {
|
||||
sess := db.GetEngine(ctx).Where("pull_request.base_repo_id=?", repoID).
|
||||
Join("INNER", "issue", "pull_request.issue_id = issue.id")
|
||||
|
||||
if merged {
|
||||
sess.And("pull_request.has_merged = ?", true)
|
||||
sess.And("pull_request.merged_unix >= ?", fromTime.Unix())
|
||||
} else {
|
||||
sess.And("issue.is_closed = ?", false)
|
||||
sess.And("issue.created_unix >= ?", fromTime.Unix())
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// FillIssues returns issue information for activity page
|
||||
func (stats *ActivityStats) FillIssues(ctx context.Context, repoID int64, fromTime time.Time) error {
|
||||
var err error
|
||||
var count int64
|
||||
|
||||
// Closed issues
|
||||
sess := issuesForActivityStatement(ctx, repoID, fromTime, true, false)
|
||||
sess.OrderBy("issue.closed_unix DESC")
|
||||
stats.ClosedIssues = make(issues_model.IssueList, 0)
|
||||
if err = sess.Find(&stats.ClosedIssues); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Closed issue authors
|
||||
sess = issuesForActivityStatement(ctx, repoID, fromTime, true, false)
|
||||
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.ClosedIssueAuthorCount = count
|
||||
|
||||
// New issues
|
||||
sess = newlyCreatedIssues(ctx, repoID, fromTime)
|
||||
sess.OrderBy("issue.created_unix ASC")
|
||||
stats.OpenedIssues = make(issues_model.IssueList, 0)
|
||||
if err = sess.Find(&stats.OpenedIssues); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Active issues
|
||||
sess = activeIssues(ctx, repoID, fromTime)
|
||||
sess.OrderBy("issue.created_unix ASC")
|
||||
stats.ActiveIssues = make(issues_model.IssueList, 0)
|
||||
if err = sess.Find(&stats.ActiveIssues); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Opened issue authors
|
||||
sess = issuesForActivityStatement(ctx, repoID, fromTime, false, false)
|
||||
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.OpenedIssueAuthorCount = count
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FillUnresolvedIssues returns unresolved issue and pull request information for activity page
|
||||
func (stats *ActivityStats) FillUnresolvedIssues(ctx context.Context, repoID int64, fromTime time.Time, issues, prs bool) error {
|
||||
// Check if we need to select anything
|
||||
if !issues && !prs {
|
||||
return nil
|
||||
}
|
||||
sess := issuesForActivityStatement(ctx, repoID, fromTime, false, true)
|
||||
if !issues || !prs {
|
||||
sess.And("issue.is_pull = ?", prs)
|
||||
}
|
||||
sess.OrderBy("issue.updated_unix DESC")
|
||||
stats.UnresolvedIssues = make(issues_model.IssueList, 0)
|
||||
return sess.Find(&stats.UnresolvedIssues)
|
||||
}
|
||||
|
||||
func newlyCreatedIssues(ctx context.Context, repoID int64, fromTime time.Time) db.Session {
|
||||
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
|
||||
And("issue.is_pull = ?", false). // Retain the is_pull check to exclude pull requests
|
||||
And("issue.created_unix >= ?", fromTime.Unix()) // Include all issues created after fromTime
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
func activeIssues(ctx context.Context, repoID int64, fromTime time.Time) db.Session {
|
||||
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
|
||||
And("issue.is_pull = ?", false).
|
||||
And(builder.Or(
|
||||
builder.Gte{"issue.created_unix": fromTime.Unix()},
|
||||
builder.Gte{"issue.closed_unix": fromTime.Unix()},
|
||||
))
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
func issuesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time, closed, unresolved bool) db.Session {
|
||||
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
|
||||
And("issue.is_closed = ?", closed)
|
||||
|
||||
if !unresolved {
|
||||
sess.And("issue.is_pull = ?", false)
|
||||
if closed {
|
||||
sess.And("issue.closed_unix >= ?", fromTime.Unix())
|
||||
} else {
|
||||
sess.And("issue.created_unix >= ?", fromTime.Unix())
|
||||
}
|
||||
} else {
|
||||
sess.And("issue.created_unix < ?", fromTime.Unix())
|
||||
sess.And("issue.updated_unix >= ?", fromTime.Unix())
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// FillReleases returns release information for activity page
|
||||
func (stats *ActivityStats) FillReleases(ctx context.Context, repoID int64, fromTime time.Time) error {
|
||||
var err error
|
||||
var count int64
|
||||
|
||||
// Published releases list
|
||||
sess := releasesForActivityStatement(ctx, repoID, fromTime)
|
||||
sess.OrderBy("`release`.created_unix DESC")
|
||||
stats.PublishedReleases = make([]*repo_model.Release, 0)
|
||||
if err = sess.Find(&stats.PublishedReleases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Published releases authors
|
||||
sess = releasesForActivityStatement(ctx, repoID, fromTime)
|
||||
if _, err = sess.Select("count(distinct `release`.publisher_id) as `count`").Table("release").Get(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.PublishedReleaseAuthorCount = count
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func releasesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time) db.Session {
|
||||
return db.GetEngine(ctx).Where("`release`.repo_id = ?", repoID).
|
||||
And("`release`.is_draft = ?", false).
|
||||
And("`release`.created_unix >= ?", fromTime.Unix())
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/models/webhook"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
)
|
||||
|
||||
// Statistic contains the database statistics
|
||||
type Statistic struct {
|
||||
Counter struct {
|
||||
UsersActive, UsersNotActive,
|
||||
Org, PublicKey,
|
||||
Repo, Watch, Star, Access,
|
||||
Issue, IssueClosed, IssueOpen,
|
||||
Comment, Oauth, Follow,
|
||||
Mirror, Release, AuthSource, Webhook,
|
||||
Milestone, Label, HookTask,
|
||||
Team, UpdateTask, Project,
|
||||
ProjectColumn, Attachment,
|
||||
Branches, Tags, CommitStatus int64
|
||||
IssueByLabel []IssueByLabelCount
|
||||
IssueByRepository []IssueByRepositoryCount
|
||||
}
|
||||
}
|
||||
|
||||
// IssueByLabelCount contains the number of issue group by label
|
||||
type IssueByLabelCount struct {
|
||||
Count int64
|
||||
Label string
|
||||
}
|
||||
|
||||
// IssueByRepositoryCount contains the number of issue group by repository
|
||||
type IssueByRepositoryCount struct {
|
||||
Count int64
|
||||
OwnerName string
|
||||
Repository string
|
||||
}
|
||||
|
||||
// GetStatistic returns the database statistics
|
||||
func GetStatistic(ctx context.Context) (stats Statistic) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
// Number of active users
|
||||
usersActiveOpts := user_model.CountUserFilter{
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
stats.Counter.UsersActive = user_model.CountUsers(ctx, &usersActiveOpts)
|
||||
|
||||
// Number of inactive users
|
||||
usersNotActiveOpts := user_model.CountUserFilter{
|
||||
IsActive: optional.Some(false),
|
||||
}
|
||||
stats.Counter.UsersNotActive = user_model.CountUsers(ctx, &usersNotActiveOpts)
|
||||
|
||||
stats.Counter.Org, _ = db.Count[organization.Organization](ctx, organization.FindOrgOptions{IncludeVisibility: structs.VisibleTypePrivate})
|
||||
stats.Counter.PublicKey, _ = e.Count(new(asymkey_model.PublicKey))
|
||||
stats.Counter.Repo, _ = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{})
|
||||
stats.Counter.Watch, _ = e.Count(new(repo_model.Watch))
|
||||
stats.Counter.Star, _ = e.Count(new(repo_model.Star))
|
||||
stats.Counter.Access, _ = e.Count(new(access_model.Access))
|
||||
stats.Counter.Branches, _ = e.Count(new(git_model.Branch))
|
||||
stats.Counter.Tags, _ = e.Where("is_draft=?", false).Count(new(repo_model.Release))
|
||||
stats.Counter.CommitStatus, _ = e.Count(new(git_model.CommitStatus))
|
||||
|
||||
type IssueCount struct {
|
||||
Count int64
|
||||
IsClosed bool
|
||||
}
|
||||
|
||||
if setting.Metrics.EnabledIssueByLabel {
|
||||
stats.Counter.IssueByLabel = []IssueByLabelCount{}
|
||||
|
||||
_ = e.Select("COUNT(*) AS count, l.name AS label").
|
||||
Join("LEFT", "label l", "l.id=il.label_id").
|
||||
Table("issue_label il").
|
||||
GroupBy("l.name").
|
||||
Find(&stats.Counter.IssueByLabel)
|
||||
}
|
||||
|
||||
if setting.Metrics.EnabledIssueByRepository {
|
||||
stats.Counter.IssueByRepository = []IssueByRepositoryCount{}
|
||||
|
||||
_ = e.Select("COUNT(*) AS count, r.owner_name, r.name AS repository").
|
||||
Join("LEFT", "repository r", "r.id=i.repo_id").
|
||||
Table("issue i").
|
||||
GroupBy("r.owner_name, r.name").
|
||||
Find(&stats.Counter.IssueByRepository)
|
||||
}
|
||||
|
||||
var issueCounts []IssueCount
|
||||
|
||||
_ = e.Select("COUNT(*) AS count, is_closed").Table("issue").GroupBy("is_closed").Find(&issueCounts)
|
||||
for _, c := range issueCounts {
|
||||
if c.IsClosed {
|
||||
stats.Counter.IssueClosed = c.Count
|
||||
} else {
|
||||
stats.Counter.IssueOpen = c.Count
|
||||
}
|
||||
}
|
||||
|
||||
stats.Counter.Issue = stats.Counter.IssueClosed + stats.Counter.IssueOpen
|
||||
|
||||
stats.Counter.Comment, _ = e.Count(new(issues_model.Comment))
|
||||
stats.Counter.Oauth = 0
|
||||
stats.Counter.Follow, _ = e.Count(new(user_model.Follow))
|
||||
stats.Counter.Mirror, _ = e.Count(new(repo_model.Mirror))
|
||||
stats.Counter.Release, _ = e.Count(new(repo_model.Release))
|
||||
stats.Counter.AuthSource, _ = db.Count[auth.Source](ctx, auth.FindSourcesOptions{})
|
||||
stats.Counter.Webhook, _ = e.Count(new(webhook.Webhook))
|
||||
stats.Counter.Milestone, _ = e.Count(new(issues_model.Milestone))
|
||||
stats.Counter.Label, _ = e.Count(new(issues_model.Label))
|
||||
stats.Counter.HookTask, _ = e.Count(new(webhook.HookTask))
|
||||
stats.Counter.Team, _ = e.Count(new(organization.Team))
|
||||
stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment))
|
||||
stats.Counter.Project, _ = e.Count(new(project_model.Project))
|
||||
stats.Counter.ProjectColumn, _ = e.Count(new(project_model.Column))
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/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, it checks whether doer can access user's activity
|
||||
func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
return getUserHeatmapData(ctx, user, nil, doer)
|
||||
}
|
||||
|
||||
// GetUserHeatmapDataByOrgTeam returns an array of UserHeatmapData, it checks whether doer can access org's activity
|
||||
func GetUserHeatmapDataByOrgTeam(ctx context.Context, org *organization.Organization, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
return getUserHeatmapData(ctx, org.AsUser(), team, doer)
|
||||
}
|
||||
|
||||
func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
hdata := make([]*UserHeatmapData, 0)
|
||||
|
||||
if !ActivityReadable(user, doer) {
|
||||
return hdata, nil
|
||||
}
|
||||
|
||||
// Group by 15 minute intervals which will allow the client to accurately shift the timestamp to their timezone.
|
||||
// The interval is based on the fact that there are timezones such as UTC +5:30 and UTC +12:45.
|
||||
groupBy := "created_unix / 900 * 900"
|
||||
groupByName := "timestamp" // We need this extra case because mssql doesn't allow grouping by alias
|
||||
switch {
|
||||
case setting.Database.Type.IsMySQL():
|
||||
groupBy = "created_unix DIV 900 * 900"
|
||||
case setting.Database.Type.IsMSSQL():
|
||||
groupByName = groupBy
|
||||
}
|
||||
|
||||
cond, err := ActivityQueryCondition(ctx, GetFeedsOptions{
|
||||
RequestedUser: user,
|
||||
RequestedTeam: team,
|
||||
Actor: doer,
|
||||
IncludePrivate: true, // don't filter by private, as we already filter by repo access
|
||||
IncludeDeleted: true,
|
||||
// * Heatmaps for individual users only include actions that the user themself did.
|
||||
// * For organizations actions by all users that were made in owned
|
||||
// repositories are counted.
|
||||
OnlyPerformedBy: !user.IsOrganization(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// HINT: USER-ACTIVITY-PUSH-COMMITS: it only uses the doer's action time, it doesn't use git commit's time
|
||||
return hdata, db.GetEngine(ctx).
|
||||
Select(groupBy+" AS timestamp, count(user_id) as contributions").
|
||||
Table("action").
|
||||
Where(cond).
|
||||
And("created_unix > ?", timeutil.TimeStampNow()-(366+7)*86400). // (366+7) days to include the first week for the heatmap
|
||||
GroupBy(groupByName).
|
||||
OrderBy("timestamp").
|
||||
Find(&hdata)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activities_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/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.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
for _, tc := range testCases {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.userID})
|
||||
|
||||
var doer *user_model.User
|
||||
if tc.doerID != 0 {
|
||||
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
|
||||
}
|
||||
|
||||
// get the action for comparison
|
||||
actions, count, err := activities_model.GetFeeds(t.Context(), 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(t.Context(), 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, count, int64(contributions))
|
||||
assert.Equal(t, tc.CountResult, contributions, "testcase '%s'", tc.desc)
|
||||
|
||||
// Test JSON rendering
|
||||
jsonData, err := json.Marshal(heatmap)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, tc.JSONResult, string(jsonData))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user