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
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:
+73
-41
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user