8b256360bf
Address review feedback from round-3 sonnet review: - PostReview doc comment now accurately describes vcs.ReviewEvent → GitHub wire-format string cast and notes nil-Comments omitempty behavior. - Rename 'expected' field to 'want' in TestTranslateGitHubReviewState to match the project's established naming convention.
195 lines
6.4 KiB
Go
195 lines
6.4 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
// ErrCannotDeleteSubmittedReview is returned when DeleteReview is called on
|
|
// a review that has already been submitted (APPROVED, REQUEST_CHANGES, COMMENT).
|
|
// GitHub only allows deletion of PENDING reviews. Callers that need to replace
|
|
// a submitted review should use DismissReview instead.
|
|
var ErrCannotDeleteSubmittedReview = errors.New("cannot delete submitted review: use DismissReview instead")
|
|
|
|
// postReviewRequest is the GitHub API request body for creating a review.
|
|
type postReviewRequest struct {
|
|
CommitID string `json:"commit_id,omitempty"`
|
|
Body string `json:"body"`
|
|
Event string `json:"event"`
|
|
Comments []reviewCommentEntry `json:"comments,omitempty"`
|
|
}
|
|
|
|
// reviewCommentEntry is a single inline comment in a review creation request.
|
|
type reviewCommentEntry struct {
|
|
Path string `json:"path"`
|
|
Position int `json:"position"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// reviewResponse is the GitHub API response for a review.
|
|
type reviewResponse struct {
|
|
ID int64 `json:"id"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
CommitID string `json:"commit_id"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
// dismissReviewRequest is the GitHub API request body for dismissing a review.
|
|
type dismissReviewRequest struct {
|
|
Message string `json:"message"`
|
|
Event string `json:"event"`
|
|
}
|
|
|
|
// translateGitHubReviewState translates a GitHub API review state to the
|
|
// canonical vcs.Review.State value.
|
|
func translateGitHubReviewState(state string) string {
|
|
switch state {
|
|
case "CHANGES_REQUESTED":
|
|
return "REQUEST_CHANGES"
|
|
case "COMMENTED":
|
|
return "COMMENT"
|
|
default:
|
|
// States like APPROVED, DISMISSED, and PENDING pass through unchanged
|
|
// as they already match the canonical vcs representation. PENDING appears
|
|
// on draft reviews that have not yet been submitted via the GitHub UI or API.
|
|
return state
|
|
}
|
|
}
|
|
|
|
// PostReview submits a review on a pull request.
|
|
//
|
|
// The vcs.ReviewEvent constants (ReviewEventApprove, ReviewEventRequestChanges,
|
|
// ReviewEventComment) have string values that match GitHub's wire-format event
|
|
// strings (APPROVE, REQUEST_CHANGES, COMMENT), so Event is cast directly to
|
|
// string without translation.
|
|
//
|
|
// ReviewComment.Position maps directly to the GitHub API position field.
|
|
// When req.Comments is empty, the payload omits the comments field entirely
|
|
// (via the omitempty tag on postReviewRequest.Comments).
|
|
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 := postReviewRequest{
|
|
Body: req.Body,
|
|
Event: string(req.Event),
|
|
}
|
|
|
|
// Populate CommitID from the first comment and build the payload in one pass.
|
|
// All comments in a single review share the same commit_id.
|
|
for _, comment := range req.Comments {
|
|
if payload.CommitID == "" && comment.CommitID != "" {
|
|
payload.CommitID = comment.CommitID
|
|
}
|
|
payload.Comments = append(payload.Comments, reviewCommentEntry{
|
|
Path: comment.Path,
|
|
Position: comment.Position,
|
|
Body: comment.Body,
|
|
})
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal review request: %w", err)
|
|
}
|
|
|
|
body, err := c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
|
|
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: translateGitHubReviewState(resp.State),
|
|
CommitID: resp.CommitID,
|
|
}, nil
|
|
}
|
|
|
|
// ListReviews retrieves all reviews for a pull request.
|
|
// GitHub review states are translated to canonical vcs values.
|
|
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list reviews: %w", err)
|
|
}
|
|
|
|
var responses []reviewResponse
|
|
if err := json.Unmarshal(body, &responses); err != nil {
|
|
return nil, fmt.Errorf("parse reviews response: %w", err)
|
|
}
|
|
|
|
reviews := make([]vcs.Review, len(responses))
|
|
for i, r := range responses {
|
|
reviews[i] = vcs.Review{
|
|
ID: r.ID,
|
|
Body: r.Body,
|
|
User: vcs.UserInfo{Login: r.User.Login},
|
|
State: translateGitHubReviewState(r.State),
|
|
CommitID: r.CommitID,
|
|
}
|
|
}
|
|
return reviews, nil
|
|
}
|
|
|
|
// DeleteReview deletes a pull request review.
|
|
// Only PENDING reviews can be deleted; attempting to delete a submitted review
|
|
// (APPROVED, CHANGES_REQUESTED, or COMMENTED per GitHub API naming) returns
|
|
// ErrCannotDeleteSubmittedReview.
|
|
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.doRequestWithBody(ctx, http.MethodDelete, reqURL, nil)
|
|
if err != nil {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.StatusCode == 422 {
|
|
return fmt.Errorf("delete review: %w", ErrCannotDeleteSubmittedReview)
|
|
}
|
|
return fmt.Errorf("delete review: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DismissReview dismisses a submitted review on a pull request.
|
|
// This is the correct way to "remove" a submitted review (APPROVED, REQUEST_CHANGES).
|
|
// GitHub does not allow deleting submitted reviews — they must be dismissed.
|
|
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,
|
|
Event: "DISMISS",
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal dismiss request: %w", err)
|
|
}
|
|
|
|
_, err = c.doRequestWithBody(ctx, http.MethodPut, reqURL, data)
|
|
if err != nil {
|
|
return fmt.Errorf("dismiss review: %w", err)
|
|
}
|
|
return nil
|
|
}
|