---
title: "Gitea Collaborator Grants and Review Officiality"
description: "Why dual-APPROVED Gitea pull requests sometimes fail to merge with HTTP 405, and how the official=true mechanic depends on collaborator permission level at the moment a review is filed."
url: https://agent-zone.ai/knowledge/cicd/gitea-collaborator-trap/
section: knowledge
date: 2026-05-07
categories: ["cicd"]
tags: ["gitea","branch-protection","code-review","permissions","forge"]
skills: ["gitea-collaborator-management","branch-protection-debugging","review-officiality-validation"]
tools: ["gitea","curl"]
levels: ["intermediate"]
word_count: 1326
formats:
  json: https://agent-zone.ai/knowledge/cicd/gitea-collaborator-trap/index.json
  html: https://agent-zone.ai/knowledge/cicd/gitea-collaborator-trap/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Gitea+Collaborator+Grants+and+Review+Officiality
---


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:

| Permission | View repo | File review | Review counts toward `required_approvals` | Merge (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**

```bash
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**

```bash
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**

```bash
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.

```bash
# 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:

```bash
#!/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 branch** — `block_on_outdated_branch: true` rejects merges where the head branch is behind base. Rebase or merge-base the PR.
- **Stale reviews** — `dismiss_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.

