diff options
author | Joe Chen <jc@unknwon.io> | 2022-06-25 18:07:39 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-06-25 18:07:39 +0800 |
commit | 083c3ee659c6c5542687f3bafae68cbc24dbc90f (patch) | |
tree | 0103bf3b5c5ebfccd368a7cb6a425a521fd669d9 /internal/db/actions.go | |
parent | 9df4e3ae3c555a86f691f0d78a43834842e77d8b (diff) |
db: refactor "action" table to use GORM (#7054)
Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
Diffstat (limited to 'internal/db/actions.go')
-rw-r--r-- | internal/db/actions.go | 962 |
1 files changed, 962 insertions, 0 deletions
diff --git a/internal/db/actions.go b/internal/db/actions.go new file mode 100644 index 00000000..b0a96b80 --- /dev/null +++ b/internal/db/actions.go @@ -0,0 +1,962 @@ +// Copyright 2020 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "context" + "fmt" + "path" + "strconv" + "strings" + "time" + "unicode" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + jsoniter "github.com/json-iterator/go" + "github.com/pkg/errors" + "gorm.io/gorm" + log "unknwon.dev/clog/v2" + + "gogs.io/gogs/internal/conf" + "gogs.io/gogs/internal/lazyregexp" + "gogs.io/gogs/internal/repoutil" + "gogs.io/gogs/internal/strutil" + "gogs.io/gogs/internal/testutil" + "gogs.io/gogs/internal/tool" +) + +// ActionsStore is the persistent interface for actions. +// +// NOTE: All methods are sorted in alphabetical order. +type ActionsStore interface { + // CommitRepo creates actions for pushing commits to the repository. An action + // with the type ActionDeleteBranch is created if the push deletes a branch; an + // action with the type ActionCommitRepo is created for a regular push. If the + // regular push also creates a new branch, then another action with type + // ActionCreateBranch is created. + CommitRepo(ctx context.Context, opts CommitRepoOptions) error + // ListByOrganization returns actions of the organization viewable by the actor. + // Results are paginated if `afterID` is given. + ListByOrganization(ctx context.Context, orgID, actorID, afterID int64) ([]*Action, error) + // ListByUser returns actions of the user viewable by the actor. Results are + // paginated if `afterID` is given. The `isProfile` indicates whether repository + // permissions should be considered. + ListByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) ([]*Action, error) + // MergePullRequest creates an action for merging a pull request. + MergePullRequest(ctx context.Context, doer, owner *User, repo *Repository, pull *Issue) error + // MirrorSyncCreate creates an action for mirror synchronization of a new + // reference. + MirrorSyncCreate(ctx context.Context, owner *User, repo *Repository, refName string) error + // MirrorSyncDelete creates an action for mirror synchronization of a reference + // deletion. + MirrorSyncDelete(ctx context.Context, owner *User, repo *Repository, refName string) error + // MirrorSyncPush creates an action for mirror synchronization of pushed + // commits. + MirrorSyncPush(ctx context.Context, opts MirrorSyncPushOptions) error + // NewRepo creates an action for creating a new repository. The action type + // could be ActionCreateRepo or ActionForkRepo based on whether the repository + // is a fork. + NewRepo(ctx context.Context, doer, owner *User, repo *Repository) error + // PushTag creates an action for pushing tags to the repository. An action with + // the type ActionDeleteTag is created if the push deletes a tag. Otherwise, an + // action with the type ActionPushTag is created for a regular push. + PushTag(ctx context.Context, opts PushTagOptions) error + // RenameRepo creates an action for renaming a repository. + RenameRepo(ctx context.Context, doer, owner *User, oldRepoName string, repo *Repository) error + // TransferRepo creates an action for transferring a repository to a new owner. + TransferRepo(ctx context.Context, doer, oldOwner, newOwner *User, repo *Repository) error +} + +var Actions ActionsStore + +var _ ActionsStore = (*actions)(nil) + +type actions struct { + *gorm.DB +} + +// NewActionsStore returns a persistent interface for actions with given +// database connection. +func NewActionsStore(db *gorm.DB) ActionsStore { + return &actions{DB: db} +} + +func (db *actions) listByOrganization(ctx context.Context, orgID, actorID, afterID int64) *gorm.DB { + /* + Equivalent SQL for PostgreSQL: + + SELECT * FROM "action" + WHERE + user_id = @userID + AND (@skipAfter OR id < @afterID) + AND repo_id IN ( + SELECT repository.id FROM "repository" + JOIN team_repo ON repository.id = team_repo.repo_id + WHERE team_repo.team_id IN ( + SELECT team_id FROM "team_user" + WHERE + team_user.org_id = @orgID AND uid = @actorID) + OR (repository.is_private = FALSE AND repository.is_unlisted = FALSE) + ) + ORDER BY id DESC + LIMIT @limit + */ + return db.WithContext(ctx). + Where("user_id = ?", orgID). + Where(db. + // Not apply when afterID is not given + Where("?", afterID <= 0). + Or("id < ?", afterID), + ). + Where("repo_id IN (?)", + db.Select("repository.id"). + Table("repository"). + Joins("JOIN team_repo ON repository.id = team_repo.repo_id"). + Where("team_repo.team_id IN (?)", + db.Select("team_id"). + Table("team_user"). + Where("team_user.org_id = ? AND uid = ?", orgID, actorID), + ). + Or("repository.is_private = ? AND repository.is_unlisted = ?", false, false), + ). + Limit(conf.UI.User.NewsFeedPagingNum). + Order("id DESC") +} + +func (db *actions) ListByOrganization(ctx context.Context, orgID, actorID, afterID int64) ([]*Action, error) { + actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum) + return actions, db.listByOrganization(ctx, orgID, actorID, afterID).Find(&actions).Error +} + +func (db *actions) listByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) *gorm.DB { + /* + Equivalent SQL for PostgreSQL: + + SELECT * FROM "action" + WHERE + user_id = @userID + AND (@skipAfter OR id < @afterID) + AND (@includePrivate OR (is_private = FALSE AND act_user_id = @actorID)) + ORDER BY id DESC + LIMIT @limit + */ + return db.WithContext(ctx). + Where("user_id = ?", userID). + Where(db. + // Not apply when afterID is not given + Where("?", afterID <= 0). + Or("id < ?", afterID), + ). + Where(db. + // Not apply when in not profile page or the user is viewing own profile + Where("?", !isProfile || actorID == userID). + Or("is_private = ? AND act_user_id = ?", false, userID), + ). + Limit(conf.UI.User.NewsFeedPagingNum). + Order("id DESC") +} + +func (db *actions) ListByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) ([]*Action, error) { + actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum) + return actions, db.listByUser(ctx, userID, actorID, afterID, isProfile).Find(&actions).Error +} + +// notifyWatchers creates rows in action table for watchers who are able to see the action. +func (db *actions) notifyWatchers(ctx context.Context, act *Action) error { + watches, err := NewWatchesStore(db.DB).ListByRepo(ctx, act.RepoID) + if err != nil { + return errors.Wrap(err, "list watches") + } + + // Clone returns a deep copy of the action with UserID assigned + clone := func(userID int64) *Action { + tmp := *act + tmp.UserID = userID + return &tmp + } + + // Plus one for the actor + actions := make([]*Action, 0, len(watches)+1) + actions = append(actions, clone(act.ActUserID)) + + for _, watch := range watches { + if act.ActUserID == watch.UserID { + continue + } + actions = append(actions, clone(watch.UserID)) + } + + return db.Create(actions).Error +} + +func (db *actions) NewRepo(ctx context.Context, doer, owner *User, repo *Repository) error { + opType := ActionCreateRepo + if repo.IsFork { + opType = ActionForkRepo + } + + return db.notifyWatchers(ctx, + &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: opType, + RepoID: repo.ID, + RepoUserName: owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate || repo.IsUnlisted, + }, + ) +} + +func (db *actions) RenameRepo(ctx context.Context, doer, owner *User, oldRepoName string, repo *Repository) error { + return db.notifyWatchers(ctx, + &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: ActionRenameRepo, + RepoID: repo.ID, + RepoUserName: owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate || repo.IsUnlisted, + Content: oldRepoName, + }, + ) +} + +func (db *actions) mirrorSyncAction(ctx context.Context, opType ActionType, owner *User, repo *Repository, refName string, content []byte) error { + return db.notifyWatchers(ctx, + &Action{ + ActUserID: owner.ID, + ActUserName: owner.Name, + OpType: opType, + Content: string(content), + RepoID: repo.ID, + RepoUserName: owner.Name, + RepoName: repo.Name, + RefName: refName, + IsPrivate: repo.IsPrivate || repo.IsUnlisted, + }, + ) +} + +type MirrorSyncPushOptions struct { + Owner *User + Repo *Repository + RefName string + OldCommitID string + NewCommitID string + Commits *PushCommits +} + +func (db *actions) MirrorSyncPush(ctx context.Context, opts MirrorSyncPushOptions) error { + if conf.UI.FeedMaxCommitNum > 0 && len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum { + opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum] + } + + apiCommits, err := opts.Commits.APIFormat(ctx, + NewUsersStore(db.DB), + repoutil.RepositoryPath(opts.Owner.Name, opts.Repo.Name), + repoutil.HTMLURL(opts.Owner.Name, opts.Repo.Name), + ) + if err != nil { + return errors.Wrap(err, "convert commits to API format") + } + + opts.Commits.CompareURL = repoutil.CompareCommitsPath(opts.Owner.Name, opts.Repo.Name, opts.OldCommitID, opts.NewCommitID) + apiPusher := opts.Owner.APIFormat() + err = PrepareWebhooks( + opts.Repo, + HOOK_EVENT_PUSH, + &api.PushPayload{ + Ref: opts.RefName, + Before: opts.OldCommitID, + After: opts.NewCommitID, + CompareURL: conf.Server.ExternalURL + opts.Commits.CompareURL, + Commits: apiCommits, + Repo: opts.Repo.APIFormat(opts.Owner), + Pusher: apiPusher, + Sender: apiPusher, + }, + ) + if err != nil { + return errors.Wrap(err, "prepare webhooks") + } + + data, err := jsoniter.Marshal(opts.Commits) + if err != nil { + return errors.Wrap(err, "marshal JSON") + } + + return db.mirrorSyncAction(ctx, ActionMirrorSyncPush, opts.Owner, opts.Repo, opts.RefName, data) +} + +func (db *actions) MirrorSyncCreate(ctx context.Context, owner *User, repo *Repository, refName string) error { + return db.mirrorSyncAction(ctx, ActionMirrorSyncCreate, owner, repo, refName, nil) +} + +func (db *actions) MirrorSyncDelete(ctx context.Context, owner *User, repo *Repository, refName string) error { + return db.mirrorSyncAction(ctx, ActionMirrorSyncDelete, owner, repo, refName, nil) +} + +func (db *actions) MergePullRequest(ctx context.Context, doer, owner *User, repo *Repository, pull *Issue) error { + return db.notifyWatchers(ctx, + &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: ActionMergePullRequest, + Content: fmt.Sprintf("%d|%s", pull.Index, pull.Title), + RepoID: repo.ID, + RepoUserName: owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate || repo.IsUnlisted, + }, + ) +} + +func (db *actions) TransferRepo(ctx context.Context, doer, oldOwner, newOwner *User, repo *Repository) error { + return db.notifyWatchers(ctx, + &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: ActionTransferRepo, + RepoID: repo.ID, + RepoUserName: newOwner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate || repo.IsUnlisted, + Content: oldOwner.Name + "/" + repo.Name, + }, + ) +} + +var ( + // Same as GitHub, see https://docs.github.com/en/free-pro-team@latest/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue + issueCloseKeywords = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"} + issueReopenKeywords = []string{"reopen", "reopens", "reopened"} + + issueCloseKeywordsPattern = lazyregexp.New(assembleKeywordsPattern(issueCloseKeywords)) + issueReopenKeywordsPattern = lazyregexp.New(assembleKeywordsPattern(issueReopenKeywords)) + issueReferencePattern = lazyregexp.New(`(?i)(?:)(^| )\S*#\d+`) +) + +func assembleKeywordsPattern(words []string) string { + return fmt.Sprintf(`(?i)(?:%s) \S+`, strings.Join(words, "|")) +} + +// updateCommitReferencesToIssues checks if issues are manipulated by commit message. +func updateCommitReferencesToIssues(doer *User, repo *Repository, commits []*PushCommit) error { + trimRightNonDigits := func(c rune) bool { + return !unicode.IsDigit(c) + } + + // Commits are appended in the reverse order. + for i := len(commits) - 1; i >= 0; i-- { + c := commits[i] + + refMarked := make(map[int64]bool) + for _, ref := range issueReferencePattern.FindAllString(c.Message, -1) { + ref = strings.TrimSpace(ref) + ref = strings.TrimRightFunc(ref, trimRightNonDigits) + + if ref == "" { + continue + } + + // Add repo name if missing + if ref[0] == '#' { + ref = fmt.Sprintf("%s%s", repo.FullName(), ref) + } else if !strings.Contains(ref, "/") { + // FIXME: We don't support User#ID syntax yet + continue + } + + issue, err := GetIssueByRef(ref) + if err != nil { + if IsErrIssueNotExist(err) { + continue + } + return err + } + + if refMarked[issue.ID] { + continue + } + refMarked[issue.ID] = true + + msgLines := strings.Split(c.Message, "\n") + shortMsg := msgLines[0] + if len(msgLines) > 2 { + shortMsg += "..." + } + message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, repo.Link(), c.Sha1, shortMsg) + if err = CreateRefComment(doer, repo, issue, message, c.Sha1); err != nil { + return err + } + } + + refMarked = make(map[int64]bool) + // FIXME: Can merge this and the next for loop to a common function. + for _, ref := range issueCloseKeywordsPattern.FindAllString(c.Message, -1) { + ref = ref[strings.IndexByte(ref, byte(' '))+1:] + ref = strings.TrimRightFunc(ref, trimRightNonDigits) + + if ref == "" { + continue + } + + // Add repo name if missing + if ref[0] == '#' { + ref = fmt.Sprintf("%s%s", repo.FullName(), ref) + } else if !strings.Contains(ref, "/") { + // FIXME: We don't support User#ID syntax yet + continue + } + + issue, err := GetIssueByRef(ref) + if err != nil { + if IsErrIssueNotExist(err) { + continue + } + return err + } + + if refMarked[issue.ID] { + continue + } + refMarked[issue.ID] = true + + if issue.RepoID != repo.ID || issue.IsClosed { + continue + } + + if err = issue.ChangeStatus(doer, repo, true); err != nil { + return err + } + } + + // It is conflict to have close and reopen at same time, so refsMarkd doesn't need to reinit here. + for _, ref := range issueReopenKeywordsPattern.FindAllString(c.Message, -1) { + ref = ref[strings.IndexByte(ref, byte(' '))+1:] + ref = strings.TrimRightFunc(ref, trimRightNonDigits) + + if ref == "" { + continue + } + + // Add repo name if missing + if ref[0] == '#' { + ref = fmt.Sprintf("%s%s", repo.FullName(), ref) + } else if !strings.Contains(ref, "/") { + // We don't support User#ID syntax yet + // return ErrNotImplemented + continue + } + + issue, err := GetIssueByRef(ref) + if err != nil { + if IsErrIssueNotExist(err) { + continue + } + return err + } + + if refMarked[issue.ID] { + continue + } + refMarked[issue.ID] = true + + if issue.RepoID != repo.ID || !issue.IsClosed { + continue + } + + if err = issue.ChangeStatus(doer, repo, false); err != nil { + return err + } + } + } + return nil +} + +type CommitRepoOptions struct { + Owner *User + Repo *Repository + PusherName string + RefFullName string + OldCommitID string + NewCommitID string + Commits *PushCommits +} + +func (db *actions) CommitRepo(ctx context.Context, opts CommitRepoOptions) error { + err := NewReposStore(db.DB).Touch(ctx, opts.Repo.ID) + if err != nil { + return errors.Wrap(err, "touch repository") + } + + pusher, err := NewUsersStore(db.DB).GetByUsername(ctx, opts.PusherName) + if err != nil { + return errors.Wrapf(err, "get pusher [name: %s]", opts.PusherName) + } + + isNewRef := opts.OldCommitID == git.EmptyID + isDelRef := opts.NewCommitID == git.EmptyID + + // If not the first commit, set the compare URL. + if !isNewRef && !isDelRef { + opts.Commits.CompareURL = repoutil.CompareCommitsPath(opts.Owner.Name, opts.Repo.Name, opts.OldCommitID, opts.NewCommitID) + } + + refName := git.RefShortName(opts.RefFullName) + action := &Action{ + ActUserID: pusher.ID, + ActUserName: pusher.Name, + RepoID: opts.Repo.ID, + RepoUserName: opts.Owner.Name, + RepoName: opts.Repo.Name, + RefName: refName, + IsPrivate: opts.Repo.IsPrivate || opts.Repo.IsUnlisted, + } + + apiRepo := opts.Repo.APIFormat(opts.Owner) + apiPusher := pusher.APIFormat() + if isDelRef { + err = PrepareWebhooks( + opts.Repo, + HOOK_EVENT_DELETE, + &api.DeletePayload{ + Ref: refName, + RefType: "branch", + PusherType: api.PUSHER_TYPE_USER, + Repo: apiRepo, + Sender: apiPusher, + }, + ) + if err != nil { + return errors.Wrap(err, "prepare webhooks for delete branch") + } + + action.OpType = ActionDeleteBranch + err = db.notifyWatchers(ctx, action) + if err != nil { + return errors.Wrap(err, "notify watchers") + } + + // Delete branch doesn't have anything to push or compare + return nil + } + + // Only update issues via commits when internal issue tracker is enabled + if opts.Repo.EnableIssues && !opts.Repo.EnableExternalTracker { + if err = updateCommitReferencesToIssues(pusher, opts.Repo, opts.Commits.Commits); err != nil { + log.Error("update commit references to issues: %v", err) + } + } + + if conf.UI.FeedMaxCommitNum > 0 && len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum { + opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum] + } + + data, err := jsoniter.Marshal(opts.Commits) + if err != nil { + return errors.Wrap(err, "marshal JSON") + } + action.Content = string(data) + + var compareURL string + if isNewRef { + err = PrepareWebhooks( + opts.Repo, + HOOK_EVENT_CREATE, + &api.CreatePayload{ + Ref: refName, + RefType: "branch", + DefaultBranch: opts.Repo.DefaultBranch, + Repo: apiRepo, + Sender: apiPusher, + }, + ) + if err != nil { + return errors.Wrap(err, "prepare webhooks for new branch") + } + + action.OpType = ActionCreateBranch + err = db.notifyWatchers(ctx, action) + if err != nil { + return errors.Wrap(err, "notify watchers") + } + } else { + compareURL = conf.Server.ExternalURL + opts.Commits.CompareURL + } + + commits, err := opts.Commits.APIFormat(ctx, + NewUsersStore(db.DB), + repoutil.RepositoryPath(opts.Owner.Name, opts.Repo.Name), + repoutil.HTMLURL(opts.Owner.Name, opts.Repo.Name), + ) + if err != nil { + return errors.Wrap(err, "convert commits to API format") + } + + err = PrepareWebhooks( + opts.Repo, + HOOK_EVENT_PUSH, + &api.PushPayload{ + Ref: opts.RefFullName, + Before: opts.OldCommitID, + After: opts.NewCommitID, + CompareURL: compareURL, + Commits: commits, + Repo: apiRepo, + Pusher: apiPusher, + Sender: apiPusher, + }, + ) + if err != nil { + return errors.Wrap(err, "prepare webhooks for new commit") + } + + action.OpType = ActionCommitRepo + err = db.notifyWatchers(ctx, action) + if err != nil { + return errors.Wrap(err, "notify watchers") + } + return nil +} + +type PushTagOptions struct { + Owner *User + Repo *Repository + PusherName string + RefFullName string + NewCommitID string +} + +func (db *actions) PushTag(ctx context.Context, opts PushTagOptions) error { + err := NewReposStore(db.DB).Touch(ctx, opts.Repo.ID) + if err != nil { + return errors.Wrap(err, "touch repository") + } + + pusher, err := NewUsersStore(db.DB).GetByUsername(ctx, opts.PusherName) + if err != nil { + return errors.Wrapf(err, "get pusher [name: %s]", opts.PusherName) + } + + refName := git.RefShortName(opts.RefFullName) + action := &Action{ + ActUserID: pusher.ID, + ActUserName: pusher.Name, + RepoID: opts.Repo.ID, + RepoUserName: opts.Owner.Name, + RepoName: opts.Repo.Name, + RefName: refName, + IsPrivate: opts.Repo.IsPrivate || opts.Repo.IsUnlisted, + } + + apiRepo := opts.Repo.APIFormat(opts.Owner) + apiPusher := pusher.APIFormat() + if opts.NewCommitID == git.EmptyID { + err = PrepareWebhooks( + opts.Repo, + HOOK_EVENT_DELETE, + &api.DeletePayload{ + Ref: refName, + RefType: "tag", + PusherType: api.PUSHER_TYPE_USER, + Repo: apiRepo, + Sender: apiPusher, + }, + ) + if err != nil { + return errors.Wrap(err, "prepare webhooks for delete tag") + } + + action.OpType = ActionDeleteTag + err = db.notifyWatchers(ctx, action) + if err != nil { + return errors.Wrap(err, "notify watchers") + } + return nil + } + + err = PrepareWebhooks( + opts.Repo, + HOOK_EVENT_CREATE, + &api.CreatePayload{ + Ref: refName, + RefType: "tag", + Sha: opts.NewCommitID, + DefaultBranch: opts.Repo.DefaultBranch, + Repo: apiRepo, + Sender: apiPusher, + }, + ) + if err != nil { + return errors.Wrapf(err, "prepare webhooks for new tag") + } + + action.OpType = ActionPushTag + err = db.notifyWatchers(ctx, action) + if err != nil { + return errors.Wrap(err, "notify watchers") + } + return nil +} + +// ActionType is the type of an action. +type ActionType int + +// ⚠️ WARNING: Only append to the end of list to maintain backward compatibility. +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 + ActionCreateBranch // 16 + ActionDeleteBranch // 17 + ActionDeleteTag // 18 + ActionForkRepo // 19 + ActionMirrorSyncPush // 20 + ActionMirrorSyncCreate // 21 + ActionMirrorSyncDelete // 22 +) + +// Action is a user operation to a repository. It implements template.Actioner +// interface to be able to use it in template rendering. +type Action struct { + ID int64 `gorm:"primaryKey"` + UserID int64 `gorm:"index"` // Receiver user ID + OpType ActionType + ActUserID int64 // Doer user ID + ActUserName string // Doer user name + ActAvatar string `xorm:"-" gorm:"-" json:"-"` + RepoID int64 `xorm:"INDEX" gorm:"index"` + RepoUserName string + RepoName string + RefName string + IsPrivate bool `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"` + Content string `xorm:"TEXT"` + + Created time.Time `xorm:"-" gorm:"-" json:"-"` + CreatedUnix int64 +} + +// BeforeCreate implements the GORM create hook. +func (a *Action) BeforeCreate(tx *gorm.DB) error { + if a.CreatedUnix <= 0 { + a.CreatedUnix = tx.NowFunc().Unix() + } + return nil +} + +// AfterFind implements the GORM query hook. +func (a *Action) AfterFind(_ *gorm.DB) error { + a.Created = time.Unix(a.CreatedUnix, 0).Local() + return nil +} + +func (a *Action) GetOpType() int { + return int(a.OpType) +} + +func (a *Action) GetActUserName() string { + return a.ActUserName +} + +func (a *Action) ShortActUserName() string { + return strutil.Ellipsis(a.ActUserName, 20) +} + +func (a *Action) GetRepoUserName() string { + return a.RepoUserName +} + +func (a *Action) ShortRepoUserName() string { + return strutil.Ellipsis(a.RepoUserName, 20) +} + +func (a *Action) GetRepoName() string { + return a.RepoName +} + +func (a *Action) ShortRepoName() string { + return strutil.Ellipsis(a.RepoName, 33) +} + +func (a *Action) GetRepoPath() string { + return path.Join(a.RepoUserName, a.RepoName) +} + +func (a *Action) ShortRepoPath() string { + return path.Join(a.ShortRepoUserName(), a.ShortRepoName()) +} + +func (a *Action) GetRepoLink() string { + if conf.Server.Subpath != "" { + return path.Join(conf.Server.Subpath, a.GetRepoPath()) + } + return "/" + a.GetRepoPath() +} + +func (a *Action) GetBranch() string { + return a.RefName +} + +func (a *Action) GetContent() string { + return a.Content +} + +func (a *Action) GetCreate() time.Time { + return a.Created +} + +func (a *Action) GetIssueInfos() []string { + return strings.SplitN(a.Content, "|", 2) +} + +func (a *Action) GetIssueTitle() string { + index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64) + issue, err := GetIssueByIndex(a.RepoID, index) + if err != nil { + log.Error("Failed to get issue title [repo_id: %d, index: %d]: %v", a.RepoID, index, err) + return "error getting issue" + } + return issue.Title +} + +func (a *Action) GetIssueContent() string { + index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64) + issue, err := GetIssueByIndex(a.RepoID, index) + if err != nil { + log.Error("Failed to get issue content [repo_id: %d, index: %d]: %v", a.RepoID, index, err) + return "error getting issue" + } + return issue.Content +} + +// PushCommit contains information of a pushed commit. +type PushCommit struct { + Sha1 string + Message string + AuthorEmail string + AuthorName string + CommitterEmail string + CommitterName string + Timestamp time.Time +} + +// PushCommits is a list of pushed commits. +type PushCommits struct { + Len int + Commits []*PushCommit + CompareURL string + + avatars map[string]string +} + +// NewPushCommits returns a new PushCommits. +func NewPushCommits() *PushCommits { + return &PushCommits{ + avatars: make(map[string]string), + } +} + +func (pcs *PushCommits) APIFormat(ctx context.Context, usersStore UsersStore, repoPath, repoURL string) ([]*api.PayloadCommit, error) { + // NOTE: We cache query results in case there are many commits in a single push. + usernameByEmail := make(map[string]string) + getUsernameByEmail := func(email string) (string, error) { + username, ok := usernameByEmail[email] + if ok { + return username, nil + } + + user, err := usersStore.GetByEmail(ctx, email) + if err != nil { + if IsErrUserNotExist(err) { + usernameByEmail[email] = "" + return "", nil + } + return "", err + } + + usernameByEmail[email] = user.Name + return user.Name, nil + } + + commits := make([]*api.PayloadCommit, len(pcs.Commits)) + for i, commit := range pcs.Commits { + authorUsername, err := getUsernameByEmail(commit.AuthorEmail) + if err != nil { + return nil, errors.Wrap(err, "get author username") + } + + committerUsername, err := getUsernameByEmail(commit.CommitterEmail) + if err != nil { + return nil, errors.Wrap(err, "get committer username") + } + + nameStatus := &git.NameStatus{} + if !testutil.InTest { + nameStatus, err = git.ShowNameStatus(repoPath, commit.Sha1) + if err != nil { + return nil, errors.Wrapf(err, "show name status [commit_sha1: %s]", commit.Sha1) + } + } + + commits[i] = &api.PayloadCommit{ + ID: commit.Sha1, + Message: commit.Message, + URL: fmt.Sprintf("%s/commit/%s", repoURL, commit.Sha1), + Author: &api.PayloadUser{ + Name: commit.AuthorName, + Email: commit.AuthorEmail, + UserName: authorUsername, + }, + Committer: &api.PayloadUser{ + Name: commit.CommitterName, + Email: commit.CommitterEmail, + UserName: committerUsername, + }, + Added: nameStatus.Added, + Removed: nameStatus.Removed, + Modified: nameStatus.Modified, + Timestamp: commit.Timestamp, + } + } + return commits, nil +} + +// AvatarLink tries to match user in database with email in order to show custom +// avatars, and falls back to general avatar link. +// +// FIXME: This method does not belong to PushCommits, should be a pure template +// function. +func (pcs *PushCommits) AvatarLink(email string) string { + _, ok := pcs.avatars[email] + if !ok { + u, err := Users.GetByEmail(context.Background(), email) + if err != nil { + pcs.avatars[email] = tool.AvatarLink(email) + if !IsErrUserNotExist(err) { + log.Error("Failed to get user [email: %s]: %v", email, err) + } + } else { + pcs.avatars[email] = u.RelAvatarLink() + } + } + + return pcs.avatars[email] +} |