d8270262d6
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 33s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m27s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m35s
Add fmt.Errorf wrapping to the two remaining unwrapped error returns
in the adapter:
- GetPullRequest: 'get pull request: %w'
- PostReview (final client call): 'post review: %w'
This makes all error paths in the adapter consistent with the wrapping
pattern used by the diff-fetch and position-translation errors.
Addresses self-review findings #1 and #2 from b2eea502.
233 lines
7.6 KiB
Go
233 lines
7.6 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
// Adapter wraps a gitea.Client and satisfies the vcs.Client interface.
|
|
// It handles translation between GitHub-canonical diff positions and Gitea
|
|
// line numbers, and between canonical review event strings and Gitea-native values.
|
|
type Adapter struct {
|
|
client *Client
|
|
}
|
|
|
|
// Compile-time interface conformance assertion.
|
|
var _ vcs.Client = (*Adapter)(nil)
|
|
|
|
// NewAdapter creates a new Adapter wrapping the given gitea Client.
|
|
func NewAdapter(client *Client) *Adapter {
|
|
return &Adapter{client: client}
|
|
}
|
|
|
|
// Underlying returns the wrapped gitea.Client for Gitea-specific operations
|
|
// that have no vcs.Client equivalent (resolve comment, timeline, supersede flow).
|
|
func (a *Adapter) Underlying() *Client {
|
|
return a.client
|
|
}
|
|
|
|
// --- PRReader ---
|
|
|
|
// GetPullRequest maps gitea.PullRequest to vcs.PullRequest.
|
|
func (a *Adapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcs.PullRequest, error) {
|
|
pr, err := a.client.GetPullRequest(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get pull request: %w", err)
|
|
}
|
|
return &vcs.PullRequest{
|
|
Number: number,
|
|
Title: pr.Title,
|
|
Body: pr.Body,
|
|
Head: vcs.HeadRef{
|
|
SHA: pr.Head.Sha,
|
|
Ref: pr.Head.Ref,
|
|
},
|
|
Base: vcs.BaseRef{
|
|
Ref: pr.Base.Ref,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// GetPullRequestDiff is a pass-through to the underlying client.
|
|
func (a *Adapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
return a.client.GetPullRequestDiff(ctx, owner, repo, number)
|
|
}
|
|
|
|
// GetPullRequestFiles maps []gitea.ChangedFile to []vcs.ChangedFile.
|
|
// Patch field is omitted (zero-value) since Gitea's /pulls/{n}/files does not return patch text.
|
|
func (a *Adapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcs.ChangedFile, error) {
|
|
files, err := a.client.GetPullRequestFiles(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]vcs.ChangedFile, len(files))
|
|
for i, f := range files {
|
|
result[i] = vcs.ChangedFile{
|
|
Filename: f.Filename,
|
|
Status: f.Status,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetFileContentAtRef is a pass-through to the underlying client.
|
|
func (a *Adapter) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
|
return a.client.GetFileContentAtRef(ctx, owner, repo, path, ref)
|
|
}
|
|
|
|
// GetCommitStatuses maps []gitea.CommitStatus to []vcs.CommitStatus.
|
|
func (a *Adapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcs.CommitStatus, error) {
|
|
statuses, err := a.client.GetCommitStatuses(ctx, owner, repo, sha)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]vcs.CommitStatus, len(statuses))
|
|
for i, s := range statuses {
|
|
result[i] = vcs.CommitStatus{
|
|
Status: s.Status,
|
|
Context: s.Context,
|
|
Description: s.Description,
|
|
TargetURL: s.TargetURL,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// --- FileReader ---
|
|
|
|
// GetFileContent delegates to the underlying client, routing to the ref-aware
|
|
// variant when ref is non-empty.
|
|
func (a *Adapter) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
|
if ref != "" {
|
|
return a.client.GetFileContentRef(ctx, owner, repo, path, ref)
|
|
}
|
|
return a.client.GetFileContent(ctx, owner, repo, path)
|
|
}
|
|
|
|
// ListContents maps []gitea.ContentEntry to []vcs.ContentEntry.
|
|
func (a *Adapter) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
|
|
entries, err := a.client.ListContents(ctx, owner, repo, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]vcs.ContentEntry, len(entries))
|
|
for i, e := range entries {
|
|
result[i] = vcs.ContentEntry{
|
|
Name: e.Name,
|
|
Path: e.Path,
|
|
Type: e.Type,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// --- Reviewer ---
|
|
|
|
// translateEvent translates a vcs.ReviewEvent (GitHub-canonical) to a Gitea-native event string.
|
|
func translateEvent(event vcs.ReviewEvent) string {
|
|
switch event {
|
|
case vcs.ReviewEventApprove:
|
|
return "APPROVED"
|
|
case vcs.ReviewEventRequestChanges:
|
|
return "REQUEST_CHANGES"
|
|
case vcs.ReviewEventComment:
|
|
return "COMMENT"
|
|
default:
|
|
// Unknown events pass through as-is. This is intentional: new event types
|
|
// added to vcs.ReviewEvent will still be forwarded without a code change here,
|
|
// and Gitea will reject truly invalid values with a clear API error.
|
|
return string(event)
|
|
}
|
|
}
|
|
|
|
// PostReview translates vcs.ReviewRequest to the Gitea-native format.
|
|
// It fetches the PR diff, builds a position-to-line map, and translates each
|
|
// ReviewComment.Position (GitHub diff-position) to a Gitea new_position (line number).
|
|
func (a *Adapter) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) {
|
|
event := translateEvent(req.Event)
|
|
|
|
var giteaComments []ReviewComment
|
|
if len(req.Comments) > 0 {
|
|
// Fetch diff to build position → line number map.
|
|
// The diff is fetched unconditionally when comments exist. This adds latency
|
|
// for reviews with inline comments but keeps the implementation simple — caching
|
|
// the diff across calls would add complexity for minimal gain since PostReview
|
|
// is called at most once per review cycle.
|
|
diff, err := a.client.GetPullRequestDiff(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch diff for position translation: %w", err)
|
|
}
|
|
|
|
posMap := BuildPositionToLineMap(diff)
|
|
|
|
for _, c := range req.Comments {
|
|
lineNum, err := posMap.Translate(c.Path, c.Position)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("translate position %d in %s: %w", c.Position, c.Path, err)
|
|
}
|
|
// CommitID from vcs.ReviewComment is intentionally not forwarded:
|
|
// Gitea review comments are pinned to the PR head SHA automatically,
|
|
// and the CreatePullReview API has no per-comment commit_id field.
|
|
giteaComments = append(giteaComments, ReviewComment{
|
|
Path: c.Path,
|
|
NewPosition: int64(lineNum),
|
|
Body: c.Body,
|
|
})
|
|
}
|
|
}
|
|
|
|
review, err := a.client.PostReview(ctx, owner, repo, number, event, req.Body, giteaComments)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("post review: %w", err)
|
|
}
|
|
|
|
return &vcs.Review{
|
|
ID: review.ID,
|
|
Body: review.Body,
|
|
User: vcs.UserInfo{Login: review.User.Login},
|
|
State: review.State,
|
|
Stale: review.Stale,
|
|
CommitID: review.CommitID,
|
|
}, nil
|
|
}
|
|
|
|
// ListReviews maps []gitea.Review to []vcs.Review.
|
|
func (a *Adapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
|
|
reviews, err := a.client.ListReviews(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]vcs.Review, len(reviews))
|
|
for i, r := range reviews {
|
|
result[i] = vcs.Review{
|
|
ID: r.ID,
|
|
Body: r.Body,
|
|
User: vcs.UserInfo{Login: r.User.Login},
|
|
State: r.State,
|
|
Stale: r.Stale,
|
|
CommitID: r.CommitID,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteReview is a pass-through to the underlying client.
|
|
func (a *Adapter) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
|
return a.client.DeleteReview(ctx, owner, repo, number, reviewID)
|
|
}
|
|
|
|
// DismissReview deletes the review. Gitea supports full deletion of any review state.
|
|
// The message parameter is intentionally unused — Gitea deletion has no dismissal message.
|
|
func (a *Adapter) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
|
|
return a.client.DeleteReview(ctx, owner, repo, number, reviewID)
|
|
}
|
|
|
|
// --- Identity ---
|
|
|
|
// GetAuthenticatedUser is a pass-through to the underlying client.
|
|
func (a *Adapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
return a.client.GetAuthenticatedUser(ctx)
|
|
}
|