diff --git a/README.md b/README.md index ee43bd4..10e805b 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,14 @@ On the next run, it finds and deletes any review containing its own sentinel (ex If `reviewer-name` is empty, cleanup is skipped (reviews stack like before). +### Shared Token: Worst-Wins Behavior + +When multiple review types share the same Gitea bot account (e.g. code-quality and security), Gitea determines the user's approval state from their **most recent review**. This creates a race condition: if security finds issues (REQUEST_CHANGES) but code-quality finishes last (APPROVE), the PR appears approved. + +review-bot handles this automatically with **worst-wins reconciliation**: after posting, each job checks whether any sibling review from the same user has REQUEST_CHANGES. If so and this job posted APPROVE, it deletes its review and re-posts as REQUEST_CHANGES. This ensures the PR stays blocked until all checks pass, regardless of execution order. + +**If you need independent approval/block per review type**, use separate Gitea bot accounts with their own tokens. + ## Custom Review Prompts Use `system-prompt-file` to specialize the review focus. The file contents are appended to the base system prompt as "Additional Review Instructions." diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 8684cac..39eed6b 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -249,6 +249,28 @@ func main() { } } } + + // Worst-wins: if any sibling review from the same user has REQUEST_CHANGES + // and we posted APPROVE, re-post as REQUEST_CHANGES to ensure Gitea's + // "last review wins" behavior reflects the most restrictive state. + if event == "APPROVED" { + for _, r := range reviews { + if r.ID != posted.ID && r.User.Login == posted.User.Login && !r.Stale && r.State == "REQUEST_CHANGES" { + log.Printf("Sibling review %d has REQUEST_CHANGES; escalating our review", r.ID) + if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID); err != nil { + log.Printf("Warning: could not delete review for escalation: %v", err) + } else { + escalated, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody) + if err != nil { + log.Printf("Warning: could not re-post escalated review: %v", err) + } else { + log.Printf("Review escalated to REQUEST_CHANGES (new id=%d)", escalated.ID) + } + } + break + } + } + } } } }