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 NewLine int64 // absolute line number on the new (right) side of the diff, used by both Gitea and GitHub adapters 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.NewLine, 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 Line+Side (absolute line on the RIGHT side). // NewLine from diff parsing gives absolute new-file line numbers. // Comments that cannot be mapped will be omitted (GitHub rejects invalid positions). gc[i] = github.ReviewComment{ Path: c.Path, Line: c.NewLine, 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) }