Files
claude-botandClaude Opus 5.5 5ccb3ceaa6 feat(review-bot): report queued/running/retrying/failed per PR
Every phase of a pull request's review is now visible in two places:

- a commit status "review-bot" on the reviewed head: pending while
  queued, running or waiting to retry; success once reviewed (with the
  verdict) or skipped; error with the reason when the review failed;
  warning when new commits superseded the reviewed head. The statuses
  are posted one at a time in order, so a later phase never lands before
  an earlier one, and never block the queue.
- GET /status and GET /status/{owner}/{repo}/{n}: the in-memory state
  of every PR seen since start, with the queue position while queued.
  Finished entries are dropped after a day.

Until now a failed review posted nothing, so a PR whose review failed
looked the same as one still waiting.

Also gofmt report_test.go, which the CI gofmt check flags on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:12:42 +02:00

218 lines
5.7 KiB
Go

package main
import (
"context"
"log/slog"
"slices"
"sync"
"time"
)
// Phase is where a pull request's review stands.
type Phase string
const (
PhaseQueued Phase = "queued"
PhaseRunning Phase = "running"
PhaseRetrying Phase = "retrying"
PhasePosted Phase = "posted"
PhaseSkipped Phase = "skipped"
PhaseFailed Phase = "failed"
PhaseStale Phase = "stale"
)
// statusContext is the commit status context the bot reports under.
const statusContext = "review-bot"
// keepFinished is how long a finished review stays listed on /status.
const keepFinished = 24 * time.Hour
// commitState maps a phase to the Gitea commit status state.
func commitState(p Phase) string {
switch p {
case PhasePosted, PhaseSkipped:
return "success"
case PhaseFailed:
return "error"
case PhaseStale:
return "warning"
}
return "pending"
}
func (p Phase) finished() bool {
return p == PhasePosted || p == PhaseSkipped || p == PhaseFailed || p == PhaseStale
}
// PRStatus is one pull request's review state as /status lists it.
type PRStatus struct {
Repo string `json:"repo"`
Number int `json:"number"`
Head string `json:"head,omitempty"`
Phase Phase `json:"phase"`
Detail string `json:"detail,omitempty"`
URL string `json:"url,omitempty"`
Position int `json:"position,omitempty"` // 1 for the next to run, while queued
Since time.Time `json:"since"` // when the phase began
}
type statusPost struct {
slug, sha, state, description, url string
flushed chan struct{}
}
// Tracker holds each pull request's review state in memory and mirrors every change into a
// commit status on the reviewed head. The commit statuses go out one at a time, in the order of
// the changes, so a later phase never lands before an earlier one.
type Tracker struct {
gitea *Gitea
log *slog.Logger
metrics *Metrics
now func() time.Time
mu sync.Mutex
prs map[string]*PRStatus
posts chan statusPost
}
func newTracker(g *Gitea, log *slog.Logger, m *Metrics) *Tracker {
return &Tracker{gitea: g, log: log, metrics: m, now: time.Now, prs: map[string]*PRStatus{},
posts: make(chan statusPost, 256)}
}
// Set records the phase of slug#n. head is the commit it is about; "" keeps the one known, and
// only a failure is then reported on it, since it is the review of that commit that failed. It
// never blocks: when the commit status backlog is full the status is only logged as dropped.
func (t *Tracker) Set(slug string, n int, head string, phase Phase, detail, url string) {
t.mu.Lock()
k := key(slug, n)
s := t.prs[k]
if s == nil {
s = &PRStatus{Repo: slug, Number: n}
t.prs[k] = s
}
post := head
if head == "" && phase == PhaseFailed {
post = s.Head
}
if head != "" {
s.Head = head
}
if s.Phase != phase || phase == PhaseRetrying {
s.Since = t.now()
}
s.Phase, s.Detail, s.URL = phase, detail, url
t.pruneLocked()
t.mu.Unlock()
if post == "" {
return
}
select {
case t.posts <- statusPost{slug: slug, sha: post, state: commitState(phase), description: describe(phase, detail), url: url}:
default:
t.metrics.Inc("status_posts_total", "result", "dropped")
t.log.Warn("commit status backlog full, dropped", "pr", k, "phase", phase)
}
}
func (t *Tracker) pruneLocked() {
for k, s := range t.prs {
if s.Phase.finished() && t.now().Sub(s.Since) > keepFinished {
delete(t.prs, k)
}
}
}
// List returns every tracked pull request, the queued ones numbered in the order they run.
func (t *Tracker) List() []PRStatus {
t.mu.Lock()
defer t.mu.Unlock()
t.pruneLocked()
out := make([]PRStatus, 0, len(t.prs))
for _, s := range t.prs {
out = append(out, *s)
}
slices.SortFunc(out, func(a, b PRStatus) int { return a.Since.Compare(b.Since) })
pos := 0
for i := range out {
if out[i].Phase == PhaseQueued {
pos++
out[i].Position = pos
}
}
return out
}
// Get returns slug#n's state, or false when it is not tracked.
func (t *Tracker) Get(slug string, n int) (PRStatus, bool) {
for _, s := range t.List() {
if s.Repo == slug && s.Number == n {
return s, true
}
}
return PRStatus{}, false
}
// Run posts the commit statuses until ctx ends.
func (t *Tracker) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case p := <-t.posts:
if p.flushed != nil {
close(p.flushed)
continue
}
pctx, cancel := context.WithTimeout(ctx, 15*time.Second)
err := t.gitea.SetStatus(pctx, p.slug, p.sha, p.state, p.description, p.url)
cancel()
if err != nil {
t.metrics.Inc("status_posts_total", "result", "failed")
t.log.Warn("cannot set the commit status", "repo", p.slug, "sha", p.sha, "state", p.state, "err", err)
continue
}
t.metrics.Inc("status_posts_total", "result", "ok")
}
}
}
// Flush waits until every commit status set so far is posted (or failed); Run must be running.
func (t *Tracker) Flush() {
done := make(chan struct{})
t.posts <- statusPost{flushed: done}
<-done
}
// describe is the commit status description: the phase in words, then the detail.
func describe(p Phase, detail string) string {
words := map[Phase]string{
PhaseQueued: "queued",
PhaseRunning: "review running",
PhaseRetrying: "waiting to retry",
PhasePosted: "reviewed",
PhaseSkipped: "skipped",
PhaseFailed: "review failed",
PhaseStale: "superseded",
}[p]
if detail != "" {
words += ": " + detail
}
if r := []rune(words); len(r) > 140 {
words = string(r[:139]) + "…"
}
return words
}
// verdictWords is the review event in the words of the commit status.
func verdictWords(event string) string {
switch event {
case "APPROVE", "APPROVED":
return "approve"
case "REQUEST_CHANGES":
return "request changes"
}
return "comment"
}