b531da1355
The supersede path calls GetTimelineReviewCommentIDForReview then EditComment.
On GitHub, EditComment targets /pulls/comments/{id} (for inline review comments),
but review bodies are not inline comments. The PATCH will return 404 and be
logged as a warning. The review itself posts correctly regardless.
This is a known limitation pending a future fix via the reviews PATCH endpoint.
553 lines
18 KiB
Go
553 lines
18 KiB
Go
// 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 supersede path (EditComment + ResolveComment). On GitHub,
|
|
// we return the review ID itself. Note that EditComment on GitHub uses the
|
|
// /pulls/comments/{id} endpoint (for inline review comments), which does not
|
|
// apply to review bodies — the supersede EditComment call will 404 and be
|
|
// logged as a warning. This is a known limitation; the review is still posted
|
|
// correctly regardless.
|
|
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, "/")
|
|
}
|