Skip to content

CreatePullRequest never updates an existing PR on Gitea/Forgejo: head filter uses GitHub's owner:branch format, causing a 409 on every run after the first #1979

Description

@JonBasse

Summary

On the Gitea/Forgejo backend, CreatePullRequest can never find an existing pull request, so its documented update path is unreachable. From the second run onward it always attempts a create and the forge answers HTTP 409.

The docs state:

Create a pull request on every configured forge from the current branch to a specified branch. If a pull request for those already exists, this step will overwrite the title and body of the existing pull request.

The code intends exactly that — the function is create_or_update_pull_request — but on Gitea/Forgejo the lookup always returns zero rows, so the else (create) branch is always taken.

Cause

crates/knope/src/integrations/gitea/create_pull_request.rs builds the lookup query using GitHub's cross-repo owner:branch convention:

.query(&[
    ("state", "open"),
    (
        "head",
        &format!("{owner}:{current_branch}", owner = config.owner),
    ),
    ("base", base),
    ("access_token", &token),
])

Gitea/Forgejo's list endpoint does not use that convention. GET /repos/{owner}/{repo}/pulls documents head as "Filter by head branch name" and passes the raw string straight through — routers/api/v1/repo/pull.go:

BaseBranch:  ctx.FormTrim("base"),
HeadBranch:  ctx.FormTrim("head"),

which is then matched by exact SQL equality — models/issues/pull_list.go:

if opts.HeadBranch != "" {
    sess.And("pull_request.head_branch=?", opts.HeadBranch)
}

So knope sends head=myowner:release-1.2.3 while the stored head_branch is release-1.2.3. They never compare equal → empty list → existing_pulls.first() is Nonecreate_pull_request → the forge finds the existing unmerged PR and returns 409 Conflict (ctx.Error(http.StatusConflict, "GetUnmergedPullRequest", err)).

Worth noting: Gitea/Forgejo does accept the owner:branch form, but only on a different endpoint — GET /repos/{owner}/{repo}/pulls/{base}/{head}, which splits on : explicitly. knope does not call that one.

The GitHub backend is unaffected: owner:branch is correct for GitHub's API.

Impact

Any prepare-release-style workflow that maintains a long-lived release PR on Gitea/Forgejo fails on every run after the first. Because the branch is usually force-pushed before CreatePullRequest, the PR content is already current and the 409 is cosmetically harmless — but the step exits non-zero, so CI must special-case the error to stay green, and the documented title/body refresh silently never happens.

Suggested fix

Send the bare branch name on the Gitea backend:

("head", current_branch),

The base filter and the same-repo assumption already make the owner prefix redundant here. (If cross-repo head support is ever wanted, GET /pulls/{base}/{head} is the endpoint that accepts owner:branch.)

Versions

  • knope 0.23.0 (knope-x86_64-unknown-linux-musl)
  • Forgejo 16.0.1 (Gitea-compatible API)
  • Same-repo release branch, [gitea] backend, PAT in GITEA_TOKEN

I verified the relevant code is identical on main at the time of filing.

Secondary note (not part of this report)

While reading the backend I noticed all Gitea calls authenticate via the ?access_token= query parameter (create_release.rs, list_issues.rs, create_pull_request.rs). PR #1902 ("use Authorization header for Gitea API calls to support Forgejo/Codeberg") was closed unmerged on 2026-05-24, so this remains the current behaviour. Query-parameter token auth is a long-standing deprecation candidate in Gitea/Forgejo; if it were removed the backend would stop working entirely. Flagging it as an observation only — happy to open a separate issue if that would be more useful.

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