7e3b6ec8f1
CI / test (pull_request) Successful in 20s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 46s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m24s
Add CommitID field to vcs.ReviewRequest so the commit anchor propagates through the vcs.Client interface to platform adapters. Changes: - vcs/types.go: Add CommitID string field to ReviewRequest - gitea/client.go: Add commitID parameter to PostReview, include in API payload - gitea/adapter.go: Pass req.CommitID to underlying client - github/review.go: Use req.CommitID as primary, fall back to comment-level - cmd/review-bot/main.go: Set CommitID on ReviewRequest from pr.Head.SHA Fixes #114
317 lines
11 KiB
Go
317 lines
11 KiB
Go
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("<details><summary>Previous findings (commit ")
|
|
sb.WriteString(shortSHA)
|
|
sb.WriteString(")</summary>\n\n")
|
|
} else {
|
|
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
|
|
}
|
|
sb.WriteString(originalBody)
|
|
sb.WriteString("\n\n</details>\n\n")
|
|
sb.WriteString(sentinel)
|
|
return sb.String()
|
|
}
|