初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+614
View File
@@ -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
}
+284
View File
@@ -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
}
+159
View File
@@ -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)
}
+17
View File
@@ -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)
}
+418
View File
@@ -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
}
+479
View File
@@ -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
}
+140
View File
@@ -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)
}
+391
View File
@@ -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())
}
+135
View File
@@ -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
}
+74
View File
@@ -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)
}
+97
View File
@@ -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))
}
}