30798ff023
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 46s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 59s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m6s
- client.go: fix misleading timer.Stop() comment (finding #1) - pr.go: document all-or-nothing semantics for GetCommitStatuses when check-runs endpoint fails after statuses succeed (finding #2) - files.go: include both array and object unmarshal errors in ListContents fallback error message (finding #3) - pr.go: expand mapCheckRunStatus comment to explain non-blocking policy decision (finding #4)
213 lines
6.8 KiB
Go
213 lines
6.8 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
// pullRequestResponse is the GitHub API response for a pull request.
|
|
type pullRequestResponse struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Head struct {
|
|
SHA string `json:"sha"`
|
|
Ref string `json:"ref"`
|
|
} `json:"head"`
|
|
Base struct {
|
|
Ref string `json:"ref"`
|
|
} `json:"base"`
|
|
}
|
|
|
|
// changedFileResponse is the GitHub API response for a changed file in a PR.
|
|
type changedFileResponse struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"`
|
|
Patch string `json:"patch"`
|
|
}
|
|
|
|
// commitStatusResponse is the GitHub combined status API response.
|
|
type commitStatusResponse struct {
|
|
Statuses []struct {
|
|
Context string `json:"context"`
|
|
State string `json:"state"`
|
|
Description string `json:"description"`
|
|
TargetURL string `json:"target_url"`
|
|
} `json:"statuses"`
|
|
}
|
|
|
|
// checkRunsResponse is the GitHub check runs API response.
|
|
type checkRunsResponse struct {
|
|
CheckRuns []struct {
|
|
Name string `json:"name"`
|
|
Conclusion *string `json:"conclusion"`
|
|
Status string `json:"status"`
|
|
HTMLURL string `json:"html_url"`
|
|
} `json:"check_runs"`
|
|
}
|
|
|
|
// GetPullRequest fetches PR metadata.
|
|
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcs.PullRequest, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch PR: %w", err)
|
|
}
|
|
var resp pullRequestResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("parse PR JSON: %w", err)
|
|
}
|
|
return &vcs.PullRequest{
|
|
Number: resp.Number,
|
|
Title: resp.Title,
|
|
Body: resp.Body,
|
|
Head: vcs.HeadRef{SHA: resp.Head.SHA, Ref: resp.Head.Ref},
|
|
Base: vcs.BaseRef{Ref: resp.Base.Ref},
|
|
}, nil
|
|
}
|
|
|
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
|
// Uses Accept: application/vnd.github.diff to get raw diff text.
|
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
body, err := c.doRequest(ctx, http.MethodGet, reqURL, "application/vnd.github.diff")
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch diff: %w", err)
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
// maxPages is the upper bound on pagination loops to prevent unbounded iteration
|
|
// in case the server returns a full page indefinitely.
|
|
const maxPages = 100
|
|
|
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
|
// Paginates through all pages (100 per page) to collect all files.
|
|
// Returns nil (not an empty slice) when the PR has no changed files.
|
|
// Callers can safely range over or check len() on a nil slice.
|
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcs.ChangedFile, error) {
|
|
var allFiles []vcs.ChangedFile
|
|
|
|
for page := 1; page <= maxPages; page++ {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=100&page=%d",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch PR files page %d: %w", page, err)
|
|
}
|
|
var files []changedFileResponse
|
|
if err := json.Unmarshal(body, &files); err != nil {
|
|
return nil, fmt.Errorf("parse PR files JSON: %w", err)
|
|
}
|
|
if len(files) == 0 {
|
|
break
|
|
}
|
|
for _, f := range files {
|
|
allFiles = append(allFiles, vcs.ChangedFile{
|
|
Filename: f.Filename,
|
|
Status: f.Status,
|
|
Patch: f.Patch,
|
|
})
|
|
}
|
|
if len(files) < 100 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return allFiles, nil
|
|
}
|
|
|
|
// GetCommitStatuses fetches both commit statuses and check runs for a SHA,
|
|
// merging them into a unified []vcs.CommitStatus slice.
|
|
// Returns nil (not an empty slice) when there are no statuses or check runs.
|
|
// If the commit statuses endpoint fails (e.g. 404 for an unknown SHA), the
|
|
// function returns immediately without attempting the check-runs endpoint.
|
|
// If the check-runs endpoint fails after statuses were fetched successfully,
|
|
// the function returns an error (not a partial result) so callers always get
|
|
// either a complete view or a clear error signal.
|
|
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcs.CommitStatus, error) {
|
|
var result []vcs.CommitStatus
|
|
|
|
// Fetch commit statuses
|
|
statusURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
|
statusBody, err := c.doGet(ctx, statusURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch commit statuses: %w", err)
|
|
}
|
|
var statusResp commitStatusResponse
|
|
if err := json.Unmarshal(statusBody, &statusResp); err != nil {
|
|
return nil, fmt.Errorf("parse commit statuses JSON: %w", err)
|
|
}
|
|
for _, s := range statusResp.Statuses {
|
|
result = append(result, vcs.CommitStatus{
|
|
Context: s.Context,
|
|
Status: s.State,
|
|
Description: s.Description,
|
|
TargetURL: s.TargetURL,
|
|
})
|
|
}
|
|
|
|
// Fetch check runs (paginated)
|
|
for checkPage := 1; checkPage <= maxPages; checkPage++ {
|
|
checkURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs?per_page=100&page=%d",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha), checkPage)
|
|
checkBody, err := c.doGet(ctx, checkURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch check runs page %d: %w", checkPage, err)
|
|
}
|
|
var checkResp checkRunsResponse
|
|
if err := json.Unmarshal(checkBody, &checkResp); err != nil {
|
|
return nil, fmt.Errorf("parse check runs JSON: %w", err)
|
|
}
|
|
for _, cr := range checkResp.CheckRuns {
|
|
result = append(result, vcs.CommitStatus{
|
|
Context: cr.Name,
|
|
Status: mapCheckRunStatus(cr.Conclusion),
|
|
Description: derefString(cr.Conclusion),
|
|
TargetURL: cr.HTMLURL,
|
|
})
|
|
}
|
|
if len(checkResp.CheckRuns) < 100 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// mapCheckRunStatus maps a check run conclusion to a vcs.CommitStatus status string.
|
|
// Conclusion alone determines the mapped state: nil conclusion means the run is
|
|
// still in progress (pending), regardless of the status field value.
|
|
func mapCheckRunStatus(conclusion *string) string {
|
|
if conclusion == nil {
|
|
// Still running or queued
|
|
return "pending"
|
|
}
|
|
switch *conclusion {
|
|
case "success":
|
|
return "success"
|
|
case "failure", "action_required", "timed_out":
|
|
return "failure"
|
|
case "cancelled", "skipped", "neutral":
|
|
return "success" // non-blocking: these do not indicate a blocking failure per GitHub check suite semantics
|
|
case "stale", "waiting":
|
|
return "pending"
|
|
default:
|
|
return "pending"
|
|
}
|
|
}
|
|
|
|
// derefString safely dereferences a string pointer, returning empty string if nil.
|
|
func derefString(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|