Pull requests and forge integrations
What the product does and does not do against your forge, what a forge-side merge is designed to require, and where the pull-request path stops on this release.
What Saphan Studio can read from your forge today, what it cannot do to it at all, and which parts of the pull-request path are built but not yet proven
⚠ Read section 1 before anything else in this chapter. Parts of what follows are shipped and exercised, and parts are built and not yet proven. The chapter says which is which, per capability, at the place you meet it. Nothing here is described as available when it is not.
The state of every capability on this page
The word exercised below means: the behaviour is covered by tests that run on every build. The word proven against a live forge means something stricter and is claimed for nothing on this page — see section 1.2 for exactly what stands behind each row.
The table
| capability | does it exist | is it exercised | what is therefore not proven |
|---|---|---|---|
| Read a Gitea or Forgejo repository — list pull requests, inspect one by number, ask what a branch requires | yes | yes — against response bodies recorded from a real Gitea server, replayed byte for byte | that a credentialed call succeeds against your particular deployment, and that your Gitea version answers as the recorded one did |
| The forge abstraction itself — the shared vocabulary of refusals, the three-valued protection answer, per-forge capability facts | yes | yes | nothing beyond the abstraction; it is a shape, not a connection |
| Write anything to a forge — post a status, submit a review, merge a pull request | ⛔ no. It does not exist | not applicable | there is nothing to prove. See section 2 |
| A GitHub adapter | ⛔ no. It does not exist | not applicable | GitHub's behaviour has been measured and is recorded as the acceptance criteria a future adapter must meet, but no code in the product speaks to GitHub |
| Pull-request merge preconditions — the sixteen named refusals that decide whether a forge-side merge is legal, and the signed pin that binds one human decision to one commit | yes | yes, every refusal individually | it is not reachable from any command: see section 4 |
| Calling a forge's merge endpoint | ⛔ no. It does not exist | not applicable | the act that would create the merge commit on the forge is not built |
| Configuring a forge at all | ⛔ no | not applicable | there is no configuration section for a forge on this release, so no forge connection can be established by an operator |
| Pull-request pre-flight over your branch — seven read-only checks before you open a pull request | yes, as the verb saphan pr-preflight | yes | it observes your local branch and talks to no forge; see section 5 |
What "exercised" is standing on, stated plainly
The Gitea reader is tested against bytes a real Gitea server sent. Its response fixtures were recorded from a live Gitea 1.26.4 instance and are replayed verbatim, so the decoders are proven against what the forge actually says rather than against what their author imagined it says. That is a stronger claim than most integration tests earn.
⚠ And it is still not the same claim as "it works against your server." Three things it does not establish:
- No test opens a 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 all outside what the suite has measured.
- One server version. A deployment that answers differently is a legitimate difference the design anticipates — capability facts are per-forge values, not product constants — but that difference has not been measured for your deployment.
- The credentialed read path is inferred, not exercised end to end. A Gitea reader is deliberately usable with no token, because the branch object that carries the protection answer is readable anonymously on a public repository. Your token's behaviour is your deployment's fact.
⛔ Nothing on this page has been proven against a live forge inside a customer installation. Treat the Gitea reader as a well-tested library that has not yet been operated in your environment, and see section 4 for the parts that are not yet operable at all.
What the product cannot do to your forge — and why that is the whole value
The read surface is four questions and nothing else:
- list the repository's pull requests;
- inspect one pull request by number;
- ask what the forge requires of one branch;
- ask what this forge is capable of — a pure local accessor that makes no request.
Every one of those is a query. None of them changes anything on the far side. That is not a policy statement; it is the shape of the interface. There is no method that posts, submits, approves or merges, so there is no such call to audit for, and adding one would mean widening the interface itself.
Beside that, the local git surface described in
Working with git holds: a read-only allowlist of git
subcommands, a short closed list of individually named operations whose arguments are fixed
inside the product, no push path anywhere, and no code path that names, fetches from, pushes
to or mutates your origin.
Therefore, today, the product cannot merge your pull request, cannot approve it, and cannot write a status onto your commits. If your auditor's question is "what can this thing do to our repository without a human", the answer for the forge is: read four things.
The one thing your auditor should ask for next
Nothing merges without a human's recorded act. In the product's local merge that is mechanical and already true: a merge stands on a gate decision that a named human recorded, the merge command's arguments are fixed inside the product, and the human's invocation is the act. The forge-side equivalent is designed to the same standard — section 3 describes the mechanism — and the acting half of it is not built.
⚠ So the honest statement for a compliance reader is: for the forge, the product is currently read-only by construction, and the human-gated write path is designed and partly built but not yet operable. Do not accept a stronger sentence than that from anyone, including us.
How a forge-side merge is designed to be gated
This section describes a mechanism that is built as far as the refusals and the signature, and no further. It is here because a capability that exists and is unfinished is still worth understanding — especially this one, because it is the part an auditor will care about — and because section 4 states exactly where it stops.
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 is one question asked sixteen ways: is the thing the forge would merge still the thing the human approved?
The pin
When a forge-side merge is authorized, the full 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 recomputing the content does not produce the signature.
The sixteen refusals, in the reader's terms
Each is a named refusal, not a message to grep. They are collected together — one attempt tells you everything that is wrong, not the first thing:
About the ask itself, refused before any question reaches the forge:
| refusal | what it means |
|---|---|
| forge credential absent | no forge connection was composed for this act at all |
| repository reference unusable | the owner or name could not be part of a URL path |
| no pull-request number | zero is "you named none", never "the first one" |
| head pin absent or unusable | there is nothing to seal into the signed record. An unpinned merge is the one shape this act may never take |
About what the forge answered:
| refusal | what it means |
|---|---|
| no such pull request | the forge says it does not exist |
| origin unreachable | the question could never be put — name resolution, dial, TLS, timeout. Its own refusal, because the remedy is the opposite of the one above |
| pull request not open | closed, or already merged. The two are named apart: one means "already done", the other "abandoned" |
| branch mismatch | the pull request does not connect the two references this stream's own record names. Both ends are checked — a right head with a wrong base lands the work somewhere nobody decided about |
| stale head | the commit the human approved is not the commit the forge now reports. This is the load-bearing one: it is the moment a push landed between the decision and the act |
About whether your host and the forge are looking at the same commit:
| refusal | what it means |
|---|---|
| branch not on origin | your repository holds no remote-tracking reference for this branch, so the branch was never published and the forge cannot be looking at your work |
| local tip is not the pull request's head | your branch and the forge's head are different commits. The forge would merge what it has; the human read what you have |
| base conflict | git itself cannot combine the two ends. ⚠ Measured locally in a detached tree, never taken from the forge's own mergeability hint — see section 6 |
| the record names no repository, branch or base | the local half could not be measured at all: there is no reference to look up, no tip to compare, nothing to probe. ⛔ It is a refusal and not a skip — written as a skip, a stream with no recorded repository verified clean while not one local check had run, and the plan it produced was indistinguishable from a verified one. The refusal names which of the three is missing |
About the record:
| refusal | what it means |
|---|---|
| state not gated | the stream is not at a state this act is legal from — the identical window the local merge requires, deliberately: routing a merge through a pull request changes where the commit is made, never which human decision authorizes it |
| a run is still live | an agent is still writing to this branch. A forge asked to merge a branch a leg is still writing merges a moving target |
| the record refuses | the review lane is engaged with no decision answering it, or a named review was declared and never ran, or a decision this merge stands on fails signature verification |
⚠ Two of those deserve a second look, because they are the reason a second merge door is dangerous at all. A forge-side merge that skipped the review lane or the signature check would be an easier door to an unreviewed, unsigned merge — reachable by adding one flag. So the forge-side path reads the same derivations as the local merge, in the same words, and its state window is byte-for-byte the local one's.
⚠ And one gap in the design is declared rather than hidden: the preconditions record no gate themselves, so the act that eventually calls a forge must run the record-writing rules before it calls — otherwise the merge would exist on the forge while the record authorizing it is refused afterwards.
The absent measurement is never reported as a passing one
The local conflict probe needs a scratch location outside your repository. When none is supplied the probe does not run, and the resulting plan says so in words: this plan carries no evidence that the pull request's ends combine. It is not reported as a clean probe. Likewise, if your host does not hold the pull request's base at all, the probe declines and names that.
And the residue of what a clean probe means is declared too: it is evidence about the pair your host can see. Your remote-tracking base can be stale, so a clean local probe 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 in section 3 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.
⛔ Where the pull-request path stops on this release
Stated once, without softening, because for an enterprise reader this is the sentence that matters:
- There is no command that merges through a pull request. The merge verb has no flag for it. The preconditions described in section 3 have no caller anywhere in the product.
- There is no code that calls a forge's merge endpoint. That act is not built.
- There is no configuration section for a forge. Even the read-only Gitea adapter cannot be pointed at your server by an operator on this release, because there is nowhere to declare the server.
- There is no GitHub adapter. GitHub is a name the product recognizes as a kind of forge — enough to refuse a mismatched configuration — and its API behaviour is recorded as the criteria a future adapter must satisfy. No code speaks to it.
⚠ What this means in practice: if you were told the product "supports merging through pull requests on Gitea and GitHub", that is not what this release does. What this release contains is a tested read-only Gitea reader, a tested forge abstraction, and a tested-but-unreachable set of preconditions and signatures for a forge-side merge. The merge you can perform today is the local merge in Working with git, and it is fully operable.
The observation stage exists and is already the right shape for a forge-side merge when it lands:
the designed path hands a stream to saphan fleet observe, so merged would still enter the
record from a git observation rather than from the product asserting a fact it did not read.