Skip to content

[Bug]: Gitea has no head= filter and no upgrade path, so every Forgejo-provider PR lookup reads the whole pull request list #23551

Closed

Description

@erannave

Note

Written by Claude Opus 5.5 on behalf of @erannave

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Related, not duplicates: #19347 (closed, Forgejo 15) is the same loop. It was closed with "upgrade to Forgejo 16" as the workaround, and that workaround does not exist for Gitea. #19657 and #21620 multiply the number of lookups; this issue is about the cost of each one.

Area

apps/server, packages/source-control-forgejo

Steps to reproduce

  1. Use a Gitea server (28.2.0, the latest release, 2026-10-18) with a repository that has a few thousand pull requests. Ours has 3,089.
  2. Request GET /api/v1/repos/<owner>/<repo>/pulls?state=all&head=no-such-branch&sort=recentupdate&limit=50&page=2. It returns 50 pull requests, none with that head: Gitea ignores head.
  3. Connect T3 to the repository and open threads on a few branches that have no pull request.
  4. Count ForgejoCli.api spans in server.trace.ndjson, or tea api …/pulls?…&page=N processes under the T3 server.

Gitea does not support head on this endpoint and has no plans to:

  • routers/api/v1/repo/pull.go on Gitea main (checked 2026-10-22) reads base_branch, state, sort, milestone, labels, poster, page and limit. There is no head.
  • go-gitea/gitea has no issue or pull request, open or closed, that asks for or adds a head filter on the list endpoint.

Expected behavior

A branch PR lookup costs a bounded number of requests on Gitea, as it does on GitHub, GitLab and Forgejo 16.

Actual behavior

findLatestPrForHeadContext calls listChangeRequests with state: "all" and the default limit: 20. The Forgejo provider has no headBranchProbe. The loop in ForgejoSourceControlProvider.listChangeRequests (line ~316 on main) stops only after 20 matches or an empty page. A branch never has 20 pull requests, so every lookup reads every page.

Measured against our server:

Pages per lookup 63 (62 full pages of 50, then the empty one)
One page 656 KB, about 1.1 s on an idle server
One lookup about 40 MB and over a minute idle; 431–695 s per lookup under load
Sustained rate 300–500 tea api requests per minute, about 200–300 MB of JSON per minute

The rate holds because the "no PR" result is cached for 5 minutes (PR_LOOKUP_NO_OPEN_PR_CACHE_TTL), so each branch's 63-request scan starts again. Our instance had 97 distinct (project, worktree, branch) groups to look up; most came from hidden subagent threads (#19657). After a T3 restart, the startup backfill (#21620) added 621 more groups from settled threads.

This is one T3 instance. Every developer who runs T3 against the same Gitea server adds the same load. With five developers on one repository, Gitea serves roughly 1,500–2,500 full-page list requests per minute, about 1–1.5 GB of JSON per minute, to answer "does this branch have a PR?".

A Gitea restart makes it worse. Every lookup that failed during the outage retries at once: 40 lookups reached page 5 within the first minute after our server came back.

Impact

Major degradation or frequent failure

The load is continuous for as long as T3 runs, and it grows with the number of open branches, subagent threads, and developers. Each list page makes Gitea convert 50 pull requests to API objects. Pull requests whose refs are missing (ours: 58 old pull requests whose refs went away in a history rewrite) log GetRefCommitID[refs/pull/N/head]: object does not exist on every page that contains them. That log is how we noticed this.

Version or commit

T3 Code 0.1.1 (server 0.1.1). The loop is unchanged on main as of a8e8e7e (2026-10-20).

Environment

Windows + WSL2 (T3 server in WSL), tea 0.16.0, Gitea 28.2.0 (self-hosted).

Logs or stack traces

tea api --include --login <login> --repo <owner>/<repo> --method GET https://<gitea-host>/api/v1/repos/<owner>/<repo>/pulls?state=all&head=<branch-a>&sort=recentupdate&limit=50&page=22
tea api --include --login <login> --repo <owner>/<repo> --method GET https://<gitea-host>/api/v1/repos/<owner>/<repo>/pulls?state=all&head=<branch-b>&sort=recentupdate&limit=50&page=17

In a 3-minute sample, 951 tea processes had the T3 server as parent, covering 44 branches at pages 1–46. In the traces, every completed lookup on the 3,089-PR repository made exactly 63 ForgejoCli.api calls.

Suggested fix

Gitea has a direct lookup: GET /repos/{owner}/{repo}/pulls/{base}/{head} (present in 28.2.0). Against our server it answers in one request: about 1 s with the pull request, and 404 in 0.32 s when the branch has none.

It has a known limitation, go-gitea/gitea#35217: it always returns the first-created pull request for that base and head, and it needs the base branch. So it can't replace the list on its own. One option that doesn't trade correctness for speed, unlike the page caps rejected in #17201 and #19679:

  • Detect that the server ignored head (page 1 contains a pull request with another head ref).
  • Then ask pulls/{base}/{head} for the candidate bases T3 already resolves (default branch, configured merge base). Fall back to the full scan only when the result is closed or merged and a newer pull request might exist.

Workaround

None removes the per-lookup cost. Lookups become fewer if you settle or archive threads (including hidden subagent threads, through inactivity auto-settle) and avoid restarting T3.

Activity

  1. 0pilatos0 commented on Nov 2, 2026

    @0pilatos0
    Contributor

    Same issue here on Gitea 28.2.0 with T3 Code 0.1.7-nightly.20261102.4066 (details in #29514). Our nextgen repo has 2,651 PRs, so each lookup for a branch without a PR walks 55 pages: about 25k calls in 72 h, roughly 650 an hour per idle thread, 23 to 29 s per lookup on the client. Every walk also logs unable to resolve PR head ref for imported PRs missing refs/pull/N/head (17k times in 72 h). Hoping #25569 lands soon.

  2. Cammymoop commented on Nov 10, 2026

    @Cammymoop

    Data point from Forgejo 15.0.10, where head= is ignored exactly as on Gitea. We have T3 0.1.7 and 0.1.8 and a 595-PR repository, so a lookup takes 13 list requests.

    Each T3 server start re-checks every settled thread that has no branchPullRequest (ThreadPullRequestServiceV2 startup backfill, concurrency: 32). With about 480 such threads, that produced about 26–28 minutes of 85–205 pulls?state=all&head=… requests per minute on the forge. Median latency rose from about 1.6 s per page on an idle server to about 11 s. Archiving most of those threads reduced it to one 2-minute burst.

    For Forgejo users, upgrading to 16+ (where head= works) removes most of this. Gitea has no such upgrade.

    The number of threads re-checked on every start is inflated by a separate bug, #33660: merged PRs whose branch was deleted report head.ref = refs/pull/<N>/head and are never matched, so those threads never get a branchPullRequest.
    </opus>

    I'll be updating my private forgejo so I wont be dealing with this anymore but posting this info for anyone it may concern

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions