mirror of
https://codeberg.org/forgejo/forgejo
synced 2024-12-05 02:54:46 +01:00
ca0921a95a
fixes #22907
Tested:
- [x] issue content edit
- [x] issue content change tasklist
- [x] pull request content edit
- [x] pull request change tasklist
![issue-content-edit](https://github.com/go-gitea/gitea/assets/29250154/a0828889-fb96-4bc4-8600-da92e3205812)
(cherry picked from commit aa92b13164e84c26be91153b6022220ce0a27720)
Conflicts:
models/issues/comment.go
c7a389f2b2
[FEAT] allow setting the update date on issues and comments
options/locale/locale_en-US.ini
trivial context conflicts
routers/api/v1/repo/issue_comment.go
routers/api/v1/repo/issue_comment_attachment.go
services/issue/comments.go
services/issue/content.go
user blocking is implemented differently in Forgejo
routers/web/repo/issue.go
trivial difference from 6a0750177f Allow to save empty comment
user blocking is implemented differently in Forgejo
templates/repo/issue/view_content/conversation.tmpl
templates changed a lot in Forgejo but the change is
trivially ported
tests/integration/issue_test.go
other tests were added in the same region
web_src/js/features/repo-issue-edit.js
the code is still web_src/js/features/repo-legacy.js
trivially ported
795 lines
23 KiB
Go
795 lines
23 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package issues
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
"code.gitea.io/gitea/models/organization"
|
|
"code.gitea.io/gitea/models/perm"
|
|
access_model "code.gitea.io/gitea/models/perm/access"
|
|
project_model "code.gitea.io/gitea/models/project"
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
system_model "code.gitea.io/gitea/models/system"
|
|
"code.gitea.io/gitea/models/unit"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
"code.gitea.io/gitea/modules/git"
|
|
"code.gitea.io/gitea/modules/references"
|
|
api "code.gitea.io/gitea/modules/structs"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error {
|
|
_, err := UpdateIssueColsWithCond(ctx, issue, builder.NewCond(), cols...)
|
|
return err
|
|
}
|
|
|
|
func UpdateIssueColsWithCond(ctx context.Context, issue *Issue, cond builder.Cond, cols ...string) (int64, error) {
|
|
sess := db.GetEngine(ctx).ID(issue.ID)
|
|
if issue.NoAutoTime {
|
|
cols = append(cols, []string{"updated_unix"}...)
|
|
sess.NoAutoTime()
|
|
}
|
|
return sess.Cols(cols...).Where(cond).Update(issue)
|
|
}
|
|
|
|
func changeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed, isMergePull bool) (*Comment, error) {
|
|
// Reload the issue
|
|
currentIssue, err := GetIssueByID(ctx, issue.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Nothing should be performed if current status is same as target status
|
|
if currentIssue.IsClosed == isClosed {
|
|
if !issue.IsPull {
|
|
return nil, ErrIssueWasClosed{
|
|
ID: issue.ID,
|
|
}
|
|
}
|
|
return nil, ErrPullWasClosed{
|
|
ID: issue.ID,
|
|
}
|
|
}
|
|
|
|
issue.IsClosed = isClosed
|
|
return doChangeIssueStatus(ctx, issue, doer, isMergePull)
|
|
}
|
|
|
|
func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isMergePull bool) (*Comment, error) {
|
|
// Check for open dependencies
|
|
if issue.IsClosed && issue.Repo.IsDependenciesEnabled(ctx) {
|
|
// only check if dependencies are enabled and we're about to close an issue, otherwise reopening an issue would fail when there are unsatisfied dependencies
|
|
noDeps, err := IssueNoDependenciesLeft(ctx, issue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !noDeps {
|
|
return nil, ErrDependenciesLeft{issue.ID}
|
|
}
|
|
}
|
|
|
|
if issue.IsClosed {
|
|
if issue.NoAutoTime {
|
|
issue.ClosedUnix = issue.UpdatedUnix
|
|
} else {
|
|
issue.ClosedUnix = timeutil.TimeStampNow()
|
|
}
|
|
} else {
|
|
issue.ClosedUnix = 0
|
|
}
|
|
|
|
if err := UpdateIssueCols(ctx, issue, "is_closed", "closed_unix"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update issue count of labels
|
|
if err := issue.LoadLabels(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
for idx := range issue.Labels {
|
|
if err := updateLabelCols(ctx, issue.Labels[idx], "num_issues", "num_closed_issue"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Update issue count of milestone
|
|
if issue.MilestoneID > 0 {
|
|
if issue.NoAutoTime {
|
|
if err := UpdateMilestoneCountersWithDate(ctx, issue.MilestoneID, issue.UpdatedUnix); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// update repository's issue closed number
|
|
if err := repo_model.UpdateRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, true); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// New action comment
|
|
cmtType := CommentTypeClose
|
|
if !issue.IsClosed {
|
|
cmtType = CommentTypeReopen
|
|
} else if isMergePull {
|
|
cmtType = CommentTypeMergePull
|
|
}
|
|
|
|
return CreateComment(ctx, &CreateCommentOptions{
|
|
Type: cmtType,
|
|
Doer: doer,
|
|
Repo: issue.Repo,
|
|
Issue: issue,
|
|
})
|
|
}
|
|
|
|
// ChangeIssueStatus changes issue status to open or closed.
|
|
func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed bool) (*Comment, error) {
|
|
if err := issue.LoadRepo(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := issue.LoadPoster(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return changeIssueStatus(ctx, issue, doer, isClosed, false)
|
|
}
|
|
|
|
// ChangeIssueTitle changes the title of this issue, as the given user.
|
|
func ChangeIssueTitle(ctx context.Context, issue *Issue, doer *user_model.User, oldTitle string) (err error) {
|
|
ctx, committer, err := db.TxContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer committer.Close()
|
|
|
|
if err = UpdateIssueCols(ctx, issue, "name"); err != nil {
|
|
return fmt.Errorf("updateIssueCols: %w", err)
|
|
}
|
|
|
|
if err = issue.LoadRepo(ctx); err != nil {
|
|
return fmt.Errorf("loadRepo: %w", err)
|
|
}
|
|
|
|
opts := &CreateCommentOptions{
|
|
Type: CommentTypeChangeTitle,
|
|
Doer: doer,
|
|
Repo: issue.Repo,
|
|
Issue: issue,
|
|
OldTitle: oldTitle,
|
|
NewTitle: issue.Title,
|
|
}
|
|
if _, err = CreateComment(ctx, opts); err != nil {
|
|
return fmt.Errorf("createComment: %w", err)
|
|
}
|
|
if err = issue.AddCrossReferences(ctx, doer, true); err != nil {
|
|
return fmt.Errorf("addCrossReferences: %w", err)
|
|
}
|
|
|
|
return committer.Commit()
|
|
}
|
|
|
|
// ChangeIssueRef changes the branch of this issue, as the given user.
|
|
func ChangeIssueRef(ctx context.Context, issue *Issue, doer *user_model.User, oldRef string) (err error) {
|
|
ctx, committer, err := db.TxContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer committer.Close()
|
|
|
|
if err = UpdateIssueCols(ctx, issue, "ref"); err != nil {
|
|
return fmt.Errorf("updateIssueCols: %w", err)
|
|
}
|
|
|
|
if err = issue.LoadRepo(ctx); err != nil {
|
|
return fmt.Errorf("loadRepo: %w", err)
|
|
}
|
|
oldRefFriendly := strings.TrimPrefix(oldRef, git.BranchPrefix)
|
|
newRefFriendly := strings.TrimPrefix(issue.Ref, git.BranchPrefix)
|
|
|
|
opts := &CreateCommentOptions{
|
|
Type: CommentTypeChangeIssueRef,
|
|
Doer: doer,
|
|
Repo: issue.Repo,
|
|
Issue: issue,
|
|
OldRef: oldRefFriendly,
|
|
NewRef: newRefFriendly,
|
|
}
|
|
if _, err = CreateComment(ctx, opts); err != nil {
|
|
return fmt.Errorf("createComment: %w", err)
|
|
}
|
|
|
|
return committer.Commit()
|
|
}
|
|
|
|
// AddDeletePRBranchComment adds delete branch comment for pull request issue
|
|
func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issueID int64, branchName string) error {
|
|
issue, err := GetIssueByID(ctx, issueID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts := &CreateCommentOptions{
|
|
Type: CommentTypeDeleteBranch,
|
|
Doer: doer,
|
|
Repo: repo,
|
|
Issue: issue,
|
|
OldRef: branchName,
|
|
}
|
|
_, err = CreateComment(ctx, opts)
|
|
return err
|
|
}
|
|
|
|
// UpdateIssueAttachments update attachments by UUIDs for the issue
|
|
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
|
|
ctx, committer, err := db.TxContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer committer.Close()
|
|
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
|
if err != nil {
|
|
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
|
}
|
|
for i := 0; i < len(attachments); i++ {
|
|
attachments[i].IssueID = issueID
|
|
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
|
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
|
|
}
|
|
}
|
|
return committer.Commit()
|
|
}
|
|
|
|
// ChangeIssueContent changes issue content, as the given user.
|
|
func ChangeIssueContent(ctx context.Context, issue *Issue, doer *user_model.User, content string, contentVersion int) (err error) {
|
|
ctx, committer, err := db.TxContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer committer.Close()
|
|
|
|
hasContentHistory, err := HasIssueContentHistory(ctx, issue.ID, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("HasIssueContentHistory: %w", err)
|
|
}
|
|
if !hasContentHistory {
|
|
if err = SaveIssueContentHistory(ctx, issue.PosterID, issue.ID, 0,
|
|
issue.CreatedUnix, issue.Content, true); err != nil {
|
|
return fmt.Errorf("SaveIssueContentHistory: %w", err)
|
|
}
|
|
}
|
|
|
|
issue.Content = content
|
|
issue.ContentVersion = contentVersion + 1
|
|
|
|
expectedContentVersion := builder.NewCond().And(builder.Eq{"content_version": contentVersion})
|
|
affected, err := UpdateIssueColsWithCond(ctx, issue, expectedContentVersion, "content", "content_version")
|
|
if err != nil {
|
|
return fmt.Errorf("UpdateIssueCols: %w", err)
|
|
}
|
|
if affected == 0 {
|
|
return ErrIssueAlreadyChanged
|
|
}
|
|
|
|
historyDate := timeutil.TimeStampNow()
|
|
if issue.NoAutoTime {
|
|
historyDate = issue.UpdatedUnix
|
|
}
|
|
if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0,
|
|
historyDate, issue.Content, false); err != nil {
|
|
return fmt.Errorf("SaveIssueContentHistory: %w", err)
|
|
}
|
|
|
|
if err = issue.AddCrossReferences(ctx, doer, true); err != nil {
|
|
return fmt.Errorf("addCrossReferences: %w", err)
|
|
}
|
|
|
|
return committer.Commit()
|
|
}
|
|
|
|
// NewIssueOptions represents the options of a new issue.
|
|
type NewIssueOptions struct {
|
|
Repo *repo_model.Repository
|
|
Issue *Issue
|
|
LabelIDs []int64
|
|
Attachments []string // In UUID format.
|
|
IsPull bool
|
|
}
|
|
|
|
// NewIssueWithIndex creates issue with given index
|
|
func NewIssueWithIndex(ctx context.Context, doer *user_model.User, opts NewIssueOptions) (err error) {
|
|
e := db.GetEngine(ctx)
|
|
opts.Issue.Title = strings.TrimSpace(opts.Issue.Title)
|
|
|
|
if opts.Issue.MilestoneID > 0 {
|
|
milestone, err := GetMilestoneByRepoID(ctx, opts.Issue.RepoID, opts.Issue.MilestoneID)
|
|
if err != nil && !IsErrMilestoneNotExist(err) {
|
|
return fmt.Errorf("getMilestoneByID: %w", err)
|
|
}
|
|
|
|
// Assume milestone is invalid and drop silently.
|
|
opts.Issue.MilestoneID = 0
|
|
if milestone != nil {
|
|
opts.Issue.MilestoneID = milestone.ID
|
|
opts.Issue.Milestone = milestone
|
|
}
|
|
}
|
|
|
|
if opts.Issue.Index <= 0 {
|
|
return fmt.Errorf("no issue index provided")
|
|
}
|
|
if opts.Issue.ID > 0 {
|
|
return fmt.Errorf("issue exist")
|
|
}
|
|
|
|
opts.Issue.Created = timeutil.TimeStampNanoNow()
|
|
|
|
if _, err := e.Insert(opts.Issue); err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.Issue.MilestoneID > 0 {
|
|
if err := UpdateMilestoneCounters(ctx, opts.Issue.MilestoneID); err != nil {
|
|
return err
|
|
}
|
|
|
|
opts := &CreateCommentOptions{
|
|
Type: CommentTypeMilestone,
|
|
Doer: doer,
|
|
Repo: opts.Repo,
|
|
Issue: opts.Issue,
|
|
OldMilestoneID: 0,
|
|
MilestoneID: opts.Issue.MilestoneID,
|
|
}
|
|
if _, err = CreateComment(ctx, opts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.IsPull, false); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(opts.LabelIDs) > 0 {
|
|
// During the session, SQLite3 driver cannot handle retrieve objects after update something.
|
|
// So we have to get all needed labels first.
|
|
labels := make([]*Label, 0, len(opts.LabelIDs))
|
|
if err = e.In("id", opts.LabelIDs).Find(&labels); err != nil {
|
|
return fmt.Errorf("find all labels [label_ids: %v]: %w", opts.LabelIDs, err)
|
|
}
|
|
|
|
if err = opts.Issue.LoadPoster(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, label := range labels {
|
|
// Silently drop invalid labels.
|
|
if label.RepoID != opts.Repo.ID && label.OrgID != opts.Repo.OwnerID {
|
|
continue
|
|
}
|
|
|
|
if err = newIssueLabel(ctx, opts.Issue, label, opts.Issue.Poster); err != nil {
|
|
return fmt.Errorf("addLabel [id: %d]: %w", label.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err = NewIssueUsers(ctx, opts.Repo, opts.Issue); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(opts.Attachments) > 0 {
|
|
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments)
|
|
if err != nil {
|
|
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err)
|
|
}
|
|
|
|
for i := 0; i < len(attachments); i++ {
|
|
attachments[i].IssueID = opts.Issue.ID
|
|
if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil {
|
|
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
|
|
}
|
|
}
|
|
}
|
|
if err = opts.Issue.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
return opts.Issue.AddCrossReferences(ctx, doer, false)
|
|
}
|
|
|
|
// NewIssue creates new issue with labels for repository.
|
|
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string) (err error) {
|
|
ctx, committer, err := db.TxContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer committer.Close()
|
|
|
|
idx, err := db.GetNextResourceIndex(ctx, "issue_index", repo.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("generate issue index failed: %w", err)
|
|
}
|
|
|
|
issue.Index = idx
|
|
|
|
if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
|
|
Repo: repo,
|
|
Issue: issue,
|
|
LabelIDs: labelIDs,
|
|
Attachments: uuids,
|
|
}); err != nil {
|
|
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) {
|
|
return err
|
|
}
|
|
return fmt.Errorf("newIssue: %w", err)
|
|
}
|
|
|
|
if err = committer.Commit(); err != nil {
|
|
return fmt.Errorf("Commit: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateIssueMentions updates issue-user relations for mentioned users.
|
|
func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_model.User) error {
|
|
if len(mentions) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]int64, len(mentions))
|
|
for i, u := range mentions {
|
|
ids[i] = u.ID
|
|
}
|
|
if err := UpdateIssueUsersByMentions(ctx, issueID, ids); err != nil {
|
|
return fmt.Errorf("UpdateIssueUsersByMentions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it.
|
|
func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeutil.TimeStamp, doer *user_model.User) (err error) {
|
|
// if the deadline hasn't changed do nothing
|
|
if issue.DeadlineUnix == deadlineUnix {
|
|
return nil
|
|
}
|
|
ctx, committer, err := db.TxContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer committer.Close()
|
|
|
|
// Update the deadline
|
|
if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix, NoAutoTime: issue.NoAutoTime, UpdatedUnix: issue.UpdatedUnix}, "deadline_unix"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Make the comment
|
|
if _, err = createDeadlineComment(ctx, doer, issue, deadlineUnix); err != nil {
|
|
return fmt.Errorf("createRemovedDueDateComment: %w", err)
|
|
}
|
|
|
|
return committer.Commit()
|
|
}
|
|
|
|
// FindAndUpdateIssueMentions finds users mentioned in the given content string, and saves them in the database.
|
|
func FindAndUpdateIssueMentions(ctx context.Context, issue *Issue, doer *user_model.User, content string) (mentions []*user_model.User, err error) {
|
|
rawMentions := references.FindAllMentionsMarkdown(content)
|
|
mentions, err = ResolveIssueMentionsByVisibility(ctx, issue, doer, rawMentions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err)
|
|
}
|
|
if err = UpdateIssueMentions(ctx, issue.ID, mentions); err != nil {
|
|
return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err)
|
|
}
|
|
return mentions, err
|
|
}
|
|
|
|
// ResolveIssueMentionsByVisibility returns the users mentioned in an issue, removing those that
|
|
// don't have access to reading it. Teams are expanded into their users, but organizations are ignored.
|
|
func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *user_model.User, mentions []string) (users []*user_model.User, err error) {
|
|
if len(mentions) == 0 {
|
|
return nil, nil
|
|
}
|
|
if err = issue.LoadRepo(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resolved := make(map[string]bool, 10)
|
|
var mentionTeams []string
|
|
|
|
if err := issue.Repo.LoadOwner(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
repoOwnerIsOrg := issue.Repo.Owner.IsOrganization()
|
|
if repoOwnerIsOrg {
|
|
mentionTeams = make([]string, 0, 5)
|
|
}
|
|
|
|
resolved[doer.LowerName] = true
|
|
for _, name := range mentions {
|
|
name := strings.ToLower(name)
|
|
if _, ok := resolved[name]; ok {
|
|
continue
|
|
}
|
|
if repoOwnerIsOrg && strings.Contains(name, "/") {
|
|
names := strings.Split(name, "/")
|
|
if len(names) < 2 || names[0] != issue.Repo.Owner.LowerName {
|
|
continue
|
|
}
|
|
mentionTeams = append(mentionTeams, names[1])
|
|
resolved[name] = true
|
|
} else {
|
|
resolved[name] = false
|
|
}
|
|
}
|
|
|
|
if issue.Repo.Owner.IsOrganization() && len(mentionTeams) > 0 {
|
|
teams := make([]*organization.Team, 0, len(mentionTeams))
|
|
if err := db.GetEngine(ctx).
|
|
Join("INNER", "team_repo", "team_repo.team_id = team.id").
|
|
Where("team_repo.repo_id=?", issue.Repo.ID).
|
|
In("team.lower_name", mentionTeams).
|
|
Find(&teams); err != nil {
|
|
return nil, fmt.Errorf("find mentioned teams: %w", err)
|
|
}
|
|
if len(teams) != 0 {
|
|
checked := make([]int64, 0, len(teams))
|
|
unittype := unit.TypeIssues
|
|
if issue.IsPull {
|
|
unittype = unit.TypePullRequests
|
|
}
|
|
for _, team := range teams {
|
|
if team.AccessMode >= perm.AccessModeAdmin {
|
|
checked = append(checked, team.ID)
|
|
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
|
|
continue
|
|
}
|
|
has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: issue.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
|
|
}
|
|
if has {
|
|
checked = append(checked, team.ID)
|
|
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
|
|
}
|
|
}
|
|
if len(checked) != 0 {
|
|
teamusers := make([]*user_model.User, 0, 20)
|
|
if err := db.GetEngine(ctx).
|
|
Join("INNER", "team_user", "team_user.uid = `user`.id").
|
|
Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
|
|
In("`team_user`.team_id", checked).
|
|
And("`user`.is_active = ?", true).
|
|
And("`user`.prohibit_login = ?", false).
|
|
And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
|
|
Find(&teamusers); err != nil {
|
|
return nil, fmt.Errorf("get teams users: %w", err)
|
|
}
|
|
if len(teamusers) > 0 {
|
|
users = make([]*user_model.User, 0, len(teamusers))
|
|
for _, user := range teamusers {
|
|
if already, ok := resolved[user.LowerName]; !ok || !already {
|
|
users = append(users, user)
|
|
resolved[user.LowerName] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove names already in the list to avoid querying the database if pending names remain
|
|
mentionUsers := make([]string, 0, len(resolved))
|
|
for name, already := range resolved {
|
|
if !already {
|
|
mentionUsers = append(mentionUsers, name)
|
|
}
|
|
}
|
|
if len(mentionUsers) == 0 {
|
|
return users, err
|
|
}
|
|
|
|
if users == nil {
|
|
users = make([]*user_model.User, 0, len(mentionUsers))
|
|
}
|
|
|
|
unchecked := make([]*user_model.User, 0, len(mentionUsers))
|
|
if err := db.GetEngine(ctx).
|
|
Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
|
|
Where("`user`.is_active = ?", true).
|
|
And("`user`.prohibit_login = ?", false).
|
|
And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
|
|
In("`user`.lower_name", mentionUsers).
|
|
Find(&unchecked); err != nil {
|
|
return nil, fmt.Errorf("find mentioned users: %w", err)
|
|
}
|
|
for _, user := range unchecked {
|
|
if already := resolved[user.LowerName]; already || user.IsOrganization() {
|
|
continue
|
|
}
|
|
// Normal users must have read access to the referencing issue
|
|
perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetUserRepoPermission [%d]: %w", user.ID, err)
|
|
}
|
|
if !perm.CanReadIssuesOrPulls(issue.IsPull) {
|
|
continue
|
|
}
|
|
users = append(users, user)
|
|
}
|
|
|
|
return users, err
|
|
}
|
|
|
|
// UpdateIssuesMigrationsByType updates all migrated repositories' issues from gitServiceType to replace originalAuthorID to posterID
|
|
func UpdateIssuesMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, posterID int64) error {
|
|
_, err := db.GetEngine(ctx).Table("issue").
|
|
Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType).
|
|
And("original_author_id = ?", originalAuthorID).
|
|
Update(map[string]any{
|
|
"poster_id": posterID,
|
|
"original_author": "",
|
|
"original_author_id": 0,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// UpdateReactionsMigrationsByType updates all migrated repositories' reactions from gitServiceType to replace originalAuthorID to posterID
|
|
func UpdateReactionsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, userID int64) error {
|
|
_, err := db.GetEngine(ctx).Table("reaction").
|
|
Where("original_author_id = ?", originalAuthorID).
|
|
And(migratedIssueCond(gitServiceType)).
|
|
Update(map[string]any{
|
|
"user_id": userID,
|
|
"original_author": "",
|
|
"original_author_id": 0,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// DeleteIssuesByRepoID deletes issues by repositories id
|
|
func DeleteIssuesByRepoID(ctx context.Context, repoID int64) (attachmentPaths []string, err error) {
|
|
// MariaDB has a performance bug: https://jira.mariadb.org/browse/MDEV-16289
|
|
// so here it uses "DELETE ... WHERE IN" with pre-queried IDs.
|
|
sess := db.GetEngine(ctx)
|
|
|
|
for {
|
|
issueIDs := make([]int64, 0, db.DefaultMaxInSize)
|
|
|
|
err := sess.Table(&Issue{}).Where("repo_id = ?", repoID).OrderBy("id").Limit(db.DefaultMaxInSize).Cols("id").Find(&issueIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(issueIDs) == 0 {
|
|
break
|
|
}
|
|
|
|
// Delete content histories
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&ContentHistory{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Delete comments and attachments
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&Comment{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Dependencies for issues in this repository
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&IssueDependency{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Delete dependencies for issues in other repositories
|
|
_, err = sess.In("dependency_id", issueIDs).Delete(&IssueDependency{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&IssueUser{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&Reaction{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&IssueWatch{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&Stopwatch{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&TrackedTime{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&project_model.ProjectIssue{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("dependent_issue_id", issueIDs).Delete(&Comment{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var attachments []*repo_model.Attachment
|
|
err = sess.In("issue_id", issueIDs).Find(&attachments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for j := range attachments {
|
|
attachmentPaths = append(attachmentPaths, attachments[j].RelativePath())
|
|
}
|
|
|
|
_, err = sess.In("issue_id", issueIDs).Delete(&repo_model.Attachment{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = sess.In("id", issueIDs).Delete(&Issue{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return attachmentPaths, err
|
|
}
|
|
|
|
// DeleteOrphanedIssues delete issues without a repo
|
|
func DeleteOrphanedIssues(ctx context.Context) error {
|
|
var attachmentPaths []string
|
|
err := db.WithTx(ctx, func(ctx context.Context) error {
|
|
var ids []int64
|
|
|
|
if err := db.GetEngine(ctx).Table("issue").Distinct("issue.repo_id").
|
|
Join("LEFT", "repository", "issue.repo_id=repository.id").
|
|
Where(builder.IsNull{"repository.id"}).GroupBy("issue.repo_id").
|
|
Find(&ids); err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range ids {
|
|
paths, err := DeleteIssuesByRepoID(ctx, ids[i])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachmentPaths = append(attachmentPaths, paths...)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove issue attachment files.
|
|
for i := range attachmentPaths {
|
|
system_model.RemoveAllWithNotice(ctx, "Delete issue attachment", attachmentPaths[i])
|
|
}
|
|
return nil
|
|
}
|