From 062fd4c217cc7302f56acf043d6214a9db46ee2f Mon Sep 17 00:00:00 2001 From: "Otto Richter (fnetX)" Date: Tue, 1 Mar 2022 01:20:15 +0100 Subject: [PATCH] [API] Allow removing issues (#18879) Add new feature to delete issues and pulls via API Co-authored-by: fnetx Co-authored-by: Lunny Xiao Co-authored-by: Gusted Co-authored-by: 6543 <6543@obermui.de> --- models/issue.go | 114 ++++++++++++++++++++++++++ models/issue_comment.go | 4 +- models/issue_test.go | 52 ++++++++++++ modules/nosql/manager_leveldb.go | 1 + modules/notification/base/notifier.go | 1 + modules/notification/base/null.go | 4 + modules/notification/notification.go | 7 ++ routers/api/v1/api.go | 3 +- routers/api/v1/repo/issue.go | 46 +++++++++++ services/issue/issue.go | 29 +++++++ templates/swagger/v1_json.tmpl | 42 ++++++++++ 11 files changed, 299 insertions(+), 4 deletions(-) diff --git a/models/issue.go b/models/issue.go index 91d4df32d1..625374faaf 100644 --- a/models/issue.go +++ b/models/issue.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + admin_model "code.gitea.io/gitea/models/admin" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/perm" @@ -24,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -1990,6 +1992,118 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix timeutil.TimeStamp, doer *us 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. type DependencyInfo struct { Issue `xorm:"extends"` diff --git a/models/issue_comment.go b/models/issue_comment.go index 31bd041ca7..0af45e80e8 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -1152,9 +1152,7 @@ func DeleteComment(comment *Comment) error { } func deleteComment(e db.Engine, comment *Comment) error { - if _, err := e.Delete(&Comment{ - ID: comment.ID, - }); err != nil { + if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil { return err } diff --git a/models/issue_test.go b/models/issue_test.go index 9344d385a7..7cc0aa61b0 100644 --- a/models/issue_test.go +++ b/models/issue_test.go @@ -397,6 +397,58 @@ func TestIssue_InsertIssue(t *testing.T) { 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) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/nosql/manager_leveldb.go b/modules/nosql/manager_leveldb.go index 97f917af78..de4ef14d7d 100644 --- a/modules/nosql/manager_leveldb.go +++ b/modules/nosql/manager_leveldb.go @@ -11,6 +11,7 @@ import ( "strings" "code.gitea.io/gitea/modules/log" + "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/errors" "github.com/syndtr/goleveldb/leveldb/opt" diff --git a/modules/notification/base/notifier.go b/modules/notification/base/notifier.go index 7f5caa3bcc..8174741169 100644 --- a/modules/notification/base/notifier.go +++ b/modules/notification/base/notifier.go @@ -22,6 +22,7 @@ type Notifier interface { NotifyTransferRepository(doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) NotifyNewIssue(issue *models.Issue, mentions []*user_model.User) 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) 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) diff --git a/modules/notification/base/null.go b/modules/notification/base/null.go index bd52b843a7..2bfcaafda9 100644 --- a/modules/notification/base/null.go +++ b/modules/notification/base/null.go @@ -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) { } +// NotifyDeleteIssue notify when some issue deleted +func (*NullNotifier) NotifyDeleteIssue(doer *user_model.User, issue *models.Issue) { +} + // NotifyNewPullRequest places a place holder function func (*NullNotifier) NotifyNewPullRequest(pr *models.PullRequest, mentions []*user_model.User) { } diff --git a/modules/notification/notification.go b/modules/notification/notification.go index e8d5d07b34..a31e3810e2 100644 --- a/modules/notification/notification.go +++ b/modules/notification/notification.go @@ -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 func NotifyMergePullRequest(pr *models.PullRequest, doer *user_model.User) { for _, notifier := range notifiers { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 6d8ab8ce98..d4891daef0 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -835,7 +835,8 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route { }) m.Group("/{index}", func() { 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.Combo("").Get(repo.ListIssueComments). Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index e2afa72498..9e550c4c47 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -834,6 +834,52 @@ func EditIssue(ctx *context.APIContext) { 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 func UpdateIssueDeadline(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline diff --git a/services/issue/issue.go b/services/issue/issue.go index 8b6262c571..6e5e4bfd37 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -5,6 +5,8 @@ package issue import ( + "fmt" + "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" @@ -125,6 +127,33 @@ func UpdateAssignees(issue *models.Issue, oneAssignee string, multipleAssignees 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. // Also checks for access of assigned user func AddAssigneeIfNotAssigned(issue *models.Issue, doer *user_model.User, assigneeID int64) (err error) { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0b0b83ebbc..69abe24020 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": { "consumes": [ "application/json"