[API] Allow removing issues (#18879)

Add new feature to delete issues and pulls via API

Co-authored-by: fnetx <git@fralix.ovh>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Gusted <williamzijl7@hotmail.com>
Co-authored-by: 6543 <6543@obermui.de>
This commit is contained in:
Otto Richter (fnetX) 2022-03-01 01:20:15 +01:00 committed by GitHub
parent 6859b69198
commit 062fd4c217
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 299 additions and 4 deletions

View file

@ -13,6 +13,7 @@ import (
"strconv" "strconv"
"strings" "strings"
admin_model "code.gitea.io/gitea/models/admin"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
@ -24,6 +25,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -1990,6 +1992,118 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix timeutil.TimeStamp, doer *us
return committer.Commit() return committer.Commit()
} }
// DeleteIssue deletes the issue
func DeleteIssue(issue *Issue) error {
ctx, committer, err := db.TxContext()
if err != nil {
return err
}
defer committer.Close()
if err := deleteIssue(ctx, issue); err != nil {
return err
}
return committer.Commit()
}
func deleteInIssue(e db.Engine, issueID int64, beans ...interface{}) error {
for _, bean := range beans {
if _, err := e.In("issue_id", issueID).Delete(bean); err != nil {
return err
}
}
return nil
}
func deleteIssue(ctx context.Context, issue *Issue) error {
e := db.GetEngine(ctx)
if _, err := e.ID(issue.ID).NoAutoCondition().Delete(issue); err != nil {
return err
}
if issue.IsPull {
if _, err := e.ID(issue.RepoID).Decr("num_pulls").Update(new(repo_model.Repository)); err != nil {
return err
}
if issue.IsClosed {
if _, err := e.ID(issue.RepoID).Decr("num_closed_pulls").Update(new(repo_model.Repository)); err != nil {
return err
}
}
} else {
if _, err := e.ID(issue.RepoID).Decr("num_issues").Update(new(repo_model.Repository)); err != nil {
return err
}
if issue.IsClosed {
if _, err := e.ID(issue.RepoID).Decr("num_closed_issues").Update(new(repo_model.Repository)); err != nil {
return err
}
}
}
// delete actions assigned to this issue
var comments []int64
if err := e.Table(new(Comment)).In("issue_id", issue.ID).Cols("id").Find(&comments); err != nil {
return err
}
for i := range comments {
if _, err := e.Where("comment_id = ?", comments[i]).Delete(&Action{}); err != nil {
return err
}
}
if _, err := e.Table("action").Where("repo_id = ?", issue.RepoID).In("op_type", ActionCreateIssue, ActionCreatePullRequest).
Where("content LIKE ?", strconv.FormatInt(issue.ID, 10)+"|%").Delete(&Action{}); err != nil {
return err
}
// find attachments related to this issue and remove them
var attachments []*repo_model.Attachment
if err := e.In("issue_id", issue.ID).Find(&attachments); err != nil {
return err
}
for i := range attachments {
admin_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", attachments[i].RelativePath())
}
// delete all database data still assigned to this issue
if err := deleteInIssue(e, issue.ID,
&issues.ContentHistory{},
&Comment{},
&IssueLabel{},
&IssueDependency{},
&IssueAssignees{},
&IssueUser{},
&Reaction{},
&IssueWatch{},
&Stopwatch{},
&TrackedTime{},
&ProjectIssue{},
&repo_model.Attachment{},
&PullRequest{},
); err != nil {
return err
}
// References to this issue in other issues
if _, err := e.In("ref_issue_id", issue.ID).Delete(&Comment{}); err != nil {
return err
}
// Delete dependencies for issues in other repositories
if _, err := e.In("dependency_id", issue.ID).Delete(&IssueDependency{}); err != nil {
return err
}
// delete from dependent issues
if _, err := e.In("dependent_issue_id", issue.ID).Delete(&Comment{}); err != nil {
return err
}
return nil
}
// DependencyInfo represents high level information about an issue which is a dependency of another issue. // DependencyInfo represents high level information about an issue which is a dependency of another issue.
type DependencyInfo struct { type DependencyInfo struct {
Issue `xorm:"extends"` Issue `xorm:"extends"`

View file

@ -1152,9 +1152,7 @@ func DeleteComment(comment *Comment) error {
} }
func deleteComment(e db.Engine, comment *Comment) error { func deleteComment(e db.Engine, comment *Comment) error {
if _, err := e.Delete(&Comment{ if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil {
ID: comment.ID,
}); err != nil {
return err return err
} }

View file

@ -397,6 +397,58 @@ func TestIssue_InsertIssue(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestIssue_DeleteIssue(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issueIDs, err := GetIssueIDsByRepoID(1)
assert.NoError(t, err)
assert.EqualValues(t, 5, len(issueIDs))
issue := &Issue{
RepoID: 1,
ID: issueIDs[2],
}
err = DeleteIssue(issue)
assert.NoError(t, err)
issueIDs, err = GetIssueIDsByRepoID(1)
assert.NoError(t, err)
assert.EqualValues(t, 4, len(issueIDs))
// check attachment removal
attachments, err := repo_model.GetAttachmentsByIssueID(4)
assert.NoError(t, err)
issue, err = GetIssueByID(4)
assert.NoError(t, err)
err = DeleteIssue(issue)
assert.NoError(t, err)
assert.EqualValues(t, 2, len(attachments))
for i := range attachments {
attachment, err := repo_model.GetAttachmentByUUID(attachments[i].UUID)
assert.Error(t, err)
assert.True(t, repo_model.IsErrAttachmentNotExist(err))
assert.Nil(t, attachment)
}
// check issue dependencies
user, err := user_model.GetUserByID(1)
assert.NoError(t, err)
issue1, err := GetIssueByID(1)
assert.NoError(t, err)
issue2, err := GetIssueByID(2)
assert.NoError(t, err)
err = CreateIssueDependency(user, issue1, issue2)
assert.NoError(t, err)
left, err := IssueNoDependenciesLeft(issue1)
assert.NoError(t, err)
assert.False(t, left)
err = DeleteIssue(&Issue{ID: 2})
assert.NoError(t, err)
left, err = IssueNoDependenciesLeft(issue1)
assert.NoError(t, err)
assert.True(t, left)
}
func TestIssue_ResolveMentions(t *testing.T) { func TestIssue_ResolveMentions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb"
"github.com/syndtr/goleveldb/leveldb/errors" "github.com/syndtr/goleveldb/leveldb/errors"
"github.com/syndtr/goleveldb/leveldb/opt" "github.com/syndtr/goleveldb/leveldb/opt"

View file

@ -22,6 +22,7 @@ type Notifier interface {
NotifyTransferRepository(doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) NotifyTransferRepository(doer *user_model.User, repo *repo_model.Repository, oldOwnerName string)
NotifyNewIssue(issue *models.Issue, mentions []*user_model.User) NotifyNewIssue(issue *models.Issue, mentions []*user_model.User)
NotifyIssueChangeStatus(*user_model.User, *models.Issue, *models.Comment, bool) NotifyIssueChangeStatus(*user_model.User, *models.Issue, *models.Comment, bool)
NotifyDeleteIssue(*user_model.User, *models.Issue)
NotifyIssueChangeMilestone(doer *user_model.User, issue *models.Issue, oldMilestoneID int64) NotifyIssueChangeMilestone(doer *user_model.User, issue *models.Issue, oldMilestoneID int64)
NotifyIssueChangeAssignee(doer *user_model.User, issue *models.Issue, assignee *user_model.User, removed bool, comment *models.Comment) NotifyIssueChangeAssignee(doer *user_model.User, issue *models.Issue, assignee *user_model.User, removed bool, comment *models.Comment)
NotifyPullReviewRequest(doer *user_model.User, issue *models.Issue, reviewer *user_model.User, isRequest bool, comment *models.Comment) NotifyPullReviewRequest(doer *user_model.User, issue *models.Issue, reviewer *user_model.User, isRequest bool, comment *models.Comment)

View file

@ -33,6 +33,10 @@ func (*NullNotifier) NotifyNewIssue(issue *models.Issue, mentions []*user_model.
func (*NullNotifier) NotifyIssueChangeStatus(doer *user_model.User, issue *models.Issue, actionComment *models.Comment, isClosed bool) { func (*NullNotifier) NotifyIssueChangeStatus(doer *user_model.User, issue *models.Issue, actionComment *models.Comment, isClosed bool) {
} }
// NotifyDeleteIssue notify when some issue deleted
func (*NullNotifier) NotifyDeleteIssue(doer *user_model.User, issue *models.Issue) {
}
// NotifyNewPullRequest places a place holder function // NotifyNewPullRequest places a place holder function
func (*NullNotifier) NotifyNewPullRequest(pr *models.PullRequest, mentions []*user_model.User) { func (*NullNotifier) NotifyNewPullRequest(pr *models.PullRequest, mentions []*user_model.User) {
} }

View file

@ -60,6 +60,13 @@ func NotifyIssueChangeStatus(doer *user_model.User, issue *models.Issue, actionC
} }
} }
// NotifyDeleteIssue notify when some issue deleted
func NotifyDeleteIssue(doer *user_model.User, issue *models.Issue) {
for _, notifier := range notifiers {
notifier.NotifyDeleteIssue(doer, issue)
}
}
// NotifyMergePullRequest notifies merge pull request to notifiers // NotifyMergePullRequest notifies merge pull request to notifiers
func NotifyMergePullRequest(pr *models.PullRequest, doer *user_model.User) { func NotifyMergePullRequest(pr *models.PullRequest, doer *user_model.User) {
for _, notifier := range notifiers { for _, notifier := range notifiers {

View file

@ -835,7 +835,8 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route {
}) })
m.Group("/{index}", func() { m.Group("/{index}", func() {
m.Combo("").Get(repo.GetIssue). m.Combo("").Get(repo.GetIssue).
Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue) Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue).
Delete(reqToken(), reqAdmin(), repo.DeleteIssue)
m.Group("/comments", func() { m.Group("/comments", func() {
m.Combo("").Get(repo.ListIssueComments). m.Combo("").Get(repo.ListIssueComments).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment)

View file

@ -834,6 +834,52 @@ func EditIssue(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convert.ToAPIIssue(issue)) ctx.JSON(http.StatusCreated, convert.ToAPIIssue(issue))
} }
func DeleteIssue(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index} issue issueDelete
// ---
// summary: Delete an issue
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: index
// in: path
// description: index of issue to delete
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
if err != nil {
if models.IsErrIssueNotExist(err) {
ctx.NotFound(err)
} else {
ctx.Error(http.StatusInternalServerError, "GetIssueByID", err)
}
return
}
if err = issue_service.DeleteIssue(ctx.User, ctx.Repo.GitRepo, issue); err != nil {
ctx.Error(http.StatusInternalServerError, "DeleteIssueByID", err)
return
}
ctx.Status(http.StatusNoContent)
}
// UpdateIssueDeadline updates an issue deadline // UpdateIssueDeadline updates an issue deadline
func UpdateIssueDeadline(ctx *context.APIContext) { func UpdateIssueDeadline(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline

View file

@ -5,6 +5,8 @@
package issue package issue
import ( import (
"fmt"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -125,6 +127,33 @@ func UpdateAssignees(issue *models.Issue, oneAssignee string, multipleAssignees
return return
} }
// DeleteIssue deletes an issue
func DeleteIssue(doer *user_model.User, gitRepo *git.Repository, issue *models.Issue) error {
// load issue before deleting it
if err := issue.LoadAttributes(); err != nil {
return err
}
if err := issue.LoadPullRequest(); err != nil {
return err
}
// delete entries in database
if err := models.DeleteIssue(issue); err != nil {
return err
}
// delete pull request related git data
if issue.IsPull {
if err := gitRepo.RemoveReference(fmt.Sprintf("%s%d", git.PullPrefix, issue.PullRequest.Index)); err != nil {
return err
}
}
notification.NotifyDeleteIssue(doer, issue)
return nil
}
// AddAssigneeIfNotAssigned adds an assignee only if he isn't already assigned to the issue. // AddAssigneeIfNotAssigned adds an assignee only if he isn't already assigned to the issue.
// Also checks for access of assigned user // Also checks for access of assigned user
func AddAssigneeIfNotAssigned(issue *models.Issue, doer *user_model.User, assigneeID int64) (err error) { func AddAssigneeIfNotAssigned(issue *models.Issue, doer *user_model.User, assigneeID int64) (err error) {

View file

@ -4966,6 +4966,48 @@
} }
} }
}, },
"delete": {
"tags": [
"issue"
],
"summary": "Delete an issue",
"operationId": "issueDelete",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "index of issue to delete",
"name": "index",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"patch": { "patch": {
"consumes": [ "consumes": [
"application/json" "application/json"