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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user