mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-20 20:07:03 +01:00
[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:
parent
6859b69198
commit
062fd4c217
11 changed files with 299 additions and 4 deletions
114
models/issue.go
114
models/issue.go
|
@ -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"`
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue