//! PR-based config-flow merge primitives — the forge-side mechanics //! hive-c0re's deploy apply node (`actions::run_deploy_apply`) orchestrates to //! land an operator-approved config PR. Part of the operator trust //! boundary; moved verbatim from the `forge` module root. use anyhow::Context; use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo, StateType}; use super::{CONFIG_ORG, api, core_token, forge_git_url}; // --------------------------------------------------------------------------- // PR-based config-flow merge primitives (part of the // dashboard-approve-driven config-change flow). // // The dashboard-approve-driven flow has hive-c0re verify an operator-approved // config PR, then land it: fast-forward-merge the verified sha into the // protected default branch via the forge merge API (= the merge). These fns are // the forge-side mechanics the c0re deploy apply node (`run_deploy_apply`) // orchestrates; the orchestration fetches the verified sha into the agent's // applied repo (for the eval-verify) before calling `merge_config_pr_ff`. The // core token is sourced internally (`core_token`), never passed in. `repo` is // the agent's editable forge config repo in `owner/name` form (e.g. // `agent-configs/`). // --------------------------------------------------------------------------- /// Typed failure for the merge primitives so the c0re approve-handler can /// `match` recoverable drift (surface a re-review message) against a hard /// failure (fail the approval). `HeadDrift` carries the observed sha; `Other` /// is everything else (a raced non-ff `main`, transport, API, unexpected) and /// is not auto-retried. #[derive(Debug)] pub enum ForgeMergeError { /// The PR head moved off `expected` (now at `actual`) since the handler's /// pre-merge `pr_head_sha` re-read — caught by the `head_commit_id` pin on /// the merge call, which refuses to merge a head that isn't the reviewed sha. HeadDrift { expected: String, actual: String }, /// Transport / API / unexpected failure — hard-fail, no auto-retry. Covers /// a raced non-fast-forwardable `main` (the `fast-forward-only` merge is /// refused by the forge) as well. Other(anyhow::Error), } impl std::fmt::Display for ForgeMergeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::HeadDrift { expected, actual } => { write!(f, "PR head drifted: expected {expected}, found {actual}") } Self::Other(e) => write!(f, "{e}"), } } } impl std::error::Error for ForgeMergeError {} impl From for ForgeMergeError { fn from(e: anyhow::Error) -> Self { Self::Other(e) } } /// Agent name from an `owner/name` forge repo string (the trailing segment). fn repo_agent_name(repo: &str) -> &str { repo.rsplit('/').next().unwrap_or(repo) } /// Resolve a PR's head sha via `git ls-remote refs/pull//head` /// (Forgejo exposes PR heads there). Pure read, no mutation — the handler's /// primary drift gate (compare against the approved sha), and /// `merge_config_pr_ff` uses it to classify a merge-API rejection as head-drift. /// /// # Errors /// `Other` on transport failure or an empty/missing ref. pub async fn pr_head_sha(repo: &str, pr: u64) -> Result { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let url = forge_git_url(&token, repo); let refspec = format!("refs/pull/{pr}/head"); let out = crate::lifecycle::git_command() .args(["ls-remote", &url, &refspec]) .output() .await .context("git ls-remote PR head")?; if !out.status.success() { return Err(ForgeMergeError::Other(anyhow::anyhow!( "git ls-remote {repo} {refspec} failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ))); } let stdout = String::from_utf8_lossy(&out.stdout); let sha = stdout .split_whitespace() .next() .filter(|s| !s.is_empty()) .ok_or_else(|| { ForgeMergeError::Other(anyhow::anyhow!( "no head ref for PR #{pr} in {repo} (ls-remote empty)" )) })?; Ok(sha.to_string()) } /// Check whether PR `pr` on `repo` is still open. Returns `Ok(true)` if /// open, `Ok(false)` if closed or merged, or an error on transport failure. /// /// Called at submission time to give an early, actionable error rather than /// queuing an approval card that will fail later in the approve handler. /// /// # Errors /// `Other` on transport failure or a missing/malformed PR response. pub async fn pr_is_open(repo: &str, pr: u64) -> Result { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let (owner, name) = repo.split_once('/').ok_or_else(|| { ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name")) })?; let index = i64::try_from(pr) .map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?; let client = api(&token).map_err(ForgeMergeError::Other)?; let pull = client .repo_get_pull_request(owner, name, index) .await .map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("GET pull request")))?; Ok(pull.state == Some(StateType::Open)) } /// 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//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 `git_update_ref(main, …)` in the deploy tail and /// the eval-verify both resolve it locally before the irreversible merge. /// /// # 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 = forge_git_url(&token, repo); let applied = crate::paths::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-merge PR `pr` on `repo` to `sha` via the Forgejo merge API — /// THE merge in the config-PR flow. Uses `Do=fast-forward-only` so `main` only /// ever advances by fast-forward (never a merge commit; a raced non-ff `main` /// is refused by the forge → `Other`), and pins `head_commit_id = sha` so the /// forge atomically refuses to merge a head that isn't the reviewed sha — /// closing the check-then-merge race without ever pushing the protected branch. /// `core` needs only the repo's merge whitelist, never push access to `main`. /// The single call fast-forwards `main` to exactly `sha` and marks the PR /// merged. /// /// # Errors /// `HeadDrift` if the live PR head no longer matches `sha` (the pin rejected /// it); `Other` for a raced non-ff `main`, transport, or API failure. pub async fn merge_config_pr_ff(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let (owner, name) = repo.split_once('/').ok_or_else(|| { ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name")) })?; let index = i64::try_from(pr) .map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?; let body = MergePullRequestOption { r#do: MergePullRequestOptionDo::FastForwardOnly, merge_commit_id: None, merge_message_field: None, merge_title_field: None, delete_branch_after_merge: None, force_merge: None, head_commit_id: Some(sha.to_owned()), merge_when_checks_succeed: None, }; let client = api(&token).map_err(ForgeMergeError::Other)?; match client .repo_merge_pull_request(owner, name, index, body) .await { Ok(()) => Ok(()), Err(e) => { // Classify: a live head that no longer matches `sha` is drift (the // `head_commit_id` pin rejected it); everything else — a raced // non-ff `main`, transport, an API error — is a hard failure. // Best-effort, since the handler's pre-merge re-read is the primary // gate; a transport failure that also breaks this read falls through // to `Other`. match pr_head_sha(repo, pr).await { Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift { expected: sha.to_string(), actual, }), _ => Err(ForgeMergeError::Other( anyhow::Error::from(e).context(format!("ff-merge PR #{pr} in {repo} to {sha}")), )), } } } } /// Post a comment to PR (= issue) `pr` on `repo` as the core forge user. /// PRs are issues in Forgejo, so the PR number is the issue index. Used to /// surface a failed config-approval deploy's build log back onto the PR so /// the manager sees why it was rejected without leaving the forge. `repo` /// is `owner/name`. /// /// # Errors /// `Other` on absent core token, malformed repo, or transport/API failure. pub async fn post_pr_comment(repo: &str, pr: u64, body: &str) -> Result<(), ForgeMergeError> { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let (owner, name) = repo.split_once('/').ok_or_else(|| { ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name")) })?; let index = i64::try_from(pr) .map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?; let client = api(&token).map_err(ForgeMergeError::Other)?; client .issue_create_comment( owner, name, index, forgejo_api::structs::CreateIssueCommentOption { body: body.to_owned(), updated_at: None, }, ) .await .map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("post PR comment")))?; Ok(()) } #[cfg(test)] mod tests { use super::repo_agent_name; use crate::forge::git_url_with_base; #[test] fn repo_agent_name_takes_trailing_segment() { assert_eq!(repo_agent_name("agent-configs/atlas"), "atlas"); assert_eq!(repo_agent_name("atlas"), "atlas"); assert_eq!(repo_agent_name("a/b/c"), "c"); } #[test] fn forge_git_url_shape() { // Tests the pure half: credentials go between scheme and // authority. Deliberately not via `forge_git_url`, which reads // HIVE_FORGE_URL — setting that here would race every other // test in this binary, and there is no fallback to lean on any // more (a guessed base is the bug this issue removes). let url = git_url_with_base("http://forge.example.test", "tok", "a/iris"); assert_eq!(url, "http://core:tok@forge.example.test/a/iris.git"); } #[test] fn forge_git_url_preserves_https() { // The scheme is carried through rather than assumed: a swarm // whose forge is behind TLS must not be downgraded to http. let url = git_url_with_base("https://forge.example.test", "tok", "a/iris"); assert!( url.starts_with("https://core:tok@"), "https must survive: {url}" ); } }