package gitea import ( "context" "fmt" "log/slog" "strings" "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) var _ vcs.ReviewerSelfRequester = (*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) } // Per-comment CommitID is not forwarded to Gitea inline comments: // Gitea's CreatePullReview API has no per-comment commit_id field. // The review-level commit anchor is set via req.CommitID instead. 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, req.CommitID, 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) } // RequestReviewerSelf adds the given user as a requested reviewer on a pull request. // This implements vcs.ReviewerSelfRequester for the Gitea adapter. func (a *Adapter) RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error { return a.client.RequestReviewer(ctx, owner, repo, number, user) } // Compile-time interface conformance assertion for ReviewSuperseder. var _ vcs.ReviewSuperseder = (*Adapter)(nil) // SupersedeReviews marks prior reviews as superseded by editing their body with a // link to the new review and resolving their inline comments. This is Gitea-specific // behavior that has no GitHub equivalent (GitHub uses DismissReview instead). // // baseURL is the Gitea instance URL used to construct review permalink URLs. // sentinel is the HTML comment sentinel that identifies reviews belonging to this reviewer. func (a *Adapter) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, baseURL, sentinel string) error { // Validate baseURL scheme before embedding in Markdown link (defense-in-depth). if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { return fmt.Errorf("SupersedeReviews: baseURL must have http or https scheme, got %q", baseURL) } underlying := a.client newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(baseURL, "/"), owner, repo, prNumber, newReviewID) for _, oldReview := range oldReviews { cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repo, prNumber, oldReview.ID) if err != nil { slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err) continue } supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel) if err := underlying.EditComment(ctx, owner, repo, cid, supersededBody); err != nil { slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "error", err) continue } // Resolve old review's inline comments oldComments, err := underlying.ListReviewComments(ctx, owner, repo, prNumber, oldReview.ID) if err != nil { slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err) continue } for _, c := range oldComments { if c.ID == 0 { continue } if err := underlying.ResolveComment(ctx, owner, repo, c.ID); err != nil { slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err) } } } return nil } // buildSupersededBody creates the body for a superseded review: struck-through banner // with collapsed original content and the commit it was evaluated against. func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string { shortSHA := commitSHA if len(shortSHA) > 8 { shortSHA = shortSHA[:8] } var sb strings.Builder sb.WriteString("~~Original review~~\n\n") sb.WriteString("**Superseded** \u2014 [see current review](") sb.WriteString(newReviewURL) sb.WriteString(") for up-to-date findings.\n\n") if shortSHA != "" { sb.WriteString("
Previous findings (commit ") sb.WriteString(shortSHA) sb.WriteString(")\n\n") } else { sb.WriteString("
Previous findings\n\n") } sb.WriteString(originalBody) sb.WriteString("\n\n
\n\n") sb.WriteString(sentinel) return sb.String() }