package github import ( "context" "encoding/json" "fmt" "net/http" "net/url" "gitea.weiker.me/rodin/review-bot/vcs" ) // pullRequestResponse is the GitHub API response for a pull request. type pullRequestResponse struct { Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` Head struct { SHA string `json:"sha"` Ref string `json:"ref"` } `json:"head"` Base struct { Ref string `json:"ref"` } `json:"base"` } // changedFileResponse is the GitHub API response for a changed file in a PR. type changedFileResponse struct { Filename string `json:"filename"` Status string `json:"status"` Patch string `json:"patch"` } // commitStatusResponse is the GitHub combined status API response. type commitStatusResponse struct { State string `json:"state"` Statuses []struct { Context string `json:"context"` State string `json:"state"` Description string `json:"description"` TargetURL string `json:"target_url"` } `json:"statuses"` } // checkRunsResponse is the GitHub check runs API response. type checkRunsResponse struct { CheckRuns []struct { Name string `json:"name"` Conclusion *string `json:"conclusion"` Status string `json:"status"` HTMLURL string `json:"html_url"` } `json:"check_runs"` } // GetPullRequest fetches PR metadata. func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcs.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 resp pullRequestResponse if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("parse PR JSON: %w", err) } return &vcs.PullRequest{ Number: resp.Number, Title: resp.Title, Body: resp.Body, Head: vcs.HeadRef{SHA: resp.Head.SHA, Ref: resp.Head.Ref}, Base: vcs.BaseRef{Ref: resp.Base.Ref}, }, nil } // GetPullRequestDiff fetches the unified diff for a PR. // Uses Accept: application/vnd.github.diff to get raw diff text. 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.diff") if err != nil { return "", fmt.Errorf("fetch diff: %w", err) } return string(body), nil } // maxPages is the upper bound on pagination loops to prevent unbounded iteration // in case the server returns a full page indefinitely. const maxPages = 100 // GetPullRequestFiles fetches the list of files changed in a PR. // Paginates through all pages (100 per page) to collect all files. // Returns nil (not an empty slice) when the PR has no changed files. // Callers can safely range over or check len() on a nil slice. func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcs.ChangedFile, error) { var allFiles []vcs.ChangedFile for page := 1; page <= maxPages; page++ { reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=100&page=%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, page) body, err := c.doGet(ctx, reqURL) if err != nil { return nil, fmt.Errorf("fetch PR files page %d: %w", page, err) } var files []changedFileResponse if err := json.Unmarshal(body, &files); err != nil { return nil, fmt.Errorf("parse PR files JSON: %w", err) } if len(files) == 0 { break } for _, f := range files { allFiles = append(allFiles, vcs.ChangedFile{ Filename: f.Filename, Status: f.Status, Patch: f.Patch, }) } if len(files) < 100 { break } } return allFiles, nil } // GetFileContentAtRef fetches a file at a specific ref from a repo. // If ref is empty, the query parameter is omitted (uses default branch). // // Note: dot-segments ("." and "..") in the path are silently removed to // prevent path traversal. This means a path like "foo/../bar" resolves // to "foo/bar" rather than "bar". func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) { reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path)) if ref != "" { reqURL += "?ref=" + url.QueryEscape(ref) } body, err := c.doGet(ctx, reqURL) if err != nil { return "", fmt.Errorf("fetch file %s: %w", path, err) } var resp struct { Content string `json:"content"` Encoding string `json:"encoding"` } if err := json.Unmarshal(body, &resp); err != nil { return "", fmt.Errorf("parse file content JSON: %w", err) } if resp.Encoding != "base64" { return "", fmt.Errorf("unexpected encoding %q for file %s", resp.Encoding, path) } decoded, err := decodeBase64Content(resp.Content) if err != nil { return "", fmt.Errorf("decode base64 content for %s: %w", path, err) } return decoded, nil } // GetCommitStatuses fetches both commit statuses and check runs for a SHA, // merging them into a unified []vcs.CommitStatus slice. // Returns nil (not an empty slice) when there are no statuses or check runs. // If the commit statuses endpoint fails (e.g. 404 for an unknown SHA), the // function returns immediately without attempting the check-runs endpoint. func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcs.CommitStatus, error) { var result []vcs.CommitStatus // Fetch commit statuses statusURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) statusBody, err := c.doGet(ctx, statusURL) if err != nil { return nil, fmt.Errorf("fetch commit statuses: %w", err) } var statusResp commitStatusResponse if err := json.Unmarshal(statusBody, &statusResp); err != nil { return nil, fmt.Errorf("parse commit statuses JSON: %w", err) } for _, s := range statusResp.Statuses { result = append(result, vcs.CommitStatus{ Context: s.Context, Status: s.State, Description: s.Description, TargetURL: s.TargetURL, }) } // Fetch check runs (paginated) for checkPage := 1; checkPage <= maxPages; checkPage++ { checkURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs?per_page=100&page=%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha), checkPage) checkBody, err := c.doGet(ctx, checkURL) if err != nil { return nil, fmt.Errorf("fetch check runs page %d: %w", checkPage, err) } var checkResp checkRunsResponse if err := json.Unmarshal(checkBody, &checkResp); err != nil { return nil, fmt.Errorf("parse check runs JSON: %w", err) } for _, cr := range checkResp.CheckRuns { result = append(result, vcs.CommitStatus{ Context: cr.Name, Status: mapCheckRunStatus(cr.Conclusion), Description: derefString(cr.Conclusion), TargetURL: cr.HTMLURL, }) } if len(checkResp.CheckRuns) < 100 { break } } return result, nil } // mapCheckRunStatus maps a check run conclusion to a vcs.CommitStatus status string. // Conclusion alone determines the mapped state: nil conclusion means the run is // still in progress (pending), regardless of the status field value. func mapCheckRunStatus(conclusion *string) string { if conclusion == nil { // Still running or queued return "pending" } switch *conclusion { case "success": return "success" case "failure", "action_required", "timed_out": return "failure" case "cancelled", "skipped", "neutral": return "success" // non-blocking case "stale", "waiting": return "pending" default: return "pending" } } // derefString safely dereferences a string pointer, returning empty string if nil. func derefString(s *string) string { if s == nil { return "" } return *s }