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