Pull requests and forge integrations
What the product reads from your forge, the one write it makes after a signed human decision, how that merge is gated, and which parts are not available on this release.
What Saphan Studio reads from your forge, the one thing it writes to it, and which parts of the pull-request path are built but not available
⚠ Read the state table first. Parts of what follows are shipped and exercised, and parts are built and not available. The page says which is which, per capability, at the place you meet it.
The state of every capability on this page
The word exercised below means: the behaviour is covered by tests that run on every build, against forge responses recorded from real servers and replayed byte for byte. Proven against your forge is claimed for nothing on this page.
| capability | does it exist | is it exercised | what is therefore not proven |
|---|---|---|---|
| Configure a forge — named connections bound to workspace repositories | yes — [forge.<id>] and [repo.<name>] in the configuration file, or the console's connect-a-forge form | yes | — |
| Read a Gitea repository — list pull requests, inspect one, ask what a branch requires | yes, polled by the server into the record | yes | that your Gitea version answers as the recorded one did |
| Read a GitHub repository | yes, the same three questions | yes | the same, for GitHub |
| Merge a pull request on Gitea after a signed human decision — post a commit status, file an approval, call the merge API, read the result back | yes, from the console's pull-request screen | yes, each step and each refusal | that it completes against your deployment, your proxy and your token's scopes |
| Merge a pull request on GitHub | ⛔ no — a GitHub connection is refused by name when a merge is composed (config-kind-unknown) | not applicable | reading works; writing is not built |
Push a stream branch to your origin | ⚠ built as a capability, called by no act | not applicable | do not plan on the product publishing your branches; push them yourself |
| Merge a pull request from the terminal | ⛔ no — there is no command for it; the merge is a console act | not applicable | — |
| GitLab, Bitbucket | ⛔ no — recognised and refused as not implemented (forge-kind-deferred) | not applicable | — |
| Pull-request pre-flight over your branch — seven read-only checks before you open a pull request | yes, as saphan pr-preflight | yes | it observes your local branch and talks to no forge |
⚠ The tests open no network socket. The HTTP boundary is substituted in every test, so the transport, your TLS, your private certificate authority, your reverse proxy and your token's scope are outside what the suite has measured. Treat the forge integration as well tested and not yet operated in your environment until you have run it there.
Configuring a forge
A connection says where a forge is and which credentials it uses; a binding says which workspace repository it serves, under which forge repository name, and under which policy.
kindisgiteaorgithub.base_urlcarries the scheme and the port explicitly — nothing completes, upgrades or infers them. For GitHub it ishttps://api.github.com:443.- Credentials are never in this file.
token_laneandapproval_token_lanename lanes whose credential lives in the host's credentials file.token_laneis the identity that opens and merges;approval_token_laneis a second identity that approves, and the identity that opens a request can never be the one that approves it. enforcementmust name at least one ofstatus-check,attribution-ci,bot-approval; a forge configured to enforce nothing is refused.bot-approvalrequiresapproval_token_lane.merge_methodismerge(the default) orsquash.status_contextdefaults tosaphan/gate.poll_intervaldefaults to sixty seconds.- ⚠ A forge requires
workspace.canon = origin. It is never inferred from the presence of a forge; a forge configured without it is refused (forge-requires-origin-canon).
saphan config forge show lists, per repository, where each value came from and five states:
saved, reachable, authenticated, branch protection verified, merge available. An older single
[forge] section keeps working until saphan config forge migrate converts it. See
saphan config for every key.
What the product does to your forge
Reading is four questions: list the repository's pull requests, inspect one by number, ask what the forge requires of one branch, and ask what this kind of forge is capable of (a local answer that makes no request).
Writing is one act — the merge of one pull request, as the delivery of one recorded human decision. In order:
- A person approves the merge on the console's pull-request screen with their passkey. The signature covers the repository, the pull-request number and the full head commit the person was looking at.
- The signed gate row is written before any byte leaves the machine. The act then checks,
from the record, that the gate row is strictly earlier than every outbound step, and refuses
otherwise (
gate-not-earlier-than-act). - A commit status is posted on the pinned head (the configured status context).
- An approval is filed — under the second identity's account.
- The forge's merge API is called with the configured merge method.
- The pull request is read back. A successful API call is not a merge: if the forge does not
then say merged, the act refuses (
forge-merge-unconfirmed).
Each step is recorded once per gate row, so an interrupted act resumes rather than repeats: a completed step is skipped, an attempted-but-unfinished one is retried under the same idempotency key, and a step that completed with an error ends that decision — the remedy is a new decision at the current head.
The stream then stands at awaiting-observation, never at merged. The merge commit was
written by the forge, and merged still enters the record only through
saphan fleet observe — the same door as a local merge.
What it never does: force-push, rewrite history, delete a branch, discover a remote, or act on a forge it was not given by explicit configuration. Branch protection remains your forge's decision.
How a forge-side merge is gated
The hazard it is built against
A human approves one tree. A forge merges whatever is on the branch when it is asked. If a commit lands in between, an approval recorded about the first tree silently becomes an approval of the second. Everything below asks one question many ways: is the thing the forge would merge still the thing the human approved?
The pin
The full head commit the decision is about is written inside the signed portion of the gate
record, not beside it. The approval therefore cannot transfer to commits pushed afterwards: a
record whose pin was edited no longer matches the signature that names it. And the repository the
passkey signed for must be the repository the record resolves from the number — otherwise the act
refuses (pr-repo-mismatch), because a number alone names nothing once two repositories are
polled into one workspace.
The refusals checked before anything is written
Each is a named refusal. They are collected together — one attempt tells you everything that is wrong, not only the first thing.
| refusal | what it means |
|---|---|
forge-token-absent | no forge connection was composed for this act |
pr-repo-invalid | the owner or name could not be part of a URL path |
pr-number-absent | zero is "you named none", never "the first one" |
pr-head-pin-absent | there is nothing to seal into the signed record. An unpinned merge is the one shape this act may never take |
pr-not-found | the forge says it does not exist |
origin-unreachable | the question could never be put — name resolution, dial, TLS, timeout |
pr-not-open | closed, or already merged — named apart |
pr-branch-mismatch | the pull request does not connect the two references this stream's record names. Both ends are checked |
pr-stale-head | the commit the human approved is not the commit the forge now reports. This is the load-bearing one — and it is checked again immediately before the merge call |
pr-forge-head-unknown | the forge named no head at all, so there was nothing to compare the pin against |
branch-not-on-origin | your repository holds no remote-tracking reference for this branch, so the forge cannot be looking at your work |
local-tip-not-pr-head | your branch and the forge's head are different commits |
pr-base-conflict | git itself cannot combine the two ends — ⚠ measured locally in a detached tree, never taken from the forge's own mergeability hint |
pr-local-record-incomplete | the record names no repository, branch or base, so the local half could not be measured at all. ⛔ It is a refusal and not a skip — a skipped check produces a plan indistinguishable from a verified one |
pr-state-not-gated | the stream is not at a state this act is legal from — the same window the local merge requires |
live-run-refused | an agent is still recorded as writing to this branch |
pr-record-refused | the review lane is engaged with no decision answering it, a named review never ran, or a decision this merge stands on fails signature verification — the same derivations, in the same words, as the local merge |
⚠ Why the last two matter. A forge-side merge that skipped the review lane or the signature check would be an easier door to an unreviewed, unsigned merge. So it reads the local merge's own rules, and its state window is the local one's.
Refusals the act itself can meet while delivering include status-post-failed,
approval-post-failed (a review the forge does not count as an approval is a failure, whatever
the HTTP status said), forge-merge-failed, forge-merge-unconfirmed,
forge-cannot-carry-trailers (the forge would not keep the composed merge message, so the
attribution trailers would be lost — refused before the gate row is written), and
forge-merge-attribution-unknown (the pull request is merged and the record cannot say whether
this act merged it — nothing is written, and the question is handed to a person).
The absent measurement is never reported as a passing one
The local conflict probe needs a scratch location outside your repository. When it cannot run, the plan says so in words rather than reporting a clean probe; if your host does not hold the pull request's base at all, the probe declines and names that. And a clean local probe is evidence about the pair your host can see — your remote-tracking base can be stale, so it is never a promise about the pair the forge holds.
Pull-request pre-flight — a verb you can run today
This one is shipped and exercised, and it touches no forge at all.
Seven independent, read-only observations over the branch you are about to turn into a pull request:
| check | what it looks at |
|---|---|
| branch | you are not on the base branch, HEAD is not detached, and the name matches your project's pattern |
| status | the working tree is clean |
| log | your commit subjects match your project's pattern |
| behind-base | your branch is not behind its base |
| submodule | no submodule pointer moved and no submodule is dirty |
| build | your build command succeeds |
| scoped-test | your scoped test command succeeds |
⚠ A failed check does not end the run. The checks are independent observations, so you get the complete list of things to fix in one pass rather than one at a time. Only "this is not a git repository" aborts the rest, because no later check would mean anything.
The verdict maps to the exit code: 0 everything passed · 2 there are items to fix, and they are all named · 1 the verb itself could not run.
The base branch, the naming patterns and the build and test commands are your project's facts and are read from your configuration or your flags — never from inside the product. And the product fixes nothing: every item tells you what to do.
Two measured traps, if you build on the forge reader
Both are properties of real forges, and both are the reason the abstraction is shaped as it is.
⛔ "Not protected" and "we could not tell" are different answers
An answer about what a forge requires of a branch has three values, not two: required (the forge named its checks and they were read), not required (an authoritative endpoint answered and named none), and could not be determined (the credential was too narrow, the server has no such endpoint, or the endpoint that answered is one known to answer wrongly).
Why the third value exists, measured on a real GitHub repository: at one moment, the classic branch-protection endpoint answered 404 "branch not protected" while the newer rules endpoint answered 200 naming a required check — and the forge was simultaneously refusing merges because that very check was missing. A reader written against the older endpoint alone reports a gate absent while it is enforcing.
⚠ That is the worst answer shape available: a confident, wrong negative. So a negative from an endpoint a newer mechanism can defeat is reported as could not be determined, never as not required. Every answer also carries which endpoint produced it, because "who said so" is what decides whether a negative may be believed. And when a reader asks "is this check required?", it gets a second value saying whether the first means anything at all.
⛔ A forge's own mergeability hint is not a gate signal
Measured on real Gitea: the pull request's mergeable field read true in every arm of the
test — including while the same forge was returning 405 "not all required status checks
successful" for that same pull request. On GitHub the equivalent field tracked correctly.
So the hint is carried as a three-state value (yes, no, unknown — both forges compute it asynchronously, so unknown is normal and frequent rather than an error), and beside it a per-forge fact saying whether the hint may be believed on this forge at all. For Gitea that fact is no. A caller that renders a merge affordance from the hint has written the bug the capability facts exist to prevent.
⚠ This is also why the base-conflict check probes git locally instead of asking the forge.
What the reader guarantees regardless
- Commit identifiers are full forty-character values or they are refused. A shortened identifier reads like an identifier, compares unequal to the full one, and is ambiguous by construction — and this is the field that gets sealed into a signed record. It is read from the branch reference wherever a forge lets the pull request object lag behind, which GitHub was measured doing.
- A merged pull request is reported as merged. Gitea reports one as closed with a merged flag; collapsing that into "closed" loses the one distinction a gate cares about, so it is promoted to its own state.
- Human-facing links are composed from the destination you declared, never echoed back from the forge. A forge reachable on two front doors rewrites the links it writes about itself to whichever door you asked through, and a link captured through one door is dead for a reader who only reaches the other.
- A missing or unparseable private certificate authority bundle refuses at construction, rather than falling back to the system trust store and failing later, on the far side of a handshake, as something that looks like a network problem.
- Nothing upgrades
httptohttps, and nothing supplies a scheme that is absent. A forge behind a reverse proxy on plain HTTP, or on a non-standard port, or both, is a normal configuration — so the scheme is declared, and a base URL with no scheme is refused rather than guessed. - The forge host is declared, never inferred from a URL string. A configuration that says two different things about which host is the forge is refused rather than reconciled, because reconciling means silently picking one.
⛔ What is not available on this release
- No merge through a GitHub pull request. GitHub is read, not written.
- No terminal command merges a pull request. The forge-side merge is a console act signed with a passkey.
- The product does not push your stream branches to
origin. Publish the branch yourself; a pull request whose head is not on the forge is refused (branch-not-on-origin). - No GitLab or Bitbucket.
The merge that works everywhere is the local merge in Merging.