From 1e1a50581f8c3189f9ea056adc79cceecec31535 Mon Sep 17 00:00:00 2001 From: Rodin Date: Thu, 14 May 2026 20:15:25 +0000 Subject: [PATCH] feat(cmd): add VCS client abstraction for GitHub and Gitea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/review-bot/vcs.go | 295 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 cmd/review-bot/vcs.go diff --git a/cmd/review-bot/vcs.go b/cmd/review-bot/vcs.go new file mode 100644 index 0000000..67b9714 --- /dev/null +++ b/cmd/review-bot/vcs.go @@ -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:///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) +} +