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" 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 }