Skip to content

feat(bin): watch and merge Forgejo pull requests through the guarded PR path - #3245

Open
yvp95 wants to merge 4 commits into
kunchenguid:mainfrom
loom-loki:fm/firstmate-forgejo-pr-path
Open

yvp95 wants to merge 4 commits into
kunchenguid:mainfrom
loom-loki:fm/firstmate-forgejo-pr-path

Conversation

@yvp95

@yvp95 yvp95 commented Aug 28, 2026

Copy link
Copy Markdown

Why

firstmate's guarded PR path speaks GitHub and GitLab. fm_pr_url_parse accepts github.com/OWNER/REPO/pull/N and GitLab's .../-/merge_requests/N, and anything else is refused.

That leaves self-hosted Forgejo out. A Forgejo pull request URL is https://HOST/OWNER/REPO/pulls/N - note the plural pulls - so bin/fm-pr-check.sh and bin/fm-pr-merge.sh both refuse it outright. For anyone whose projects live on a Forgejo instance, that means no pr= or pr_head= can be recorded, no merge poll can be armed, and merging has to happen by calling the forge CLI directly, outside the guarded path entirely.

This adds forgejo as a third provider across that path, following the shape the GitLab provider already established.

What this adds

Parsing (bin/fm-pr-lib.sh). A forgejo branch in fm_pr_url_parse with its own host, owner, and repository rules rather than a loosened GitHub or GitLab rule. The owner and repository rules follow Forgejo's own validation (owner: at most 40 characters, starts alphanumeric, allows -, _, ., no run of two of those and no trailing one; repository: at most 100 characters over the same set, never . or .., never ending in a reserved .git/.wiki/.rss/.atom suffix).

The shared lowercase-DNS host shape that GitLab and Forgejo both need moves into one fm_pr_dns_host_valid helper so it is stated once. GitLab's accepted set is unchanged by that extraction, and the parser matrix asserts it.

The three providers are told apart by the segment the others cannot carry: GitHub's own host plus /pull/, GitLab's /-/merge_requests/, and Forgejo's plural /pulls/. github.com and gitlab.com are refused as Forgejo hosts for the same reason github.com is already refused as a GitLab one - they are those forges' own hosts and never a Forgejo instance, so https://github.com/o/r/pulls/1 would otherwise arm a watch that can never succeed.

Polling (bin/fm-pr-poll.sh). A forgejo branch reading forgejo-axi pr merged, waking only on an exact merged proof. The poll source stays byte-for-byte identical for every task and the identity stays in the private sidecar, exactly as the existing contract requires. The sidecar and registration formats were already provider-generic (provider, url, host, path, number), so a third provider needed no format change and none was made.

Merging (bin/fm-pr-merge.sh). A forgejo branch built on forgejo-axi pr merge --expected-head, which re-reads the pull request before merging, sends the verified commit as the merge request's head_commit_id, and refuses to report a merge it cannot prove landed at that exact commit - the same head-binding guarantee glab mr merge --sha gives the GitLab path.

Before that, one live forgejo-axi pr mergeability read has to report the forge's own mergeable verdict and passing checks at the current head. Every failing condition is reported rather than only the first, along with the forge's own reasons. A recorded pr_head= that disagrees with the live head is reported, not trusted, because a rebase leaves it stale.

Two details worth calling out

The instance is passed explicitly, never inferred from the URL. forgejo-axi accepts a pull request URL, but it reads only the owner, repository, and number out of one and still sends the request to whatever host its own configuration resolves. A forgejo.example URL answering from codeberg.org demonstrates it:

$ forgejo-axi pr merged --base-url https://codeberg.org https://forgejo.example/forgejo/forgejo/pulls/1000
proof:
  merged: true
  url: "https://codeberg.org/forgejo/forgejo/pulls/1000"

Both the poll and the merge therefore pass the validated record's host as --base-url and the parsed pair as --repo, and never hand a URL to the CLI. This mirrors how the GitLab path uses GITLAB_HOST plus -R <project URL>, and it is asserted by tests.

A merge method is named here, unlike on GitLab. GitLab's merge API applies the project's own merge method, so imposing one there would override that convention. Forgejo's merge API takes the method in the request body and has no such fallback, so some method is always chosen. This path names the same --squash default GitHub gets rather than inheriting forgejo-axi's own merge default, and a caller who wants another passes -- --method merge or -- --method rebase.

GitHub's --squash, --merge and --rebase spellings are not flags forgejo-axi takes, so on the Forgejo path they are refused by name before anything is recorded, naming the --method form instead. Forwarding them would first suppress this path's own --method squash default and then fail at the CLI as an unknown flag, after the live pre-merge read had already run.

Degrading honestly

The poll is silent on every error by design, so a missing forgejo-axi would be indistinguishable from a pull request that never merges. Arming is the one point where that can be reported, so bin/fm-pr-check.sh refuses there rather than arming a watch that sees nothing - the same stance the GitLab path takes when glab is absent. Merging additionally needs jq, and either tool missing is reported together before anything is recorded.

One consequence is deliberate and documented: a repository whose pull requests report no passing checks cannot merge through this path, exactly as a GitLab project that runs no pipeline cannot. Passing checks at the head is a condition, and "there are no checks" does not satisfy it.

forgejo-axi pr mergeability reads commit statuses and branch protection, never the Actions runs API, so an instance that does not serve that API is fully supported.

Scope

This stays inside the PR path. The only shared change is the small fm_pr_dns_host_valid extraction described above, made to keep the DNS host rule stated once rather than to restructure the provider abstraction.

bin/fm-teardown.sh's pr_is_merged still uses gh pr view and falls back to its provider-agnostic content check for a Forgejo URL. That is the same pre-existing shape GitLab already has, and it is left alone here.

How it is tested

Colocated with the existing suites, no new runner:

  • tests/fm-pr-check-security.test.sh gains Forgejo rows in the parser matrix, the plural-versus-singular boundary asserted in both directions, both cross-provider spoof refusals, around thirty new malformed-URL rejection rows covering the owner and repository rules, and a test_forgejo_merge_watch mirroring the existing GitLab one: exact sidecar bytes, only an exact merged proof wakes, silence on CLI failure and on an absent CLI, the arming refusal, doctored-sidecar refusals including a nested project path, addressing by --base-url and never by a URL, and pr_head= recording.
  • tests/fm-pr-merge.test.sh gains fourteen Forgejo merge tests covering the merge invocation, the instance coming from the URL, the squash default and an explicit method, the refusal of GitHub's method spellings, each pre-merge condition independently plus all of them together with the forge's reasons, a view that answered for another pull request, a stale recorded head, unreadable state, an invalid head, merge failure propagation, unconfirmed landings leaving the poll armed, missing tools, and rejected override arguments.

Every new guard was mutation-tested: each one was deliberately broken, the specific test confirmed to turn red, and the code restored to green.

docs/forgejo-merge-watch.md records the live evidence, alongside the existing docs/gitlab-merge-watch.md. It reads only public pull requests on codeberg.org, so every command in it can be rerun without a credential.

bin/fm-lint.sh (ShellCheck 0.11.0, actionlint 1.7.12) and bin/fm-doc-audience-check.sh are clean, and tests/fm-pr-check-security.test.sh (42 ok), tests/fm-pr-merge.test.sh (75 ok), and tests/fm-teardown.test.sh (58 ok) all pass.

Happy to adjust any of the choices above if you would rather they went a different way.


Updates from git push no-mistakes

ypadhi added 3 commits August 28, 2026 20:06
…PR path

A Forgejo pull request URL carries the plural /pulls/ segment, which
fm_pr_url_parse did not accept, so bin/fm-pr-check.sh and bin/fm-pr-merge.sh
both refused one outright. Nothing could record pr= or pr_head=, no merge poll
could be armed, and merging had to happen outside the guard entirely.

Add forgejo as a third provider across the PR path:

- fm_pr_url_parse gets its own host, owner, and repository rules rather than a
  loosened GitHub or GitLab rule. The shared lowercase-DNS host shape moves into
  fm_pr_dns_host_valid so it is stated once; GitLab's accepted set is unchanged.
  github.com and gitlab.com are refused as Forgejo hosts for the same reason
  github.com is already refused as a GitLab one.
- bin/fm-pr-poll.sh reads forgejo-axi pr merged and wakes only on an exact
  merged proof. The poll source stays byte-for-byte identical for every task and
  the identity stays in the private sidecar.
- bin/fm-pr-merge.sh verifies one live mergeability read, reports every failing
  condition plus the forge's own reasons, and binds the merge to the verified
  head with forgejo-axi pr merge --expected-head.

Both the poll and the merge pass the host from the validated record as
--base-url. forgejo-axi reads only owner/repository/number out of a pull request
URL and still sends the request to whatever host its own configuration resolves,
so passing the URL alone would let an ambient default answer for the host the
record names.

An absent forgejo-axi refuses at arming and at merge rather than watching
nothing, matching how the GitLab path handles an absent glab.

docs/forgejo-merge-watch.md records the live evidence against a public Forgejo
instance.
@greptile-apps

greptile-apps Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (2): Last reviewed commit: "fix(bin): refuse Forgejo repository name..." | Re-trigger Greptile

Comment thread bin/fm-pr-lib.sh
Comment on lines +170 to +177
fm_pr_forgejo_repo_valid() {
local repo=${1-}
local LC_ALL=C
[ "${#repo}" -ge 1 ] && [ "${#repo}" -le 100 ] || return 1
case "$repo" in
.|..|*[!A-Za-z0-9._-]*|*.git|*.wiki|*.rss|*.atom) return 1 ;;
esac
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Invalid Forgejo repositories accepted

When a Forgejo URL uses - as its repository name or contains consecutive dots, the new validator accepts the identity and arms a poll for a repository Forgejo cannot host, causing a watch that can never wake. Apply the same rejection in the duplicated poll validation.

@yvp95

yvp95 commented Aug 28, 2026

Copy link
Copy Markdown
Author

Thanks - this is a good catch, and it is half right. I checked both halves against Forgejo's own source before acting, so here is the reasoning on the record.

- as a repository name: correct, and I will fix it. models/repo/repo.go has:

reservedRepoNames    = []string{".", "..", "-"}
reservedRepoPatterns = []string{"*.git", "*.wiki", "*.rss", "*.atom"}

I reject . and .. and the four suffix patterns, but not -. So https://host/owner/-/pulls/1 parses and arms a poll for a repository the forge structurally cannot host, which is a watch that can never wake. That is exactly the failure this validation exists to prevent.

Consecutive dots: I do not think this one holds, so I am not applying it. Repository names are checked with AlphaDashDotPattern, which in models/db/name.go is:

// AlphaDashDotPattern characters prohibited in a user name (anything except A-Za-z0-9_.-)
AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`)

That prohibits out-of-set characters only; there is no consecutive-character rule for repositories. The [-._]{2,} restriction lives in invalidUsernamePattern and applies to user names, which this change already enforces separately for the owner segment. So a repository called a..b is one Forgejo can genuinely host, and rejecting it would make the parser stricter than the forge - refusing a valid pull request URL, which is a worse failure here than the one being reported. Happy to be shown otherwise if I have misread it.

One more in the same class, which I found while checking the above. IsUsableName lowercases before comparing:

name = strings.TrimSpace(strings.ToLower(name))

The reserved suffixes are therefore case-insensitive on the forge, while my case patterns are case-sensitive - so repo.GIT and repo.Wiki are reserved by Forgejo but currently accepted here. Fixing that alongside -.

Both fixes land in bin/fm-pr-lib.sh and bin/fm-pr-poll.sh, since the poll deliberately re-validates rather than trusting the sidecar, with a comment on each side pointing at the other so a future change does not update one and miss the other.

One deliberate omission, flagged so it reads as a decision rather than an oversight: I am not encoding Forgejo's reserved username list for the owner segment. That list is forge policy that varies by version, so baking it in would rot into refusing owners the forge accepts. - is different in kind - it can never be hostable.

An upstream review flagged that a Forgejo URL naming "-" as its repository
parsed and armed a merge poll. Forgejo reserves that name outright, so the
poll watched something the forge cannot host and could never wake - the
silent failure this validation exists to prevent.

Forgejo's own rules, checked against its source rather than inferred:

  reservedRepoNames    = []string{".", "..", "-"}
  reservedRepoPatterns = []string{"*.git", "*.wiki", "*.rss", "*.atom"}

"." and ".." were already refused; "-" was not. IsUsableName also lowercases
before comparing, so the reserved suffixes are case-insensitive on the forge
while the patterns here were not: "repo.GIT" and "repo.Wiki" were accepted.
Both gaps are fixed, in bin/fm-pr-lib.sh and in bin/fm-pr-poll.sh, which
re-validates rather than trusting its sidecar. Each side now points at the
other so a later change cannot fix one and miss it.

Two things deliberately left alone, with the reasoning in the comments:

The same review claimed consecutive dots are invalid. They are not.
AlphaDashDotPattern is [^\w-\.] and carries no consecutive-character rule for
repositories; that restriction is invalidUsernamePattern's, and it already
applies to the owner segment here. Refusing "a..b" would make this stricter
than the forge and reject a repository it can genuinely host, which is a worse
defect than the one reported. A test now pins that, so the claim cannot be
adopted later by accident.

The forge's reserved USERNAME list stays unencoded for the owner. It is
version-varying policy that would rot into refusing owners the forge accepts,
unlike "-", which can never name a hostable repository.
@yvp95

yvp95 commented Aug 28, 2026

Copy link
Copy Markdown
Author

Fixed in 803ca4d, which is now the head of this PR.

  • - is refused, matching reservedRepoNames = []string{".", "..", "-"}.
  • Reserved suffixes are now matched case-insensitively, since IsUsableName lowercases before comparing - so repo.GIT and repo.Wiki are refused too. That gap was not in the original report; it is the same class of bug and worth having.
  • Both land in bin/fm-pr-lib.sh and bin/fm-pr-poll.sh, each with a comment pointing at the other, because the poll deliberately re-validates instead of trusting its sidecar.
  • Consecutive dots are still accepted, for the AlphaDashDotPattern reason in my previous comment. There is now a test pinning that, so the claim cannot be adopted later by accident and quietly start refusing repository names the forge can host.

The new rejections were confirmed to fail against the pre-fix code and pass after it, rather than only passing once the change was in.

Thanks for the review - the - case was a genuine miss on my part.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed HEAD 803ca4da2d9cca675e144bed4ba86b8cadc57ec2 vs main. Whole thread read (Greptile 5/5; author addressed the reserved-repo - / case-insensitive suffix review in 803ca4da). yvp95 is not blocked. Not flagged-malicious. Compare to main shows zero .github/workflows/* file changes. No sudo / pull_request_target / privilege widening.

Class: opt-in. Adds forgejo as a third guarded-PR provider (/pulls/ URL + forgejo-axi). GitHub /pull/ and GitLab /-/merge_requests/ accepted sets are unchanged. Host is passed as --base-url, never inferred from a URL. Missing forgejo-axi refuses at arming rather than watching nothing.

VISION.md per-rule (inspected bin/fm-pr-lib.sh fm_pr_url_parse / fm_pr_forgejo_host_valid / owner+repo rules, bin/fm-pr-check.sh arming gate, bin/fm-pr-poll.sh exact merged proof + --base-url, bin/fm-pr-merge.sh live mergeability + --expected-head + squash default, docs/forgejo-merge-watch.md, tests):

  • One captain, one interface — aligns (watch/merge stay firstmate-owned; missing CLI fails at arm, not as a silent never-merged poll).
  • Authority is explicit — aligns as opt-in (no GitHub/GitLab behavior change; Forgejo runs only on a captain-supplied /pulls/ URL).
  • Scripts own the mechanics — aligns (strict parse, poll silent on error, merge head-bound).
  • A restart is a non-event — aligns (same sidecar identity/receipt as GitHub/GitLab).
  • Delegation with a spine — aligns (existing merge/watch primitive; third forge).
  • The fleet outlives any vendor — aligns (explicit --base-url; not a GitHub-only path).
  • Scope — aligns (PR path only; teardown pr_is_merged left on the existing GitLab-shaped fallback).

Attestation: mismatch. Body head_sha 1d07b48929d21cfa4e2710dc430d07874231c7b0 ≠ HEAD 803ca4da (reserved-name fix commit).

Fork CI: approved this pass on this HEAD only: 33192440245 (CI), 33192440239 (Require no-mistakes).

Overlap hold: open #3068 (Gitea/Forgejo forge support) and #3009 (Gitea tea path) already implement the same bin/fm-pr-*.sh surface. Do not land this while those are open. Attestation mismatch is waiting on the author. The overlap hold is a maintainer integration decision and is not waiting on the author. Not a captain-decision for product behavior. No auto-merge.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants