diff --git a/github/methods.go b/github/methods.go new file mode 100644 index 0000000..9e2436b --- /dev/null +++ b/github/methods.go @@ -0,0 +1,549 @@ +// Package github provides a client for the GitHub API. +// This file contains the higher-level PR/review methods built on top of the +// HTTP client in client.go. All methods use GitHub REST API v3 paths. +package github + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" +) + +// 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. +// GitHub uses "state" (success/failure/pending/error) unlike Gitea's "status". +type CommitStatus struct { + State 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"` +} + +// ReviewComment represents an inline comment to attach to a review. +// GitHub uses "path" + "position" or "line" for positioning. +type ReviewComment struct { + ID int64 `json:"id,omitempty"` + Path string `json:"path"` + // Position is the line position in the diff (used when submitting). + // Side+Line is an alternative for GitHub (line in the file), but + // we mirror the Gitea interface using NewPosition mapped to position. + Position int64 `json:"position,omitempty"` + Body string `json:"body"` +} + +// 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" +} + +// Review represents a pull request review. +type Review struct { + ID int64 `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + State string `json:"state"` + CommitID string `json:"commit_id"` +} + +// 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.v3.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 at 30 files/page (max 3000 files total). +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's combined status endpoint returns the most-relevant state per context. +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: %w", err) + } + var batch []CommitStatus + if err := json.Unmarshal(body, &batch); err != nil { + return nil, fmt.Errorf("parse statuses JSON: %w", err) + } + all = append(all, batch...) + if len(batch) < perPage { + break + } + } + return all, nil +} + +// GetFileContent fetches a file from the default branch of a repo. +// GitHub's contents API returns base64-encoded content. +func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) { + return c.GetFileContentRef(ctx, owner, repo, filepath, "") +} + +// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo. +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 %s: %w", filepath, err) + } + // GitHub returns JSON with base64-encoded content + var result struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse file content JSON: %w", err) + } + if result.Encoding != "base64" { + return "", fmt.Errorf("unexpected encoding %q for file %s", result.Encoding, filepath) + } + // GitHub wraps base64 content in newlines — strip them before decoding + cleaned := strings.ReplaceAll(result.Content, "\n", "") + decoded, err := base64.StdEncoding.DecodeString(cleaned) + if err != nil { + return "", fmt.Errorf("decode file content: %w", err) + } + return string(decoded), nil +} + +// ListContents lists files and directories at a given path in a repo. +// Pass an empty path to list the repository root. +func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) { + if path == "." { + path = "" + } + var reqURL string + if 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 also returns a single object when path is a file + var single ContentEntry + 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{single} + } + return entries, nil +} + +// GetAllFilesInPath recursively fetches all file contents under a path. +// If the path is a file, returns just that file's content. +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) { + // Try fetching as a file 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 + } + return nil, fmt.Errorf("list contents %q: %w", path, err) + } + + 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 +} + +// PostReview submits a review to a PR and returns the created review. +// event should be "APPROVE", "REQUEST_CHANGES", or "COMMENT". +// commitID anchors the review to a specific commit SHA. +// comments are optional inline comments. +// +// Note: GitHub uses "APPROVE" (not "APPROVED") for the event name. +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) + + // GitHub uses "APPROVE" not "APPROVED", "REQUEST_CHANGES" and "COMMENT" match + ghEvent := event + if event == "APPROVED" { + ghEvent = "APPROVE" + } + + payload := struct { + Body string `json:"body"` + Event string `json:"event"` + CommitID string `json:"commit_id,omitempty"` + Comments []ReviewComment `json:"comments,omitempty"` + }{ + Body: body, + Event: ghEvent, + 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, 64*1024)) + return nil, fmt.Errorf("post review failed (status %d): %s", resp.StatusCode, string(respBody)) + } + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + 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. +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 deletes a review by ID. +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, 256)) + return fmt.Errorf("delete review failed (status %d): %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// GetAuthenticatedUser returns the login of the user authenticated by the token. +func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) { + reqURL := fmt.Sprintf("%s/user", c.baseURL) + 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 the given user as a requested reviewer on a pull request. +// This is idempotent on GitHub — requesting an already-requested reviewer succeats. +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 < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return fmt.Errorf("request reviewer failed (status %d): %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// EditComment updates the body of a PR review comment. +// GitHub uses PATCH /repos/{owner}/{repo}/pulls/comments/{comment_id}. +func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error { + reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/comments/%d", + c.baseURL, + url.PathEscape(owner), + url.PathEscape(repo), + commentID) + + payload := struct { + Body string `json:"body"` + }{Body: newBody} + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal edit payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("create edit 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("edit comment: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return fmt.Errorf("edit comment failed (status %d): %s", resp.StatusCode, body) + } + return nil +} + +// ListReviewComments returns the inline comments attached to a specific review. +func (c *Client) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]ReviewComment, error) { + const perPage = 100 + var all []ReviewComment + for page := 1; ; page++ { + reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d/comments?per_page=%d&page=%d", + c.baseURL, + url.PathEscape(owner), + url.PathEscape(repo), + prNumber, + reviewID, + perPage, + page) + body, err := c.doGet(ctx, reqURL) + if err != nil { + return nil, fmt.Errorf("list review comments (page %d): %w", page, err) + } + var batch []ReviewComment + if err := json.Unmarshal(body, &batch); err != nil { + return nil, fmt.Errorf("parse review comments (page %d): %w", page, err) + } + all = append(all, batch...) + if len(batch) < perPage { + break + } + } + return all, nil +} + +// ResolveComment is a no-op on GitHub. GitHub does not support resolving +// individual review comments via the REST API (only via the GraphQL API). +// This method exists to satisfy the VCSClient interface. +func (c *Client) ResolveComment(_ context.Context, _, _ string, _ int64) error { + return nil +} + +// GetTimelineReviewCommentIDForReview finds the timeline comment ID for a review. +// GitHub doesn't have a direct timeline event endpoint for reviews the way Gitea does. +// This is primarily used by the cleanup path (EditComment + resolve). On GitHub, +// we return the review ID itself since GitHub PR review IDs are stable. +// Returns the reviewID unchanged for compatibility. +func (c *Client) GetTimelineReviewCommentIDForReview(_ context.Context, _, _ string, _ int, reviewID int64) (int64, error) { + return reviewID, nil +} + +// escapePath escapes each path segment individually while preserving slashes. +// This avoids double-escaping the forward slash separator in file paths. +// NOTE: Intentionally duplicated from gitea/client.go to keep the packages independent. +func escapePath(p string) string { + parts := strings.Split(p, "/") + escaped := make([]string, len(parts)) + for i, part := range parts { + escaped[i] = url.PathEscape(part) + } + return strings.Join(escaped, "/") +}