// 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 "" } u.User = nil if u.RawQuery != "" { u.RawQuery = "" } 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, "") } // --- 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) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("create review request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/vnd.github+json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("post review: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) return nil, &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} } respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) if err != nil { return nil, fmt.Errorf("read review response: %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) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) if err != nil { return fmt.Errorf("create delete request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/vnd.github+json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("delete review: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} } 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) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data)) if err != nil { return fmt.Errorf("create reviewer request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/vnd.github+json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request reviewer: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} } 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, "/") }