d545abe392
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 40s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m38s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m49s
PostReview, DeleteReview, and RequestReviewer were calling c.httpClient.Do directly, bypassing the scheme check in doRequest that rejects http:// URLs unless AllowInsecureHTTP is explicitly enabled. Introduce doRequestWithBody(ctx, method, url, body) with the same HTTPS guard, and refactor all three write methods to use it. This ensures tokens are never sent over plaintext regardless of which API path is exercised. Add scheme validation tests for each method.
832 lines
28 KiB
Go
832 lines
28 KiB
Go
// Package github provides a client for the GitHub API.
|
|
// It supports pull request operations, file content retrieval,
|
|
// and review submission for both github.com and GitHub Enterprise.
|
|
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.github.com"
|
|
|
|
// maxRetryAttempts is the number of times doRequest will attempt a request.
|
|
maxRetryAttempts = 3
|
|
|
|
// maxRetryAfter caps the maximum delay from a Retry-After header to prevent
|
|
// a server from stalling the client indefinitely.
|
|
maxRetryAfter = 60 * time.Second
|
|
|
|
// maxErrorBodyBytes limits how much of an error response body we read
|
|
// to protect against malicious servers sending unbounded data.
|
|
maxErrorBodyBytes = 64 * 1024 // 64 KB
|
|
|
|
// maxResponseBodyBytes limits how much of a successful response body we read
|
|
// for defense-in-depth against servers returning excessively large payloads.
|
|
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
|
|
)
|
|
|
|
// APIError represents an HTTP error response from the GitHub API.
|
|
// It carries the status code so callers can distinguish between
|
|
// different failure modes (e.g. 404 vs 500).
|
|
//
|
|
// The Body field stores up to 64 KiB of the raw response for programmatic
|
|
// inspection. Error() truncates to 200 bytes for safe logging, but callers
|
|
// should avoid logging or propagating Body directly in production since it may
|
|
// contain sensitive details from the upstream server.
|
|
type APIError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
body := e.Body
|
|
if len(body) > 200 {
|
|
body = body[:200] + "...(truncated)"
|
|
}
|
|
// Sanitize newlines to prevent log injection from upstream response bodies.
|
|
body = strings.ReplaceAll(body, "\n", " ")
|
|
body = strings.ReplaceAll(body, "\r", " ")
|
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
|
|
}
|
|
|
|
// IsNotFound reports whether an error is an API 404 response.
|
|
func IsNotFound(err error) bool {
|
|
if apiErr, ok := asAPIError(err); ok {
|
|
return apiErr.StatusCode == http.StatusNotFound
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsUnauthorized reports whether an error is an API 401 response.
|
|
func IsUnauthorized(err error) bool {
|
|
if apiErr, ok := asAPIError(err); ok {
|
|
return apiErr.StatusCode == http.StatusUnauthorized
|
|
}
|
|
return false
|
|
}
|
|
|
|
func asAPIError(err error) (*APIError, bool) {
|
|
if err == nil {
|
|
return nil, false
|
|
}
|
|
var target *APIError
|
|
if errors.As(err, &target) {
|
|
return target, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// Client interacts with the GitHub API.
|
|
// A Client is safe for concurrent use by multiple goroutines.
|
|
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
|
|
// be called before any goroutines issue requests; they have no synchronization.
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
|
|
// allowInsecureHTTP permits requests to HTTP (non-TLS) endpoints.
|
|
// When false, doRequest rejects URLs with an http:// scheme.
|
|
allowInsecureHTTP bool
|
|
|
|
// retryBackoff defines the delays between retry attempts for 429 responses.
|
|
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
|
|
// If nil, defaults to {1s, 2s}.
|
|
retryBackoff []time.Duration
|
|
|
|
// now returns the current time. Defaults to time.Now.
|
|
// Override in tests to control HTTP-date Retry-After calculations.
|
|
now func() time.Time
|
|
}
|
|
|
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
|
// NOTE: This function is intentionally duplicated in gitea/client.go (and vice versa)
|
|
// because the packages are separate. Changes here must be mirrored there.
|
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
|
// and cross-host redirects (to prevent following responses from untrusted
|
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
// Guard for direct invocation in tests and any future callers;
|
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
|
if len(via) == 0 {
|
|
return nil
|
|
}
|
|
prev := via[len(via)-1]
|
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
|
}
|
|
// Reject cross-host redirect entirely to avoid consuming responses
|
|
// from untrusted endpoints.
|
|
if req.URL.Host != prev.URL.Host {
|
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClientOption configures optional behavior of a Client.
|
|
type ClientOption func(*clientConfig)
|
|
|
|
type clientConfig struct {
|
|
allowInsecureHTTP bool
|
|
insecureIsTestBypass bool
|
|
}
|
|
|
|
// AllowInsecureHTTP permits sending credentials over plaintext HTTP connections.
|
|
// In production, this option is gated by the REVIEW_BOT_ALLOW_INSECURE=1
|
|
// environment variable. Without the env var set, the option is ignored
|
|
// and a warning is logged.
|
|
//
|
|
// For tests, use AllowInsecureHTTPForTest (defined in a _test.go file in the same package) which bypasses the env gate.
|
|
func AllowInsecureHTTP() ClientOption {
|
|
return func(cfg *clientConfig) {
|
|
cfg.allowInsecureHTTP = true
|
|
}
|
|
}
|
|
|
|
// NewClient creates a new GitHub API client.
|
|
// If baseURL is empty, it defaults to https://api.github.com.
|
|
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
|
|
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
|
|
var cfg clientConfig
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
|
|
if cfg.allowInsecureHTTP && !cfg.insecureIsTestBypass {
|
|
if os.Getenv("REVIEW_BOT_ALLOW_INSECURE") != "1" {
|
|
slog.Warn("AllowInsecureHTTP ignored: set REVIEW_BOT_ALLOW_INSECURE=1 to enable")
|
|
cfg.allowInsecureHTTP = false
|
|
} else {
|
|
slog.Warn("AllowInsecureHTTP enabled — credentials may be sent over plaintext",
|
|
"env", "REVIEW_BOT_ALLOW_INSECURE=1")
|
|
}
|
|
}
|
|
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
allowInsecureHTTP: cfg.allowInsecureHTTP,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
},
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
|
// This is intended for test setup only to inject mock transports; it must be
|
|
// called before any goroutines issue requests.
|
|
//
|
|
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
|
// CheckRedirect policy matching NewClient).
|
|
//
|
|
// Callers providing a non-nil client are responsible for configuring a safe
|
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
|
// redirects and may forward the Authorization header to untrusted hosts.
|
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
|
if hc == nil {
|
|
hc = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
}
|
|
}
|
|
c.httpClient = hc
|
|
}
|
|
|
|
// SetRetryBackoff sets the delays between retry attempts.
|
|
// This is intended for testing to speed up retry tests.
|
|
//
|
|
// Note: if an empty non-nil slice is provided, Retry-After delays parsed from
|
|
// server responses will be computed and capped but not applied (because
|
|
// attempt < len(backoff) is always false). This is acceptable for the
|
|
// test-only use case but callers should be aware of this edge case.
|
|
func (c *Client) SetRetryBackoff(backoff []time.Duration) {
|
|
c.retryBackoff = backoff
|
|
}
|
|
|
|
// parseRetryAfter parses a Retry-After header value, supporting both integer
|
|
// seconds (e.g. "120") and HTTP-date format (e.g. "Thu, 01 Dec 2025 16:00:00 GMT")
|
|
// as specified in RFC 7231 §7.1.3.
|
|
//
|
|
// For integer values, it returns the duration directly.
|
|
// For HTTP-date values, it computes the delay as the difference between the
|
|
// parsed time and now. If the date is in the past, it returns 0.
|
|
//
|
|
// Returns (0, false) if the value cannot be parsed as either format.
|
|
func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
|
|
value = strings.TrimSpace(value)
|
|
|
|
// Try integer seconds first (most common from GitHub).
|
|
// RFC 7231 allows delta-seconds of 0 to indicate immediate retry.
|
|
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
|
|
return time.Duration(seconds) * time.Second, true
|
|
}
|
|
|
|
// Try HTTP-date format (RFC 7231 §7.1.3).
|
|
// http.ParseTime handles RFC 1123, RFC 850, and ASCTIME formats.
|
|
if retryAt, err := http.ParseTime(value); err == nil {
|
|
delay := retryAt.Sub(c.now())
|
|
if delay < 0 {
|
|
delay = 0
|
|
}
|
|
return delay, true
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
|
|
// redactURL redacts sensitive components from a URL for safe inclusion in error
|
|
// messages and log output. It removes userinfo (e.g., user:pass@) and replaces
|
|
// query parameters with a placeholder.
|
|
func redactURL(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "<unparseable URL>"
|
|
}
|
|
u.User = nil
|
|
|
|
if u.RawQuery != "" {
|
|
u.RawQuery = "<redacted>"
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
|
// It respects the Retry-After header when present, supporting both integer
|
|
// seconds and HTTP-date formats (capped at maxRetryAfter).
|
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
|
// NOTE: This parses reqURL a second time (http.NewRequestWithContext parses it
|
|
// again internally). Acceptable cost: URL parsing is cheap and threading the
|
|
// parsed *url.URL through would complicate the interface for negligible gain.
|
|
if !c.allowInsecureHTTP {
|
|
parsed, err := url.Parse(reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse request URL: %w", err)
|
|
}
|
|
if strings.EqualFold(parsed.Scheme, "http") {
|
|
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
|
|
}
|
|
}
|
|
|
|
var backoff []time.Duration
|
|
if c.retryBackoff != nil {
|
|
backoff = append([]time.Duration(nil), c.retryBackoff...)
|
|
} else {
|
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
var delay time.Duration
|
|
if attempt-1 < len(backoff) {
|
|
delay = backoff[attempt-1]
|
|
}
|
|
if delay > 0 {
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-timer.C:
|
|
timer.Stop() // no-op after fire; kept for symmetry with the ctx.Done case
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
if accept != "" {
|
|
req.Header.Set("Accept", accept)
|
|
} else {
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response body: %w", err)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
|
resp.Body.Close()
|
|
|
|
lastErr = &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
|
|
|
// Retry on 429 rate limit
|
|
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
|
// Check for Retry-After header and override backoff if present.
|
|
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if delay, ok := c.parseRetryAfter(ra); ok {
|
|
if delay > maxRetryAfter {
|
|
delay = maxRetryAfter
|
|
}
|
|
if attempt < len(backoff) {
|
|
backoff[attempt] = delay
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Don't retry other errors
|
|
return nil, lastErr
|
|
}
|
|
|
|
return nil, lastErr
|
|
}
|
|
|
|
// doGet is a convenience wrapper for GET requests with the default Accept header.
|
|
func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
|
|
return c.doRequest(ctx, http.MethodGet, url, "")
|
|
}
|
|
|
|
// doRequestWithBody performs an HTTP request with an optional body, applying the
|
|
// same HTTPS enforcement as doRequest. It is used by write methods (POST, PUT,
|
|
// DELETE) that bypass the retry loop in doRequest because write operations are
|
|
// not idempotent.
|
|
//
|
|
// body may be nil for requests that carry no payload (e.g. DELETE).
|
|
// When body is non-nil, Content-Type is set to application/json.
|
|
func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, body []byte) ([]byte, error) {
|
|
if !c.allowInsecureHTTP {
|
|
parsed, err := url.Parse(reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse request URL: %w", err)
|
|
}
|
|
if strings.EqualFold(parsed.Scheme, "http") {
|
|
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
|
|
}
|
|
}
|
|
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
reqBody = bytes.NewReader(body)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response body: %w", err)
|
|
}
|
|
return respBody, nil
|
|
}
|
|
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
|
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
|
}
|
|
|
|
// --- API types ---
|
|
|
|
// PullRequest holds relevant PR metadata.
|
|
type PullRequest struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Head struct {
|
|
Sha string `json:"sha"`
|
|
Ref string `json:"ref"`
|
|
} `json:"head"`
|
|
Draft bool `json:"draft"`
|
|
}
|
|
|
|
// CommitStatus represents a single CI status entry.
|
|
// GitHub returns "state" not "status"; this type uses Status for consistency
|
|
// with the gitea package (both are normalized before use).
|
|
type CommitStatus struct {
|
|
Status string `json:"state"` // GitHub field is "state"
|
|
Context string `json:"context"`
|
|
Description string `json:"description"`
|
|
TargetURL string `json:"target_url"`
|
|
}
|
|
|
|
// ChangedFile represents a file modified in a PR.
|
|
type ChangedFile struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ReviewComment represents an inline comment to attach to a review.
|
|
// GitHub uses "position" (diff hunk position), whereas Gitea uses "new_position" (line number).
|
|
// When posting inline comments on GitHub, position is required; line numbers
|
|
// from the diff cannot be used directly.
|
|
type ReviewComment struct {
|
|
ID int64 `json:"id,omitempty"`
|
|
Path string `json:"path"`
|
|
Position int64 `json:"position,omitempty"` // GitHub diff hunk position
|
|
Line int64 `json:"line,omitempty"` // GitHub absolute line number (alternative to position)
|
|
Side string `json:"side,omitempty"` // "RIGHT" or "LEFT"
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// Review represents a pull request review from the GitHub API.
|
|
type Review struct {
|
|
ID int64 `json:"id"`
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// contentResponse is the GitHub contents API response for a single file.
|
|
type contentResponse struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // "file" or "dir" or "symlink" or "submodule"
|
|
Content string `json:"content"` // Base64-encoded file content (with embedded newlines)
|
|
Encoding string `json:"encoding"` // "base64" or ""
|
|
}
|
|
|
|
// ContentEntry represents a file or directory entry from the contents API.
|
|
type ContentEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // "file" or "dir"
|
|
}
|
|
|
|
// --- PR methods ---
|
|
|
|
// GetPullRequest fetches PR metadata.
|
|
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*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 pr PullRequest
|
|
if err := json.Unmarshal(body, &pr); err != nil {
|
|
return nil, fmt.Errorf("parse PR JSON: %w", err)
|
|
}
|
|
return &pr, nil
|
|
}
|
|
|
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
|
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
|
|
}
|
|
|
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
|
// GitHub paginates this endpoint (100 per page max).
|
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
|
const perPage = 100
|
|
var all []ChangedFile
|
|
for page := 1; ; page++ {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=%d&page=%d",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, perPage, page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch PR files (page %d): %w", page, err)
|
|
}
|
|
var batch []ChangedFile
|
|
if err := json.Unmarshal(body, &batch); err != nil {
|
|
return nil, fmt.Errorf("parse PR files JSON (page %d): %w", page, err)
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < perPage {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// GetCommitStatuses fetches CI statuses for a commit SHA.
|
|
// GitHub has two status systems: legacy "commit statuses" and newer "check runs".
|
|
// This method returns commit statuses only; check runs are a separate API.
|
|
// Note: GitHub returns "state" in the JSON; CommitStatus.Status is tagged accordingly.
|
|
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
|
|
const perPage = 100
|
|
var all []CommitStatus
|
|
for page := 1; ; page++ {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/statuses?per_page=%d&page=%d",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha), perPage, page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch commit statuses (page %d): %w", page, err)
|
|
}
|
|
var batch []CommitStatus
|
|
if err := json.Unmarshal(body, &batch); err != nil {
|
|
return nil, fmt.Errorf("parse statuses JSON (page %d): %w", page, err)
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < perPage {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// --- File content methods ---
|
|
|
|
// GetFileContent fetches a file from the default branch of a repo.
|
|
// GitHub returns base64-encoded content; this method decodes it.
|
|
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
return c.getFileContentAtRef(ctx, owner, repo, filepath, "")
|
|
}
|
|
|
|
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha).
|
|
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
|
return c.getFileContentAtRef(ctx, owner, repo, filepath, ref)
|
|
}
|
|
|
|
// getFileContentAtRef fetches a file at the given ref (empty = default branch).
|
|
// GitHub's contents API returns base64-encoded file content.
|
|
func (c *Client) getFileContentAtRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath))
|
|
if ref != "" {
|
|
reqURL += "?ref=" + url.QueryEscape(ref)
|
|
}
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
|
|
}
|
|
var resp contentResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return "", fmt.Errorf("parse file content JSON for %s: %w", filepath, err)
|
|
}
|
|
if resp.Type != "file" {
|
|
return "", fmt.Errorf("path %s is a %s, not a file", filepath, resp.Type)
|
|
}
|
|
if resp.Encoding == "base64" {
|
|
// GitHub embeds newlines in the base64 content for readability.
|
|
// Strip them before decoding.
|
|
cleaned := strings.ReplaceAll(resp.Content, "\n", "")
|
|
decoded, err := base64.StdEncoding.DecodeString(cleaned)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode base64 content for %s: %w", filepath, err)
|
|
}
|
|
return string(decoded), nil
|
|
}
|
|
// Non-base64 encoding (shouldn't happen normally, but handle gracefully).
|
|
return resp.Content, nil
|
|
}
|
|
|
|
// ListContents lists files and directories at a given path.
|
|
// Pass an empty path to list the repository root.
|
|
// GitHub returns a single object (not array) when path is a file — this
|
|
// method normalizes both cases to a slice, matching Gitea's behavior.
|
|
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
|
var reqURL string
|
|
if path == "" || path == "." {
|
|
reqURL = fmt.Sprintf("%s/repos/%s/%s/contents",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo))
|
|
} else {
|
|
reqURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
|
|
}
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list contents %s: %w", path, err)
|
|
}
|
|
|
|
var entries []ContentEntry
|
|
if err := json.Unmarshal(body, &entries); err != nil {
|
|
// GitHub returns a single object when path is a file (not an array).
|
|
var single contentResponse
|
|
if err2 := json.Unmarshal(body, &single); err2 != nil {
|
|
return nil, fmt.Errorf("parse contents JSON: %w", err)
|
|
}
|
|
if single.Name == "" && single.Path == "" {
|
|
return nil, fmt.Errorf("parse contents JSON: empty response for path %q", path)
|
|
}
|
|
entries = []ContentEntry{{
|
|
Name: single.Name,
|
|
Path: single.Path,
|
|
Type: single.Type,
|
|
}}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// GetAllFilesInPath recursively fetches all file contents under a path.
|
|
// If the path is a file, returns just that file's content.
|
|
// If the path is a directory, recursively fetches all files within it.
|
|
func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
|
|
results := make(map[string]string)
|
|
|
|
entries, err := c.ListContents(ctx, owner, repo, path)
|
|
if err != nil {
|
|
if !IsNotFound(err) {
|
|
return nil, fmt.Errorf("list contents %q: %w", path, err)
|
|
}
|
|
// 404 means path may be a file — try fetching directly.
|
|
content, fileErr := c.GetFileContent(ctx, owner, repo, path)
|
|
if fileErr != nil {
|
|
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, fileErr)
|
|
}
|
|
results[path] = content
|
|
return results, nil
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
switch entry.Type {
|
|
case "file":
|
|
content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
|
|
if err != nil {
|
|
slog.Warn("could not fetch file from patterns repo", "file", entry.Path, "error", err)
|
|
continue
|
|
}
|
|
results[entry.Path] = content
|
|
case "dir":
|
|
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
|
|
if err != nil {
|
|
slog.Warn("could not recurse into directory", "dir", entry.Path, "error", err)
|
|
continue
|
|
}
|
|
for k, v := range subResults {
|
|
results[k] = v
|
|
}
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// --- Review methods ---
|
|
|
|
// PostReview submits a review to a PR.
|
|
// event should be one of "APPROVE", "REQUEST_CHANGES", or "COMMENT".
|
|
// commitID anchors the review to a specific commit SHA. If empty, defaults to current HEAD.
|
|
// comments are optional inline comments; GitHub uses diff hunk position (not line numbers).
|
|
// Note: unlike Gitea, GitHub does not support deleting submitted reviews.
|
|
// Use COMMENT event to supersede old reviews.
|
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
|
|
payload := struct {
|
|
Body string `json:"body"`
|
|
Event string `json:"event"`
|
|
CommitID string `json:"commit_id,omitempty"`
|
|
Comments []ReviewComment `json:"comments,omitempty"`
|
|
}{
|
|
Body: body,
|
|
Event: event,
|
|
CommitID: commitID,
|
|
Comments: comments,
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal review payload: %w", err)
|
|
}
|
|
|
|
respBody, err := c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("post review: %w", err)
|
|
}
|
|
|
|
var review Review
|
|
if err := json.Unmarshal(respBody, &review); err != nil {
|
|
return nil, fmt.Errorf("parse review response: %w", err)
|
|
}
|
|
return &review, nil
|
|
}
|
|
|
|
// ListReviews returns all reviews on a pull request.
|
|
// GitHub paginates via Link header; this method uses per_page=100.
|
|
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error) {
|
|
const perPage = 100
|
|
var all []Review
|
|
for page := 1; ; 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, perPage, page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list reviews (page %d): %w", page, err)
|
|
}
|
|
var batch []Review
|
|
if err := json.Unmarshal(body, &batch); err != nil {
|
|
return nil, fmt.Errorf("parse reviews (page %d): %w", page, err)
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < perPage {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// DeleteReview attempts to delete a pull request review.
|
|
// GitHub only allows deleting PENDING (draft) reviews. Submitted reviews cannot
|
|
// be deleted via the API; this method returns a descriptive error in that case.
|
|
// review-bot callers should handle this error gracefully (e.g., by not attempting
|
|
// supersede and instead posting a new review alongside the old one).
|
|
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 {
|
|
return fmt.Errorf("delete review: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAuthenticatedUser returns the login of the authenticated user.
|
|
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
reqURL := c.baseURL + "/user"
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
|
}
|
|
var result struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return "", fmt.Errorf("parse user response: %w", err)
|
|
}
|
|
return result.Login, nil
|
|
}
|
|
|
|
// RequestReviewer adds a user as a requested reviewer on a pull request.
|
|
// This is idempotent — requesting an already-requested reviewer is a no-op.
|
|
func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
|
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/requested_reviewers",
|
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
|
|
payload := struct {
|
|
Reviewers []string `json:"reviewers"`
|
|
}{Reviewers: []string{reviewer}}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal reviewer request: %w", err)
|
|
}
|
|
|
|
_, err = c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
|
|
if err != nil {
|
|
return fmt.Errorf("request reviewer: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
// escapePath escapes each segment of a relative file path for use in URLs.
|
|
// Slashes are preserved as path separators; other special characters are escaped.
|
|
func escapePath(p string) string {
|
|
parts := strings.Split(p, "/")
|
|
for i, part := range parts {
|
|
parts[i] = url.PathEscape(part)
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|