// 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, "/") }