From b9850375fca1ed72f5a5ba265d48c241aff2e21e Mon Sep 17 00:00:00 2001 From: a1012112796 <1012112796@qq.com> Date: Wed, 21 Oct 2020 02:18:25 +0800 Subject: [PATCH] Add review request api (#11355) * Add review request api * add : POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers * Remove : DELET /repos/{owner}/{repo}/pulls/{index}/requested_reviewers * fix some request review bug * block delet request review by models/DeleteReview() Signed-off-by: a1012112796 <1012112796@qq.com> * make fmt * fix bug * fix test code * fix typo * Apply suggestion from code review @jonasfranz * fix swagger ref * fix typo Co-authored-by: Lauris BH * fix comment * Change response message * chang response so some simplfy * Add ErrIllLegalReviewRequest fix some nits * make fmt * Apply suggestions from code review Co-authored-by: silverwind * * Add team support * fix test * fix an known bug * fix nit * fix test * Apply suggestions from code review Co-authored-by: zeripath * update get api and add test Co-authored-by: Lauris BH Co-authored-by: silverwind Co-authored-by: zeripath --- integrations/api_issue_test.go | 10 +- integrations/api_pull_review_test.go | 106 +++++++++ .../d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 | Bin 0 -> 814 bytes .../ec/f0db3c1ec806522de4b491fb9a3c7457398c61 | Bin 0 -> 62 bytes .../ee/16d127df463aa491e08958120f2108b02468df | Bin 0 -> 84 bytes .../user3/repo3.git/refs/heads/test_branch | 1 + models/error.go | 2 +- models/fixtures/issue.yml | 12 + models/fixtures/pull_request.yml | 13 ++ models/fixtures/repository.yml | 2 +- models/fixtures/review.yml | 19 ++ models/review.go | 13 +- modules/convert/convert.go | 4 + modules/convert/pull_review.go | 1 + modules/structs/pull_review.go | 7 + routers/api/v1/api.go | 4 +- routers/api/v1/repo/pull_review.go | 212 ++++++++++++++++++ routers/api/v1/swagger/options.go | 3 + routers/repo/issue.go | 158 +------------ services/issue/assignee.go | 164 +++++++++++++- templates/swagger/v1_json.tmpl | 134 ++++++++++- 21 files changed, 694 insertions(+), 171 deletions(-) create mode 100644 integrations/gitea-repositories-meta/user3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 create mode 100644 integrations/gitea-repositories-meta/user3/repo3.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61 create mode 100644 integrations/gitea-repositories-meta/user3/repo3.git/objects/ee/16d127df463aa491e08958120f2108b02468df create mode 100644 integrations/gitea-repositories-meta/user3/repo3.git/refs/heads/test_branch diff --git a/integrations/api_issue_test.go b/integrations/api_issue_test.go index d74204933..9311d50c5 100644 --- a/integrations/api_issue_test.go +++ b/integrations/api_issue_test.go @@ -153,7 +153,7 @@ func TestAPISearchIssues(t *testing.T) { var apiIssues []*api.Issue DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 9) + assert.Len(t, apiIssues, 10) query := url.Values{} query.Add("token", token) @@ -161,7 +161,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 9) + assert.Len(t, apiIssues, 10) query.Add("state", "closed") link.RawQuery = query.Encode() @@ -182,7 +182,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 1) + assert.Len(t, apiIssues, 2) } func TestAPISearchIssuesWithLabels(t *testing.T) { @@ -197,7 +197,7 @@ func TestAPISearchIssuesWithLabels(t *testing.T) { var apiIssues []*api.Issue DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 9) + assert.Len(t, apiIssues, 10) query := url.Values{} query.Add("token", token) @@ -205,7 +205,7 @@ func TestAPISearchIssuesWithLabels(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 9) + assert.Len(t, apiIssues, 10) query.Add("labels", "label1") link.RawQuery = query.Encode() diff --git a/integrations/api_pull_review_test.go b/integrations/api_pull_review_test.go index 28eed8725..261a3a8bf 100644 --- a/integrations/api_pull_review_test.go +++ b/integrations/api_pull_review_test.go @@ -122,4 +122,110 @@ func TestAPIPullReview(t *testing.T) { assert.EqualValues(t, 0, review.CodeCommentsCount) req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, review.ID, token) resp = session.MakeRequest(t, req, http.StatusNoContent) + + // test get review requests + // to make it simple, use same api with get review + pullIssue12 := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 12}).(*models.Issue) + assert.NoError(t, pullIssue12.LoadAttributes()) + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: pullIssue12.RepoID}).(*models.Repository) + + req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &reviews) + assert.EqualValues(t, 11, reviews[0].ID) + assert.EqualValues(t, "REQUEST_REVIEW", reviews[0].State) + assert.EqualValues(t, 0, reviews[0].CodeCommentsCount) + assert.EqualValues(t, false, reviews[0].Stale) + assert.EqualValues(t, true, reviews[0].Official) + assert.EqualValues(t, "test_team", reviews[0].ReviewerTeam.Name) + + assert.EqualValues(t, 12, reviews[1].ID) + assert.EqualValues(t, "REQUEST_REVIEW", reviews[1].State) + assert.EqualValues(t, 0, reviews[0].CodeCommentsCount) + assert.EqualValues(t, false, reviews[1].Stale) + assert.EqualValues(t, true, reviews[1].Official) + assert.EqualValues(t, 1, reviews[1].Reviewer.ID) +} + +func TestAPIPullReviewRequest(t *testing.T) { + defer prepareTestEnv(t)() + pullIssue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 3}).(*models.Issue) + assert.NoError(t, pullIssue.LoadAttributes()) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: pullIssue.RepoID}).(*models.Repository) + + // Test add Review Request + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token), &api.PullReviewRequestOptions{ + Reviewers: []string{"user4@example.com", "user8"}, + }) + session.MakeRequest(t, req, http.StatusCreated) + + // poster of pr can't be reviewer + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token), &api.PullReviewRequestOptions{ + Reviewers: []string{"user1"}, + }) + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + + // test user not exist + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token), &api.PullReviewRequestOptions{ + Reviewers: []string{"testOther"}, + }) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test Remove Review Request + session2 := loginUser(t, "user4") + token2 := getTokenForLoggedInUser(t, session2) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token2), &api.PullReviewRequestOptions{ + Reviewers: []string{"user4"}, + }) + session.MakeRequest(t, req, http.StatusNoContent) + + // doer is not admin + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token2), &api.PullReviewRequestOptions{ + Reviewers: []string{"user8"}, + }) + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token), &api.PullReviewRequestOptions{ + Reviewers: []string{"user8"}, + }) + session.MakeRequest(t, req, http.StatusNoContent) + + // Test team review request + pullIssue12 := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 12}).(*models.Issue) + assert.NoError(t, pullIssue12.LoadAttributes()) + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: pullIssue12.RepoID}).(*models.Repository) + + // Test add Team Review Request + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"team1", "owners"}, + }) + session.MakeRequest(t, req, http.StatusCreated) + + // Test add Team Review Request to not allowned + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"test_team"}, + }) + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test add Team Review Request to not exist + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"not_exist_team"}, + }) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test Remove team Review Request + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"team1"}, + }) + session.MakeRequest(t, req, http.StatusNoContent) + + // empty request test + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token), &api.PullReviewRequestOptions{}) + session.MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers?token=%s", repo3.OwnerName, repo3.Name, pullIssue12.Index, token), &api.PullReviewRequestOptions{}) + session.MakeRequest(t, req, http.StatusNoContent) } diff --git a/integrations/gitea-repositories-meta/user3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 b/integrations/gitea-repositories-meta/user3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 new file mode 100644 index 0000000000000000000000000000000000000000..e319f8ce3447ea6a2a5c36db6841d582ed4fd02c GIT binary patch literal 814 zcmV+}1JV3=0hNet()&&B`aCw{mU`%owRF4;Pzn% z@u^AqrtIh8?4O&RxUMO+3wpTC10PEVuvM4oI{lE(FLu}mbB_3ly;d=}h>m*a}70U@7KwMj5g?@Ptm9v&D}q^RpA z@wF3D?oP{r!GEMHLslWW$BeUIoJcp><1kNB*vr(AJ@%-}Fz^xFHPNcBHG?5);#}+; z1D?{03~4{OdsRKh*D*6wWz%hwL&b4r?-{wsyORf6To4#=$DLWSqszS^Ay!mpG!KQ( zllokZjEQ~eozR#B3YHdCylD01>Z<6TBD2jpsFS7EG2?TCo%v<+7hRJ%;1%Zm4 z!4Bh>S>!JXRe`GSi!Y>n7UybKW_iz4#)R@d!a!R*Z+7W>8irN-fAY=HhZmElw`VEGWs$&r?XtFM6XD~D{Ff%bx2y%6F@paY9O<{QR;kJ$33AP~JCtD`|o@G-K qZrPJ)VgLjRDf!6^>LvH~y~;iAbjGuArS66Y-BY)*b^rk6+8wjRASSs0 literal 0 HcmV?d00001 diff --git a/integrations/gitea-repositories-meta/user3/repo3.git/refs/heads/test_branch b/integrations/gitea-repositories-meta/user3/repo3.git/refs/heads/test_branch new file mode 100644 index 000000000..dfe0c6a12 --- /dev/null +++ b/integrations/gitea-repositories-meta/user3/repo3.git/refs/heads/test_branch @@ -0,0 +1 @@ +d22b4d4daa5be07329fcef6ed458f00cf3392da0 diff --git a/models/error.go b/models/error.go index be94d7889..b2273f74c 100644 --- a/models/error.go +++ b/models/error.go @@ -2003,7 +2003,7 @@ type ErrNotValidReviewRequest struct { // IsErrNotValidReviewRequest checks if an error is a ErrNotValidReviewRequest. func IsErrNotValidReviewRequest(err error) bool { - _, ok := err.(ErrReviewNotExist) + _, ok := err.(ErrNotValidReviewRequest) return ok } diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index 39a96dc55..3e836bf5d 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -135,3 +135,15 @@ is_pull: true created_unix: 1579194806 updated_unix: 1579194806 + +- + id: 12 + repo_id: 3 + index: 2 + poster_id: 2 + name: pull6 + content: content for the a pull request + is_closed: false + is_pull: true + created_unix: 1602935696 + updated_unix: 1602935696 diff --git a/models/fixtures/pull_request.yml b/models/fixtures/pull_request.yml index b555da83e..d45baa711 100644 --- a/models/fixtures/pull_request.yml +++ b/models/fixtures/pull_request.yml @@ -63,3 +63,16 @@ base_branch: branch1 merge_base: 1234567890abcdef has_merged: false + +- + id: 6 + type: 0 # gitea pull request + status: 2 # mergable + issue_id: 12 + index: 2 + head_repo_id: 3 + base_repo_id: 3 + head_branch: test_branch + base_branch: master + merge_base: 2a47ca4b614a9f5a + has_merged: false diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index a44e48027..c7f55a8f7 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -41,7 +41,7 @@ is_private: true num_issues: 1 num_closed_issues: 0 - num_pulls: 0 + num_pulls: 1 num_closed_pulls: 0 num_watches: 0 num_projects: 1 diff --git a/models/fixtures/review.yml b/models/fixtures/review.yml index 3db0b4735..c7c16fb10 100644 --- a/models/fixtures/review.yml +++ b/models/fixtures/review.yml @@ -86,3 +86,22 @@ official: true updated_unix: 946684815 created_unix: 946684815 + +- + id: 11 + type: 4 + reviewer_id: 0 + reviewer_team_id: 7 + issue_id: 12 + official: true + updated_unix: 1602936509 + created_unix: 1602936509 + +- + id: 12 + type: 4 + reviewer_id: 1 + issue_id: 12 + official: true + updated_unix: 1603196749 + created_unix: 1603196749 \ No newline at end of file diff --git a/models/review.go b/models/review.go index 326b06b5e..aeb5f21ea 100644 --- a/models/review.go +++ b/models/review.go @@ -627,13 +627,14 @@ func AddReviewRequest(issue *Issue, reviewer, doer *User) (*Comment, error) { } } - if _, err = createReview(sess, CreateReviewOptions{ + review, err = createReview(sess, CreateReviewOptions{ Type: ReviewTypeRequest, Issue: issue, Reviewer: reviewer, Official: official, Stale: false, - }); err != nil { + }) + if err != nil { return nil, err } @@ -644,6 +645,7 @@ func AddReviewRequest(issue *Issue, reviewer, doer *User) (*Comment, error) { Issue: issue, RemovedAssignee: false, // Use RemovedAssignee as !isRequest AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID + ReviewID: review.ID, }) if err != nil { return nil, err @@ -732,7 +734,7 @@ func AddTeamReviewRequest(issue *Issue, reviewer *Team, doer *User) (*Comment, e } } - if _, err = createReview(sess, CreateReviewOptions{ + if review, err = createReview(sess, CreateReviewOptions{ Type: ReviewTypeRequest, Issue: issue, ReviewerTeam: reviewer, @@ -755,6 +757,7 @@ func AddTeamReviewRequest(issue *Issue, reviewer *Team, doer *User) (*Comment, e Issue: issue, RemovedAssignee: false, // Use RemovedAssignee as !isRequest AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID + ReviewID: review.ID, }) if err != nil { return nil, fmt.Errorf("createComment(): %v", err) @@ -894,6 +897,10 @@ func DeleteReview(r *Review) error { return fmt.Errorf("review is not allowed to be 0") } + if r.Type == ReviewTypeRequest { + return fmt.Errorf("review request can not be deleted using this method") + } + opts := FindCommentsOptions{ Type: CommentTypeCode, IssueID: r.IssueID, diff --git a/modules/convert/convert.go b/modules/convert/convert.go index e81df0c0c..5d056c379 100644 --- a/modules/convert/convert.go +++ b/modules/convert/convert.go @@ -284,6 +284,10 @@ func ToOrganization(org *models.User) *api.Organization { // ToTeam convert models.Team to api.Team func ToTeam(team *models.Team) *api.Team { + if team == nil { + return nil + } + return &api.Team{ ID: team.ID, Name: team.Name, diff --git a/modules/convert/pull_review.go b/modules/convert/pull_review.go index 032d3617f..0ef1fec39 100644 --- a/modules/convert/pull_review.go +++ b/modules/convert/pull_review.go @@ -28,6 +28,7 @@ func ToPullReview(r *models.Review, doer *models.User) (*api.PullReview, error) result := &api.PullReview{ ID: r.ID, Reviewer: ToUser(r.Reviewer, doer != nil, auth), + ReviewerTeam: ToTeam(r.ReviewerTeam), State: api.ReviewStateUnknown, Body: r.Content, CommitID: r.CommitID, diff --git a/modules/structs/pull_review.go b/modules/structs/pull_review.go index bf9eafc24..07fa968d2 100644 --- a/modules/structs/pull_review.go +++ b/modules/structs/pull_review.go @@ -30,6 +30,7 @@ const ( type PullReview struct { ID int64 `json:"id"` Reviewer *User `json:"user"` + ReviewerTeam *Team `json:"team"` State ReviewStateType `json:"state"` Body string `json:"body"` CommitID string `json:"commit_id"` @@ -90,3 +91,9 @@ type SubmitPullReviewOptions struct { Event ReviewStateType `json:"event"` Body string `json:"body"` } + +// PullReviewRequestOptions are options to add or remove pull review requests +type PullReviewRequestOptions struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index acd97648b..147cb8e27 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -827,7 +827,9 @@ func RegisterRoutes(m *macaron.Macaron) { Get(repo.GetPullReviewComments) }) }) - + m.Combo("/requested_reviewers"). + Delete(reqToken(), bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests). + Post(reqToken(), bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests) }) }, mustAllowPulls, reqRepoReader(models.UnitTypeCode), context.ReferencesGitRepo(false)) m.Group("/statuses", func() { diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go index 86c084acd..9e7fd1566 100644 --- a/routers/api/v1/repo/pull_review.go +++ b/routers/api/v1/repo/pull_review.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" ) @@ -539,3 +540,214 @@ func prepareSingleReview(ctx *context.APIContext) (*models.Review, *models.PullR return review, pr, false } + +// CreateReviewRequests create review requests to an pull request +func CreateReviewRequests(ctx *context.APIContext, opts api.PullReviewRequestOptions) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers repository repoCreatePullReviewRequests + // --- + // summary: create review requests for a pull request + // produces: + // - application/json + // 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 the pull request + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/PullReviewRequestOptions" + // responses: + // "201": + // "$ref": "#/responses/PullReviewList" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + apiReviewRequest(ctx, opts, true) +} + +// DeleteReviewRequests delete review requests to an pull request +func DeleteReviewRequests(ctx *context.APIContext, opts api.PullReviewRequestOptions) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/requested_reviewers repository repoDeletePullReviewRequests + // --- + // summary: cancel review requests for a pull request + // produces: + // - application/json + // 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 the pull request + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/PullReviewRequestOptions" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + apiReviewRequest(ctx, opts, false) +} + +func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) { + pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err := pr.Issue.LoadRepo(); err != nil { + ctx.Error(http.StatusInternalServerError, "pr.Issue.LoadRepo", err) + return + } + + reviewers := make([]*models.User, 0, len(opts.Reviewers)) + + permDoer, err := models.GetUserRepoPermission(pr.Issue.Repo, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + + for _, r := range opts.Reviewers { + var reviewer *models.User + if strings.Contains(r, "@") { + reviewer, err = models.GetUserByEmail(r) + } else { + reviewer, err = models.GetUserByName(r) + } + + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r)) + return + } + ctx.Error(http.StatusInternalServerError, "GetUser", err) + return + } + + err = issue_service.IsValidReviewRequest(reviewer, ctx.User, isAdd, pr.Issue, &permDoer) + if err != nil { + if models.IsErrNotValidReviewRequest(err) { + ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err) + return + } + ctx.Error(http.StatusInternalServerError, "IsValidReviewRequest", err) + return + } + + reviewers = append(reviewers, reviewer) + } + + var reviews []*models.Review + if isAdd { + reviews = make([]*models.Review, 0, len(reviewers)) + } + + for _, reviewer := range reviewers { + comment, err := issue_service.ReviewRequest(pr.Issue, ctx.User, reviewer, isAdd) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) + return + } + + if comment != nil && isAdd { + if err = comment.LoadReview(); err != nil { + ctx.ServerError("ReviewRequest", err) + return + } + reviews = append(reviews, comment.Review) + } + } + + if ctx.Repo.Repository.Owner.IsOrganization() && len(opts.TeamReviewers) > 0 { + + teamReviewers := make([]*models.Team, 0, len(opts.TeamReviewers)) + for _, t := range opts.TeamReviewers { + var teamReviewer *models.Team + teamReviewer, err = models.GetTeam(ctx.Repo.Owner.ID, t) + if err != nil { + if models.IsErrTeamNotExist(err) { + ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t)) + return + } + ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) + return + } + + err = issue_service.IsValidTeamReviewRequest(teamReviewer, ctx.User, isAdd, pr.Issue) + if err != nil { + if models.IsErrNotValidReviewRequest(err) { + ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err) + return + } + ctx.Error(http.StatusInternalServerError, "IsValidTeamReviewRequest", err) + return + } + + teamReviewers = append(teamReviewers, teamReviewer) + } + + for _, teamReviewer := range teamReviewers { + comment, err := issue_service.TeamReviewRequest(pr.Issue, ctx.User, teamReviewer, isAdd) + if err != nil { + ctx.ServerError("TeamReviewRequest", err) + return + } + + if comment != nil && isAdd { + if err = comment.LoadReview(); err != nil { + ctx.ServerError("ReviewRequest", err) + return + } + reviews = append(reviews, comment.Review) + } + } + } + + if isAdd { + apiReviews, err := convert.ToPullReviewList(reviews, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReviewList", err) + return + } + ctx.JSON(http.StatusCreated, apiReviews) + } else { + ctx.Status(http.StatusNoContent) + return + } +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index ced6589e4..a3bb9cc65 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -152,4 +152,7 @@ type swaggerParameterBodies struct { // in:body MigrateRepoOptions api.MigrateRepoOptions + + // in:body + PullReviewRequestOptions api.PullReviewRequestOptions } diff --git a/routers/repo/issue.go b/routers/repo/issue.go index bc65e73f1..835a952e5 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -1699,154 +1699,6 @@ func UpdateIssueAssignee(ctx *context.Context) { }) } -func isValidReviewRequest(reviewer, doer *models.User, isAdd bool, issue *models.Issue) error { - if reviewer.IsOrganization() { - return models.ErrNotValidReviewRequest{ - Reason: "Organization can't be added as reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - if doer.IsOrganization() { - return models.ErrNotValidReviewRequest{ - Reason: "Organization can't be doer to add reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - - permReviewer, err := models.GetUserRepoPermission(issue.Repo, reviewer) - if err != nil { - return err - } - - permDoer, err := models.GetUserRepoPermission(issue.Repo, doer) - if err != nil { - return err - } - - lastreview, err := models.GetReviewByIssueIDAndUserID(issue.ID, reviewer.ID) - if err != nil && !models.IsErrReviewNotExist(err) { - return err - } - - var pemResult bool - if isAdd { - pemResult = permReviewer.CanAccessAny(models.AccessModeRead, models.UnitTypePullRequests) - if !pemResult { - return models.ErrNotValidReviewRequest{ - Reason: "Reviewer can't read", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - - if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != models.ReviewTypeRequest { - return nil - } - - pemResult = permDoer.CanAccessAny(models.AccessModeWrite, models.UnitTypePullRequests) - if !pemResult { - pemResult, err = models.IsOfficialReviewer(issue, doer) - if err != nil { - return err - } - if !pemResult { - return models.ErrNotValidReviewRequest{ - Reason: "Doer can't choose reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - } - - if doer.ID == reviewer.ID { - return models.ErrNotValidReviewRequest{ - Reason: "doer can't be reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - - if reviewer.ID == issue.PosterID && issue.OriginalAuthorID == 0 { - return models.ErrNotValidReviewRequest{ - Reason: "poster of pr can't be reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - } else { - if lastreview != nil && lastreview.Type == models.ReviewTypeRequest && lastreview.ReviewerID == doer.ID { - return nil - } - - pemResult = permDoer.IsAdmin() - if !pemResult { - return models.ErrNotValidReviewRequest{ - Reason: "Doer is not admin", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - } - - return nil -} - -func isValidTeamReviewRequest(reviewer *models.Team, doer *models.User, isAdd bool, issue *models.Issue) error { - if doer.IsOrganization() { - return models.ErrNotValidReviewRequest{ - Reason: "Organization can't be doer to add reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - - permission, err := models.GetUserRepoPermission(issue.Repo, doer) - if err != nil { - log.Error("Unable to GetUserRepoPermission for %-v in %-v#%d", doer, issue.Repo, issue.Index) - return err - } - - if isAdd { - if issue.Repo.IsPrivate { - hasTeam := models.HasTeamRepo(reviewer.OrgID, reviewer.ID, issue.RepoID) - - if !hasTeam { - return models.ErrNotValidReviewRequest{ - Reason: "Reviewing team can't read repo", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - } - - doerCanWrite := permission.CanAccessAny(models.AccessModeWrite, models.UnitTypePullRequests) - if !doerCanWrite { - official, err := models.IsOfficialReviewer(issue, doer) - if err != nil { - log.Error("Unable to Check if IsOfficialReviewer for %-v in %-v#%d", doer, issue.Repo, issue.Index) - return err - } - if !official { - return models.ErrNotValidReviewRequest{ - Reason: "Doer can't choose reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - } - } else if !permission.IsAdmin() { - return models.ErrNotValidReviewRequest{ - Reason: "Only admin users can remove team requests. Doer is not admin", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - - return nil -} - // UpdatePullReviewRequest add or remove review request func UpdatePullReviewRequest(ctx *context.Context) { issues := getActionIssues(ctx) @@ -1907,7 +1759,7 @@ func UpdatePullReviewRequest(ctx *context.Context) { return } - err = isValidTeamReviewRequest(team, ctx.User, action == "attach", issue) + err = issue_service.IsValidTeamReviewRequest(team, ctx.User, action == "attach", issue) if err != nil { if models.IsErrNotValidReviewRequest(err) { log.Warn( @@ -1918,11 +1770,11 @@ func UpdatePullReviewRequest(ctx *context.Context) { ctx.Status(403) return } - ctx.ServerError("isValidTeamReviewRequest", err) + ctx.ServerError("IsValidTeamReviewRequest", err) return } - err = issue_service.TeamReviewRequest(issue, ctx.User, team, action == "attach") + _, err = issue_service.TeamReviewRequest(issue, ctx.User, team, action == "attach") if err != nil { ctx.ServerError("TeamReviewRequest", err) return @@ -1945,7 +1797,7 @@ func UpdatePullReviewRequest(ctx *context.Context) { return } - err = isValidReviewRequest(reviewer, ctx.User, action == "attach", issue) + err = issue_service.IsValidReviewRequest(reviewer, ctx.User, action == "attach", issue, nil) if err != nil { if models.IsErrNotValidReviewRequest(err) { log.Warn( @@ -1960,7 +1812,7 @@ func UpdatePullReviewRequest(ctx *context.Context) { return } - err = issue_service.ReviewRequest(issue, ctx.User, reviewer, action == "attach") + _, err = issue_service.ReviewRequest(issue, ctx.User, reviewer, action == "attach") if err != nil { ctx.ServerError("ReviewRequest", err) return diff --git a/services/issue/assignee.go b/services/issue/assignee.go index f48e55e53..f24a242f6 100644 --- a/services/issue/assignee.go +++ b/services/issue/assignee.go @@ -6,6 +6,7 @@ package issue import ( "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification" ) @@ -53,8 +54,7 @@ func ToggleAssignee(issue *models.Issue, doer *models.User, assigneeID int64) (r } // ReviewRequest add or remove a review request from a user for this PR, and make comment for it. -func ReviewRequest(issue *models.Issue, doer *models.User, reviewer *models.User, isAdd bool) (err error) { - var comment *models.Comment +func ReviewRequest(issue *models.Issue, doer *models.User, reviewer *models.User, isAdd bool) (comment *models.Comment, err error) { if isAdd { comment, err = models.AddReviewRequest(issue, reviewer, doer) } else { @@ -62,19 +62,171 @@ func ReviewRequest(issue *models.Issue, doer *models.User, reviewer *models.User } if err != nil { - return + return nil, err } if comment != nil { notification.NotifyPullReviewRequest(doer, issue, reviewer, isAdd, comment) } + return +} + +// IsValidReviewRequest Check permission for ReviewRequest +func IsValidReviewRequest(reviewer, doer *models.User, isAdd bool, issue *models.Issue, permDoer *models.Permission) error { + if reviewer.IsOrganization() { + return models.ErrNotValidReviewRequest{ + Reason: "Organization can't be added as reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + if doer.IsOrganization() { + return models.ErrNotValidReviewRequest{ + Reason: "Organization can't be doer to add reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + permReviewer, err := models.GetUserRepoPermission(issue.Repo, reviewer) + if err != nil { + return err + } + + if permDoer == nil { + permDoer = new(models.Permission) + *permDoer, err = models.GetUserRepoPermission(issue.Repo, doer) + if err != nil { + return err + } + } + + lastreview, err := models.GetReviewByIssueIDAndUserID(issue.ID, reviewer.ID) + if err != nil && !models.IsErrReviewNotExist(err) { + return err + } + + var pemResult bool + if isAdd { + pemResult = permReviewer.CanAccessAny(models.AccessModeRead, models.UnitTypePullRequests) + if !pemResult { + return models.ErrNotValidReviewRequest{ + Reason: "Reviewer can't read", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != models.ReviewTypeRequest { + return nil + } + + pemResult = permDoer.CanAccessAny(models.AccessModeWrite, models.UnitTypePullRequests) + if !pemResult { + pemResult, err = models.IsOfficialReviewer(issue, doer) + if err != nil { + return err + } + if !pemResult { + return models.ErrNotValidReviewRequest{ + Reason: "Doer can't choose reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + } + + if doer.ID == reviewer.ID { + return models.ErrNotValidReviewRequest{ + Reason: "doer can't be reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + if reviewer.ID == issue.PosterID && issue.OriginalAuthorID == 0 { + return models.ErrNotValidReviewRequest{ + Reason: "poster of pr can't be reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + } else { + if lastreview != nil && lastreview.Type == models.ReviewTypeRequest && lastreview.ReviewerID == doer.ID { + return nil + } + + pemResult = permDoer.IsAdmin() + if !pemResult { + return models.ErrNotValidReviewRequest{ + Reason: "Doer is not admin", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + } + + return nil +} + +// IsValidTeamReviewRequest Check permission for ReviewRequest Team +func IsValidTeamReviewRequest(reviewer *models.Team, doer *models.User, isAdd bool, issue *models.Issue) error { + if doer.IsOrganization() { + return models.ErrNotValidReviewRequest{ + Reason: "Organization can't be doer to add reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + permission, err := models.GetUserRepoPermission(issue.Repo, doer) + if err != nil { + log.Error("Unable to GetUserRepoPermission for %-v in %-v#%d", doer, issue.Repo, issue.Index) + return err + } + + if isAdd { + if issue.Repo.IsPrivate { + hasTeam := models.HasTeamRepo(reviewer.OrgID, reviewer.ID, issue.RepoID) + + if !hasTeam { + return models.ErrNotValidReviewRequest{ + Reason: "Reviewing team can't read repo", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + } + + doerCanWrite := permission.CanAccessAny(models.AccessModeWrite, models.UnitTypePullRequests) + if !doerCanWrite { + official, err := models.IsOfficialReviewer(issue, doer) + if err != nil { + log.Error("Unable to Check if IsOfficialReviewer for %-v in %-v#%d", doer, issue.Repo, issue.Index) + return err + } + if !official { + return models.ErrNotValidReviewRequest{ + Reason: "Doer can't choose reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + } + } else if !permission.IsAdmin() { + return models.ErrNotValidReviewRequest{ + Reason: "Only admin users can remove team requests. Doer is not admin", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + return nil } // TeamReviewRequest add or remove a review request from a team for this PR, and make comment for it. -func TeamReviewRequest(issue *models.Issue, doer *models.User, reviewer *models.Team, isAdd bool) (err error) { - var comment *models.Comment +func TeamReviewRequest(issue *models.Issue, doer *models.User, reviewer *models.Team, isAdd bool) (comment *models.Comment, err error) { if isAdd { comment, err = models.AddTeamReviewRequest(issue, reviewer, doer) } else { @@ -106,5 +258,5 @@ func TeamReviewRequest(issue *models.Issue, doer *models.User, reviewer *models. notification.NotifyPullReviewRequest(doer, issue, member, isAdd, comment) } - return nil + return } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e7655f02a..90a76643d 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7164,6 +7164,114 @@ } } }, + "/repos/{owner}/{repo}/pulls/{index}/requested_reviewers": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "create review requests for a pull request", + "operationId": "repoCreatePullReviewRequests", + "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 the pull request", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PullReviewRequestOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/PullReviewList" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "cancel review requests for a pull request", + "operationId": "repoDeletePullReviewRequests", + "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 the pull request", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PullReviewRequestOptions" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/pulls/{index}/reviews": { "get": { "produces": [ @@ -14540,6 +14648,9 @@ "format": "date-time", "x-go-name": "Submitted" }, + "team": { + "$ref": "#/definitions/Team" + }, "user": { "$ref": "#/definitions/User" } @@ -14614,6 +14725,27 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "PullReviewRequestOptions": { + "description": "PullReviewRequestOptions are options to add or remove pull review requests", + "type": "object", + "properties": { + "reviewers": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Reviewers" + }, + "team_reviewers": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "TeamReviewers" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Reaction": { "description": "Reaction contain one reaction", "type": "object", @@ -16162,7 +16294,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/MigrateRepoOptions" + "$ref": "#/definitions/PullReviewRequestOptions" } }, "redirect": {