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))
|
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 != "" {
|
if *updateExisting && *reviewerName != "" {
|
||||||
existingReviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
|
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 {
|
if err != nil {
|
||||||
log.Printf("Warning: could not list existing reviews: %v", err)
|
log.Printf("Warning: could not list existing reviews: %v", err)
|
||||||
} else {
|
} else {
|
||||||
for _, r := range reviews {
|
// Worst-wins: escalate if a sibling blocks (need own login from existing review)
|
||||||
if r.ID != posted.ID && r.User.Login == posted.User.Login && strings.Contains(r.Body, sentinel) {
|
ownLogin := ""
|
||||||
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, r.ID); err != nil {
|
existing := findOwnReview(existingReviews, sentinel)
|
||||||
log.Printf("Warning: could not delete old review %d: %v", r.ID, err)
|
if existing != nil {
|
||||||
} else {
|
ownLogin = existing.User.Login
|
||||||
log.Printf("Deleted stale review %d", r.ID)
|
}
|
||||||
}
|
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
|
if existing != nil {
|
||||||
// same user (same token, different role) has REQUEST_CHANGES,
|
if reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
|
||||||
// delete ours and re-post as REQUEST_CHANGES to maintain the block.
|
log.Printf("Review unchanged from previous run; skipping to preserve threads")
|
||||||
if event == "APPROVED" && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) {
|
return
|
||||||
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)
|
// Same state → PATCH in place
|
||||||
} else {
|
if existing.State == event {
|
||||||
_, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody, inlineComments)
|
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
|
||||||
if err != nil {
|
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 {
|
} 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.
|
// fetchFileContext fetches the full content of modified files from the PR branch.
|
||||||
@@ -463,12 +477,20 @@ func validateReviewerName(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldEscalate checks if the current APPROVED review should be escalated
|
// shouldEscalate checks if any sibling bot review from the same user
|
||||||
// to REQUEST_CHANGES because a sibling bot review (same user, different role)
|
// (different sentinel, same token) has REQUEST_CHANGES.
|
||||||
// already has REQUEST_CHANGES.
|
// ownLogin is the bot user login; if empty, escalation check is skipped.
|
||||||
func shouldEscalate(reviews []gitea.Review, postedID int64, postedLogin, ownSentinel string) bool {
|
// 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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,3 +516,13 @@ func reviewUnchanged(reviews []gitea.Review, newBody, newEvent, sentinel string)
|
|||||||
}
|
}
|
||||||
return false
|
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
|
name string
|
||||||
reviews []gitea.Review
|
reviews []gitea.Review
|
||||||
postedID int64
|
postedID int64
|
||||||
postedLogin string
|
ownLogin string
|
||||||
|
|
||||||
ownSentinel string
|
ownSentinel string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
@@ -63,7 +64,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
name: "no reviews",
|
name: "no reviews",
|
||||||
reviews: nil,
|
reviews: nil,
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -73,7 +74,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(101, "bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:security -->"),
|
makeReview(101, "bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:security -->"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
@@ -83,7 +84,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(101, "other-bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:gpt -->"),
|
makeReview(101, "other-bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:gpt -->"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -93,7 +94,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(101, "bot", "REQUEST_CHANGES", true, "old\n<!-- review-bot:security -->"),
|
makeReview(101, "bot", "REQUEST_CHANGES", true, "old\n<!-- review-bot:security -->"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -103,7 +104,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(101, "bot", "REQUEST_CHANGES", false, "old\n<!-- review-bot:sonnet -->"),
|
makeReview(101, "bot", "REQUEST_CHANGES", false, "old\n<!-- review-bot:sonnet -->"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -113,7 +114,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(101, "bot", "APPROVED", false, "good\n<!-- review-bot:security -->"),
|
makeReview(101, "bot", "APPROVED", false, "good\n<!-- review-bot:security -->"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -123,7 +124,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(101, "bot", "REQUEST_CHANGES", false, "please fix this"),
|
makeReview(101, "bot", "REQUEST_CHANGES", false, "please fix this"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -133,7 +134,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
makeReview(100, "bot", "REQUEST_CHANGES", false, "x\n<!-- review-bot:security -->"),
|
makeReview(100, "bot", "REQUEST_CHANGES", false, "x\n<!-- review-bot:security -->"),
|
||||||
},
|
},
|
||||||
postedID: 100,
|
postedID: 100,
|
||||||
postedLogin: "bot",
|
ownLogin: "bot",
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
ownSentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
@@ -141,7 +142,7 @@ func TestShouldEscalate(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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 {
|
if got != tc.want {
|
||||||
t.Errorf("shouldEscalate() = %v, want %v", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -353,3 +353,78 @@ func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number in
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TimelineEvent represents an entry from the issue timeline API.
|
||||||
|
type TimelineEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
User struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTimelineReviewCommentID finds the comment ID for a review body by
|
||||||
|
// scanning the issue timeline for a review event containing the sentinel.
|
||||||
|
func (c *Client) GetTimelineReviewCommentID(ctx context.Context, owner, repo string, number int, sentinel string) (int64, error) {
|
||||||
|
const pageSize = 50
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline?limit=%d&page=%d",
|
||||||
|
c.baseURL,
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
number,
|
||||||
|
pageSize,
|
||||||
|
page)
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get timeline (page %d): %w", page, err)
|
||||||
|
}
|
||||||
|
var events []TimelineEvent
|
||||||
|
if err := json.Unmarshal(body, &events); err != nil {
|
||||||
|
return 0, fmt.Errorf("parse timeline (page %d): %w", page, err)
|
||||||
|
}
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.Type == "review" && strings.Contains(ev.Body, sentinel) {
|
||||||
|
return ev.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(events) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no timeline event found with sentinel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditComment updates the body of an issue/review comment.
|
||||||
|
func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d",
|
||||||
|
c.baseURL,
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
commentID)
|
||||||
|
|
||||||
|
payload := struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{Body: newBody}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create edit request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("edit comment: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("edit comment failed (status %d): %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -426,3 +426,82 @@ func TestDeleteReview_Forbidden(t *testing.T) {
|
|||||||
t.Fatal("expected error for 403, got nil")
|
t.Fatal("expected error for 403, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditComment(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPatch {
|
||||||
|
t.Errorf("expected PATCH, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/repos/owner/repo/issues/comments/42" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload.Body != "updated body" {
|
||||||
|
t.Errorf("unexpected body: %s", payload.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id": 42, "body": "updated body"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EditComment() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditComment_Forbidden(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"message": "not allowed"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.EditComment(context.Background(), "owner", "repo", 42, "new body")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTimelineReviewCommentID(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/repos/owner/repo/issues/5/timeline" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Write([]byte(`[
|
||||||
|
{"id": 100, "type": "comment", "body": "random"},
|
||||||
|
{"id": 200, "type": "review", "body": "other review <!-- review-bot:gpt -->"},
|
||||||
|
{"id": 300, "type": "review", "body": "our review <!-- review-bot:sonnet -->"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTimelineReviewCommentID() error = %v", err)
|
||||||
|
}
|
||||||
|
if id != 300 {
|
||||||
|
t.Errorf("got id=%d, want 300", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`[{"id": 100, "type": "review", "body": "no match"}]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
_, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when sentinel not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user