be68e51898
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 29s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 57s
231 lines
8.3 KiB
Go
231 lines
8.3 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")
|
|
|
|
// ErrConflictingCommitIDs is returned when PostReview receives comments with
|
|
// differing non-empty CommitIDs. The GitHub API accepts only a single commit_id
|
|
// per review submission; callers must ensure all comments target the same commit.
|
|
var ErrConflictingCommitIDs = errors.New("comments contain conflicting commit IDs: all must target the same commit")
|
|
|
|
// 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).
|
|
//
|
|
// The GitHub API accepts a single commit_id per review submission. PostReview
|
|
// uses req.CommitID as the primary commit anchor. If req.CommitID is empty,
|
|
// it falls back to extracting from the first comment with a non-empty CommitID.
|
|
// If any subsequent comment specifies a different CommitID, PostReview returns
|
|
// ErrConflictingCommitIDs. Comments with an empty CommitID are allowed and
|
|
// inherit the review-level value.
|
|
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),
|
|
CommitID: req.CommitID,
|
|
}
|
|
|
|
// Build the payload in one pass. The GitHub API accepts a single commit_id
|
|
// per review. req.CommitID is the primary source; if empty, we extract from
|
|
// the first comment that supplies one. Reject if any comment disagrees with
|
|
// the resolved commit_id.
|
|
for _, comment := range req.Comments {
|
|
if comment.CommitID != "" {
|
|
if payload.CommitID == "" { // only reachable when req.CommitID is empty
|
|
payload.CommitID = comment.CommitID
|
|
} else if payload.CommitID != comment.CommitID {
|
|
return nil, ErrConflictingCommitIDs
|
|
}
|
|
// else: matching SHA is a no-op by design
|
|
}
|
|
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)
|
|
|
|
// nil body: the GitHub DELETE endpoint for reviews requires no request body.
|
|
_, 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 is required by the GitHub API for dismissal requests, even though
|
|
// "DISMISS" is the only valid value for this endpoint.
|
|
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
|
|
}
|
|
|
|
// SupersedeReviews marks prior reviews as superseded by dismissing them.
|
|
// This implements vcs.ReviewSuperseder for the GitHub adapter.
|
|
// The baseURL and sentinel parameters are unused for GitHub (dismissal is the mechanism).
|
|
func (c *Client) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, _, _ string) error {
|
|
var errs []error
|
|
for _, old := range oldReviews {
|
|
if err := c.DismissReview(ctx, owner, repo, prNumber, old.ID, "Superseded by new review"); err != nil {
|
|
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|