feat(missions): an operator button to merge a mission's branch into main

`MergePolicy::Never` — the default for anything touching code — has always meant
"do not merge on your own", deferring to a human. There was no way for that human
to say yes: `auto_merge` was reachable only from the paper-harvest path, no
workflow template declares `merge_policy`, and every mission ended at a branch.

`POST /api/missions/{id}/merge` is that yes, with a button on the artifacts tab.
The additive-only gate does NOT apply here, deliberately: an operator reading a
code change is exactly the judgement the policy was holding out for.

What is not waived:

  - the branch comes from the artifact delivery RECORDED, not rebuilt from the
    mission id, and must have `pushed: true`. A phase that never pushed shows no
    button instead of one that cannot work.
  - an empty branch is refused. A button reporting success for merging nothing
    is worse than no button.
  - a conflict refuses, aborts, and leaves the repo clean rather than forcing.

It works in a FRESH CLONE under `_merge/<mission>`, never the mission checkout:
that directory is reaped on a timer after a mission ends, so a merge using it
would succeed right after a run and fail inexplicably an hour later. The clone is
made by the server process, so nothing runs as root and ordinary cleanup works —
unlike the copies in `root_copy`.

`merge_and_push` is split out so the operator path and the automatic path run the
SAME git commands; only the gates differ. A test asserts both call it, that the
operator path does not re-apply the additive gate it exists to bypass, and that
it still refuses an empty branch.

Harness 43/43 across all five recipes before this change, with `_gate`, `_bench`
and `_verify` all at zero.

246 lib tests, 20 binaries, 89 frontend tests, clean build.
This commit is contained in:
Omar Sobh
2026-08-07 18:53:38 -07:00
parent a8b8efba6a
commit 3616bc4733
6 changed files with 284 additions and 5 deletions
+2 -2
View File
@@ -117,7 +117,7 @@ const FORGE_HOST: &str = "git.redclaw.dev";
/// port, a different case in the host — silently came back unauthenticated, and
/// the first symptom was git opening `/dev/tty` several layers later. Tracing
/// #55 cost hours to a failure whose cause was one unlogged early return.
pub(crate) struct Authed {
pub struct Authed {
pub url: String,
/// `None` when the token was applied; otherwise WHY it was not.
pub unauthenticated: Option<String>,
@@ -163,7 +163,7 @@ fn host_of(url: &str) -> Option<&str> {
/// token, a host that is not ours, or a shape a token cannot be injected into.
/// The token is never logged — only the rewritten URL is passed to git, via
/// argv.
pub(crate) fn with_ambient_auth(url: &str) -> Authed {
pub fn with_ambient_auth(url: &str) -> Authed {
auth_with_token(url, std::env::var("GITEA_TOKEN").ok().as_deref())
}