Files
claude-botandClaude Opus 5.5 04f1f1d639
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: approve
ci/woodpecker/pr/review-bot-test Pipeline was successful
fix(review-bot): approve with Gitea's APPROVED event
The bot posted approvals with event "APPROVE". Gitea knows APPROVED,
REQUEST_CHANGES and COMMENT and stores any other value as a pending review
that only its author sees (routers/api/v1/repo/pull_review.go,
preparePullReviewType, default branch). Every approval since cdba8b62 is such
a pending draft: homelab #1270, #1278, #1287, #1318, homeassistant #385.

The bot also counted its own pending review as "reviewed at this head", so
those PRs were never reviewed again. A pending review no longer counts; the
sweep reviews them again, and Gitea submits the existing pending review with
the new event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:09:47 +02:00

156 lines
4.3 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
type PR struct {
Number int `json:"number"`
State string `json:"state"`
Draft bool `json:"draft"`
Title string `json:"title"`
Body string `json:"body"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangedFiles int `json:"changed_files"`
HTMLURL string `json:"html_url"`
Head struct {
SHA string `json:"sha"`
} `json:"head"`
}
type Review struct {
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
User struct {
Login string `json:"login"`
} `json:"user"`
}
type Gitea struct {
base string
token string
http *http.Client
}
func (g *Gitea) do(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, g.base+"/api/v1"+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+g.token)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := g.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("gitea %s %s: HTTP %d: %s", method, path, resp.StatusCode, snippet(data))
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}
func (g *Gitea) PR(ctx context.Context, slug string, n int) (PR, error) {
var pr PR
err := g.do(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/pulls/%d", slug, n), nil, &pr)
return pr, err
}
func (g *Gitea) OpenPRs(ctx context.Context, slug string) ([]PR, error) {
var prs []PR
err := g.do(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", slug), nil, &prs)
return prs, err
}
func (g *Gitea) Reviews(ctx context.Context, slug string, n int) ([]Review, error) {
var rv []Review
err := g.do(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/pulls/%d/reviews?limit=100", slug, n), nil, &rv)
return rv, err
}
// Files lists the changed files of a pull request (the first 100).
func (g *Gitea) Files(ctx context.Context, slug string, n int) ([]PRFile, error) {
var files []PRFile
err := g.do(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/pulls/%d/files?limit=100", slug, n), nil, &files)
return files, err
}
// PostReview posts the review and returns its web address.
func (g *Gitea) PostReview(ctx context.Context, slug string, n int, body, event, commit string) (string, error) {
in := map[string]string{"body": body, "event": event, "commit_id": commit}
var out Review
err := g.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/pulls/%d/reviews", slug, n), in, &out)
return out.HTMLURL, err
}
// SetStatus sets the bot's commit status on sha.
func (g *Gitea) SetStatus(ctx context.Context, slug, sha, state, description, url string) error {
in := map[string]string{"state": state, "description": description, "target_url": url, "context": statusContext}
return g.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/statuses/%s", slug, sha), in, nil)
}
var wipTitle = regexp.MustCompile(`^[Ww][Ii][Pp][: ]`)
func marker(sha string) string { return "pr-review:sha=" + sha }
// eligible says why a PR needs no review ("closed", "draft", "wip", "reviewed") or "ok".
func eligible(pr PR, reviews []Review, bot string) string {
switch {
case pr.State != "open":
return "closed"
case pr.Draft:
return "draft"
case wipTitle.MatchString(pr.Title):
return "wip"
}
if _, ok := headReview(pr, reviews, bot); ok {
return "reviewed"
}
return "ok"
}
// headReview is the bot's last submitted review of the PR's head commit. A pending review is
// visible only to the bot, so it does not count.
func headReview(pr PR, reviews []Review, bot string) (Review, bool) {
for i := len(reviews) - 1; i >= 0; i-- {
if r := reviews[i]; r.User.Login == bot && r.State != "PENDING" && strings.Contains(r.Body, marker(pr.Head.SHA)) {
return r, true
}
}
return Review{}, false
}
func snippet(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 300 {
s = s[:300] + "…"
}
return s
}