0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-21 11:28:12 +01:00

feat(api): add in_reply_to field for pull review comment replies

Add `in_reply_to` field to `CreatePullReviewComment` that accepts a
comment ID, consistent with GitHub's API. The handler resolves the
comment ID to the internal review ID and creates the reply as a
non-pending comment so it threads correctly under the original review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
silverwind 2026-02-20 14:10:15 +01:00
parent 5e9b9b33d1
commit eb8adf3297
No known key found for this signature in database
GPG Key ID: 2E62B41C93869443
4 changed files with 93 additions and 2 deletions

View File

@ -89,6 +89,8 @@ type CreatePullReviewComment struct {
OldLineNum int64 `json:"old_position"`
// if comment to new file line or 0
NewLineNum int64 `json:"new_position"`
// if replying to an existing review comment, the comment ID to reply to
InReplyToID int64 `json:"in_reply_to_id"`
}
// SubmitPullReviewOptions are options to submit a pending pull request review

View File

@ -388,6 +388,34 @@ func DeletePullReview(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
// resolveInReplyTo resolves an in_reply_to comment ID to the review ID it
// belongs to. Returns 0 when commentID is 0 (not a reply).
func resolveInReplyTo(ctx *context.APIContext, commentID int64, pr *issues_model.PullRequest) (int64, error) {
if commentID == 0 {
return 0, nil
}
comment, err := issues_model.GetCommentByID(ctx, commentID)
if err != nil {
if issues_model.IsErrCommentNotExist(err) {
ctx.APIErrorNotFound("GetCommentByID", err)
} else {
ctx.APIErrorInternal(err)
}
return 0, err
}
if comment.IssueID != pr.IssueID {
err := issues_model.ErrCommentNotExist{ID: commentID}
ctx.APIErrorNotFound("GetCommentByID", err)
return 0, err
}
if comment.Type != issues_model.CommentTypeCode || comment.ReviewID == 0 {
err := errors.New("comment is not a review comment")
ctx.APIError(http.StatusUnprocessableEntity, err)
return 0, err
}
return comment.ReviewID, nil
}
// CreatePullReview create a review to a pull request
func CreatePullReview(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews repository repoCreatePullReview
@ -466,12 +494,21 @@ func CreatePullReview(ctx *context.APIContext) {
}
// create review comments
var lastReplyReviewID int64
for _, c := range opts.Comments {
line := c.NewLineNum
if c.OldLineNum > 0 {
line = c.OldLineNum * -1
}
replyReviewID, err := resolveInReplyTo(ctx, c.InReplyToID, pr)
if err != nil {
return
}
if replyReviewID != 0 {
lastReplyReviewID = replyReviewID
}
if _, err := pull_service.CreateCodeComment(ctx,
ctx.Doer,
ctx.Repo.GitRepo,
@ -479,8 +516,8 @@ func CreatePullReview(ctx *context.APIContext) {
line,
c.Body,
c.Path,
true, // pending review
0, // no reply
replyReviewID == 0, // pending
replyReviewID, // reply
opts.CommitID,
nil,
); err != nil {
@ -491,6 +528,10 @@ func CreatePullReview(ctx *context.APIContext) {
// create review and associate all pending review comments
review, _, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID, nil)
// reply-only requests have no pending review, fall back to the reply's review
if issues_model.IsContentEmptyErr(err) && lastReplyReviewID != 0 {
review, err = issues_model.GetReviewByID(ctx, lastReplyReviewID)
}
if err != nil {
if errors.Is(err, pull_service.ErrSubmitReviewOnClosedPR) {
ctx.APIError(http.StatusUnprocessableEntity, err)

View File

@ -23615,6 +23615,12 @@
"type": "string",
"x-go-name": "Body"
},
"in_reply_to_id": {
"description": "if replying to an existing review comment, the comment ID to reply to",
"type": "integer",
"format": "int64",
"x-go-name": "InReplyToID"
},
"new_position": {
"description": "if comment to new file line or 0",
"type": "integer",

View File

@ -530,6 +530,48 @@ func TestAPIPullReviewStayDismissed(t *testing.T) {
pullIssue.ID, user8.ID, 2, 0, 3, false)
}
func TestAPIPullReviewCommentReply(t *testing.T) {
defer tests.PrepareTestEnv(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)
// create an initial code comment to reply to
originalComment, err := pull_service.CreateCodeComment(t.Context(), doer, gitRepo, pullIssue, 1, "original comment", "README.md", false, 0, commitID, nil)
require.NoError(t, err)
session := loginUser(t, doer.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", pullIssue.Repo.OwnerName, pullIssue.Repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
Event: "COMMENT",
Comments: []api.CreatePullReviewComment{{
Path: "README.md", Body: "reply to original", NewLineNum: 1, InReplyToID: originalComment.ID,
}},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var review api.PullReview
DecodeJSON(t, resp, &review)
assert.EqualValues(t, "COMMENT", review.State)
// verify the reply is threaded under the original review
comments, err := issues_model.FindComments(t.Context(), &issues_model.FindCommentsOptions{
ReviewID: originalComment.ReviewID,
Type: issues_model.CommentTypeCode,
})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(comments), 2)
}
func reviewsCountCheck(t *testing.T, name string, issueID, reviewerID int64, expectedDismissed, expectedRequested, expectedTotal int, expectApproval bool) {
t.Run(name, func(t *testing.T) {
unittest.AssertCountByCond(t, "review", builder.Eq{