A pull request has two state: APPROVED reviews from different reviewers. Branch protection requires required_approvals: 1. The merge attempt returns HTTP 405 — "Does not have enough approvals". The protection config looks correct, the reviews look correct, and the error message looks misleading. The actual root cause is hidden in a field most operators never check: official.

What official means#

Every Gitea review carries an official boolean. Branch protection’s required_approvals counts only reviews where official: true. A reviewer’s APPROVE only flips to official: true if they were a write-level repository collaborator at the moment the review was filed.

GET /api/v1/repos/{owner}/{repo}/pulls/{n}/reviews

[{
  "id": 244,
  "user": {"login": "review-bot-a"},
  "state": "APPROVED",
  "official": false,         ← does not count toward required_approvals
  "submitted_at": "..."
}, {
  "id": 245,
  "user": {"login": "review-bot-b"},
  "state": "APPROVED",
  "official": false,         ← same
  "submitted_at": "..."
}]

Two APPROVED reviews. Zero officials. required_approvals: 1 is not satisfied.

The permission requirement is asymmetric:

PermissionView repoFile reviewReview counts toward required_approvalsMerge (if on whitelist)
read✓ (state=APPROVED records)✗ (official: false)
write✓ (official: true)
admin

read is enough to file a review. It is not enough to make that review count.

The retroactive-flip rule#

When a user is added as a collaborator AFTER they have already filed a review, the existing review’s official field stays at its filing-time value. Adding the collaborator does not retroactively promote prior reviews. Re-filing produces a new review record with the correct official value.

T0:   review-bot-a (no collab access) files APPROVE   →  id=244  official=false
T1:   admin grants review-bot-a write collaborator
T2:   GET .../pulls/{n}/reviews                       →  id=244  official=false   (UNCHANGED)
T3:   review-bot-a files APPROVE again                →  id=246  official=true    (new row)
T4:   merge succeeds                                   ←  required_approvals met by id=246

This is the trap. The fix isn’t a permission edit alone; it’s a permission edit followed by a re-file.

Diagnosing the failure#

The 405 response carries no useful information beyond “Does not have enough approvals.” The diagnostic ladder runs in three steps.

Step 1: confirm review states and officiality

curl -s -u admin:pass "https://gitea-host/api/v1/repos/{owner}/{repo}/pulls/{n}/reviews" | \
  jq -r '.[] | "\(.id) \(.user.login) state=\(.state) official=\(.official)"'

official: false on the APPROVED reviews confirms the trap. If the reviews show official: true, the cause is something else (whitelist mismatch, status check requirement, etc.) and this article does not apply.

Step 2: check reviewer permission level

curl -s -u admin:pass "https://gitea-host/api/v1/repos/{owner}/{repo}/collaborators/{reviewer}/permission" | \
  jq -r .permission

Output read or none confirms the cause. Output write or admin rules it out — re-filing won’t help.

Step 3: confirm protection config is otherwise correct

curl -s -u admin:pass "https://gitea-host/api/v1/repos/{owner}/{repo}/branch_protections/main" | \
  jq '{required_approvals, merge_whitelist_usernames, enable_merge_whitelist}'

Confirm required_approvals is set, the merging user is on merge_whitelist_usernames, and enable_merge_whitelist is true. If protection looks fine, the trap is the cause.

The fix#

Three calls. The order matters.

# 1. Upgrade the reviewer's permission to at least write
curl -s -X PUT -u admin:pass \
  "https://gitea-host/api/v1/repos/{owner}/{repo}/collaborators/{reviewer}" \
  -H 'Content-Type: application/json' \
  -d '{"permission": "write"}'

# 2. Reviewer re-files the review (as themselves, not admin)
curl -s -X POST -H "Authorization: token <reviewer-pat>" \
  "https://gitea-host/api/v1/repos/{owner}/{repo}/pulls/{n}/reviews" \
  -H 'Content-Type: application/json' \
  -d '{"event": "APPROVED", "body": "Re-filing post-collaborator-grant for officiality."}'

# 3. Verify the new review is official, then retry the merge
curl -s -u admin:pass "https://gitea-host/api/v1/repos/{owner}/{repo}/pulls/{n}/reviews" | \
  jq -r '.[] | "\(.id) \(.user.login) official=\(.official)"'
# Expected: a new id with official=true alongside the old id with official=false

curl -s -X POST -H "Authorization: token <merging-user-pat>" \
  "https://gitea-host/api/v1/repos/{owner}/{repo}/pulls/{n}/merge" \
  -H 'Content-Type: application/json' \
  -d '{"Do": "squash"}'
# Expected: HTTP 200

The old official: false review row remains in the history. It is harmless and worth leaving as evidence of the original sequence. The new official: true row satisfies required_approvals.

Why least privilege defeats official approvals#

The principle of least privilege says: grant read to a reviewer who only needs to view code and file feedback. Gitea’s branch-protection mechanic says: only write collaborators produce official approvals. These two principles are in direct conflict for any reviewer that should both (a) leave actual approvals and (b) hold minimal permissions.

The pragmatic resolution is to grant write to bot or service-account reviewers, accepting that they have technical push access they will not use. The risk surface is bounded by:

  • Per-identity tokens scoped to write:repository, not org-wide admin
  • Branch protection still enforces merge_whitelist_usernames and required_approvals (the bot can push to a feature branch but can’t merge to main unless explicitly whitelisted there)
  • Audit logs record every push by the bot identity, so accidental or malicious pushes are observable

Human reviewers usually need write anyway for related repository operations (assigning reviews, editing comments). The trap mostly bites bot reviewers, where the impulse to grant minimal permission is strongest.

Auditing for the trap#

The trap regenerates whenever a new repository is added to the org or a new reviewer identity is created. An idempotent audit script catches it before PRs start blocking:

#!/usr/bin/env bash
# Verify all reviewer identities have write collaborator status on every protected repo.
set -euo pipefail

GITEA_URL="${GITEA_URL:-https://gitea-host}"
ADMIN_USER="${ADMIN_USER:?}"
ADMIN_PASS="${ADMIN_PASS:?}"
REVIEWERS=("review-bot-a" "review-bot-b" "release-bot")
DRY_RUN="${DRY_RUN:-false}"

# List all repos with branch protection on main
mapfile -t repos < <(
  curl -s -u "$ADMIN_USER:$ADMIN_PASS" "$GITEA_URL/api/v1/repos/search?owner={owner}&limit=200" \
    | jq -r '.data[].name'
)

for repo in "${repos[@]}"; do
  protection=$(curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
    "$GITEA_URL/api/v1/repos/{owner}/$repo/branch_protections/main")
  [[ $(echo "$protection" | jq -r '.required_approvals // 0') -lt 1 ]] && continue

  for reviewer in "${REVIEWERS[@]}"; do
    perm=$(curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
      "$GITEA_URL/api/v1/repos/{owner}/$repo/collaborators/$reviewer/permission" \
      | jq -r '.permission // "none"')
    if [[ "$perm" != "write" && "$perm" != "admin" ]]; then
      echo "DRIFT: $repo $reviewer = $perm (expected write)"
      if [[ "$DRY_RUN" != "true" ]]; then
        curl -s -X PUT -u "$ADMIN_USER:$ADMIN_PASS" \
          "$GITEA_URL/api/v1/repos/{owner}/$repo/collaborators/$reviewer" \
          -H 'Content-Type: application/json' \
          -d '{"permission": "write"}'
        echo "FIXED: $repo $reviewer → write"
      fi
    fi
  done
done

Run on a cron (daily is enough; the trap surfaces only when a repo or reviewer is added). Set DRY_RUN=true first to confirm scope before allowing the script to make changes. The 404 on collaborators/{reviewer}/permission for a non-collaborator returns a permission of none — the script treats this as drift.

The audit script does NOT re-file existing official: false reviews. After it grants write, the affected reviewer must still re-file on any blocked PR. In practice this is fine: most blocked PRs surface within hours and the reviewer’s daemon re-files on its next cycle.

Adjacent forges#

GitHub has a parallel mechanic with different surface area. The GitHub equivalent of official is implicit in CODEOWNERS + branch protection’s Require review from Code Owners setting: an APPROVE from a non-codeowner does not satisfy the codeowner-required check. Granting CODEOWNERS membership after a review does not retroactively re-mark the review as a codeowner approval; the reviewer must re-approve.

GitLab’s required-approvers behaves similarly: approval rules tied to specific users only count when the approving user matches at the time of approval; later membership changes are not retroactive.

The lesson generalizes: every forge with a “this review counts” mechanic ties officiality to permission-or-membership state at the moment the review is filed. The fix in every case is the same: grant the right permission first, re-file the review second, retry the merge third.

Debugging when this isn’t the cause#

If reviews show official: true and the merge still 405s, the cause is elsewhere. Common alternates:

  • Required status check missing — protection requires a CI status (enable_status_check: true with a context like ci/jenkins) that has not run, or has run with a non-success state. Check protection.status_check_contexts against the PR’s actual status entries.
  • Outdated branchblock_on_outdated_branch: true rejects merges where the head branch is behind base. Rebase or merge-base the PR.
  • Stale reviewsdismiss_stale_approvals: true invalidates approvals when new commits are pushed. Verify timestamps against the head SHA.
  • Merge whitelist exclusion — the user attempting the merge isn’t in merge_whitelist_usernames and enable_merge_whitelist: true. The error message is identical to the officiality trap; only the underlying field differs.

Each of these is a separate diagnostic path. The reviews-and-permissions check above narrows down to this trap; if it doesn’t match, work through the alternates.