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" }