diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index f52307c..78f1005 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -66,6 +66,14 @@ inputs: description: 'Print review to stdout instead of posting' required: false default: 'false' + update-existing: + description: 'Delete previous review from same bot after posting new one. Accepts: true/1/yes or false/0/no (default true)' + required: false + default: 'true' + system-prompt-file: + description: 'Local file with additional system prompt instructions (e.g. security review focus)' + required: false + default: '' runs: using: 'composite' @@ -145,6 +153,8 @@ runs: LLM_TEMPERATURE: ${{ inputs.temperature }} LLM_TIMEOUT: ${{ inputs.timeout }} LLM_PROVIDER: ${{ inputs.llm-provider }} + UPDATE_EXISTING: ${{ inputs.update-existing }} + SYSTEM_PROMPT_FILE: ${{ inputs.system-prompt-file }} run: | ARGS="" if [ "${{ inputs.dry-run }}" = "true" ]; then diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4ada9a6..b429c56 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,6 +32,10 @@ jobs: - name: gpt token_secret: GPT_REVIEW_TOKEN model: gpt-4.1 + - name: security + token_secret: SONNET_REVIEW_TOKEN + model: gpt-5 + system_prompt_file: SECURITY_REVIEW.md steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -44,6 +48,7 @@ jobs: GITEA_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} REVIEWER_TOKEN: ${{ secrets[matrix.token_secret] }} + REVIEWER_NAME: ${{ matrix.name }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_MODEL: ${{ matrix.model }} @@ -51,4 +56,5 @@ jobs: PATTERNS_REPO: "rodin/go-patterns" PATTERNS_FILES: "README.md,patterns/" LLM_TIMEOUT: "600" + SYSTEM_PROMPT_FILE: ${{ matrix.system_prompt_file }} run: ./review-bot diff --git a/README.md b/README.md index ff2356a..3822a3e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,242 @@ # review-bot -Automated code review bot for Gitea. Fetches a pull request diff, sends it to an LLM for analysis, and posts a structured review back to the PR. +AI-powered code review bot for Gitea pull requests. Fetches diff + context, sends to an LLM, and posts a structured review (APPROVE / REQUEST_CHANGES) back to the PR. ## Features -- Fetches PR metadata, diff, and CI status from Gitea API -- Sends context-rich prompts to any OpenAI-compatible LLM -- Parses structured JSON review responses -- Posts formatted reviews (APPROVE / REQUEST_CHANGES) back to Gitea -- Supports custom coding conventions via repo files -- Zero external dependencies — Go stdlib only +- **Multi-provider**: OpenAI-compatible and Anthropic Messages API +- **Context-aware**: Fetches full file content, conventions, language patterns, CI status +- **Smart budget**: Automatically trims context to fit model token limits +- **Idempotent reviews**: Posts new review, then cleans up stale ones (one review per bot) +- **Custom prompts**: Load additional instructions from a file (e.g. security-focused review) +- **Zero dependencies**: Go stdlib only -## Usage +## Quick Start: Composite Action + +The easiest way to use review-bot in your Gitea CI: + +```yaml +# .gitea/workflows/review.yml +name: Review +on: + pull_request: + types: [opened, synchronize] + +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: code-review + llm-base-url: ${{ secrets.LLM_BASE_URL }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: gpt-4.1 +``` + +That's it. Every PR gets an automated review. + +## Examples + +### Single reviewer with conventions + +```yaml +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: reviewer + llm-base-url: ${{ secrets.LLM_BASE_URL }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: gpt-4.1 + conventions-file: CONVENTIONS.md + timeout: '600' +``` + +### Two reviewers with different models (diversity of opinion) + +```yaml +jobs: + review: + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - name: gpt + model: gpt-4.1 + token_secret: GPT_REVIEW_TOKEN + - name: claude + model: claude-sonnet-4-20250514 + token_secret: CLAUDE_REVIEW_TOKEN + provider: anthropic + steps: + - uses: actions/checkout@v4 + - uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets[matrix.token_secret] }} + reviewer-name: ${{ matrix.name }} + llm-base-url: ${{ secrets.LLM_BASE_URL }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: ${{ matrix.model }} + llm-provider: ${{ matrix.provider }} + conventions-file: CONVENTIONS.md +``` + +Each reviewer posts independently and only cleans up its own stale reviews. + +### Multiple review types from a single bot account + +Use the same Gitea token but different `reviewer-name` values to run specialized reviews without needing multiple bot accounts: + +```yaml +jobs: + review: + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - name: code-quality + model: gpt-4.1 + - name: security + model: gpt-4.1 + system_prompt_file: .review/SECURITY.md + - name: performance + model: gpt-4.1 + system_prompt_file: .review/PERFORMANCE.md + steps: + - uses: actions/checkout@v4 + - uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: ${{ matrix.name }} + llm-base-url: ${{ secrets.LLM_BASE_URL }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: ${{ matrix.model }} + system-prompt-file: ${{ matrix.system_prompt_file }} +``` + +The sentinel `` ensures the security review only replaces previous security reviews, never the code-quality or performance reviews. + +### With language patterns from another repo + +```yaml +- uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: reviewer + llm-base-url: ${{ secrets.LLM_BASE_URL }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: gpt-4.1 + conventions-file: CLAUDE.md + patterns-repo: rodin/go-patterns,rodin/kubernetes-conventions + patterns-files: "README.md,patterns/" +``` + +Pattern repos are fetched at review time. The reviewer uses them as criteria for idiomatic code. + +### Dry run (test without posting) + +```yaml +- uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: test + llm-base-url: ${{ secrets.LLM_BASE_URL }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: gpt-4.1 + dry-run: 'true' +``` + +Prints the review to CI logs without posting to the PR. Useful for testing prompt changes. + +### Using Anthropic directly + +```yaml +- uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: claude + llm-base-url: https://api.anthropic.com + llm-api-key: ${{ secrets.ANTHROPIC_API_KEY }} + llm-model: claude-sonnet-4-20250514 + llm-provider: anthropic +``` + +## Action Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `reviewer-token` | Yes | — | Gitea token for posting reviews (needs `write:issue`, `write:repository`) | +| `reviewer-name` | No | `""` | Logical identity for this reviewer. Used as sentinel for idempotent cleanup. Set this when running multiple review bots on the same PR. | +| `llm-base-url` | Yes | — | LLM API base URL | +| `llm-api-key` | Yes | — | LLM API key | +| `llm-model` | Yes | — | Model name | +| `llm-provider` | No | `openai` | API provider: `openai` or `anthropic` | +| `conventions-file` | No | `""` | Path to coding conventions file in the repo | +| `patterns-repo` | No | `""` | Comma-separated repos with language patterns (e.g. `rodin/go-patterns`) | +| `patterns-files` | No | `README.md` | Files/directories to fetch from pattern repos | +| `system-prompt-file` | No | `""` | Local file with additional system prompt instructions | +| `temperature` | No | `0` | LLM temperature (0 = server default) | +| `timeout` | No | `300` | LLM request timeout in seconds | +| `dry-run` | No | `false` | Print review to stdout instead of posting | +| `update-existing` | No | `true` | Delete previous review from same bot before posting. Accepts: true/1/yes or false/0/no | +| `version` | No | `latest` | review-bot version to install | + +## How Review Cleanup Works + +When `reviewer-name` is set, the bot embeds a hidden sentinel in each review: + +```html + +``` + +On the next run, it finds and deletes any review containing its own sentinel (except the one it just posted). This means: + +- **One review per bot per PR** — no clutter from repeated pushes +- **Multiple bots coexist** — each only cleans up its own reviews +- **Same token, different roles** — a single bot account can post "code-review" and "security" reviews without conflict +- **No extra permissions** — identity comes from the sentinel, not the API + +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**: before posting, each job checks whether any sibling review from the same user already has REQUEST_CHANGES. If so and this job would post APPROVE, it posts as REQUEST_CHANGES instead — maintaining the block. 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." + +Example `SECURITY_REVIEW.md`: + +```markdown +You are performing a security-focused code review. + +Focus areas: +- Injection attacks (SQL, command, path traversal, template) +- Authentication/Authorization (missing checks, privilege escalation) +- Secrets exposure (hardcoded credentials, tokens in logs) +- Input validation (unsanitized input, unsafe deserialization) +- Race conditions (TOCTOU, unsynchronized shared state) + +Rules: +- Only report findings with security implications +- Ignore style, naming, and general code quality +- MAJOR = exploitable vulnerability, MINOR = hardening opportunity, NIT = theoretical risk +- If no security-relevant changes exist, APPROVE with empty findings +``` + +## CLI Usage ```bash review-bot \ @@ -19,71 +244,74 @@ review-bot \ --repo owner/name \ --pr 42 \ --reviewer-token "$GITEA_TOKEN" \ + --reviewer-name "code-review" \ --llm-base-url https://api.openai.com/v1 \ --llm-api-key "$OPENAI_API_KEY" \ - --llm-model gpt-4 \ - --reviewer-name "Sonnet" \ - --conventions-file CONVENTIONS.md \ - --dry-run + --llm-model gpt-4.1 \ + --conventions-file CONVENTIONS.md ``` ## Environment Variables -All flags can be set via environment variables: +All flags have environment variable equivalents: -| Flag | Env Var | Required | Description | -|------|---------|----------|-------------| -| `--gitea-url` | `GITEA_URL` | Yes | Gitea instance base URL | -| `--repo` | `GITEA_REPO` | Yes | Repository in `owner/name` format | -| `--pr` | `PR_NUMBER` | Yes | Pull request number | -| `--reviewer-token` | `REVIEWER_TOKEN` | Yes | Gitea API token for posting reviews | -| `--llm-base-url` | `LLM_BASE_URL` | Yes | OpenAI-compatible API base URL | -| `--llm-api-key` | `LLM_API_KEY` | Yes | LLM API key | -| `--llm-model` | `LLM_MODEL` | Yes | Model identifier | -| `--reviewer-name` | `REVIEWER_NAME` | No | Display name in review footer | -| `--conventions-file` | `CONVENTIONS_FILE` | No | Path to conventions file in repo | -| `--dry-run` | — | No | Print review to stdout instead of posting | +| Flag | Env Var | +|------|---------| +| `--gitea-url` | `GITEA_URL` | +| `--repo` | `GITEA_REPO` | +| `--pr` | `PR_NUMBER` | +| `--reviewer-token` | `REVIEWER_TOKEN` | +| `--reviewer-name` | `REVIEWER_NAME` | +| `--llm-base-url` | `LLM_BASE_URL` | +| `--llm-api-key` | `LLM_API_KEY` | +| `--llm-model` | `LLM_MODEL` | +| `--llm-provider` | `LLM_PROVIDER` | +| `--conventions-file` | `CONVENTIONS_FILE` | +| `--patterns-repo` | `PATTERNS_REPO` | +| `--patterns-files` | `PATTERNS_FILES` | +| `--system-prompt-file` | `SYSTEM_PROMPT_FILE` | +| `--llm-temperature` | `LLM_TEMPERATURE` | +| `--llm-timeout` | `LLM_TIMEOUT` | +| `--update-existing` | `UPDATE_EXISTING` | -## Adding to a Gitea Repository +## Setup -1. Build the binary or use the CI workflow approach (build in CI). +1. **Create a Gitea bot account** (e.g. `review-bot`) +2. **Generate a token** with scopes: `write:issue`, `write:repository` +3. **Add secrets** to your Gitea repo (Settings → Actions → Secrets): + - `REVIEW_TOKEN` — the bot's Gitea token + - `LLM_BASE_URL` — your LLM endpoint + - `LLM_API_KEY` — your LLM key +4. **Add the workflow** (see Quick Start above) -2. Add secrets to your Gitea repo (Settings → Actions → Secrets): - - `SONNET_REVIEW_TOKEN` — Gitea token for the Sonnet reviewer account - - `GPT_REVIEW_TOKEN` — Gitea token for the GPT reviewer account - - `LLM_BASE_URL` — Your LLM API endpoint - - `LLM_API_KEY` — Your LLM API key +### Token Scopes Required -3. Copy `.gitea/workflows/ci.yml` to your repo (or adapt it). +| Scope | Purpose | +|-------|---------| +| `write:issue` | Post and delete reviews | +| `write:repository` | Read PR diffs, file content, commit statuses | -4. On every PR, the bot will: - - Run tests and vet - - Build review-bot - - Post reviews from each configured LLM reviewer +No `read:user` scope needed — the bot identifies itself from the review response. ## Development ```bash -# Run tests -go test ./... - -# Run vet -go vet ./... - -# Build +go test ./... # Unit tests +go vet ./... # Static analysis go build -o review-bot ./cmd/review-bot -# Integration tests (requires env vars) +# Integration tests (requires env vars set) go test -tags=integration ./... ``` ## Architecture ``` -cmd/review-bot/ CLI entrypoint -gitea/ Gitea API client -llm/ OpenAI-compatible LLM client +cmd/review-bot/ CLI entrypoint + orchestration +gitea/ Gitea API client (reviews, PRs, files) +llm/ Multi-provider LLM client (OpenAI + Anthropic) review/ Prompt building, response parsing, formatting +budget/ Token estimation + context trimming ``` ## License diff --git a/SECURITY_REVIEW.md b/SECURITY_REVIEW.md new file mode 100644 index 0000000..4b2566f --- /dev/null +++ b/SECURITY_REVIEW.md @@ -0,0 +1,18 @@ +You are performing a security-focused code review. Your primary concern is identifying vulnerabilities, not general code quality. + +Focus areas: +- **Injection attacks**: SQL injection, command injection, path traversal, template injection +- **Authentication/Authorization**: Missing auth checks, privilege escalation, IDOR +- **Secrets exposure**: Hardcoded credentials, API keys in code, tokens in logs +- **Input validation**: Untrusted input used without sanitization, unsafe deserialization +- **Cryptography**: Weak algorithms, predictable randomness, improper key management +- **Error handling**: Information leakage in error messages, stack traces exposed +- **Dependencies**: Known vulnerable patterns, unsafe use of external libraries +- **Race conditions**: TOCTOU bugs, unsynchronized shared state +- **Resource exhaustion**: Unbounded allocations, missing timeouts, denial-of-service vectors + +Rules for this review: +- Only report findings with actual security implications. Ignore style, naming, and general code quality. +- Severity mapping: MAJOR = exploitable vulnerability or data exposure. MINOR = defense-in-depth improvement or hardening opportunity. NIT = theoretical concern with low practical risk. +- If the code has no security-relevant changes, APPROVE with an empty findings list. +- Do not duplicate findings that a standard code review would catch (logic bugs, missing error checks) unless they have a security dimension. diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 065afca..f31cae4 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "path/filepath" "strconv" "strings" "time" @@ -30,9 +31,11 @@ func main() { llmAPIKey := flag.String("llm-api-key", envOrDefault("LLM_API_KEY", ""), "LLM API key") llmModel := flag.String("llm-model", envOrDefault("LLM_MODEL", ""), "LLM model name") conventionsFile := flag.String("conventions-file", envOrDefault("CONVENTIONS_FILE", ""), "Conventions file path in repo (e.g. CLAUDE.md)") + systemPromptFile := flag.String("system-prompt-file", envOrDefault("SYSTEM_PROMPT_FILE", ""), "Local file with additional system prompt instructions") patternsRepo := flag.String("patterns-repo", envOrDefault("PATTERNS_REPO", ""), "Repo with language patterns (e.g. rodin/elixir-patterns)") patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo") dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting") + updateExisting := flag.Bool("update-existing", envOrDefaultBool("UPDATE_EXISTING", true), "Delete previous review from same bot before posting (default true)") llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)") llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)") llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic") @@ -54,6 +57,11 @@ func main() { os.Exit(1) } + // Validate reviewer-name: only safe characters allowed in sentinel + if err := validateReviewerName(*reviewerName); err != nil { + log.Fatalf("%v", err) + } + // Parse repo owner/name parts := strings.SplitN(*repo, "/", 2) if len(parts) != 2 { @@ -149,9 +157,45 @@ func main() { log.Printf("Loaded patterns from %s (%d bytes)", *patternsRepo, len(patterns)) } + // Step 6b: Load additional system prompt if specified + additionalPrompt := "" + if *systemPromptFile != "" { + workspace := os.Getenv("GITHUB_WORKSPACE") + if workspace == "" { + workspace, _ = os.Getwd() + } + absWorkspace, err := filepath.Abs(workspace) + if err != nil { + log.Fatalf("Failed to resolve workspace path: %v", err) + } + promptPath := filepath.Join(absWorkspace, *systemPromptFile) + promptPath = filepath.Clean(promptPath) + if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace { + log.Fatalf("system-prompt-file resolves outside workspace (got %q, workspace %q)", promptPath, absWorkspace) + } + // Resolve symlinks and re-validate to prevent symlink traversal + resolvedPath, err := filepath.EvalSymlinks(promptPath) + if err != nil { + log.Fatalf("Failed to resolve system prompt file %q: %v", promptPath, err) + } + if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace { + log.Fatalf("system-prompt-file symlink resolves outside workspace (got %q, workspace %q)", resolvedPath, absWorkspace) + } + data, err := os.ReadFile(resolvedPath) + if err != nil { + log.Fatalf("Failed to read system prompt file %q: %v", promptPath, err) + } + additionalPrompt = string(data) + log.Printf("Loaded system prompt file: %s (%d bytes)", *systemPromptFile, len(additionalPrompt)) + } + // Step 7: Budget-aware prompt assembly + systemBase := review.BuildSystemBase() + if additionalPrompt != "" { + systemBase += "\n\n## Additional Review Instructions\n\n" + additionalPrompt + } sections := budget.Sections{ - SystemBase: review.BuildSystemBase(), + SystemBase: systemBase, Patterns: patterns, Conventions: conventions, FileContext: fileContext, @@ -195,11 +239,49 @@ func main() { return } + sentinel := fmt.Sprintf("", *reviewerName) + log.Printf("Posting review (event=%s)...", event) - if err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody); err != nil { + posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody) + if err != nil { log.Fatalf("Failed to post review: %v", err) } - log.Printf("Review posted successfully!") + log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login) + + // Delete stale reviews from this bot using sentinel matching + if *updateExisting && *reviewerName != "" { + reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber) + if err != nil { + log.Printf("Warning: could not list existing reviews: %v", err) + } else { + for _, r := range reviews { + if r.ID != posted.ID && r.User.Login == posted.User.Login && strings.Contains(r.Body, sentinel) { + if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, r.ID); err != nil { + log.Printf("Warning: could not delete old review %d: %v", r.ID, err) + } else { + log.Printf("Deleted stale review %d", r.ID) + } + } + } + + // Worst-wins: if we posted APPROVE but a sibling review from the + // same user (same token, different role) has REQUEST_CHANGES, + // delete ours and re-post as REQUEST_CHANGES to maintain the block. + if event == "APPROVED" && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) { + log.Printf("Sibling review has REQUEST_CHANGES; escalating") + if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID); err != nil { + log.Printf("Warning: could not delete review for escalation: %v", err) + } else { + _, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody) + if err != nil { + log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err) + } else { + log.Printf("Review escalated to REQUEST_CHANGES") + } + } + } + } + } } // fetchFileContext fetches the full content of modified files from the PR branch. @@ -333,3 +415,37 @@ func envOrDefaultInt(key string, defaultVal int) int { } return defaultVal } + +func envOrDefaultBool(key string, defaultVal bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if v == "" { + return defaultVal + } + return v == "true" || v == "1" || v == "yes" +} + +// validateReviewerName checks that the name contains only safe characters +// for embedding in an HTML comment sentinel ([a-zA-Z0-9_-]). +func validateReviewerName(name string) error { + if name == "" { + return nil + } + for _, ch := range name { + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') { + return fmt.Errorf("reviewer-name must contain only [a-zA-Z0-9_-] (got %q)", name) + } + } + return nil +} + +// shouldEscalate checks if the current APPROVED review should be escalated +// to REQUEST_CHANGES because a sibling bot review (same user, different role) +// already has REQUEST_CHANGES. +func shouldEscalate(reviews []gitea.Review, postedID int64, postedLogin, ownSentinel string) bool { + for _, r := range reviews { + if r.ID != postedID && !r.Stale && r.User.Login == postedLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "", true}, + {"invalid space", "my bot", true}, + {"invalid dot", "my.bot", true}, + {"invalid slash", "my/bot", true}, + {"invalid angle", "bot