diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 5a6bcc45..68b6bb66 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -198,6 +198,9 @@ pub async fn run_approval_merge_config_pr( let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?; let agent_dir = crate::paths::agent_runtime_dir(&approval.agent); let applied_dir = crate::paths::applied_dir(&approval.agent); + // Captured up front to scope the failure-comment's build-log lookup to + // rows this deploy produced (see `post_merge_failure_to_pr`). + let since_ts = hive_sh4re::wire_time::now_unix(); 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; @@ -210,9 +213,84 @@ pub async fn run_approval_merge_config_pr( if let Err(e) = crate::forge::push_config(&approval.agent).await { tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after merge failed"); } + // On a failed deploy, surface the failing build log back onto the PR so + // the manager sees why it was rejected without leaving the forge. + if let Err(e) = &result { + post_merge_failure_to_pr(coord, &approval, since_ts, e).await; + } finish_approval(coord, &approval, result, terminal_tag, false) } +/// Max stderr bytes to inline in a PR failure comment. Keeps the comment +/// readable and under forge's size limits while still carrying the tail +/// where the nix/build error actually surfaces. +const PR_FAIL_LOG_TAIL_BYTES: usize = 4000; + +/// On a failed `MergeConfigPr` deploy, post the failing build log back to the +/// config PR as a comment so the manager sees the rejection reason on the PR +/// itself. Best-effort: any error here is logged, never allowed to disturb the +/// approval-resolution path. +/// +/// The failing `build_log` row is located heuristically: the most recent `fail` +/// row for this agent that started at/after `since_ts` (the caller's function +/// entry). Because deploys are serialised per agent through the queue, that is +/// the step which just failed — `verify`, `prepare-deploy`, `prebuild`, or the +/// container rebuild. Pre-build failures (drift gate, fetch) create no `build_log` +/// row, so the comment then carries only the error text. +async fn post_merge_failure_to_pr( + coord: &Arc, + approval: &hive_sh4re::Approval, + since_ts: i64, + err: &anyhow::Error, +) { + let Ok(pr) = approval.commit_ref.parse::() else { + return; + }; + let repo = crate::forge::config_repo(&approval.agent); + + let log_section = coord + .build_logs + .list_recent_for_agent(&approval.agent, 10) + .ok() + .and_then(|rows| { + rows.into_iter() + .find(|r| r.status.as_deref() == Some("fail") && r.started_at >= since_ts) + }) + .and_then(|row| coord.build_logs.get_full(row.id).ok().flatten()) + .map(|full| { + let tail = tail_bytes(full.stderr.trim_end(), PR_FAIL_LOG_TAIL_BYTES); + format!( + "\n\n**Failing step:** `{}` (build log #{})\n\n```\n{tail}\n```", + full.header.kind, full.header.id + ) + }) + .unwrap_or_default(); + + let body = format!( + "## ⚠️ config deploy failed\n\n\ + Approval #{} to merge this PR could not be deployed:\n\n\ + ```\n{err:#}\n```{log_section}", + approval.id + ); + + if let Err(e) = crate::forge::post_pr_comment(&repo, pr, &body).await { + tracing::warn!(agent = %approval.agent, %pr, error = ?e, "post merge-failure comment to PR failed"); + } +} + +/// Return the last `max_bytes` of `s`, snapped to a char boundary, prefixed +/// with an elision marker when truncated. +fn tail_bytes(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_owned(); + } + let mut start = s.len() - max_bytes; + while start < s.len() && !s.is_char_boundary(start) { + start += 1; + } + format!("[… truncated …]\n{}", &s[start..]) +} + /// 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 diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 9bbea410..26708c91 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -10,8 +10,8 @@ mod repos; mod users; pub use pr_merge::{ - ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, pr_head_sha, - pr_is_open, + ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, post_pr_comment, + pr_head_sha, pr_is_open, }; pub use repos::{ create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo, diff --git a/hive-c0re/src/forge/pr_merge.rs b/hive-c0re/src/forge/pr_merge.rs index 4207e963..bd259e6e 100644 --- a/hive-c0re/src/forge/pr_merge.rs +++ b/hive-c0re/src/forge/pr_merge.rs @@ -218,6 +218,38 @@ pub async fn merge_config_pr_ff(repo: &str, pr: u64, sha: &str) -> Result<(), Fo } } +/// 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;