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:
@@ -166,11 +166,35 @@ pub async fn try_merge(
|
||||
return Ok(MergeOutcome::refused("branch adds nothing"));
|
||||
}
|
||||
|
||||
merge_and_push(repo, push_url, branch, base, "auto-merge")
|
||||
.await
|
||||
.map(|o| match o.merged {
|
||||
true => MergeOutcome {
|
||||
merged: true,
|
||||
reason: format!("additive-only and verified; merged into {base}"),
|
||||
},
|
||||
false => o,
|
||||
})
|
||||
}
|
||||
|
||||
/// The git half of a merge, with no policy in it.
|
||||
///
|
||||
/// Split out so an OPERATOR-approved merge runs exactly the same commands as an
|
||||
/// automatic one — fetch the base as the remote has it, merge onto that, push.
|
||||
/// The gates differ; the mechanics must not, or the rarely-taken path is the one
|
||||
/// that breaks.
|
||||
async fn merge_and_push(
|
||||
repo: &Path,
|
||||
push_url: &str,
|
||||
branch: &str,
|
||||
base: &str,
|
||||
label: &str,
|
||||
) -> Result<MergeOutcome, String> {
|
||||
// Merge onto the freshly fetched base rather than a local branch.
|
||||
git(repo, &["checkout", "-B", base, "FETCH_HEAD"]).await?;
|
||||
if let Err(e) = git(
|
||||
repo,
|
||||
&["merge", "--no-ff", "-m", &format!("auto-merge {branch}"), branch],
|
||||
&["merge", "--no-ff", "-m", &format!("{label} {branch}"), branch],
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -184,14 +208,88 @@ pub async fn try_merge(
|
||||
git(repo, &["push", push_url, &format!("HEAD:refs/heads/{base}")]).await?;
|
||||
Ok(MergeOutcome {
|
||||
merged: true,
|
||||
reason: format!("additive-only and verified; merged into {base}"),
|
||||
reason: format!("merged into {base}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge a delivered branch because an OPERATOR asked for it.
|
||||
///
|
||||
/// `MergePolicy::Never` means "do not merge on your own" — it defers to a human,
|
||||
/// and this is that human. So the additive-only test does not apply: an operator
|
||||
/// looking at a code change is exactly the judgement the policy was holding out
|
||||
/// for.
|
||||
///
|
||||
/// What is NOT waived:
|
||||
///
|
||||
/// - the branch must exist on the remote and differ from the base, so the button
|
||||
/// cannot report success for a merge of nothing;
|
||||
/// - a conflict refuses and leaves the repo clean, rather than forcing;
|
||||
/// - the work happens in a FRESH CLONE, never the mission checkout — that
|
||||
/// directory is reaped on a timer after the mission ends, so a merge that
|
||||
/// depended on it would work right after a run and mysteriously fail later.
|
||||
pub async fn merge_on_operator_approval(
|
||||
workdir: &Path,
|
||||
push_url: &str,
|
||||
branch: &str,
|
||||
base: &str,
|
||||
) -> Result<MergeOutcome, String> {
|
||||
git(workdir, &["fetch", push_url, base]).await?;
|
||||
git(workdir, &["fetch", push_url, branch]).await?;
|
||||
git(workdir, &["branch", "-f", branch, "FETCH_HEAD"]).await?;
|
||||
git(workdir, &["fetch", push_url, base]).await?;
|
||||
|
||||
let diff = git(
|
||||
workdir,
|
||||
&["diff", "--name-status", &format!("FETCH_HEAD...{branch}")],
|
||||
)
|
||||
.await?;
|
||||
if diff.trim().is_empty() {
|
||||
return Ok(MergeOutcome::refused(
|
||||
"branch has nothing the base does not already have",
|
||||
));
|
||||
}
|
||||
|
||||
merge_and_push(workdir, push_url, branch, base, "merge mission branch").await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An operator merge and an automatic one must run the SAME git commands.
|
||||
///
|
||||
/// The gates differ — that is the whole point — but if the mechanics
|
||||
/// diverged, the rarely-taken path would be the untested one. Both go
|
||||
/// through `merge_and_push`.
|
||||
#[test]
|
||||
fn both_merge_paths_share_the_same_mechanics() {
|
||||
let src = include_str!("auto_merge.rs");
|
||||
let calls = src.matches("merge_and_push(").count();
|
||||
// one definition + one call from each path
|
||||
assert!(
|
||||
calls >= 3,
|
||||
"expected try_merge and merge_on_operator_approval to both call \
|
||||
merge_and_push, found {calls} mention(s)"
|
||||
);
|
||||
// And the operator path must NOT re-implement the policy gate it exists
|
||||
// to bypass — if this string appears there, the button is a no-op.
|
||||
let op = src
|
||||
.split("pub async fn merge_on_operator_approval")
|
||||
.nth(1)
|
||||
.unwrap_or("");
|
||||
let body = op.split("\n}").next().unwrap_or("");
|
||||
assert!(
|
||||
!body.contains("MergePolicy::AdditiveOnly"),
|
||||
"the operator path must not apply the additive-only gate"
|
||||
);
|
||||
// It must still refuse an empty branch: a button that reports success
|
||||
// for merging nothing is worse than no button.
|
||||
assert!(
|
||||
body.contains("nothing the base does not already have"),
|
||||
"the operator path must refuse an empty branch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_pure_additions_qualify() {
|
||||
assert!(non_additive_changes("A\t60 Papers/a.md\nA\t60 Papers/b.md\n").is_empty());
|
||||
|
||||
Reference in New Issue
Block a user