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
+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)
}
}
})
}
}