package github import ( "context" "encoding/json" "errors" "fmt" "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 "APPROVED": return "APPROVED" case "CHANGES_REQUESTED": return "REQUEST_CHANGES" case "COMMENTED": return "COMMENT" case "DISMISSED": return "DISMISSED" default: return state } } // PostReview submits a review on a pull request. // The ReviewRequest.Event values (APPROVE, REQUEST_CHANGES, COMMENT) are sent // directly — they match GitHub's canonical event strings. // ReviewComment.Position maps directly to the GitHub API position field. 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 if available. // All comments in a single review share the same commit_id. for _, comment := range req.Comments { if comment.CommitID != "" { payload.CommitID = comment.CommitID break } } for _, comment := range req.Comments { 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, "POST", 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, COMMENTED) 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, "DELETE", reqURL, nil) if err != nil { var apiErr *APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 422 { return 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, "PUT", reqURL, data) if err != nil { return fmt.Errorf("dismiss review: %w", err) } return nil }