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) }