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 None → create_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.
Summary
On the Gitea/Forgejo backend,
CreatePullRequestcan 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:
The code intends exactly that — the function is
create_or_update_pull_request— but on Gitea/Forgejo the lookup always returns zero rows, so theelse(create) branch is always taken.Cause
crates/knope/src/integrations/gitea/create_pull_request.rsbuilds the lookup query using GitHub's cross-repoowner:branchconvention:Gitea/Forgejo's list endpoint does not use that convention.
GET /repos/{owner}/{repo}/pullsdocumentsheadas "Filter by head branch name" and passes the raw string straight through —routers/api/v1/repo/pull.go:which is then matched by exact SQL equality —
models/issues/pull_list.go:So knope sends
head=myowner:release-1.2.3while the storedhead_branchisrelease-1.2.3. They never compare equal → empty list →existing_pulls.first()isNone→create_pull_request→ the forge finds the existing unmerged PR and returns409 Conflict(ctx.Error(http.StatusConflict, "GetUnmergedPullRequest", err)).Worth noting: Gitea/Forgejo does accept the
owner:branchform, 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:branchis 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 beforeCreatePullRequest, 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:
The
basefilter 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 acceptsowner:branch.)Versions
knope-x86_64-unknown-linux-musl)[gitea]backend, PAT inGITEA_TOKENI verified the relevant code is identical on
mainat 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.