feat: edit-in-place review updates (no more delete)
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m37s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m43s

Replace the delete-and-repost strategy with edit-in-place:

1. No existing review → POST new (first run)
2. Same state, same body → skip entirely (threads preserved)
3. Same state, body changed → PATCH body in place via timeline API
4. State change needed → PATCH old body to "Superseded", POST new

This preserves conversation threads on inline comments. Replies to
findings are never lost. The only time a new review is posted is on
first run or when the state transitions (APPROVED ↔ REQUEST_CHANGES).

New Gitea client methods:
- EditComment: PATCH /repos/{owner}/{repo}/issues/comments/{id}
- GetTimelineReviewCommentID: finds the comment ID for a review body
  by scanning the issue timeline for the sentinel

Also simplifies shouldEscalate: removes the login parameter requirement
for pre-posting scenarios (uses findOwnReview to get login from existing
review instead).

Tests: findOwnReview (4 cases), EditComment (2 cases),
GetTimelineReviewCommentID (2 cases), shouldEscalate (8 cases updated).
This commit is contained in:
Rodin
2026-05-01 22:46:45 -07:00
parent 1c2292265b
commit e261976dd8
4 changed files with 298 additions and 51 deletions
+73 -41
View File
@@ -257,56 +257,70 @@ func main() {
log.Printf("Attaching %d inline comments", len(inlineComments))
}
// Check if existing review is unchanged — skip to preserve conversation threads
// --- Review update strategy ---
// 1. No existing review → POST new
// 2. Existing review, same state → PATCH body in place (preserves threads)
// 3. Existing review, state change → PATCH old to "Superseded", POST new
if *updateExisting && *reviewerName != "" {
existingReviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err == nil && reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
log.Printf("Review unchanged from previous run; skipping to preserve threads")
return
}
}
log.Printf("Posting review (event=%s)...", event)
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
if err != nil {
log.Fatalf("Failed to post review: %v", err)
}
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login)
// Delete stale reviews from this bot using sentinel matching
if *updateExisting && *reviewerName != "" {
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err != nil {
log.Printf("Warning: could not list existing reviews: %v", err)
} else {
for _, r := range reviews {
if r.ID != posted.ID && r.User.Login == posted.User.Login && strings.Contains(r.Body, sentinel) {
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, r.ID); err != nil {
log.Printf("Warning: could not delete old review %d: %v", r.ID, err)
} else {
log.Printf("Deleted stale review %d", r.ID)
}
}
// Worst-wins: escalate if a sibling blocks (need own login from existing review)
ownLogin := ""
existing := findOwnReview(existingReviews, sentinel)
if existing != nil {
ownLogin = existing.User.Login
}
if event == "APPROVED" && shouldEscalate(existingReviews, 0, ownLogin, sentinel) {
log.Printf("Sibling review has REQUEST_CHANGES; escalating to REQUEST_CHANGES")
event = "REQUEST_CHANGES"
}
// Worst-wins: if we posted APPROVE but a sibling review from the
// same user (same token, different role) has REQUEST_CHANGES,
// delete ours and re-post as REQUEST_CHANGES to maintain the block.
if event == "APPROVED" && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) {
log.Printf("Sibling review has REQUEST_CHANGES; escalating")
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID); err != nil {
log.Printf("Warning: could not delete review for escalation: %v", err)
} else {
_, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody, inlineComments)
if existing != nil {
if reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
log.Printf("Review unchanged from previous run; skipping to preserve threads")
return
}
// Same state → PATCH in place
if existing.State == event {
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err != nil {
log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err)
log.Printf("Warning: could not find review comment ID, falling back to new post: %v", err)
} else {
log.Printf("Review escalated to REQUEST_CHANGES")
if err := giteaClient.EditComment(ctx, owner, repoName, commentID, reviewBody); err != nil {
log.Printf("Warning: could not edit review, falling back to new post: %v", err)
} else {
log.Printf("Review updated in place (comment_id=%d)", commentID)
return
}
}
} else {
// State change → mark old as superseded, post new below
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err != nil {
log.Printf("Warning: could not find old review comment ID: %v", err)
} else {
supersededBody := fmt.Sprintf("~~*This review has been superseded by a newer review below.*~~\n\n%s", sentinel)
if err := giteaClient.EditComment(ctx, owner, repoName, commentID, supersededBody); err != nil {
log.Printf("Warning: could not mark old review as superseded: %v", err)
} else {
log.Printf("Marked old review as superseded (state was %s, now %s)", existing.State, event)
}
}
}
}
}
}
// POST new review (first run, or state transition fallthrough)
log.Printf("Posting review (event=%s)...", event)
_, err = giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
if err != nil {
log.Fatalf("Failed to post review: %v", err)
}
log.Printf("Review posted successfully")
}
// fetchFileContext fetches the full content of modified files from the PR branch.
@@ -463,12 +477,20 @@ func validateReviewerName(name string) error {
return nil
}
// shouldEscalate checks if the current APPROVED review should be escalated
// to REQUEST_CHANGES because a sibling bot review (same user, different role)
// already has REQUEST_CHANGES.
func shouldEscalate(reviews []gitea.Review, postedID int64, postedLogin, ownSentinel string) bool {
// shouldEscalate checks if any sibling bot review from the same user
// (different sentinel, same token) has REQUEST_CHANGES.
// ownLogin is the bot user login; if empty, escalation check is skipped.
// postedID is excluded from consideration (0 means no exclusion needed).
func shouldEscalate(reviews []gitea.Review, postedID int64, ownLogin, ownSentinel string) bool {
if ownLogin == "" {
return false
}
for _, r := range reviews {
if r.ID != postedID && !r.Stale && r.User.Login == postedLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
if r.ID == postedID || r.Stale {
continue
}
// Sibling = same user, has a review-bot sentinel, but not OUR sentinel
if r.User.Login == ownLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
return true
}
}
@@ -494,3 +516,13 @@ func reviewUnchanged(reviews []gitea.Review, newBody, newEvent, sentinel string)
}
return false
}
// findOwnReview locates a review matching the given sentinel in its body.
func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
for i := range reviews {
if strings.Contains(reviews[i].Body, sentinel) {
return &reviews[i]
}
}
return nil
}
+71 -10
View File
@@ -55,7 +55,8 @@ func TestShouldEscalate(t *testing.T) {
name string
reviews []gitea.Review
postedID int64
postedLogin string
ownLogin string
ownSentinel string
want bool
}{
@@ -63,7 +64,7 @@ func TestShouldEscalate(t *testing.T) {
name: "no reviews",
reviews: nil,
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -73,7 +74,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(101, "bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:security -->"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: true,
},
@@ -83,7 +84,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(101, "other-bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:gpt -->"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -93,7 +94,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(101, "bot", "REQUEST_CHANGES", true, "old\n<!-- review-bot:security -->"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -103,7 +104,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(101, "bot", "REQUEST_CHANGES", false, "old\n<!-- review-bot:sonnet -->"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -113,7 +114,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(101, "bot", "APPROVED", false, "good\n<!-- review-bot:security -->"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -123,7 +124,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(101, "bot", "REQUEST_CHANGES", false, "please fix this"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -133,7 +134,7 @@ func TestShouldEscalate(t *testing.T) {
makeReview(100, "bot", "REQUEST_CHANGES", false, "x\n<!-- review-bot:security -->"),
},
postedID: 100,
postedLogin: "bot",
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
@@ -141,7 +142,7 @@ func TestShouldEscalate(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := shouldEscalate(tc.reviews, tc.postedID, tc.postedLogin, tc.ownSentinel)
got := shouldEscalate(tc.reviews, tc.postedID, tc.ownLogin, tc.ownSentinel)
if got != tc.want {
t.Errorf("shouldEscalate() = %v, want %v", got, tc.want)
}
@@ -227,3 +228,63 @@ func TestReviewUnchanged(t *testing.T) {
})
}
}
func TestFindOwnReview(t *testing.T) {
tests := []struct {
name string
reviews []gitea.Review
sentinel string
wantID int64
wantNil bool
}{
{
name: "no reviews",
reviews: nil,
sentinel: "<!-- review-bot:sonnet -->",
wantNil: true,
},
{
name: "found by sentinel",
reviews: []gitea.Review{
makeReview(42, "bot", "APPROVED", false, "review body\n<!-- review-bot:sonnet -->"),
},
sentinel: "<!-- review-bot:sonnet -->",
wantID: 42,
},
{
name: "wrong sentinel",
reviews: []gitea.Review{
makeReview(42, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
},
sentinel: "<!-- review-bot:sonnet -->",
wantNil: true,
},
{
name: "multiple reviews, returns first match",
reviews: []gitea.Review{
makeReview(10, "bot", "APPROVED", false, "old\n<!-- review-bot:gpt -->"),
makeReview(20, "bot", "APPROVED", false, "new\n<!-- review-bot:sonnet -->"),
},
sentinel: "<!-- review-bot:sonnet -->",
wantID: 20,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := findOwnReview(tc.reviews, tc.sentinel)
if tc.wantNil {
if got != nil {
t.Errorf("findOwnReview() = %v, want nil", got)
}
} else {
if got == nil {
t.Fatal("findOwnReview() = nil, want non-nil")
}
if got.ID != tc.wantID {
t.Errorf("findOwnReview().ID = %d, want %d", got.ID, tc.wantID)
}
}
})
}
}