diff --git a/integrations/api_issue_test.go b/integrations/api_issue_test.go index 24535057e2475..3c77092b8ee40 100644 --- a/integrations/api_issue_test.go +++ b/integrations/api_issue_test.go @@ -62,3 +62,86 @@ func TestAPICreateIssue(t *testing.T) { Title: title, }) } + +func TestAPICreateIssueID(t *testing.T) { + prepareTestEnv(t) + const body, firstTitle, dupTitle, freeTitle, notAllowedTitle = "apiTestBody", "apiTestTitle-first", "apiTestTitle-dup", "apiTestTitle-free", "apiTestTitle-notAllowed" + const firstIndex = int64(3000) + + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + admin := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User) + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + assert.True(t, admin.IsAdmin) + assert.False(t, owner.IsAdmin) + + session := loginUser(t, admin.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all&token=%s", owner.Name, repo.Name, token) + + // Must create with index 3000 + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Index: firstIndex, + Body: body, + Title: firstTitle, + Assignee: owner.Name, + }) + resp := session.MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + assert.Equal(t, apiIssue.Index, firstIndex) + assert.Equal(t, apiIssue.Body, body) + assert.Equal(t, apiIssue.Title, firstTitle) + + // Must fail + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Index: firstIndex, + Body: body, + Title: dupTitle, + Assignee: owner.Name, + }) + resp = session.MakeRequest(t, req, http.StatusInternalServerError) + + // Must be the first one created + models.AssertExistsAndLoadBean(t, &models.Issue{ + Index: firstIndex, + RepoID: repo.ID, + AssigneeID: owner.ID, + Content: body, + Title: firstTitle, + }) + + // Must create with index firstIndex + 1 + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Body: body, + Title: freeTitle, + Assignee: owner.Name, + }) + resp = session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &apiIssue) + assert.Equal(t, apiIssue.Index, firstIndex+1) + assert.Equal(t, apiIssue.Body, body) + assert.Equal(t, apiIssue.Title, freeTitle) + + // Must be the last one created + models.AssertExistsAndLoadBean(t, &models.Issue{ + Index: firstIndex + 1, + RepoID: repo.ID, + AssigneeID: owner.ID, + Content: body, + Title: freeTitle, + }) + + session = loginUser(t, owner.Name) + token = getTokenForLoggedInUser(t, session) + urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all&token=%s", owner.Name, repo.Name, token) + + // Must fail create with index + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Index: firstIndex + 2, + Body: body, + Title: notAllowedTitle, + Assignee: owner.Name, + }) + resp = session.MakeRequest(t, req, http.StatusBadRequest) +} diff --git a/models/issue.go b/models/issue.go index ddfa2a2e14313..fafcdee050028 100644 --- a/models/issue.go +++ b/models/issue.go @@ -1055,11 +1055,20 @@ func getMaxIndexOfIssue(e Engine, repoID int64) (int64, error) { func newIssue(e *xorm.Session, doer *User, opts NewIssueOptions) (err error) { opts.Issue.Title = strings.TrimSpace(opts.Issue.Title) - maxIndex, err := getMaxIndexOfIssue(e, opts.Issue.RepoID) - if err != nil { - return err + if opts.Issue.Index == 0 { + maxIndex, err := getMaxIndexOfIssue(e, opts.Issue.RepoID) + if err != nil { + return err + } + opts.Issue.Index = maxIndex + 1 + } else if !doer.IsAdmin { + // Require admin to specify Issue.Index + return ErrUserDoesNotHaveAccessToRepo{UserID: doer.ID, RepoName: opts.Repo.Name} + } + + if opts.Issue.Index < 1 { + return fmt.Errorf("Issue number out of range or max issue number reached") } - opts.Issue.Index = maxIndex + 1 if opts.Issue.MilestoneID > 0 { milestone, err := getMilestoneByRepoID(e, opts.Issue.RepoID, opts.Issue.MilestoneID) diff --git a/models/issue_test.go b/models/issue_test.go index 1a7e45ae02bda..30bac8fa79287 100644 --- a/models/issue_test.go +++ b/models/issue_test.go @@ -320,3 +320,109 @@ func TestIssue_SearchIssueIDsByKeyword(t *testing.T) { assert.EqualValues(t, 1, total) assert.EqualValues(t, []int64{1}, ids) } + +func TestIssueCreateWithID(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + + repo := AssertExistsAndLoadBean(t, &Repository{ID: 3}).(*Repository) + admin := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) + notadmin := AssertExistsAndLoadBean(t, &User{ID: repo.OwnerID}).(*User) + index := int64(3000) + assert.True(t, admin.IsAdmin) + assert.False(t, notadmin.IsAdmin) + + issue := &Issue{ + Index: index, + RepoID: repo.ID, + Repo: repo, + Title: "TestIssueCreateWithID", + PosterID: admin.ID, + Poster: admin, + Content: "Issue body", + } + + err := NewIssue(repo, issue, nil, nil, nil) + assert.NoError(t, err) + + issue = &Issue{ + Index: index, + RepoID: repo.ID, + Repo: repo, + Title: "duplicate TestIssueCreateWithID", + PosterID: admin.ID, + Poster: admin, + Content: "Issue body", + } + + err = NewIssue(repo, issue, nil, nil, nil) + assert.Error(t, err) + + issue = AssertExistsAndLoadBean(t, &Issue{RepoID: repo.ID, Index: index}).(*Issue) + + assert.Equal(t, "TestIssueCreateWithID", issue.Title) + + issue = &Issue{ + Index: -1, + RepoID: repo.ID, + Repo: repo, + Title: "neg index TestIssueCreateWithID", + PosterID: admin.ID, + Poster: admin, + Content: "Issue body", + } + + err = NewIssue(repo, issue, nil, nil, nil) + assert.Error(t, err) + + issue = &Issue{ + RepoID: repo.ID, + Repo: repo, + Title: "sequential TestIssueCreateWithID", + PosterID: notadmin.ID, + Poster: notadmin, + Content: "Issue body", + } + + err = NewIssue(repo, issue, nil, nil, nil) + assert.NoError(t, err) + assert.Equal(t, index+1, issue.Index) + + issue = &Issue{ + Index: index + 2, + RepoID: repo.ID, + Repo: repo, + Title: "not admin TestIssueCreateWithID", + PosterID: notadmin.ID, + Poster: notadmin, + Content: "Issue body", + } + + expectedError := ErrUserDoesNotHaveAccessToRepo{UserID: notadmin.ID, RepoName: repo.Name}.Error() + err = NewIssue(repo, issue, nil, nil, nil) + assert.EqualError(t, err, expectedError) + + issue = &Issue{ + Index: 0x7fffffffffffffff, + RepoID: repo.ID, + Repo: repo, + Title: "index barely in range TestIssueCreateWithID", + PosterID: admin.ID, + Poster: admin, + Content: "Issue body", + } + + err = NewIssue(repo, issue, nil, nil, nil) + assert.NoError(t, err) + + issue = &Issue{ + RepoID: repo.ID, + Repo: repo, + Title: "out of index space TestIssueCreateWithID", + PosterID: notadmin.ID, + Poster: notadmin, + Content: "Issue body", + } + + err = NewIssue(repo, issue, nil, nil, nil) + assert.Error(t, err) +} diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 58fd7344b4f27..7b5a47d1ae5e6 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -67,6 +67,7 @@ type ListIssueOption struct { // CreateIssueOption options to create one issue type CreateIssueOption struct { + Index int64 `json:"index"` // required:true Title string `json:"title" binding:"Required"` Body string `json:"body"` diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 8595be335b155..d3fe75637bc27 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -190,6 +190,7 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) { } issue := &models.Issue{ + Index: form.Index, RepoID: ctx.Repo.Repository.ID, Repo: ctx.Repo.Repository, Title: form.Title, diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 8168c6b010eba..5ac542ec207b2 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -252,15 +252,8 @@ func CreatePullRequest(ctx *context.APIContext, form api.CreatePullRequestOption deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix()) } - maxIndex, err := models.GetMaxIndexOfIssue(repo.ID) - if err != nil { - ctx.ServerError("GetPatch", err) - return - } - prIssue := &models.Issue{ RepoID: repo.ID, - Index: maxIndex + 1, Title: form.Title, PosterID: ctx.User.ID, Poster: ctx.User, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 2d5f8c5c9c253..799249bb3ca6f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7350,6 +7350,11 @@ "format": "date-time", "x-go-name": "Deadline" }, + "index": { + "type": "integer", + "format": "int64", + "x-go-name": "Index" + }, "labels": { "description": "list of label ids", "type": "array",