ae94ba7be6
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 38s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m31s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m38s
Address sonnet-review feedback on PR #106: - Document that the type assertion in supersedeOldReviews is guaranteed to succeed given the caller's provider switch, with the !ok branch guarding against future refactors (comment 18889). - Clarify that vcsURL is only used in the Gitea path for constructing review permalink URLs (comment 18890). - Add note explaining why the page-limit warning in ListReviews only fires when the final page is full, confirming the logic is intentional (comment 18891).
199 lines
6.1 KiB
Go
199 lines
6.1 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
const (
|
|
// reviewsPerPage is the number of reviews to fetch per API page.
|
|
reviewsPerPage = 100
|
|
// maxReviewPages is the maximum number of pages to paginate through
|
|
// when listing reviews. Acts as a safeguard against infinite pagination.
|
|
maxReviewPages = 100
|
|
)
|
|
|
|
// reviewResponse is the GitHub API response for a pull request review.
|
|
type reviewResponse struct {
|
|
ID int64 `json:"id"`
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
State string `json:"state"`
|
|
CommitID string `json:"commit_id"`
|
|
}
|
|
|
|
// reviewCreateRequest is the GitHub API request body for creating a pull request review.
|
|
type reviewCreateRequest struct {
|
|
Body string `json:"body"`
|
|
Event string `json:"event"`
|
|
Comments []reviewCommentCreate `json:"comments,omitempty"`
|
|
CommitID string `json:"commit_id,omitempty"`
|
|
}
|
|
|
|
// reviewCommentCreate is a single inline comment in a review creation request.
|
|
type reviewCommentCreate struct {
|
|
Path string `json:"path"`
|
|
Position int `json:"position"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// dismissReviewRequest is the GitHub API request body for dismissing a review.
|
|
type dismissReviewRequest struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// userResponse is the GitHub API response for the authenticated user.
|
|
type userResponse struct {
|
|
Login string `json:"login"`
|
|
}
|
|
|
|
// translateReviewEvent converts a vcs.ReviewEvent to the GitHub API event string.
|
|
func translateReviewEvent(event vcs.ReviewEvent) string {
|
|
switch event {
|
|
case vcs.ReviewEventApprove:
|
|
return "APPROVE"
|
|
case vcs.ReviewEventRequestChanges:
|
|
return "REQUEST_CHANGES"
|
|
default:
|
|
return "COMMENT"
|
|
}
|
|
}
|
|
|
|
// PostReview creates a new review on a pull request.
|
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
|
|
payload := reviewCreateRequest{
|
|
Body: req.Body,
|
|
Event: translateReviewEvent(req.Event),
|
|
}
|
|
|
|
for _, comment := range req.Comments {
|
|
rc := reviewCommentCreate{
|
|
Path: comment.Path,
|
|
Position: comment.Position,
|
|
Body: comment.Body,
|
|
}
|
|
payload.Comments = append(payload.Comments, rc)
|
|
// Use CommitID from the first comment that has one.
|
|
// All comments in a single review are expected to reference the same commit.
|
|
if payload.CommitID == "" && comment.CommitID != "" {
|
|
payload.CommitID = comment.CommitID
|
|
}
|
|
}
|
|
|
|
body, err := c.doJSONRequest(ctx, http.MethodPost, reqURL, payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("post review: %w", err)
|
|
}
|
|
|
|
var resp reviewResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("parse review response: %w", err)
|
|
}
|
|
|
|
return &vcs.Review{
|
|
ID: resp.ID,
|
|
Body: resp.Body,
|
|
User: vcs.UserInfo{Login: resp.User.Login},
|
|
State: resp.State,
|
|
CommitID: resp.CommitID,
|
|
}, nil
|
|
}
|
|
|
|
// ListReviews lists all reviews on a pull request.
|
|
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
|
|
var allReviews []vcs.Review
|
|
|
|
for page := 1; page <= maxReviewPages; page++ {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=%d&page=%d",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewsPerPage, page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list reviews page %d: %w", page, err)
|
|
}
|
|
var reviews []reviewResponse
|
|
if err := json.Unmarshal(body, &reviews); err != nil {
|
|
return nil, fmt.Errorf("parse reviews JSON: %w", err)
|
|
}
|
|
if len(reviews) == 0 {
|
|
break
|
|
}
|
|
for _, r := range reviews {
|
|
allReviews = append(allReviews, vcs.Review{
|
|
ID: r.ID,
|
|
Body: r.Body,
|
|
User: vcs.UserInfo{Login: r.User.Login},
|
|
State: r.State,
|
|
CommitID: r.CommitID,
|
|
})
|
|
}
|
|
if len(reviews) < reviewsPerPage {
|
|
break
|
|
}
|
|
// NOTE: This warning only fires when the final page was full (the short-page
|
|
// break above did not trigger), meaning additional reviews likely exist beyond
|
|
// our page limit. The loop naturally exits after this iteration since page
|
|
// increments past maxReviewPages.
|
|
if page == maxReviewPages {
|
|
slog.Warn("ListReviews hit page limit; results may be truncated",
|
|
"owner", owner, "repo", repo, "pr", number,
|
|
"maxPages", maxReviewPages, "reviewsFetched", len(allReviews))
|
|
}
|
|
}
|
|
|
|
return allReviews, nil
|
|
}
|
|
|
|
// DeleteReview permanently deletes a review from a pull request.
|
|
// Use DismissReview instead when the review should remain visible but marked as dismissed
|
|
// (e.g., superseding an outdated review while preserving history).
|
|
func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
|
|
_, err := c.doRequest(ctx, http.MethodDelete, reqURL, "")
|
|
if err != nil {
|
|
return fmt.Errorf("delete review: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DismissReview dismisses a review on a pull request with a message.
|
|
func (c *Client) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d/dismissals",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
|
|
|
|
payload := dismissReviewRequest{
|
|
Message: message,
|
|
}
|
|
|
|
_, err := c.doJSONRequest(ctx, http.MethodPut, reqURL, payload)
|
|
if err != nil {
|
|
return fmt.Errorf("dismiss review: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAuthenticatedUser returns the login name of the authenticated user.
|
|
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/user", c.baseURL)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
|
}
|
|
var resp userResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return "", fmt.Errorf("parse user response: %w", err)
|
|
}
|
|
return resp.Login, nil
|
|
}
|