c53a07b230
CI / test (push) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
title
362 lines
14 KiB
Go
362 lines
14 KiB
Go
package main
|
|
|
|
// vcs.go defines the vcsClient interface that both gitea.Client (via giteaVCSAdapter)
|
|
// and github.Client (via githubVCSAdapter) satisfy, enabling VCS-type routing in main.go.
|
|
//
|
|
// Interface design:
|
|
// - Methods cover all PR review operations used by main.go.
|
|
// - Gitea-specific operations (supersede, comment resolution) are in the separate
|
|
// giteaExtClient interface. GitHub implementations return ErrNotSupported for those.
|
|
// - Types are defined here as package-local VCS types; each adapter converts from
|
|
// its respective client package's types.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
|
"gitea.weiker.me/rodin/review-bot/github"
|
|
"gitea.weiker.me/rodin/review-bot/review"
|
|
)
|
|
|
|
// ErrNotSupported is returned by VCS methods that have no implementation for
|
|
// a particular VCS backend (e.g., Gitea-specific timeline APIs on GitHub).
|
|
var ErrNotSupported = errors.New("operation not supported on this VCS backend")
|
|
|
|
// vcsClient is the interface for all PR operations used by main.go.
|
|
// It is implemented by both giteaVCSAdapter and githubVCSAdapter.
|
|
// Interface defined here (in the consumer package) per Go idiom.
|
|
type vcsClient interface {
|
|
// PR metadata and content
|
|
GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error)
|
|
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
|
|
GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error)
|
|
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error)
|
|
GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error)
|
|
GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error)
|
|
ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error)
|
|
GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error)
|
|
|
|
// Review operations
|
|
PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error)
|
|
ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error)
|
|
DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error
|
|
GetAuthenticatedUser(ctx context.Context) (string, error)
|
|
RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error
|
|
}
|
|
|
|
// giteaExtClient extends vcsClient with Gitea-specific operations that have no
|
|
// GitHub equivalent. Code that uses these methods should first do a type assertion.
|
|
type giteaExtClient interface {
|
|
vcsClient
|
|
GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, prNum, reviewID int64) (int64, error)
|
|
EditComment(ctx context.Context, owner, repo string, commentID int64, body string) error
|
|
ListReviewComments(ctx context.Context, owner, repo string, prNum, reviewID int64) ([]gitea.ReviewComment, error)
|
|
ResolveComment(ctx context.Context, owner, repo string, commentID int64) error
|
|
}
|
|
|
|
// --- shared VCS types ---
|
|
|
|
// vcsPullRequest is VCS-agnostic PR metadata.
|
|
type vcsPullRequest struct {
|
|
Title string
|
|
Body string
|
|
Head struct {
|
|
Sha string
|
|
Ref string
|
|
}
|
|
}
|
|
|
|
// vcsChangedFile is a file changed in a PR.
|
|
type vcsChangedFile struct {
|
|
Filename string
|
|
Status string
|
|
}
|
|
|
|
// vcsCommitStatus is a CI status entry.
|
|
type vcsCommitStatus struct {
|
|
Status string
|
|
Context string
|
|
Description string
|
|
TargetURL string
|
|
}
|
|
|
|
// vcsReviewComment is an inline review comment.
|
|
type vcsReviewComment struct {
|
|
Path string
|
|
NewPosition int64 // Gitea: absolute line; GitHub: diff hunk position
|
|
Body string
|
|
}
|
|
|
|
// vcsReview is a submitted PR review.
|
|
type vcsReview struct {
|
|
ID int64
|
|
Body string
|
|
CommitID string
|
|
User struct {
|
|
Login string
|
|
}
|
|
State string
|
|
}
|
|
|
|
// ============================================================
|
|
// giteaVCSAdapter
|
|
// ============================================================
|
|
|
|
// giteaVCSAdapter wraps gitea.Client to implement vcsClient + giteaExtClient.
|
|
type giteaVCSAdapter struct {
|
|
c *gitea.Client
|
|
}
|
|
|
|
func newGiteaVCSAdapter(c *gitea.Client) *giteaVCSAdapter { return &giteaVCSAdapter{c: c} }
|
|
|
|
func (a *giteaVCSAdapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error) {
|
|
pr, err := a.c.GetPullRequest(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &vcsPullRequest{Title: pr.Title, Body: pr.Body}
|
|
r.Head.Sha = pr.Head.Sha
|
|
r.Head.Ref = pr.Head.Ref
|
|
return r, nil
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
return a.c.GetPullRequestDiff(ctx, owner, repo, number)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error) {
|
|
files, err := a.c.GetPullRequestFiles(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]vcsChangedFile, len(files))
|
|
for i, f := range files {
|
|
out[i] = vcsChangedFile{Filename: f.Filename, Status: f.Status}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error) {
|
|
statuses, err := a.c.GetCommitStatuses(ctx, owner, repo, sha)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]vcsCommitStatus, len(statuses))
|
|
for i, s := range statuses {
|
|
out[i] = vcsCommitStatus{Status: s.Status, Context: s.Context, Description: s.Description, TargetURL: s.TargetURL}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
return a.c.GetFileContent(ctx, owner, repo, filepath)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
|
return a.c.GetFileContentRef(ctx, owner, repo, filepath, ref)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
|
|
entries, err := a.c.ListContents(ctx, owner, repo, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]review.ContentEntry, len(entries))
|
|
for i, e := range entries {
|
|
out[i] = review.ContentEntry{Name: e.Name, Path: e.Path, Type: e.Type}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
|
|
return a.c.GetAllFilesInPath(ctx, owner, repo, path)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
|
gc := make([]gitea.ReviewComment, len(comments))
|
|
for i, c := range comments {
|
|
gc[i] = gitea.ReviewComment{Path: c.Path, NewPosition: c.NewPosition, Body: c.Body}
|
|
}
|
|
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &vcsReview{ID: r.ID, Body: r.Body, CommitID: r.CommitID, State: r.State}
|
|
out.User.Login = r.User.Login
|
|
return out, nil
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error) {
|
|
reviews, err := a.c.ListReviews(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]vcsReview, len(reviews))
|
|
for i, r := range reviews {
|
|
out[i] = vcsReview{ID: r.ID, Body: r.Body, CommitID: r.CommitID, State: r.State}
|
|
out[i].User.Login = r.User.Login
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
|
return a.c.DeleteReview(ctx, owner, repo, number, reviewID)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
return a.c.GetAuthenticatedUser(ctx)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
|
|
return a.c.RequestReviewer(ctx, owner, repo, number, reviewer)
|
|
}
|
|
|
|
// Gitea-specific extension methods.
|
|
|
|
func (a *giteaVCSAdapter) GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, prNum, reviewID int64) (int64, error) {
|
|
return a.c.GetTimelineReviewCommentIDForReview(ctx, owner, repo, int(prNum), reviewID)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) EditComment(ctx context.Context, owner, repo string, commentID int64, body string) error {
|
|
return a.c.EditComment(ctx, owner, repo, commentID, body)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) ListReviewComments(ctx context.Context, owner, repo string, prNum, reviewID int64) ([]gitea.ReviewComment, error) {
|
|
return a.c.ListReviewComments(ctx, owner, repo, int(prNum), reviewID)
|
|
}
|
|
|
|
func (a *giteaVCSAdapter) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
|
|
return a.c.ResolveComment(ctx, owner, repo, commentID)
|
|
}
|
|
|
|
// ============================================================
|
|
// githubVCSAdapter
|
|
// ============================================================
|
|
|
|
// githubVCSAdapter wraps github.Client to implement vcsClient.
|
|
// Gitea-specific extension methods (GetTimelineReviewCommentIDForReview, EditComment,
|
|
// ListReviewComments, ResolveComment) are not available on GitHub and will not be called
|
|
// because main.go gates them with a type assertion to giteaExtClient.
|
|
type githubVCSAdapter struct {
|
|
c *github.Client
|
|
}
|
|
|
|
func newGithubVCSAdapter(c *github.Client) *githubVCSAdapter { return &githubVCSAdapter{c: c} }
|
|
|
|
func (a *githubVCSAdapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error) {
|
|
pr, err := a.c.GetPullRequest(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &vcsPullRequest{Title: pr.Title, Body: pr.Body}
|
|
r.Head.Sha = pr.Head.Sha
|
|
r.Head.Ref = pr.Head.Ref
|
|
return r, nil
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
return a.c.GetPullRequestDiff(ctx, owner, repo, number)
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error) {
|
|
files, err := a.c.GetPullRequestFiles(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]vcsChangedFile, len(files))
|
|
for i, f := range files {
|
|
out[i] = vcsChangedFile{Filename: f.Filename, Status: f.Status}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error) {
|
|
statuses, err := a.c.GetCommitStatuses(ctx, owner, repo, sha)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]vcsCommitStatus, len(statuses))
|
|
for i, s := range statuses {
|
|
// CommitStatus.Status is tagged as json:"state" — already the normalized "state" value
|
|
out[i] = vcsCommitStatus{Status: s.Status, Context: s.Context, Description: s.Description, TargetURL: s.TargetURL}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
return a.c.GetFileContent(ctx, owner, repo, filepath)
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
|
return a.c.GetFileContentRef(ctx, owner, repo, filepath, ref)
|
|
}
|
|
|
|
func (a *githubVCSAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
|
|
entries, err := a.c.ListContents(ctx, owner, repo, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]review.ContentEntry, len(entries))
|
|
for i, e := range entries {
|
|
out[i] = review.ContentEntry{Name: e.Name, Path: e.Path, Type: e.Type}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
|
|
return a.c.GetAllFilesInPath(ctx, owner, repo, path)
|
|
}
|
|
|
|
func (a *githubVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
|
gc := make([]github.ReviewComment, len(comments))
|
|
for i, c := range comments {
|
|
// GitHub inline comments use diff hunk "position", not absolute line numbers.
|
|
// NewPosition from gitea diff parsing gives absolute line numbers, which
|
|
// will not match GitHub's position values. For initial GitHub support, we
|
|
// attach comments with Line+Side (absolute line on the RIGHT side) instead.
|
|
// Comments that cannot be mapped will be omitted (GitHub rejects invalid positions).
|
|
gc[i] = github.ReviewComment{
|
|
Path: c.Path,
|
|
Line: c.NewPosition,
|
|
Side: "RIGHT",
|
|
Body: c.Body,
|
|
}
|
|
}
|
|
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &vcsReview{ID: r.ID, Body: r.Body, State: r.State}
|
|
out.User.Login = r.User.Login
|
|
return out, nil
|
|
}
|
|
|
|
func (a *githubVCSAdapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error) {
|
|
reviews, err := a.c.ListReviews(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]vcsReview, len(reviews))
|
|
for i, r := range reviews {
|
|
out[i] = vcsReview{ID: r.ID, Body: r.Body, State: r.State}
|
|
out[i].User.Login = r.User.Login
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *githubVCSAdapter) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
|
// GitHub only allows deleting PENDING (draft) reviews. review-bot posts submitted
|
|
// reviews, so this will return an error for any review we actually posted.
|
|
// Callers should treat 422 errors here gracefully.
|
|
return a.c.DeleteReview(ctx, owner, repo, number, reviewID)
|
|
}
|
|
|
|
func (a *githubVCSAdapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
return a.c.GetAuthenticatedUser(ctx)
|
|
}
|
|
|
|
func (a *githubVCSAdapter) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
|
|
return a.c.RequestReviewer(ctx, owner, repo, number, reviewer)
|
|
}
|