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:
+285
-4
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user