mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-20 00:50:10 +02:00
Add API endpoint to reply to pull request review comments (#36683)
Adds a dedicated endpoint for replying to pull request review comments,
```
POST /repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies
{ "body": "..." }
```
The reply is threaded under the same review as the parent comment.
Ref: https://gitea.com/gitea/gitea-mcp/issues/129
Fixes: https://github.com/go-gitea/gitea/issues/37419
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
b45be5b20d
commit
331450b17a
@ -94,6 +94,11 @@ type CreatePullReviewComment struct {
|
|||||||
NewLineNum int64 `json:"new_position"`
|
NewLineNum int64 `json:"new_position"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment
|
||||||
|
type CreatePullReviewCommentReplyOptions struct {
|
||||||
|
Body string `json:"body" binding:"Required"`
|
||||||
|
}
|
||||||
|
|
||||||
// SubmitPullReviewOptions are options to submit a pending pull request review
|
// SubmitPullReviewOptions are options to submit a pending pull request review
|
||||||
type SubmitPullReviewOptions struct {
|
type SubmitPullReviewOptions struct {
|
||||||
Event ReviewStateType `json:"event"`
|
Event ReviewStateType `json:"event"`
|
||||||
|
|||||||
@ -1369,6 +1369,7 @@ func Routes() *web.Router {
|
|||||||
m.Combo("/requested_reviewers", reqToken()).
|
m.Combo("/requested_reviewers", reqToken()).
|
||||||
Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests).
|
Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests).
|
||||||
Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests)
|
Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests)
|
||||||
|
m.Post("/comments/{id}/replies", reqToken(), mustNotBeArchived, bind(api.CreatePullReviewCommentReplyOptions{}), repo.CreatePullReviewCommentReply)
|
||||||
})
|
})
|
||||||
m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
|
m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
|
||||||
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
|
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
|
||||||
|
|||||||
@ -208,6 +208,88 @@ func GetPullReviewComments(ctx *context.APIContext) {
|
|||||||
ctx.JSON(http.StatusOK, apiComments)
|
ctx.JSON(http.StatusOK, apiComments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatePullReviewCommentReply replies to a pull request review comment.
|
||||||
|
// The URL mirrors GitHub's endpoint, {index} is verified against the parent comment's pull request.
|
||||||
|
func CreatePullReviewCommentReply(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies repository repoCreatePullReviewCommentReply
|
||||||
|
// ---
|
||||||
|
// summary: Reply to a pull request review comment
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// 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 comment to reply to
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// required: true
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/CreatePullReviewCommentReplyOptions"
|
||||||
|
// responses:
|
||||||
|
// "201":
|
||||||
|
// "$ref": "#/responses/PullReviewComment"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
opts := web.GetForm(ctx).(*api.CreatePullReviewCommentReplyOptions)
|
||||||
|
|
||||||
|
parent := getPullReviewCommentToResolve(ctx)
|
||||||
|
if parent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parent.Issue.Index != ctx.PathParamInt64("index") {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parent.ReviewID == 0 {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "comment is not a review comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := pull_service.CreateCodeComment(ctx,
|
||||||
|
ctx.Doer, ctx.Repo.GitRepo, parent.Issue,
|
||||||
|
parent.Line, opts.Body, parent.TreePath,
|
||||||
|
false, parent.ReviewID,
|
||||||
|
"", nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := comment.LoadPoster(ctx); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comment.Issue = parent.Issue
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, convert.ToPullReviewComment(ctx, comment, ctx.Doer))
|
||||||
|
}
|
||||||
|
|
||||||
// ResolvePullReviewComment resolves a review comment in a pull request
|
// ResolvePullReviewComment resolves a review comment in a pull request
|
||||||
func ResolvePullReviewComment(ctx *context.APIContext) {
|
func ResolvePullReviewComment(ctx *context.APIContext) {
|
||||||
// swagger:operation POST /repos/{owner}/{repo}/pulls/comments/{id}/resolve repository repoResolvePullReviewComment
|
// swagger:operation POST /repos/{owner}/{repo}/pulls/comments/{id}/resolve repository repoResolvePullReviewComment
|
||||||
|
|||||||
@ -168,6 +168,9 @@ type swaggerParameterBodies struct {
|
|||||||
// in:body
|
// in:body
|
||||||
CreatePullReviewComment api.CreatePullReviewComment
|
CreatePullReviewComment api.CreatePullReviewComment
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
CreatePullReviewCommentReplyOptions api.CreatePullReviewCommentReplyOptions
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
SubmitPullReviewOptions api.SubmitPullReviewOptions
|
SubmitPullReviewOptions api.SubmitPullReviewOptions
|
||||||
|
|
||||||
|
|||||||
80
templates/swagger/v1_json.tmpl
generated
80
templates/swagger/v1_json.tmpl
generated
@ -14470,6 +14470,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Reply to a pull request review comment",
|
||||||
|
"operationId": "repoCreatePullReviewCommentReply",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "id of the review comment to reply to",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/CreatePullReviewCommentReplyOptions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"$ref": "#/responses/PullReviewComment"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/validationError"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"$ref": "#/responses/validationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/pulls/{index}/commits": {
|
"/repos/{owner}/{repo}/pulls/{index}/commits": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@ -24088,6 +24157,17 @@
|
|||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
},
|
},
|
||||||
|
"CreatePullReviewCommentReplyOptions": {
|
||||||
|
"description": "CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "Body"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
|
},
|
||||||
"CreatePullReviewOptions": {
|
"CreatePullReviewOptions": {
|
||||||
"description": "CreatePullReviewOptions are options to create a pull request review",
|
"description": "CreatePullReviewOptions are options to create a pull request review",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@ -29,6 +29,11 @@ import (
|
|||||||
|
|
||||||
func TestAPIPullReview(t *testing.T) {
|
func TestAPIPullReview(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
t.Run("General", testAPIPullReviewGeneral)
|
||||||
|
t.Run("CommentReply", testAPIPullReviewCommentReply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPIPullReviewGeneral(t *testing.T) {
|
||||||
pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||||
assert.NoError(t, pullIssue.LoadAttributes(t.Context()))
|
assert.NoError(t, pullIssue.LoadAttributes(t.Context()))
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID})
|
||||||
@ -526,6 +531,55 @@ func TestAPIPullReviewStayDismissed(t *testing.T) {
|
|||||||
pullIssue.ID, user8.ID, 2, 0, 3, false)
|
pullIssue.ID, user8.ID, 2, 0, 3, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAPIPullReviewCommentReply(t *testing.T) {
|
||||||
|
pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||||
|
require.NoError(t, pullIssue.LoadRepo(t.Context()))
|
||||||
|
require.NoError(t, pullIssue.LoadPullRequest(t.Context()))
|
||||||
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(t.Context(), pullIssue.Repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
commitID, err := gitRepo.GetRefCommitID(pullIssue.PullRequest.GetGitHeadRefName())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parent, err := pull_service.CreateCodeComment(t.Context(), doer, gitRepo, pullIssue, 1, "parent comment", "README.md", false, 0, commitID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotZero(t, parent.ReviewID)
|
||||||
|
|
||||||
|
repo := pullIssue.Repo
|
||||||
|
session := loginUser(t, doer.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/comments/%d/replies", repo.OwnerName, repo.Name, pullIssue.Index, parent.ID)
|
||||||
|
|
||||||
|
// happy path
|
||||||
|
req := NewRequestWithJSON(t, http.MethodPost, url, &api.CreatePullReviewCommentReplyOptions{Body: "the reply"}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var reply api.PullReviewComment
|
||||||
|
DecodeJSON(t, resp, &reply)
|
||||||
|
assert.Equal(t, "the reply", reply.Body)
|
||||||
|
assert.Equal(t, parent.ReviewID, reply.ReviewID)
|
||||||
|
assert.Equal(t, "README.md", reply.Path)
|
||||||
|
|
||||||
|
// empty body — caught by binding
|
||||||
|
req = NewRequestWithJSON(t, http.MethodPost, url, &api.CreatePullReviewCommentReplyOptions{}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||||
|
|
||||||
|
// reply to a non-existent comment
|
||||||
|
bad := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/comments/%d/replies", repo.OwnerName, repo.Name, pullIssue.Index, 999999)
|
||||||
|
req = NewRequestWithJSON(t, http.MethodPost, bad, &api.CreatePullReviewCommentReplyOptions{Body: "x"}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
|
// reply to a code comment that belongs to a different PR — 404
|
||||||
|
otherCodeComment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4, Type: issues_model.CommentTypeCode})
|
||||||
|
require.NotEqual(t, pullIssue.ID, otherCodeComment.IssueID)
|
||||||
|
wrongPR := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/comments/%d/replies", repo.OwnerName, repo.Name, pullIssue.Index, otherCodeComment.ID)
|
||||||
|
req = NewRequestWithJSON(t, http.MethodPost, wrongPR, &api.CreatePullReviewCommentReplyOptions{Body: "x"}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func reviewsCountCheck(t *testing.T, name string, issueID, reviewerID int64, expectedDismissed, expectedRequested, expectedTotal int, expectApproval bool) {
|
func reviewsCountCheck(t *testing.T, name string, issueID, reviewerID int64, expectedDismissed, expectedRequested, expectedTotal int, expectApproval bool) {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
unittest.AssertCountByCond(t, "review", builder.Eq{
|
unittest.AssertCountByCond(t, "review", builder.Eq{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user