diff options
author | 6543 <6543@obermui.de> | 2020-05-02 02:20:51 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-02 03:20:51 +0300 |
commit | c97494a4f4a0e8ba5453e293bcebb76274062b99 (patch) | |
tree | 29511446fbdc0296f77390a6566d4ad1eef4668c /routers | |
parent | 4ed7d2a2bbb421e37cc0bc23a9f997e5606258ea (diff) | |
download | gitea-c97494a4f4a0e8ba5453e293bcebb76274062b99.tar.gz gitea-c97494a4f4a0e8ba5453e293bcebb76274062b99.zip |
API: Add pull review endpoints (#11224)
* API: Added pull review read only endpoints
* Update Structs, move Conversion, Refactor
* refactor
* lint & co
* fix lint + refactor
* add new Review state, rm unessesary, refacotr loadAttributes, convert patch to diff
* add DeletePullReview
* add paggination
* draft1: Create & submit review
* fix lint
* fix lint
* impruve test
* DONT use GhostUser for loadReviewer
* expose comments_count of a PullReview
* infent GetCodeCommentsCount()
* fixes
* fix+impruve
* some nits
* Handle Ghosts :ghost:
* add TEST for GET apis
* complete TESTS
* add HTMLURL to PullReview responce
* code format as per @lafriks
* update swagger definition
* Update routers/api/v1/repo/pull_review.go
Co-authored-by: David Svantesson <davidsvantesson@gmail.com>
* add comments
Co-authored-by: Thomas Berger <loki@lokis-chaos.de>
Co-authored-by: David Svantesson <davidsvantesson@gmail.com>
Diffstat (limited to 'routers')
-rw-r--r-- | routers/api/v1/api.go | 16 | ||||
-rw-r--r-- | routers/api/v1/repo/pull_review.go | 522 | ||||
-rw-r--r-- | routers/api/v1/swagger/options.go | 9 | ||||
-rw-r--r-- | routers/api/v1/swagger/repo.go | 52 |
4 files changed, 586 insertions, 13 deletions
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index cd451c1d5b..754e146fc1 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -500,7 +500,7 @@ func RegisterRoutes(m *macaron.Macaron) { bind := binding.Bind if setting.API.EnableSwagger { - m.Get("/swagger", misc.Swagger) //Render V1 by default + m.Get("/swagger", misc.Swagger) // Render V1 by default } m.Group("/v1", func() { @@ -794,6 +794,20 @@ func RegisterRoutes(m *macaron.Macaron) { Patch(reqToken(), reqRepoWriter(models.UnitTypePullRequests), bind(api.EditPullRequestOption{}), repo.EditPullRequest) m.Combo("/merge").Get(repo.IsPullRequestMerged). Post(reqToken(), mustNotBeArchived, bind(auth.MergePullRequestForm{}), repo.MergePullRequest) + m.Group("/reviews", func() { + m.Combo(""). + Get(repo.ListPullReviews). + Post(reqToken(), bind(api.CreatePullReviewOptions{}), repo.CreatePullReview) + m.Group("/:id", func() { + m.Combo(""). + Get(repo.GetPullReview). + Delete(reqToken(), repo.DeletePullReview). + Post(reqToken(), bind(api.SubmitPullReviewOptions{}), repo.SubmitPullReview) + m.Combo("/comments"). + Get(repo.GetPullReviewComments) + }) + }) + }) }, 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 new file mode 100644 index 0000000000..b3772b00a9 --- /dev/null +++ b/routers/api/v1/repo/pull_review.go @@ -0,0 +1,522 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + pull_service "code.gitea.io/gitea/services/pull" +) + +// ListPullReviews lists all reviews of a pull request +func ListPullReviews(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews repository repoListPullReviews + // --- + // summary: List all reviews 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: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results, maximum page size is 50 + // type: integer + // responses: + // "200": + // "$ref": "#/responses/PullReviewList" + // "404": + // "$ref": "#/responses/notFound" + + 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.LoadIssue(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + if err = pr.Issue.LoadRepo(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepo", err) + return + } + + allReviews, err := models.FindReviews(models.FindReviewOptions{ + ListOptions: utils.GetListOptions(ctx), + Type: models.ReviewTypeUnknown, + IssueID: pr.IssueID, + }) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindReviews", err) + return + } + + apiReviews, err := convert.ToPullReviewList(allReviews, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReviewList", err) + return + } + + ctx.JSON(http.StatusOK, &apiReviews) +} + +// GetPullReview gets a specific review of a pull request +func GetPullReview(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoGetPullReview + // --- + // summary: Get a specific review 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: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + apiReview, err := convert.ToPullReview(review, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + + ctx.JSON(http.StatusOK, apiReview) +} + +// GetPullReviewComments lists all comments of a pull request review +func GetPullReviewComments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments repository repoGetPullReviewComments + // --- + // summary: Get a specific review 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: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullReviewCommentList" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + apiComments, err := convert.ToPullReviewCommentList(review, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReviewCommentList", err) + return + } + + ctx.JSON(http.StatusOK, apiComments) +} + +// DeletePullReview delete a specific review from a pull request +func DeletePullReview(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoDeletePullReview + // --- + // summary: Delete a specific review from 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: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + if ctx.User == nil { + ctx.NotFound() + return + } + if !ctx.User.IsAdmin && ctx.User.ID != review.ReviewerID { + ctx.Error(http.StatusForbidden, "only admin and user itself can delete a review", nil) + return + } + + if err := models.DeleteReview(review); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteReview", fmt.Errorf("can not delete ReviewID: %d", review.ID)) + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreatePullReview create a review to an pull request +func CreatePullReview(ctx *context.APIContext, opts api.CreatePullReviewOptions) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews repository repoCreatePullReview + // --- + // summary: Create a review to an 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/CreatePullReviewOptions" + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + 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 + } + + // determine review type + reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body) + if isWrong { + return + } + + if err := pr.Issue.LoadRepo(); err != nil { + ctx.Error(http.StatusInternalServerError, "pr.Issue.LoadRepo", err) + return + } + + // create review comments + for _, c := range opts.Comments { + line := c.NewLineNum + if c.OldLineNum > 0 { + line = c.OldLineNum * -1 + } + + if _, err := pull_service.CreateCodeComment( + ctx.User, + ctx.Repo.GitRepo, + pr.Issue, + line, + c.Body, + c.Path, + true, // is review + 0, // no reply + opts.CommitID, + ); err != nil { + ctx.ServerError("CreateCodeComment", err) + return + } + } + + // create review and associate all pending review comments + review, _, err := pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SubmitReview", err) + return + } + + // convert response + apiReview, err := convert.ToPullReview(review, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + ctx.JSON(http.StatusOK, apiReview) +} + +// SubmitPullReview submit a pending review to an pull request +func SubmitPullReview(ctx *context.APIContext, opts api.SubmitPullReviewOptions) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoSubmitPullReview + // --- + // summary: Submit a pending review to an 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: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/SubmitPullReviewOptions" + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + review, pr, isWrong := prepareSingleReview(ctx) + if isWrong { + return + } + + if review.Type != models.ReviewTypePending { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("only a pending review can be submitted")) + return + } + + // determine review type + reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body) + if isWrong { + return + } + + // if review stay pending return + if reviewType == models.ReviewTypePending { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review stay pending")) + return + } + + headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GitRepo: GetRefCommitID", err) + return + } + + // create review and associate all pending review comments + review, _, err = pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, headCommitID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SubmitReview", err) + return + } + + // convert response + apiReview, err := convert.ToPullReview(review, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + ctx.JSON(http.StatusOK, apiReview) +} + +// preparePullReviewType return ReviewType and false or nil and true if an error happen +func preparePullReviewType(ctx *context.APIContext, pr *models.PullRequest, event api.ReviewStateType, body string) (models.ReviewType, bool) { + if err := pr.LoadIssue(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return -1, true + } + + var reviewType models.ReviewType + switch event { + case api.ReviewStateApproved: + // can not approve your own PR + if pr.Issue.IsPoster(ctx.User.ID) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("approve your own pull is not allowed")) + return -1, true + } + reviewType = models.ReviewTypeApprove + + case api.ReviewStateRequestChanges: + // can not reject your own PR + if pr.Issue.IsPoster(ctx.User.ID) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("reject your own pull is not allowed")) + return -1, true + } + reviewType = models.ReviewTypeReject + + case api.ReviewStateComment: + reviewType = models.ReviewTypeComment + default: + reviewType = models.ReviewTypePending + } + + // reject reviews with empty body if not approve type + if reviewType != models.ReviewTypeApprove && len(strings.TrimSpace(body)) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review event %s need body", event)) + return -1, true + } + + return reviewType, false +} + +// prepareSingleReview return review, related pull and false or nil, nil and true if an error happen +func prepareSingleReview(ctx *context.APIContext) (*models.Review, *models.PullRequest, 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 nil, nil, true + } + + review, err := models.GetReviewByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrReviewNotExist(err) { + ctx.NotFound("GetReviewByID", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetReviewByID", err) + } + return nil, nil, true + } + + // validate the the review is for the given PR + if review.IssueID != pr.IssueID { + ctx.NotFound("ReviewNotInPR") + return nil, nil, true + } + + // make sure that the user has access to this review if it is pending + if review.Type == models.ReviewTypePending && review.ReviewerID != ctx.User.ID && !ctx.User.IsAdmin { + ctx.NotFound("GetReviewByID") + return nil, nil, true + } + + if err := review.LoadAttributes(); err != nil && !models.IsErrUserNotExist(err) { + ctx.Error(http.StatusInternalServerError, "ReviewLoadAttributes", err) + return nil, nil, true + } + + return review, pr, false +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 4bb649616a..f13dc63864 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -137,4 +137,13 @@ type swaggerParameterBodies struct { // in:body CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions + + // in:body + CreatePullReviewOptions api.CreatePullReviewOptions + + // in:body + CreatePullReviewComment api.CreatePullReviewComment + + // in:body + SubmitPullReviewOptions api.SubmitPullReviewOptions } diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 2a657f3122..bcbc2b5fa9 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -141,6 +141,34 @@ type swaggerResponsePullRequestList struct { Body []api.PullRequest `json:"body"` } +// PullReview +// swagger:response PullReview +type swaggerResponsePullReview struct { + // in:body + Body api.PullReview `json:"body"` +} + +// PullReviewList +// swagger:response PullReviewList +type swaggerResponsePullReviewList struct { + // in:body + Body []api.PullReview `json:"body"` +} + +// PullComment +// swagger:response PullReviewComment +type swaggerPullReviewComment struct { + // in:body + Body api.PullReviewComment `json:"body"` +} + +// PullCommentList +// swagger:response PullReviewCommentList +type swaggerResponsePullReviewCommentList struct { + // in:body + Body []api.PullReviewComment `json:"body"` +} + // Status // swagger:response Status type swaggerResponseStatus struct { @@ -172,35 +200,35 @@ type swaggerResponseSearchResults struct { // AttachmentList // swagger:response AttachmentList type swaggerResponseAttachmentList struct { - //in: body + // in: body Body []api.Attachment `json:"body"` } // Attachment // swagger:response Attachment type swaggerResponseAttachment struct { - //in: body + // in: body Body api.Attachment `json:"body"` } // GitTreeResponse // swagger:response GitTreeResponse type swaggerGitTreeResponse struct { - //in: body + // in: body Body api.GitTreeResponse `json:"body"` } // GitBlobResponse // swagger:response GitBlobResponse type swaggerGitBlobResponse struct { - //in: body + // in: body Body api.GitBlobResponse `json:"body"` } // Commit // swagger:response Commit type swaggerCommit struct { - //in: body + // in: body Body api.Commit `json:"body"` } @@ -222,28 +250,28 @@ type swaggerCommitList struct { // True if there is another page HasMore bool `json:"X-HasMore"` - //in: body + // in: body Body []api.Commit `json:"body"` } // EmptyRepository // swagger:response EmptyRepository type swaggerEmptyRepository struct { - //in: body + // in: body Body api.APIError `json:"body"` } // FileResponse // swagger:response FileResponse type swaggerFileResponse struct { - //in: body + // in: body Body api.FileResponse `json:"body"` } // ContentsResponse // swagger:response ContentsResponse type swaggerContentsResponse struct { - //in: body + // in: body Body api.ContentsResponse `json:"body"` } @@ -257,20 +285,20 @@ type swaggerContentsListResponse struct { // FileDeleteResponse // swagger:response FileDeleteResponse type swaggerFileDeleteResponse struct { - //in: body + // in: body Body api.FileDeleteResponse `json:"body"` } // TopicListResponse // swagger:response TopicListResponse type swaggerTopicListResponse struct { - //in: body + // in: body Body []api.TopicResponse `json:"body"` } // TopicNames // swagger:response TopicNames type swaggerTopicNames struct { - //in: body + // in: body Body api.TopicName `json:"body"` } |