# Forgejo pull request watch and merge verification Empirical record for the merge watch and the merge path on Forgejo, alongside the existing GitHub and GitLab ones (`docs/gitlab-merge-watch.md`). Every finding here was verified against a real, disposable Forgejo instance rather than assumed from `tea --help`, because several of them contradict what the help text alone would suggest. All evidence below was collected on 2026-09-13. A project opts in with the `forge=forgejo` registry binding, which `bin/fm-project-mode.sh` owns; the watch and merge paths recorded here key off the pull request URL itself, and the binding decides only that a generated ship brief names `tea` rather than `gh-axi`. ## Versions ``` $ tea --version Version: development golang: 1.26.4 ``` This is a locally built development binary; its exact source revision could not be determined from the binary (no embedded VCS stamp). ``` $ jq --version jq-1.7 $ bash --version | head -1 GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu) $ docker --version Docker version 29.8.0, build 88096ef ``` ## The evidence instance Unlike the GitLab doc's public fixture project, a self-hosted Forgejo instance is not something a reader can be pointed at over the network: that is the entire reason firstmate needs to support it. The evidence here instead comes from a disposable, throwaway Forgejo 7 instance (`codeberg.org/forgejo/forgejo:7`) run locally in Docker for exactly this verification and destroyed afterward, with one repository (`fixture/fixture-repo`) and several pull requests created through the same `tea` CLI a real task would use. Nothing here depends on any private or production instance, and none of it is reproducible by a reader without standing up an equivalent throwaway instance themselves. ## Why tea needed real verification before writing any code `tea --help` documents `--fields`/`-f` and `--output`/`-o` on `tea pulls`, which looks at first glance like `glab mr view -R -F json`. It is not: ``` $ tea pulls 1 --login fm-fixture --repo fixture/fixture-repo -f state -o simple # #1 Add the merged example file (merged) @fixture created 2026-09-13 22:08 **main** <- **merged-branch** merges -------- http://localhost:3980/fixture/fixture-repo/pulls/1 ``` `tea pulls ` always renders the same free-text detail view regardless of `-f`/`-o` - the help text's "will show it in detail" is the whole story, and field/output selection is silently ignored. Only the list form honors them: ``` $ tea pulls list --login fm-fixture --repo fixture/fixture-repo --state all -f index,state -o csv index,state 2,open 1,merged ``` A list read is bounded by `--limit`, so a watched pull request older than that window would never be seen merged. The merge watch (`bin/fm-pr-poll.sh`), the terminal-run PR detail (`bin/fm-crew-state.sh`), and teardown's merged-PR proof (`bin/fm-teardown.sh`) therefore read the one pull request by number through `tea api ... /repos/{owner}/{repo}/pulls/{n}`, the same REST record the merge path below reads, and accept only a record whose `merged` field is a boolean (`fm_pr_forgejo_read_record` in `bin/fm-pr-lib.sh`). **Not verified here:** that by-number read was not re-run against the evidence instance; it relies on the same `tea api` pull request record whose `state`, `merged`, and `head.sha` fields the merge path's read-back below did verify. ## Why the login is resolved fresh, and how `tea pulls`/`tea api` address a repository by `owner/repo` slug only. The host comes from a named `tea login` (`tea login add --name --url --token `), selected by `--login `; there is no flag that takes a bare host or full URL the way `glab -R ` does: ``` $ tea pulls ls --repo https://example.com/owner/repo -o simple Error: path segment [1] is empty ``` Three logins were registered on the verification machine, two real ones on a private instance plus the throwaway fixture; those two are on a private network and their identifiers stay out of this record, the same way `docs/gitlab-merge-watch.md` withholds identifiers from its private-instance runs: ``` $ tea login list --output json [ { "name": "forgejo", "url": "", "ssh_host": "", "user": "", "default": "true" }, { "name": "forgejo-admin", "url": "", "ssh_host": "", "user": "", "default": "false" }, { "name": "fm-fixture", "url": "http://localhost:3980", "ssh_host": "localhost", "user": "", "default": "false" } ] ``` Every Forgejo read parses this list as JSON with `jq`, matches the validated PR host against each login's bare hostname (stripping scheme, userinfo, port, and path), and refuses when zero or more than one login matches, rather than guessing. It never splits the output line by line, because a login printed as one compact object would then read its name where the url belongs. `fm_pr_forgejo_login` in `bin/fm-pr-lib.sh` owns that match for every sourcing script; `bin/fm-pr-poll.sh` is a static, standalone watcher body with no sourced dependency, so it keeps its own copy and re-derives the match on every poll instead of trusting a name recorded anywhere durable. ## URL shape Forgejo (and Gitea) serve pull requests at `https://///pulls/` - plural "pulls", no `/-/` route separator, confirmed by the fixture's own PR-creation output: ``` $ tea pulls create --login fm-fixture --repo fixture/fixture-repo --head merged-branch --base main --title "..." ... http://localhost:3980/fixture/fixture-repo/pulls/1 ``` This never collides with GitHub's `.../pull/` (singular), GitLab's `.../-/merge_requests/`, or Gerrit's `.../c//+/`. ## End to end: arming and polling a real pull request ``` $ fm-pr-check.sh task-f1 https://localhost/fixture/fixture-repo/pulls/4 armed: state/task-f1.check.sh $ cat state/task-f1.pr-poll forgejo https://localhost/fixture/fixture-repo/pulls/4 localhost fixture/fixture-repo 4 $ state/task-f1.check.sh (nothing - PR 4 is still open) ``` PR 4 was then merged directly through the head-bound API call described below, and the same published check was run again with no other change: ``` $ state/task-f1.check.sh merged ``` The `localhost:3980` login's actual port never appears in the canonical PR URL or the poll's host comparison - login matching only ever compares bare hostnames - so this reproduces correctly for an instance reachable on the standard port with no explicit port in its URL, which is the realistic self-hosted shape. ## A missing tea produces no wake, never a false merge ``` $ PATH="$notea" bin/fm-pr-poll.sh --validated forgejo https://localhost/fixture/fixture-repo/pulls/1 localhost fixture/fixture-repo 1 (nothing) $ PATH="$notea" bin/fm-pr-check.sh task-x https://localhost/fixture/fixture-repo/pulls/1 error: watching a Forgejo pull request requires tea on PATH ``` ## Merging: tea's merge subcommand cannot bind a head, but the API can `tea pulls merge --help` documents no `--sha`/head-commit flag, unlike `gh pr merge --match-head-commit` and `glab mr merge --sha`. Forgejo/Gitea's underlying REST API does support one, as an undocumented-by-tea `head_commit_id` field on the merge request body (confirmed against the instance's own served Swagger definition, `MergePullRequestOption`), so `bin/fm-pr-merge.sh` calls that API directly through `tea api` instead of `tea pulls merge`: ``` $ tea api --login fm-fixture --repo fixture/fixture-repo -X POST '/repos/{owner}/{repo}/pulls/2/merge' \ -f Do=merge -f head_commit_id=0000000000000000000000000000000000000000 -i HTTP/1.1 409 Conflict {"message":"head out of date", ...} $ tea api --login fm-fixture --repo fixture/fixture-repo -X POST '/repos/{owner}/{repo}/pulls/2/merge' \ -f Do=merge -f head_commit_id= -i HTTP/1.1 200 OK ``` A stale or wrong head is refused with `409 head out of date`; the exact current head succeeds and actually merges (`state` read back as `closed`, `merged: true`). This exactly parities `--match-head-commit`/`--sha`: a push landing between the verifying read and the merge call is refused rather than merged un-reviewed. The `Do` field is the style the caller names (`--merge`, `--rebase`, `--squash`, or `--method