Skip to content

Commit 1318f04

Browse files
silverwindclaude
andcommitted
Replace in_reply_to_id field with dedicated /replies endpoint
Drops the in_reply_to_id field from CreatePullReviewComment and removes the reply-handling branch in CreatePullReview. Replaces both with a dedicated POST /repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies endpoint matching GitHub's API exactly. The previous shape put in_reply_to_id on the review-creation endpoint, which GitHub does not — GitHub's reply path is the dedicated /replies endpoint. The dedicated endpoint also avoids the mixed-mode validation matrix (reply+approve, reply+body, reply+new-comment) that the in-band approach required. Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
1 parent 6b897e5 commit 1318f04

6 files changed

Lines changed: 220 additions & 100 deletions

File tree

modules/structs/pull_review.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,11 @@ type CreatePullReviewComment struct {
9292
OldLineNum int64 `json:"old_position"`
9393
// if comment to new file line or 0
9494
NewLineNum int64 `json:"new_position"`
95-
// if replying to an existing review comment, the comment ID to reply to
96-
InReplyToID int64 `json:"in_reply_to_id"`
95+
}
96+
97+
// CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment
98+
type CreatePullReviewCommentReplyOptions struct {
99+
Body string `json:"body" binding:"Required"`
97100
}
98101

99102
// SubmitPullReviewOptions are options to submit a pending pull request review

routers/api/v1/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,7 @@ func Routes() *web.Router {
13661366
m.Combo("/requested_reviewers", reqToken()).
13671367
Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests).
13681368
Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests)
1369+
m.Post("/comments/{id}/replies", reqToken(), mustNotBeArchived, bind(api.CreatePullReviewCommentReplyOptions{}), repo.CreatePullReviewCommentReply)
13691370
})
13701371
m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
13711372
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())

routers/api/v1/repo/pull_review.go

Lines changed: 108 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,105 @@ func GetPullReviewComments(ctx *context.APIContext) {
208208
ctx.JSON(http.StatusOK, apiComments)
209209
}
210210

211+
// CreatePullReviewCommentReply creates a reply to a pull request review comment
212+
func CreatePullReviewCommentReply(ctx *context.APIContext) {
213+
// swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies repository repoCreatePullReviewCommentReply
214+
// ---
215+
// summary: Reply to a pull request review comment
216+
// consumes:
217+
// - application/json
218+
// produces:
219+
// - application/json
220+
// parameters:
221+
// - name: owner
222+
// in: path
223+
// description: owner of the repo
224+
// type: string
225+
// required: true
226+
// - name: repo
227+
// in: path
228+
// description: name of the repo
229+
// type: string
230+
// required: true
231+
// - name: index
232+
// in: path
233+
// description: index of the pull request
234+
// type: integer
235+
// format: int64
236+
// required: true
237+
// - name: id
238+
// in: path
239+
// description: id of the review comment to reply to
240+
// type: integer
241+
// format: int64
242+
// required: true
243+
// - name: body
244+
// in: body
245+
// required: true
246+
// schema:
247+
// "$ref": "#/definitions/CreatePullReviewCommentReplyOptions"
248+
// responses:
249+
// "201":
250+
// "$ref": "#/responses/PullReviewComment"
251+
// "404":
252+
// "$ref": "#/responses/notFound"
253+
// "422":
254+
// "$ref": "#/responses/validationError"
255+
256+
opts := web.GetForm(ctx).(*api.CreatePullReviewCommentReplyOptions)
257+
258+
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
259+
if err != nil {
260+
if issues_model.IsErrPullRequestNotExist(err) {
261+
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
262+
} else {
263+
ctx.APIErrorInternal(err)
264+
}
265+
return
266+
}
267+
if err := pr.LoadIssue(ctx); err != nil {
268+
ctx.APIErrorInternal(err)
269+
return
270+
}
271+
272+
parent, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
273+
if err != nil {
274+
if issues_model.IsErrCommentNotExist(err) {
275+
ctx.APIErrorNotFound()
276+
} else {
277+
ctx.APIErrorInternal(err)
278+
}
279+
return
280+
}
281+
if parent.IssueID != pr.IssueID {
282+
ctx.APIErrorNotFound()
283+
return
284+
}
285+
if parent.Type != issues_model.CommentTypeCode || parent.ReviewID == 0 {
286+
ctx.APIError(http.StatusUnprocessableEntity, "comment is not a review comment")
287+
return
288+
}
289+
290+
comment, err := pull_service.CreateCodeComment(ctx,
291+
ctx.Doer, ctx.Repo.GitRepo, pr.Issue,
292+
parent.Line, opts.Body, parent.TreePath,
293+
false, // not pending — replies attach directly to the parent review
294+
parent.ReviewID, // reply target
295+
"", nil,
296+
)
297+
if err != nil {
298+
ctx.APIErrorInternal(err)
299+
return
300+
}
301+
if err := comment.LoadPoster(ctx); err != nil {
302+
ctx.APIErrorInternal(err)
303+
return
304+
}
305+
comment.Issue = pr.Issue
306+
307+
ctx.JSON(http.StatusCreated, convert.ToPullReviewComment(ctx, comment, ctx.Doer))
308+
}
309+
211310
// ResolvePullReviewComment resolves a review comment in a pull request
212311
func ResolvePullReviewComment(ctx *context.APIContext) {
213312
// swagger:operation POST /repos/{owner}/{repo}/pulls/comments/{id}/resolve repository repoResolvePullReviewComment
@@ -465,64 +564,22 @@ func CreatePullReview(ctx *context.APIContext) {
465564
opts.CommitID = headCommitID
466565
}
467566

468-
// resolve all in_reply_to IDs up front; replies in one request must target the same review
469-
var replyReviewID int64
470-
hasNonReplyComment := false
471-
for _, c := range opts.Comments {
472-
if c.InReplyToID == 0 {
473-
hasNonReplyComment = true
474-
continue
475-
}
476-
comment, err := issues_model.GetCommentByID(ctx, c.InReplyToID)
477-
if err != nil {
478-
if issues_model.IsErrCommentNotExist(err) {
479-
ctx.APIErrorNotFound()
480-
} else {
481-
ctx.APIErrorInternal(err)
482-
}
483-
return
484-
}
485-
if comment.IssueID != pr.IssueID {
486-
ctx.APIErrorNotFound()
487-
return
488-
}
489-
if comment.Type != issues_model.CommentTypeCode || comment.ReviewID == 0 {
490-
ctx.APIError(http.StatusUnprocessableEntity, "comment is not a review comment")
491-
return
492-
}
493-
if replyReviewID != 0 && comment.ReviewID != replyReviewID {
494-
ctx.APIError(http.StatusUnprocessableEntity, "replies must be to comments in the same review")
495-
return
496-
}
497-
replyReviewID = comment.ReviewID
498-
}
499-
500-
// a reply-only request attaches to the target review without creating a new one
501-
noNewContent := !hasNonReplyComment && strings.TrimSpace(opts.Body) == ""
502-
isDecisiveEvent := reviewType == issues_model.ReviewTypeApprove || reviewType == issues_model.ReviewTypeReject
503-
isReplyOnly := replyReviewID != 0 && noNewContent && !isDecisiveEvent
504-
505567
// create review comments
506568
for _, c := range opts.Comments {
507569
line := c.NewLineNum
508570
if c.OldLineNum > 0 {
509571
line = c.OldLineNum * -1
510572
}
511573

512-
var commentReviewID int64
513-
if c.InReplyToID != 0 {
514-
commentReviewID = replyReviewID
515-
}
516-
517574
if _, err := pull_service.CreateCodeComment(ctx,
518575
ctx.Doer,
519576
ctx.Repo.GitRepo,
520577
pr.Issue,
521578
line,
522579
c.Body,
523580
c.Path,
524-
commentReviewID == 0, // pending
525-
commentReviewID, // reply
581+
true, // pending review
582+
0, // no reply
526583
opts.CommitID,
527584
nil,
528585
); err != nil {
@@ -531,24 +588,15 @@ func CreatePullReview(ctx *context.APIContext) {
531588
}
532589
}
533590

534-
var review *issues_model.Review
535-
if isReplyOnly {
536-
review, err = issues_model.GetReviewByID(ctx, replyReviewID)
537-
if err != nil {
591+
// create review and associate all pending review comments
592+
review, _, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID, nil)
593+
if err != nil {
594+
if errors.Is(err, pull_service.ErrSubmitReviewOnClosedPR) {
595+
ctx.APIError(http.StatusUnprocessableEntity, err)
596+
} else {
538597
ctx.APIErrorInternal(err)
539-
return
540-
}
541-
} else {
542-
// create review and associate all pending review comments
543-
review, _, err = pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID, nil)
544-
if err != nil {
545-
if errors.Is(err, pull_service.ErrSubmitReviewOnClosedPR) {
546-
ctx.APIError(http.StatusUnprocessableEntity, err)
547-
} else {
548-
ctx.APIErrorInternal(err)
549-
}
550-
return
551598
}
599+
return
552600
}
553601

554602
// convert response

routers/api/v1/swagger/options.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ type swaggerParameterBodies struct {
168168
// in:body
169169
CreatePullReviewComment api.CreatePullReviewComment
170170

171+
// in:body
172+
CreatePullReviewCommentReplyOptions api.CreatePullReviewCommentReplyOptions
173+
171174
// in:body
172175
SubmitPullReviewOptions api.SubmitPullReviewOptions
173176

templates/swagger/v1_json.tmpl

Lines changed: 77 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)