feat(pr): support Forgejo pull requests across parse, watch, and merge #3

Merged
john merged 1 commit from fm/fm-forgejo-pr-support into main 2026-08-07 20:01:47 +00:00
Owner

What was unsupported

bin/fm-pr-lib.sh parsed only GitHub pull-request URLs (/pull/, fixed github.com) and GitLab merge-request URLs (/-/merge_requests/, self-hosted). A Forgejo pull request at https://<host>/<owner>/<repo>/pulls/<n> (note /pulls/ plural, self-hosted) failed fm_pr_url_parse, so:

  • bin/fm-pr-check.sh <id> <url> exited 2 with error: invalid PR check request and no merge poll was ever armed.
  • bin/fm-pr-merge.sh refused too: it addressed GitHub by owner/repository through gh-axi and had an explicit provider check.

On a forge whose only delivery target is Forgejo (GitHub is read-only here), that meant no pull request was ever automatically watched, and every merge and CI transition was caught by hand or by heartbeat.

What now works

URL parsing

fm_pr_url_parse accepts a Forgejo pull request URL and yields the same provider-tagged identity the other forges do: provider=forgejo, host, the owner/repository path, and number. The host is self-hosted (exactly as GitLab already is) - nothing hardcodes git.pyr0.dev. github.com and gitlab.com are refused as Forgejo hosts so a typo'd or spoofed URL can never be armed as a Forgejo watch that could never succeed. A Forgejo project is always owner/repository (two segments, no nesting); a deeper path is refused.

Merge poll

bin/fm-pr-poll.sh gained a forgejo branch that reads the REST API (GET /api/v1/repos/<owner>/<repo>/pulls/<n>) with curl and parses the merged field with jq. It revalidates host, path, number, and the reconstructed URL from the private sidecar exactly as the GitHub and GitLab branches do, so a doctored sidecar cannot redirect it. It emits exactly one merged line only when the API reports merged == true; every other reading, a missing token, a missing tool, or any API error stays silent - a failed lookup can never be read as a merge. The byte-static security contract is unchanged: task and URL data live only in the private state/<id>.pr-poll sidecar and are never interpolated into the check script.

Arming

bin/fm-pr-check.sh arms a working merge poll for a Forgejo pull request, recording pr= and the forge's pr_head= (the API's head.sha) in task metadata like the other providers. It refuses to arm when curl or jq is missing from PATH - the one point where a missing tool can be reported instead of silently watching nothing.

Merge

bin/fm-pr-merge.sh can now merge a Forgejo pull request through its REST API (POST .../pulls/<n>/merge), since Forgejo has no firstmate-wrapped CLI the way gh wraps GitHub. It defaults to Do=squash (matching the GitHub default), forwards --merge/--rebase/--method=<value>/--delete-branch, and refuses repo-override flags and any unsupported gh-axi-specific flag rather than silently dropping it. A real API merge failure propagates; it never silently succeeds. A GitLab merge request URL is still refused rather than sent to the wrong forge.

How the token is obtained

Forgejo has no CLI that manages its own credentials, so the REST API is read directly. The token (FORGEJO_API_KEY) is read from the environment at call time, falling back to the captain's documented private file ~/.zshenv_local (plain VAR=value lines, POSIX-shell compatible) when the watcher or another non-interactive invocation inherited no export - which is the common case, since ~/.zshenv_local is only sourced by interactive zsh and the variable is not exported.

The token is never embedded in a generated script, a metadata file, or anything under state/. The byte-static poll script inlines the same load contract (it cannot resolve the library path from an arbitrary state directory); fm_pr_forgejo_token in bin/fm-pr-lib.sh owns the canonical contract and is used by the check and merge paths. A missing token fails closed: the merge path refuses with an explicit error, and the poll stays silent (a private repo answers 404 to an unauthenticated request, which would otherwise read as "not merged").

What was verified against the live API

Read-only checks against https://git.pyr0.dev (no pull request was merged, closed, commented on, or otherwise mutated):

  • Parsing succeeds for pyr0/slotrift#22, pyr0/slotrift#24, and pyr0/pyr0-dev-infra#176.
  • A real fm-pr-check.sh arming against pyr0/slotrift#22 recorded pr= and pr_head=5089a7f103ada0445087061821d6a4e932bedd56, published a byte-for-byte static check, and the armed poll emitted merged.
  • The poll's --validated path returned merged for the merged pull request and stayed silent for a non-existent number and for a sidecar whose host was swapped.

Tests

Colocated with the existing PR suites:

  • tests/fm-pr-check-security.test.sh: Forgejo canonical/malformed URL parsing, the poll contract (only merged == true wakes; curl failure and doctored sidecars stay silent), arming guards for missing curl and jq, the full merge path (recording, Do=squash default, method forwarding, --delete-branch, failure propagation, unsupported-arg refusal, repo-override refusal), and Forgejo merged-poll retirement.
  • tests/fm-pr-merge.test.sh: Forgejo records pr=/pr_head= before posting, failure propagation, explicit --merge/--method= not overridden, --delete-branch forwarding, unsupported-arg refusal, repo-override refusal, missing-token refusal, and the ~/.zshenv_local token fallback.

The full test suite on the rebased branch shows no regressions attributable to this change. The pr-forge family (both PR test files) and the watcher-wake-lock family pass cleanly in isolation. The failing tests in the full --all run are all unrelated to PR tooling: pre-existing on the base (herdr-presentation-e2e, calm-pi-extension, documentation-audiences, session-start), environmental - new tests brought by the upstream merge that require a harness tool not present in this environment (fm-on, fm-remote-doctor) - or full-run resource flakiness that passes in isolation (fm-bootstrap, fm-pr-check-security, fm-watcher-lock). None exercise Forgejo parsing, the poll, or the merge path.

Still unsupported

  • GitLab merge requests are still followed by the watcher but not merged by fm-pr-merge.sh; the refusal is unchanged, since GitLab merge parity is a separate change.
  • The Forgejo merge method supports merge, squash, rebase, and rebase-merge; gh's --rebase maps to Forgejo rebase. There is no gh-style --rebase-merge flag, but --method=rebase-merge reaches it.
  • pr_head is recorded for Forgejo when the API and token are available; both consumers already treat it as optional.
## What was unsupported `bin/fm-pr-lib.sh` parsed only GitHub pull-request URLs (`/pull/`, fixed `github.com`) and GitLab merge-request URLs (`/-/merge_requests/`, self-hosted). A Forgejo pull request at `https://<host>/<owner>/<repo>/pulls/<n>` (note `/pulls/` plural, self-hosted) failed `fm_pr_url_parse`, so: - `bin/fm-pr-check.sh <id> <url>` exited `2` with `error: invalid PR check request` and **no merge poll was ever armed**. - `bin/fm-pr-merge.sh` refused too: it addressed GitHub by owner/repository through `gh-axi` and had an explicit provider check. On a forge whose only delivery target is Forgejo (GitHub is read-only here), that meant no pull request was ever automatically watched, and every merge and CI transition was caught by hand or by heartbeat. ## What now works ### URL parsing `fm_pr_url_parse` accepts a Forgejo pull request URL and yields the same provider-tagged identity the other forges do: `provider=forgejo`, `host`, the `owner/repository` path, and `number`. The host is self-hosted (exactly as GitLab already is) - nothing hardcodes `git.pyr0.dev`. `github.com` and `gitlab.com` are refused as Forgejo hosts so a typo'd or spoofed URL can never be armed as a Forgejo watch that could never succeed. A Forgejo project is always `owner/repository` (two segments, no nesting); a deeper path is refused. ### Merge poll `bin/fm-pr-poll.sh` gained a `forgejo` branch that reads the REST API (`GET /api/v1/repos/<owner>/<repo>/pulls/<n>`) with `curl` and parses the `merged` field with `jq`. It revalidates host, path, number, and the reconstructed URL from the private sidecar exactly as the GitHub and GitLab branches do, so a doctored sidecar cannot redirect it. It emits exactly one `merged` line only when the API reports `merged == true`; every other reading, a missing token, a missing tool, or any API error stays silent - a failed lookup can never be read as a merge. The byte-static security contract is unchanged: task and URL data live only in the private `state/<id>.pr-poll` sidecar and are never interpolated into the check script. ### Arming `bin/fm-pr-check.sh` arms a working merge poll for a Forgejo pull request, recording `pr=` and the forge's `pr_head=` (the API's `head.sha`) in task metadata like the other providers. It refuses to arm when `curl` or `jq` is missing from `PATH` - the one point where a missing tool can be reported instead of silently watching nothing. ### Merge `bin/fm-pr-merge.sh` can now merge a Forgejo pull request through its REST API (`POST .../pulls/<n>/merge`), since Forgejo has no firstmate-wrapped CLI the way `gh` wraps GitHub. It defaults to `Do=squash` (matching the GitHub default), forwards `--merge`/`--rebase`/`--method=<value>`/`--delete-branch`, and refuses repo-override flags and any unsupported `gh-axi`-specific flag rather than silently dropping it. A real API merge failure propagates; it never silently succeeds. A GitLab merge request URL is still refused rather than sent to the wrong forge. ## How the token is obtained Forgejo has no CLI that manages its own credentials, so the REST API is read directly. The token (`FORGEJO_API_KEY`) is read from the environment at call time, falling back to the captain's documented private file `~/.zshenv_local` (plain `VAR=value` lines, POSIX-shell compatible) when the watcher or another non-interactive invocation inherited no export - which is the common case, since `~/.zshenv_local` is only sourced by interactive zsh and the variable is not exported. The token is **never** embedded in a generated script, a metadata file, or anything under `state/`. The byte-static poll script inlines the same load contract (it cannot resolve the library path from an arbitrary state directory); `fm_pr_forgejo_token` in `bin/fm-pr-lib.sh` owns the canonical contract and is used by the check and merge paths. A missing token fails closed: the merge path refuses with an explicit error, and the poll stays silent (a private repo answers 404 to an unauthenticated request, which would otherwise read as "not merged"). ## What was verified against the live API Read-only checks against `https://git.pyr0.dev` (no pull request was merged, closed, commented on, or otherwise mutated): - Parsing succeeds for `pyr0/slotrift#22`, `pyr0/slotrift#24`, and `pyr0/pyr0-dev-infra#176`. - A real `fm-pr-check.sh` arming against `pyr0/slotrift#22` recorded `pr=` and `pr_head=5089a7f103ada0445087061821d6a4e932bedd56`, published a byte-for-byte static check, and the armed poll emitted `merged`. - The poll's `--validated` path returned `merged` for the merged pull request and stayed silent for a non-existent number and for a sidecar whose host was swapped. ## Tests Colocated with the existing PR suites: - `tests/fm-pr-check-security.test.sh`: Forgejo canonical/malformed URL parsing, the poll contract (only `merged == true` wakes; curl failure and doctored sidecars stay silent), arming guards for missing `curl` and `jq`, the full merge path (recording, `Do=squash` default, method forwarding, `--delete-branch`, failure propagation, unsupported-arg refusal, repo-override refusal), and Forgejo merged-poll retirement. - `tests/fm-pr-merge.test.sh`: Forgejo records `pr=`/`pr_head=` before posting, failure propagation, explicit `--merge`/`--method=` not overridden, `--delete-branch` forwarding, unsupported-arg refusal, repo-override refusal, missing-token refusal, and the `~/.zshenv_local` token fallback. The full test suite on the rebased branch shows no regressions attributable to this change. The pr-forge family (both PR test files) and the watcher-wake-lock family pass cleanly in isolation. The failing tests in the full `--all` run are all unrelated to PR tooling: pre-existing on the base (herdr-presentation-e2e, calm-pi-extension, documentation-audiences, session-start), environmental - new tests brought by the upstream merge that require a `harness` tool not present in this environment (fm-on, fm-remote-doctor) - or full-run resource flakiness that passes in isolation (fm-bootstrap, fm-pr-check-security, fm-watcher-lock). None exercise Forgejo parsing, the poll, or the merge path. ## Still unsupported - GitLab merge requests are still **followed** by the watcher but **not merged** by `fm-pr-merge.sh`; the refusal is unchanged, since GitLab merge parity is a separate change. - The Forgejo merge method supports `merge`, `squash`, `rebase`, and `rebase-merge`; `gh`'s `--rebase` maps to Forgejo `rebase`. There is no `gh`-style `--rebase-merge` flag, but `--method=rebase-merge` reaches it. - `pr_head` is recorded for Forgejo when the API and token are available; both consumers already treat it as optional.
feat(pr): support Forgejo pull requests across parse, watch, and merge
Some checks failed
CI / Lint shell scripts (pull_request) Has been cancelled
CI / Test coverage guard (pull_request) Has been cancelled
CI / Behavior portable parallel 1 (pull_request) Has been cancelled
CI / Behavior portable parallel 2 (pull_request) Has been cancelled
CI / Behavior portable serial 1 (pull_request) Has been cancelled
CI / Behavior portable serial 2 (pull_request) Has been cancelled
CI / Behavior portable serial 3 (pull_request) Has been cancelled
CI / Behavior portable serial 4 (pull_request) Has been cancelled
CI / Behavior tests (Herdr) (pull_request) Has been cancelled
CI / Behavior timing aggregate (pull_request) Has been cancelled
CI / Stock macOS Bash snapshot compatibility (pull_request) Has been cancelled
CI / Repo invariants (pull_request) Has been cancelled
Require no-mistakes / PR must be raised via no-mistakes (pull_request) Has been cancelled
67c7955a1a
Forgejo (and Gitea) pull requests live at /pulls/ (plural) on a self-hosted
instance and have no firstmate-wrapped CLI, so they were unsupported: a Forgejo
URL failed fm_pr_url_parse, no merge poll was armed, and fm-pr-merge.sh
refused. On a forge whose only delivery target is Forgejo, that left every pull
request watched by hand.

- fm_pr_url_parse now accepts a Forgejo pull request URL
  (https://<host>/<owner>/<repo>/pulls/<n>) and yields provider, host, the
  owner/repository path, and number, with self-hosted host validation that
  refuses github.com and gitlab.com. Forgejo projects are owner/repository
  (two segments, no nesting).
- fm-pr-poll.sh reads the Forgejo REST API with curl + jq and emits exactly
  one "merged" line only when the API reports merged == true; every other
  reading, a missing token, a missing tool, or any API error stays silent so
  a failed lookup can never be read as a merge. The byte-static security
  contract is unchanged: task and URL data live only in the private sidecar.
- fm-pr-check.sh arms a Forgejo poll (recording pr= and pr_head= from the
  API head sha) and refuses to arm when curl or jq is missing, the one point
  where a missing tool can be reported instead of watching nothing.
- fm-pr-merge.sh merges a Forgejo pull request through its REST API, defaulting
  to Do=squash, forwarding --merge/--rebase/--method=/--delete-branch, and
  refusing repo overrides and any unsupported gh-axi flag. A GitLab merge
  request URL is still refused.

The Forgejo token (FORGEJO_API_KEY) is read from the environment at call time,
falling back to the captain's documented ~/.zshenv_local for a non-interactive
invocation that inherited no export. It is never embedded in a generated
script, metadata, or anything under state/.

Tests cover Forgejo URL parsing (canonical and malformed), the poll contract,
arming guards for curl and jq, the merge path (recording, method forwarding,
delete-branch, failure propagation, unsupported-arg refusal, repo-override
refusal, missing-token refusal, and the ~/.zshenv_local token fallback), and
Forgejo merged-poll retirement. Existing GitHub and GitLab behaviour is
unchanged. Verified against the live API at git.pyr0.dev for already-merged
pull requests pyr0/slotrift#22 and #24 and pyr0/pyr0-dev-infra#176 (read only;
no pull request was merged, closed, commented on, or otherwise mutated).
john force-pushed fm/fm-forgejo-pr-support from 67c7955a1a
Some checks failed
CI / Lint shell scripts (pull_request) Has been cancelled
CI / Test coverage guard (pull_request) Has been cancelled
CI / Behavior portable parallel 1 (pull_request) Has been cancelled
CI / Behavior portable parallel 2 (pull_request) Has been cancelled
CI / Behavior portable serial 1 (pull_request) Has been cancelled
CI / Behavior portable serial 2 (pull_request) Has been cancelled
CI / Behavior portable serial 3 (pull_request) Has been cancelled
CI / Behavior portable serial 4 (pull_request) Has been cancelled
CI / Behavior tests (Herdr) (pull_request) Has been cancelled
CI / Behavior timing aggregate (pull_request) Has been cancelled
CI / Stock macOS Bash snapshot compatibility (pull_request) Has been cancelled
CI / Repo invariants (pull_request) Has been cancelled
Require no-mistakes / PR must be raised via no-mistakes (pull_request) Has been cancelled
to e4b5e41d6c
Some checks failed
Require no-mistakes / PR must be raised via no-mistakes (pull_request) Has been cancelled
CI / Lint shell scripts (pull_request) Has been cancelled
CI / Test coverage guard (pull_request) Has been cancelled
CI / Behavior portable parallel 1 (pull_request) Has been cancelled
CI / Behavior portable parallel 2 (pull_request) Has been cancelled
CI / Behavior portable serial 1 (pull_request) Has been cancelled
CI / Behavior portable serial 2 (pull_request) Has been cancelled
CI / Behavior portable serial 3 (pull_request) Has been cancelled
CI / Behavior portable serial 4 (pull_request) Has been cancelled
CI / Behavior tests (Herdr) (pull_request) Has been cancelled
CI / Behavior timing aggregate (pull_request) Has been cancelled
CI / Stock macOS Bash snapshot compatibility (pull_request) Has been cancelled
CI / Repo invariants (pull_request) Has been cancelled
2026-08-07 18:30:11 +00:00
Compare
john merged commit 9bb5ff0443 into main 2026-08-07 20:01:47 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
john/firstmate!3
No description provided.