e3fb19fa1b
CI / test (push) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
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, "/")
|
|
}
|