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>
218 lines
5.7 KiB
Go
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"
|
|
}
|