83a538f138
- Document --gitea-url/--vcs-url last-one-wins behavior when both flags are passed simultaneously (sonnet MINOR #1) - Move doJSONRequest from github/reviews.go to github/client.go where other HTTP helpers live (sonnet MINOR #2) - Return joined error from supersedeOldReviews GitHub case instead of silently swallowing DismissReview failures (sonnet MINOR #3) - Fix evaluateCIStatus to distinguish 'all checks passed' from 'no failures (N pending)' to avoid misleading status (gpt MINOR #2) - Extract reviewsPerPage and maxReviewPages named constants for ListReviews pagination (gpt NIT #3)
191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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"
|
|
case vcs.ReviewEventComment:
|
|
return "COMMENT"
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|