Compare commits

...

5 Commits

Author SHA1 Message Date
Rodin 5c1a148a24 fix: use rpl-linux-runners for github.concur.com workflows
ubuntu-24.04 is a GitHub.com public runner label — not available on GHE.
All strat/* repos use rpl-linux-runners (confirmed from kms-lite, kms-operator, hermes).
2026-05-14 20:19:47 +00:00
Rodin 1b472cc6b4 feat(ci): add GitHub Actions workflow for strat/review-bot PRs
Adds .github/workflows/review.yml to run AI code review on PRs using
SAP AI Core. Three reviewers: sonnet, gpt, security (same as Gitea CI).

Uses the .gitea/actions/review composite action which auto-detects
GitHub vs Gitea runners via GITHUB_API_URL and uses GitHub REST API
accordingly. action-repo points to strat/review-bot for binary downloads.

Required secrets: SONNET_REVIEW_TOKEN, GPT_REVIEW_TOKEN, AICORE_*
2026-05-14 20:15:38 +00:00
Rodin 06b09ce3b8 feat(cmd): wire VCS detection into main — route to github or gitea client
- Replace gitea.NewClient() with newVCSClient(vcsType, vcsURL, token)
- Replace newGiteaClientAdapter() with buildRepoPersonaClient()
- Remove now-redundant giteaClientAdapter struct (moved to vcs.go)
- Update fetchFileContext and fetchPatterns to accept vcsClient interface
- Log detected VCS type at startup

On Gitea runners: behaves identically to before (gitea.NewClient).
On GitHub/GHES runners: uses githubAdapter backed by github.Client,
  all API calls use GitHub REST API v3 with GITHUB_API_URL as base URL.
2026-05-14 20:15:32 +00:00
Rodin 1e1a50581f feat(cmd): add VCS client abstraction for GitHub and Gitea
Adds vcs.go with:
- vcsClient interface that main.go uses for all VCS operations
- githubAdapter that wraps *github.Client and converts types to gitea types
- newVCSClient() factory: detects VCS type via GITHUB_API_URL env var
  (set by GitHub Actions runners; absent on Gitea) and returns the
  appropriate client
- buildRepoPersonaClient() adapter for LoadRepoPersonas
- detectVCSType() and githubAPIBaseURL() helpers

Detection logic mirrors action.yml: GITHUB_API_URL present → github,
absent → gitea. On GitHub/GHES, uses GITHUB_API_URL as the API base URL
(trusted platform value), never user-supplied vcsURL.
2026-05-14 20:15:25 +00:00
Rodin 545ab25bbc feat(github): add high-level PR/review API methods to github client
Adds GetPullRequest, GetPullRequestDiff, GetPullRequestFiles,
GetCommitStatuses, GetFileContent, GetFileContentRef, ListContents,
GetAllFilesInPath, PostReview, ListReviews, DeleteReview,
GetAuthenticatedUser, RequestReviewer, EditComment, ListReviewComments,
ResolveComment, and GetTimelineReviewCommentIDForReview.

These mirror the gitea/ package methods but use GitHub REST API v3 paths
(/repos/{owner}/{repo}/... instead of /api/v1/repos/...) and handle
GitHub-specific behaviors:
- File content is base64-encoded in the JSON response
- Review event is APPROVE (not APPROVED)
- ResolveComment is a no-op (GitHub has no REST API for this)
- GetTimelineReviewCommentIDForReview returns the reviewID as-is
2026-05-14 20:15:17 +00:00
4 changed files with 927 additions and 32 deletions
+75
View File
@@ -0,0 +1,75 @@
name: AI Code Review
# Self-review workflow for strat/review-bot PRs on github.concur.com.
# Uses SAP AI Core as the LLM provider (same as the Gitea CI workflow).
#
# Binary source: strat/review-bot releases (if available) or Gitea releases
# (via gitea-url + action-repo inputs to the composite action).
# Reviewer tokens for each bot must be set as repo secrets.
#
# Required secrets:
# SONNET_REVIEW_TOKEN — GitHub token for the Sonnet reviewer bot
# GPT_REVIEW_TOKEN — GitHub token for the GPT reviewer bot
# AICORE_CLIENT_ID — SAP AI Core OAuth client ID
# AICORE_CLIENT_SECRET — SAP AI Core OAuth client secret
# AICORE_AUTH_URL — SAP AI Core OAuth token endpoint
# AICORE_API_URL — SAP AI Core inference API URL
# AICORE_RESOURCE_GROUP — SAP AI Core resource group (optional, default: default)
on:
pull_request:
types: [opened, synchronize]
jobs:
test:
runs-on: rpl-linux-runners
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: go test ./...
- run: go vet ./...
- run: go build -o review-bot ./cmd/review-bot
review:
runs-on: rpl-linux-runners
if: github.event_name == 'pull_request'
needs: test
strategy:
fail-fast: false
matrix:
include:
- name: sonnet
token_secret: SONNET_REVIEW_TOKEN
model: anthropic--claude-4.6-sonnet
- name: gpt
token_secret: GPT_REVIEW_TOKEN
model: gpt-5
- name: security
token_secret: GPT_REVIEW_TOKEN
model: gpt-5
system_prompt_file: SECURITY_REVIEW.md
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/review
with:
# On GHES runners, vcs-url is ignored (composite action uses github.server_url).
# Specifying vcs-url here causes the action to download the binary from
# Gitea releases when strat/review-bot has no releases yet.
vcs-url: https://gitea.weiker.me
action-repo: strat/review-bot
reviewer-token: ${{ secrets[matrix.token_secret] }}
reviewer-name: ${{ matrix.name }}
llm-provider: aicore
llm-model: ${{ matrix.model }}
aicore-client-id: ${{ secrets.AICORE_CLIENT_ID }}
aicore-client-secret: ${{ secrets.AICORE_CLIENT_SECRET }}
aicore-auth-url: ${{ secrets.AICORE_AUTH_URL }}
aicore-api-url: ${{ secrets.AICORE_API_URL }}
aicore-resource-group: ${{ secrets.AICORE_RESOURCE_GROUP || 'default' }}
conventions-file: CONVENTIONS.md
patterns-repo: rodin/go-patterns
patterns-files: README.md,patterns/
timeout: "600"
system-prompt-file: ${{ matrix.system_prompt_file || '' }}
+8 -32
View File
@@ -168,8 +168,12 @@ func main() {
os.Exit(1)
}
// Detect VCS type and initialize the appropriate client.
vcsType := detectVCSType()
slog.Info("detected VCS type", "vcs", vcsType)
// Initialize clients
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
giteaClient := newVCSClient(vcsType, *vcsURL, *reviewerToken)
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
if *llmTemp < 0 || *llmTemp > 2 {
slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2")
@@ -207,7 +211,7 @@ func main() {
var persona *review.Persona
if *personaName != "" {
// Try loading from repo first, then fall back to built-in
repoPersonas, err := review.LoadRepoPersonas(ctx, newGiteaClientAdapter(giteaClient), owner, repoName)
repoPersonas, err := review.LoadRepoPersonas(ctx, buildRepoPersonaClient(giteaClient), owner, repoName)
if err != nil {
slog.Warn("could not load repo personas", "repo", owner+"/"+repoName, "error", err)
// Continue with built-in personas only.
@@ -527,7 +531,7 @@ func main() {
}
// fetchFileContext fetches the full content of modified files from the PR branch.
func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, ref string, files []gitea.ChangedFile) string {
func fetchFileContext(ctx context.Context, client vcsClient, owner, repo, ref string, files []gitea.ChangedFile) string {
var sb strings.Builder
for _, f := range files {
if ctx.Err() != nil {
@@ -554,7 +558,7 @@ func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, re
// patternsFiles is comma-separated list of file paths or directories.
// If a path ends with / or is a directory, all files within it are fetched recursively.
// If patternsFiles is empty, all files from the repo root are fetched.
func fetchPatterns(ctx context.Context, client *gitea.Client, patternsRepo, patternsFiles string) string {
func fetchPatterns(ctx context.Context, client vcsClient, patternsRepo, patternsFiles string) string {
var sb strings.Builder
repos := strings.Split(patternsRepo, ",")
@@ -851,31 +855,3 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
return evaluatedSHA != currentSHA
}
// giteaClientAdapter adapts gitea.Client to review.GiteaClient interface.
type giteaClientAdapter struct {
client *gitea.Client
}
func newGiteaClientAdapter(c *gitea.Client) *giteaClientAdapter {
return &giteaClientAdapter{client: c}
}
func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
entries, err := a.client.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
result := make([]review.ContentEntry, len(entries))
for i, e := range entries {
result[i] = review.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
}
}
return result, nil
}
func (a *giteaClientAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return a.client.GetFileContent(ctx, owner, repo, filepath)
}
+295
View File
@@ -0,0 +1,295 @@
package main
// vcs.go — VCS client abstraction for supporting both Gitea and GitHub.
//
// This file defines the vcsClient interface that main.go uses for all VCS
// operations, and provides a githubAdapter that wraps *github.Client and
// converts between github-package types and the gitea-package types used
// throughout the rest of the binary.
//
// Design rationale: the entire codebase was written against gitea types.
// Rather than introduce a third "shared" type package and update every call
// site, the adapter converts at the boundary. The conversion is cheap — these
// are small structs fetched once per run.
import (
"context"
"os"
githubpkg "gitea.weiker.me/rodin/review-bot/github"
"gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/review"
)
// vcsClient is the interface that main.go uses for all VCS API operations.
// Both *gitea.Client (directly) and *githubAdapter (via this file) satisfy it.
type vcsClient interface {
GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error)
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]gitea.ChangedFile, error)
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]gitea.CommitStatus, error)
GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error)
GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error)
GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]gitea.Review, error)
GetAuthenticatedUser(ctx context.Context) (string, error)
RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error
PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []gitea.ReviewComment) (*gitea.Review, error)
GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, number int, reviewID int64) (int64, error)
EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error
ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]gitea.ReviewComment, error)
ResolveComment(ctx context.Context, owner, repo string, commentID int64) error
ListContents(ctx context.Context, owner, repo, path string) ([]gitea.ContentEntry, error)
}
// vcsClientAdapterForPersona adapts vcsClient to review.GiteaClient.
// Used by LoadRepoPersonas which needs only ListContents + GetFileContent.
type vcsClientAdapterForPersona struct {
client vcsClient
}
func newVCSClientAdapterForPersona(c vcsClient) *vcsClientAdapterForPersona {
return &vcsClientAdapterForPersona{client: c}
}
func (a *vcsClientAdapterForPersona) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
entries, err := a.client.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
result := make([]review.ContentEntry, len(entries))
for i, e := range entries {
result[i] = review.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
}
}
return result, nil
}
func (a *vcsClientAdapterForPersona) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return a.client.GetFileContent(ctx, owner, repo, filepath)
}
// detectVCSType returns "github" if the environment indicates a GitHub or GHES
// runner, "gitea" otherwise.
//
// Detection logic mirrors the action.yml composite action:
// - GITHUB_API_URL is set by GitHub Actions runners (github.com and GHES)
// - On Gitea Actions runners it is empty or absent
func detectVCSType() string {
if os.Getenv("GITHUB_API_URL") != "" {
return "github"
}
return "gitea"
}
// githubAPIBaseURL returns the GitHub API base URL from the environment.
// On GitHub.com this is https://api.github.com.
// On GHES this is https://<host>/api/v3.
func githubAPIBaseURL() string {
if u := os.Getenv("GITHUB_API_URL"); u != "" {
return u
}
return "https://api.github.com"
}
// githubAdapter wraps *github.Client and translates github-package types to
// gitea-package types so that the rest of main.go can remain unchanged.
type githubAdapter struct {
c *githubpkg.Client
}
func newGitHubAdapter(token, apiBaseURL string) *githubAdapter {
return &githubAdapter{c: githubpkg.NewClient(token, apiBaseURL)}
}
func (a *githubAdapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error) {
pr, err := a.c.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return nil, err
}
return &gitea.PullRequest{
Title: pr.Title,
Body: pr.Body,
Head: struct {
Sha string "json:\"sha\""
Ref string "json:\"ref\""
}{Sha: pr.Head.Sha, Ref: pr.Head.Ref},
}, nil
}
func (a *githubAdapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
return a.c.GetPullRequestDiff(ctx, owner, repo, number)
}
func (a *githubAdapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]gitea.ChangedFile, error) {
files, err := a.c.GetPullRequestFiles(ctx, owner, repo, number)
if err != nil {
return nil, err
}
result := make([]gitea.ChangedFile, len(files))
for i, f := range files {
result[i] = gitea.ChangedFile{
Filename: f.Filename,
Status: f.Status,
}
}
return result, nil
}
func (a *githubAdapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]gitea.CommitStatus, error) {
statuses, err := a.c.GetCommitStatuses(ctx, owner, repo, sha)
if err != nil {
return nil, err
}
result := make([]gitea.CommitStatus, len(statuses))
for i, s := range statuses {
// GitHub uses "state" with values: success, failure, pending, error.
// Gitea uses "status" with values: success, failure, pending, warning, error.
// Map GitHub's "state" to gitea's "status" field for evaluateCIStatus().
result[i] = gitea.CommitStatus{
Status: s.State,
Context: s.Context,
Description: s.Description,
TargetURL: s.TargetURL,
}
}
return result, nil
}
func (a *githubAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return a.c.GetFileContent(ctx, owner, repo, filepath)
}
func (a *githubAdapter) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
return a.c.GetFileContentRef(ctx, owner, repo, filepath, ref)
}
func (a *githubAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
return a.c.GetAllFilesInPath(ctx, owner, repo, path)
}
func (a *githubAdapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]gitea.Review, error) {
reviews, err := a.c.ListReviews(ctx, owner, repo, number)
if err != nil {
return nil, err
}
result := make([]gitea.Review, len(reviews))
for i, r := range reviews {
result[i] = gitea.Review{
ID: r.ID,
Body: r.Body,
User: struct {
Login string "json:\"login\""
}{Login: r.User.Login},
State: r.State,
CommitID: r.CommitID,
}
}
return result, nil
}
func (a *githubAdapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
return a.c.GetAuthenticatedUser(ctx)
}
func (a *githubAdapter) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
return a.c.RequestReviewer(ctx, owner, repo, number, reviewer)
}
func (a *githubAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []gitea.ReviewComment) (*gitea.Review, error) {
// Convert gitea ReviewComments to github ReviewComments.
// NewPosition in Gitea maps to Position in GitHub (diff line position).
ghComments := make([]githubpkg.ReviewComment, len(comments))
for i, c := range comments {
ghComments[i] = githubpkg.ReviewComment{
Path: c.Path,
Position: c.NewPosition,
Body: c.Body,
}
}
review, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, ghComments)
if err != nil {
return nil, err
}
return &gitea.Review{
ID: review.ID,
Body: review.Body,
User: struct {
Login string "json:\"login\""
}{Login: review.User.Login},
State: review.State,
CommitID: review.CommitID,
}, nil
}
func (a *githubAdapter) GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, number int, reviewID int64) (int64, error) {
return a.c.GetTimelineReviewCommentIDForReview(ctx, owner, repo, number, reviewID)
}
func (a *githubAdapter) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error {
return a.c.EditComment(ctx, owner, repo, commentID, newBody)
}
func (a *githubAdapter) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]gitea.ReviewComment, error) {
comments, err := a.c.ListReviewComments(ctx, owner, repo, prNumber, reviewID)
if err != nil {
return nil, err
}
result := make([]gitea.ReviewComment, len(comments))
for i, c := range comments {
result[i] = gitea.ReviewComment{
ID: c.ID,
Path: c.Path,
NewPosition: c.Position,
Body: c.Body,
}
}
return result, nil
}
func (a *githubAdapter) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
return a.c.ResolveComment(ctx, owner, repo, commentID)
}
func (a *githubAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]gitea.ContentEntry, error) {
entries, err := a.c.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
result := make([]gitea.ContentEntry, len(entries))
for i, e := range entries {
result[i] = gitea.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
}
}
return result, nil
}
// newVCSClient creates the appropriate VCS client based on detected VCS type.
// On GitHub/GHES (GITHUB_API_URL set), returns a githubAdapter.
// On Gitea (GITHUB_API_URL absent), returns *gitea.Client directly.
//
// For GitHub: uses GITHUB_API_URL as the API base URL (trusted platform value).
// For Gitea: uses vcsURL (validated before this call).
func newVCSClient(vcsType, vcsURL, reviewerToken string) vcsClient {
switch vcsType {
case "github":
apiURL := githubAPIBaseURL()
return newGitHubAdapter(reviewerToken, apiURL)
default:
return gitea.NewClient(vcsURL, reviewerToken)
}
}
// buildRepoPersonaClient creates a review.GiteaClient from the active vcsClient.
// This exists because LoadRepoPersonas expects the review.GiteaClient interface
// (which only requires ListContents + GetFileContent).
func buildRepoPersonaClient(c vcsClient) review.GiteaClient {
return newVCSClientAdapterForPersona(c)
}
+549
View File
@@ -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, "/")
}