1fcc0b738a
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m30s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m8s
- SetHTTPClient(nil): preserve CheckRedirect auth-stripping policy instead of restoring a plain http.Client that loses cross-host protection. - Authorization header: add comment documenting why Bearer scheme is correct (OAuth2 standard, works for both classic PATs and fine-grained tokens). - Retry-After parsing: support HTTP-date format (RFC 7231) in addition to integer seconds. GitHub only sends integers today, but the implementation is now spec-compliant. - escapePath dot-segment removal: document the behavior in public API doc comments for ListContents and GetFileContentAtRef so callers are aware without reading the internal helper.
240 lines
7.5 KiB
Go
240 lines
7.5 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 {
|
|
State string `json:"state"`
|
|
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.
|
|
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
|
|
}
|
|
|
|
// GetFileContentAtRef fetches a file at a specific ref from a repo.
|
|
// If ref is empty, the query parameter is omitted (uses default branch).
|
|
//
|
|
// Note: dot-segments ("." and "..") in the path are silently removed to
|
|
// prevent path traversal. This means a path like "foo/../bar" resolves
|
|
// to "foo/bar" rather than "bar".
|
|
func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
|
|
if ref != "" {
|
|
reqURL += "?ref=" + url.QueryEscape(ref)
|
|
}
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch file %s: %w", path, err)
|
|
}
|
|
var resp struct {
|
|
Content string `json:"content"`
|
|
Encoding string `json:"encoding"`
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return "", fmt.Errorf("parse file content JSON: %w", err)
|
|
}
|
|
if resp.Encoding != "base64" {
|
|
return "", fmt.Errorf("unexpected encoding %q for file %s", resp.Encoding, path)
|
|
}
|
|
decoded, err := decodeBase64Content(resp.Content)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode base64 content for %s: %w", path, err)
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
// GetCommitStatuses fetches both commit statuses and check runs for a SHA,
|
|
// merging them into a unified []vcs.CommitStatus slice.
|
|
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, cr.Status),
|
|
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.
|
|
// The second parameter (check run status field, e.g. "completed", "in_progress") is
|
|
// unused because 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) 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
|
|
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
|
|
}
|