feat(#130): implement GitHub API methods and vcs types

- Add vcs package with shared review types (ReviewEvent, Review, ReviewRequest, ReviewComment, UserInfo)
- Add GetPullRequest, GetPullRequestDiff, GetPullRequestFiles, GetCommitStatuses to github/client.go
- Add GetFileContent, GetFileContentRef, ListContents to github/client.go
- Add doRequestWithBody helper for POST/PUT/DELETE operations
- Add review.go with PostReview, ListReviews, DeleteReview, DismissReview
- Add identity.go with GetAuthenticatedUser
- Remove TODO comment from github/client.go
- Add escapePath helper for URL path construction
- Add comprehensive tests for all new methods using httptest.NewServer
This commit is contained in:
claw
2026-05-14 13:45:36 -07:00
parent fc3ca21329
commit 5704d167a5
4 changed files with 1070 additions and 4 deletions
+285 -4
View File
@@ -4,7 +4,10 @@
package github
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -92,10 +95,6 @@ func asAPIError(err error) (*APIError, bool) {
// 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 {
// TODO: baseURL is populated by NewClient but not yet consumed by doRequest/doGet.
// Higher-level exported methods (GetPullRequest, etc.) will use it to
// construct request URLs; remove this field if those methods end up
// accepting full URLs instead.
baseURL string
token string
httpClient *http.Client
@@ -376,3 +375,285 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
return c.doRequest(ctx, http.MethodGet, url, "")
}
// DefaultMaxDiffSize is the default maximum diff size in bytes (10 MB).
const DefaultMaxDiffSize = 10 * 1024 * 1024
// ErrDiffTooLarge is returned when a PR diff exceeds the configured MaxDiffSize.
var ErrDiffTooLarge = errors.New("diff size exceeds maximum allowed size")
// 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"`
}
// CommitStatus represents a single CI status entry.
type CommitStatus struct {
Status string `json:"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"`
}
// ContentEntry represents a file or directory in a repository.
type ContentEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
// contentResponse is the GitHub API response for a single file's content.
type contentResponse struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
Type string `json:"type"`
Name string `json:"name"`
Path string `json:"path"`
}
// 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 response: %w", err)
}
return &pr, nil
}
// GetPullRequestFiles fetches the list of files changed in a PR.
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR files: %w", err)
}
var files []ChangedFile
if err := json.Unmarshal(body, &files); err != nil {
return nil, fmt.Errorf("parse PR files response: %w", err)
}
return files, nil
}
// GetCommitStatuses fetches CI statuses for a commit SHA.
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/statuses",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch commit statuses: %w", err)
}
var statuses []CommitStatus
if err := json.Unmarshal(body, &statuses); err != nil {
return nil, fmt.Errorf("parse commit statuses response: %w", err)
}
return statuses, nil
}
// decodeFileContent decodes the content from a GitHub contents API response.
// GitHub returns content as base64-encoded string with newlines inserted;
// this function strips the newlines before decoding.
func decodeFileContent(resp contentResponse) (string, error) {
if resp.Encoding != "base64" {
return "", fmt.Errorf("unsupported content encoding: %q", resp.Encoding)
}
// GitHub inserts newlines every 60 characters; strip them before decoding.
stripped := strings.ReplaceAll(resp.Content, "\n", "")
decoded, err := base64.StdEncoding.DecodeString(stripped)
if err != nil {
return "", fmt.Errorf("decode base64 content: %w", err)
}
return string(decoded), nil
}
// GetFileContent fetches the content of a file at the default branch HEAD.
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return c.GetFileContentRef(ctx, owner, repo, filepath, "")
}
// GetFileContentRef fetches the content of a file at the given ref (branch, tag, or SHA).
// If ref is empty, the default branch is used.
func (c *Client) GetFileContentRef(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 content %q: %w", filepath, err)
}
var resp contentResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("parse file content response: %w", err)
}
content, err := decodeFileContent(resp)
if err != nil {
return "", fmt.Errorf("decode file content %q: %w", filepath, err)
}
return content, nil
}
// ListContents lists the files and directories at the given path.
// If path points to a file instead of a directory, it returns a single-entry slice.
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
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 %q: %w", path, err)
}
// The GitHub API returns an array for directories and an object for files.
// Try array first; if it fails, try object and wrap in a slice.
var entries []ContentEntry
if err := json.Unmarshal(body, &entries); err == nil {
return entries, nil
}
// Try as a single ContentEntry (file path).
var single contentResponse
if err := json.Unmarshal(body, &single); err != nil {
return nil, fmt.Errorf("parse contents response for %q: %w", path, err)
}
return []ContentEntry{{
Name: single.Name,
Path: single.Path,
Type: single.Type,
}}, nil
}
// doRequestWithBody performs an HTTP request with a JSON request body.
// Unlike doRequest, it does NOT retry — write operations should not be
// retried automatically.
func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, data []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 bodyReader *bytes.Reader
if data != nil {
bodyReader = bytes.NewReader(data)
} else {
bodyReader = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
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 data != 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)
}
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()
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
}
// escapePath escapes each segment of a file path for use in URL path components.
// Slashes are preserved as separators; individual segments are PathEscaped.
func escapePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
// GetPullRequestDiff fetches the unified diff for a PR.
// Returns ErrDiffTooLarge if the response exceeds DefaultMaxDiffSize bytes.
//
// It reads up to DefaultMaxDiffSize+1 bytes and checks for truncation:
// if exactly DefaultMaxDiffSize+1 bytes are available, the diff is too large.
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)
if !c.allowInsecureHTTP {
parsed, err := url.Parse(reqURL)
if err != nil {
return "", fmt.Errorf("parse request URL: %w", err)
}
if strings.EqualFold(parsed.Scheme, "http") {
return "", fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", fmt.Errorf("create PR diff request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github.diff")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch PR diff: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return "", &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
}
// Read up to DefaultMaxDiffSize+1 bytes. If we get exactly that many,
// the diff exceeds the limit.
limit := int64(DefaultMaxDiffSize) + 1
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return "", fmt.Errorf("read PR diff: %w", err)
}
if int64(len(raw)) == limit {
return "", ErrDiffTooLarge
}
return string(raw), nil
}