feat(#1838): run_merge_config_pr handler (verify pr head, ff forge main, deploy)
This commit is contained in:
parent
5f5626456a
commit
0b86295776
3 changed files with 207 additions and 1 deletions
|
|
@ -192,6 +192,159 @@ pub async fn run_approval_apply_commit(
|
||||||
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
|
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries —
|
||||||
|
/// the PR-based config flow's counterpart to `run_approval_apply_commit`.
|
||||||
|
/// Re-fetches the approval row, runs the merge pipeline, and fires
|
||||||
|
/// `ApprovalResolved` + the `Rebuilt` lifecycle event via `finish_approval`.
|
||||||
|
/// Unlike the apply-commit path it does NOT call `push_config` afterwards:
|
||||||
|
/// `run_merge_config_pr` already fast-forwarded the forge repo's `main` to
|
||||||
|
/// the reviewed head (that IS the merge), so a mirror push would be a no-op.
|
||||||
|
/// A `MergeConfigPr` is never a first spawn (the agent already exists).
|
||||||
|
pub async fn run_approval_merge_config_pr(
|
||||||
|
coord: &Arc<Coordinator>,
|
||||||
|
queue_entry_id: Option<u64>,
|
||||||
|
approval_id: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?;
|
||||||
|
let agent_dir = coord.ensure_runtime(&approval.agent)?;
|
||||||
|
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||||
|
coord.set_queue_step(queue_entry_id, "merge config pr");
|
||||||
|
let (result, terminal_tag) =
|
||||||
|
run_merge_config_pr(coord, &approval, &agent_dir, &applied_dir, queue_entry_id).await;
|
||||||
|
finish_approval(coord, &approval, result, terminal_tag, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PR-merge config pipeline. `approval.commit_ref` is the PR number;
|
||||||
|
/// `approval.fetched_sha` is the PR head sha the operator reviewed. Steps:
|
||||||
|
/// 1. drift gate — re-read the live PR head; if it moved since review, abort
|
||||||
|
/// WITHOUT mutating anything (the operator must re-review the new head);
|
||||||
|
/// 2. fetch the reviewed head into the applied repo so later git ops resolve
|
||||||
|
/// it locally;
|
||||||
|
/// 3. eval-verify the reviewed commit against the meta flake BEFORE the
|
||||||
|
/// irreversible push (same gate `run_apply_commit` uses);
|
||||||
|
/// 4. fast-forward the forge repo's `main` to the reviewed head — THE merge;
|
||||||
|
/// 5. mark the PR merged (best-effort: `main` is already at the head, so a
|
||||||
|
/// failure here is logged, not fatal);
|
||||||
|
/// 6. run the shared deploy tail (`deploy_applied_target`): ff applied/main,
|
||||||
|
/// meta deploy, container rebuild, finalize/rollback.
|
||||||
|
///
|
||||||
|
/// Returns `(build result, terminal tag)` like `deploy_applied_target`. Any
|
||||||
|
/// pre-merge abort returns `Err` with no mutation; the operator re-reviews.
|
||||||
|
async fn run_merge_config_pr(
|
||||||
|
coord: &Arc<Coordinator>,
|
||||||
|
approval: &hive_sh4re::Approval,
|
||||||
|
agent_dir: &std::path::Path,
|
||||||
|
applied_dir: &std::path::Path,
|
||||||
|
queue_entry_id: Option<u64>,
|
||||||
|
) -> (Result<()>, Option<String>) {
|
||||||
|
let id = approval.id;
|
||||||
|
let pr: u64 = match approval.commit_ref.parse() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"parse PR number from commit_ref {:?}: {e}",
|
||||||
|
approval.commit_ref
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let reviewed = match approval.fetched_sha.as_deref() {
|
||||||
|
Some(s) => s.to_owned(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"merge config pr approval {id} has no reviewed head sha"
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let repo = crate::forge::config_repo(&approval.agent);
|
||||||
|
|
||||||
|
// 1. Drift gate: the live PR head must still equal what was reviewed.
|
||||||
|
coord.set_queue_step(queue_entry_id, "verify PR head");
|
||||||
|
let head = match crate::forge::pr_head_sha(&repo, pr).await {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => return (Err(anyhow::anyhow!("read PR #{pr} head: {e}")), None),
|
||||||
|
};
|
||||||
|
if head != reviewed {
|
||||||
|
return (
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"PR #{pr} head drifted since review (reviewed {reviewed}, now {head}); re-review before merging"
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch the reviewed head into applied so ff/verify/deploy resolve it.
|
||||||
|
coord.set_queue_step(queue_entry_id, "fetch PR head");
|
||||||
|
if let Err(e) = crate::forge::fetch_pr_head_into_applied(&repo, pr).await {
|
||||||
|
return (
|
||||||
|
Err(anyhow::anyhow!("fetch PR #{pr} head into applied: {e}")),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Eval-verify BEFORE the irreversible push (bad nix fails fast here).
|
||||||
|
coord.set_queue_step(queue_entry_id, "verify proposal (eval)");
|
||||||
|
if let Err(e) = crate::meta::verify_commit(&approval.agent, applied_dir, &reviewed).await {
|
||||||
|
return (
|
||||||
|
Err(anyhow::anyhow!("verify merge head {reviewed}: {e:#}")),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the currently-deployed sha for the deploy tail's rollback.
|
||||||
|
let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return (Err(anyhow::anyhow!("read applied/main: {e:#}")), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. THE merge: fast-forward the forge repo's main to the reviewed head.
|
||||||
|
coord.set_queue_step(queue_entry_id, "fast-forward forge main");
|
||||||
|
match crate::forge::ff_push_to_main(&repo, &reviewed).await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(crate::forge::ForgeMergeError::NotFastForward { .. }) => {
|
||||||
|
return (
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"PR #{pr}: forge main raced ahead of reviewed {reviewed}; re-review before merging"
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => return (Err(anyhow::anyhow!("ff-push PR #{pr} to main: {e}")), None),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Mark the PR merged. Best-effort: main is already at the reviewed
|
||||||
|
// head, so the deploy is correct regardless — a failure here (incl.
|
||||||
|
// HeadDrift vs the forge PR record) is logged, not fatal.
|
||||||
|
coord.set_queue_step(queue_entry_id, "mark PR merged");
|
||||||
|
if let Err(e) = crate::forge::mark_pr_merged(&repo, pr, &reviewed).await {
|
||||||
|
tracing::warn!(
|
||||||
|
agent = %approval.agent, %id, %pr, error = ?e,
|
||||||
|
"mark PR merged failed; main already at reviewed head, continuing to deploy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Shared deploy tail. target == finalize == the reviewed head;
|
||||||
|
// never a first spawn (the agent already exists).
|
||||||
|
deploy_applied_target(
|
||||||
|
coord,
|
||||||
|
&approval.agent,
|
||||||
|
agent_dir,
|
||||||
|
applied_dir,
|
||||||
|
&reviewed,
|
||||||
|
&reviewed,
|
||||||
|
id,
|
||||||
|
&prev_main_sha,
|
||||||
|
false,
|
||||||
|
queue_entry_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
|
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
|
||||||
/// On approve, decode the `SchedulePromptPayload` JSON from the
|
/// On approve, decode the `SchedulePromptPayload` JSON from the
|
||||||
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
|
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
|
||||||
|
|
|
||||||
|
|
@ -1225,6 +1225,43 @@ pub async fn pr_head_sha(repo: &str, pr: u64) -> Result<String, ForgeMergeError>
|
||||||
Ok(sha.to_string())
|
Ok(sha.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full `owner/name` path of an agent's config repo on the forge — the
|
||||||
|
/// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards.
|
||||||
|
pub fn config_repo(agent: &str) -> String {
|
||||||
|
format!("{CONFIG_ORG}/{agent}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch PR #`pr`'s head into the agent's applied repo via
|
||||||
|
/// `refs/pull/<pr>/head` (which Forgejo always serves — a bare-sha fetch can
|
||||||
|
/// be refused by uploadpack policy). This makes the reviewed head an object
|
||||||
|
/// in the applied repo so the ancestor check in [`ff_push_to_main`], the
|
||||||
|
/// `git_update_ref(main, …)` in the deploy tail, and the eval-verify all
|
||||||
|
/// resolve it locally before the irreversible push.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// `Other` on transport failure or a non-zero git exit.
|
||||||
|
pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), ForgeMergeError> {
|
||||||
|
let token = core_token()
|
||||||
|
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||||
|
let url = tokenised_repo_url(repo, &token);
|
||||||
|
let applied = Coordinator::agent_applied_dir(repo_agent_name(repo));
|
||||||
|
let refspec = format!("refs/pull/{pr}/head");
|
||||||
|
let out = crate::lifecycle::git_command()
|
||||||
|
.current_dir(&applied)
|
||||||
|
.args(["fetch", "--no-tags", &url, &refspec])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("git fetch PR head into applied")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||||
|
"git fetch {repo} {refspec} into applied failed ({}): {}",
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow.
|
/// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow.
|
||||||
/// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it
|
/// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it
|
||||||
/// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the
|
/// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the
|
||||||
|
|
|
||||||
|
|
@ -858,7 +858,23 @@ async fn dispatch(
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
match (entry.kind, entry.approval_id) {
|
match (entry.kind, entry.approval_id) {
|
||||||
(QueueKind::Rebuild, Some(approval_id)) => {
|
(QueueKind::Rebuild, Some(approval_id)) => {
|
||||||
crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await
|
// Both ApplyCommit and MergeConfigPr approvals enqueue a Rebuild
|
||||||
|
// entry (they both end in a container rebuild); branch on the
|
||||||
|
// approval kind to pick the right pipeline. Fall back to the
|
||||||
|
// apply-commit path if the row can't be read — it re-fetches +
|
||||||
|
// surfaces a clean error itself.
|
||||||
|
let kind = coord
|
||||||
|
.approvals
|
||||||
|
.get(approval_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|a| a.kind);
|
||||||
|
if kind == Some(hive_sh4re::ApprovalKind::MergeConfigPr) {
|
||||||
|
crate::actions::run_approval_merge_config_pr(coord, Some(entry.id), approval_id)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(QueueKind::Rebuild, None) => {
|
(QueueKind::Rebuild, None) => {
|
||||||
let current_rev =
|
let current_rev =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue