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>
156 lines
4.3 KiB
Go
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
|
|
}
|