//! Side effects of one updated ref, shared by the post-receive hook and the web merge route. //! //! [`after_ref_update`] runs, in order: //! 0. Webhook events for the ref: push, and create or delete (see `webhooks::emit`). //! 1. Closing keywords and references from the commits a default-branch update added. These run //! in a spawned task and are not coalesced: every range must be processed. //! 2. Pull request sync for `refs/heads/` (see `pulls_sync`), inline. //! //! Maintenance is not part of this: it is coalesced per repo and runs once per push through //! `internal::trigger_maintenance`. Callers that update a ref outside receive-pack (the merge //! route) call it themselves. use std::path::PathBuf; use forge_core::issue_refs::{find_closing_refs, find_issue_refs}; use forge_core::{IssueState, Repo, StateReason}; use forge_git::history; use serde_json::json; use crate::internal::is_zero_oid; use crate::notify::{self, Activity}; use crate::pulls_sync; use crate::state::AppState; use crate::webhooks::emit::{Sender, emit_item_action, emit_ref_update, lookup_sender}; /// Commits read for a push that updates an existing branch. const MAX_PUSH_COMMITS: usize = 500; /// Commits read for a push that creates the branch. const MAX_NEW_BRANCH_COMMITS: usize = 50; /// Runs the per-ref post-update work for `refname`, moving `old` to `new`. `actor_id` is the user /// who made the change (empty for system updates). Closing-keyword work is spawned; pull request /// sync is awaited, so its errors are returned, while per-pull failures are logged and skipped. pub async fn after_ref_update( state: &AppState, repo: &Repo, actor_id: &str, refname: &str, old: &str, new: &str, ) -> forge_core::Result<()> { let sender = lookup_sender(state, actor_id).await; emit_ref_update(state, repo, &sender, refname, old, new).await; crate::ci::on_ref_update(state, repo, actor_id, refname, new).await; if (refname.starts_with("refs/heads/") || refname.starts_with("refs/tags/")) && let Err(e) = crate::mirrors::enqueue_push_mirrors(state, &repo.id).await { tracing::error!(error = %e, repo_id = %repo.id, "mirrors: could not queue push mirrors"); } if refname == format!("refs/heads/{}", repo.default_branch) && !is_zero_oid(new) { crate::advisories::scan::spawn_repo_scan(state, repo); } crate::replication::enqueue_repo(state, &repo.id); if refname == format!("refs/heads/{}", repo.default_branch) && !is_zero_oid(new) { match forge_git::repo::repo_path(&state.git.data_dir, &repo.id) { Ok(repo_dir) => { tokio::spawn(close_from_pushed_commits( state.clone(), repo.clone(), sender, repo_dir, actor_id.to_owned(), old.to_owned(), new.to_owned(), )); } Err(e) => { tracing::error!(error = %e, repo_id = %repo.id, "issue refs: bad repo path"); } } } pulls_sync::sync_pulls_for_ref(state, repo, actor_id, refname, old, new).await } /// Processes the commits added to the default branch by one ref update, oldest first. async fn close_from_pushed_commits( state: AppState, repo: Repo, sender: Sender, repo_dir: PathBuf, actor_id: String, old: String, new: String, ) { let old = (!is_zero_oid(&old)).then_some(old.as_str()); let max = if old.is_some() { MAX_PUSH_COMMITS } else { MAX_NEW_BRANCH_COMMITS }; let commits = match history::new_commits(&state.git, &repo_dir, old, &new, max).await { Ok(commits) => commits, Err(e) => { tracing::error!(error = %e, repo_id = %repo.id, "issue refs: listing commits failed"); return; } }; let actor = (!actor_id.is_empty()).then_some(actor_id.as_str()); for commit in &commits { let text = format!("{}\n{}", commit.summary, commit.message); if let Err(e) = apply_commit_refs(&state, &repo, &sender, actor, &commit.oid, &text).await { tracing::error!(error = %e, repo_id = %repo.id, commit = %commit.oid, "issue refs: applying commit failed"); } } } /// Closes open issues named by closing keywords in `text`, and records a `referenced` event on /// every other issue it mentions, once per (issue, commit). async fn apply_commit_refs( state: &AppState, repo: &Repo, sender: &Sender, actor: Option<&str>, oid: &str, text: &str, ) -> forge_core::Result<()> { let store = &state.store; let closing = find_closing_refs(text); for number in find_issue_refs(text) { let Some(issue) = store.issue_by_number(&repo.id, number).await? else { continue; }; if closing.contains(&number) { if issue.state == IssueState::Open { let closed = store .set_issue_state( &issue.id, actor, IssueState::Closed, Some(StateReason::Completed), json!({"commit": oid}), ) .await?; if closed { emit_item_action(state, repo, sender, false, number, "closed").await; notify::dispatch( state, repo, &issue, actor.unwrap_or(""), Activity::Closed, None, ) .await; } } continue; } let already = store.list_events(&issue.id).await?.iter().any(|e| { e.kind == "referenced" && e.data.get("commit").and_then(|c| c.as_str()) == Some(oid) }); if !already { store .add_event(&issue.id, actor, "referenced", json!({"commit": oid})) .await?; } } Ok(()) } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use forge_core::Visibility; use forge_core::webhooks::{Delivery, WebhookEvent}; use forge_git::repo::{init_bare, repo_path}; use serde_json::Value; use tempfile::TempDir; use super::*; use crate::test_support::{KEY, test_state}; const ZERO: &str = "0000000000000000000000000000000000000000"; fn git(dir: &Path, args: &[&str]) -> String { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output() .expect("git runs"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } fn commit(work: &Path, file: &str, contents: &str) -> String { std::fs::write(work.join(file), contents).expect("write file"); git(work, &["add", file]); git(work, &["commit", "-m", contents]); git(work, &["rev-parse", "HEAD"]) } /// A repo with a bare copy on disk, a hook subscribed to push and create, and a work tree. struct Fixture { _tmp: TempDir, state: AppState, repo: Repo, owner_id: String, hook_id: String, bare: PathBuf, work: PathBuf, } async fn fixture() -> Fixture { let tmp = tempfile::tempdir().expect("tempdir"); let state = test_state(tmp.path()).await; let owner = state .store .create_user("alice", None, "unused", false) .await .expect("user"); let repo = state .store .create_repo(&owner.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); let work = tmp.path().join("work"); git(tmp.path(), &["init", "-b", "main", "work"]); let sealed = forge_core::secret::seal(&KEY, b"s").expect("seal"); let hook = state .store .create_webhook( &repo.id, &owner.id, "https://hooks.example/h", sealed.as_bytes(), &[ WebhookEvent::Push, WebhookEvent::Create, WebhookEvent::Delete, ], true, ) .await .expect("hook"); Fixture { _tmp: tmp, state, repo, owner_id: owner.id, hook_id: hook.id, bare, work, } } impl Fixture { /// Pushes `new` to `refname` on the bare repo (`ZERO` deletes it) and runs the post-update /// work the hook would run. async fn update_ref(&self, refname: &str, old: &str, new: &str) { let bare = self.bare.to_str().expect("utf8"); if new == ZERO { git(&self.work, &["push", bare, &format!(":{refname}")]); } else { git(&self.work, &["push", bare, &format!("{new}:{refname}")]); } after_ref_update(&self.state, &self.repo, &self.owner_id, refname, old, new) .await .expect("post-update"); } /// The hook's deliveries of `event`, oldest first, with their bodies parsed. async fn deliveries(&self, event: WebhookEvent) -> Vec<(Delivery, Value)> { let mut rows = self .state .store .list_deliveries(&self.hook_id, 50) .await .expect("list"); rows.reverse(); rows.into_iter() .filter(|d| d.event == event) .map(|d| { let body = serde_json::from_str(&d.request_body).expect("json body"); (d, body) }) .collect() } } #[tokio::test] async fn push_through_post_update_enqueues_before_and_after() { let fx = fixture().await; let c1 = commit(&fx.work, "a.txt", "one"); fx.update_ref("refs/heads/main", ZERO, &c1).await; let pushes = fx.deliveries(WebhookEvent::Push).await; assert_eq!(pushes.len(), 1); let (_, body) = &pushes[0]; assert_eq!(body["ref"], "refs/heads/main"); assert_eq!(body["before"], ZERO); assert_eq!(body["after"], c1.as_str()); assert_eq!(body["total_commits"], 1); assert_eq!(body["commits"][0]["id"], c1.as_str()); assert_eq!(body["commits"][0]["message"], "one"); // Creating the branch also sends a create event for it. let creates = fx.deliveries(WebhookEvent::Create).await; assert_eq!(creates.len(), 1); assert_eq!(creates[0].1["ref"], "main"); assert_eq!(creates[0].1["ref_type"], "branch"); let c2 = commit(&fx.work, "b.txt", "two"); fx.update_ref("refs/heads/main", &c1, &c2).await; let pushes = fx.deliveries(WebhookEvent::Push).await; assert_eq!(pushes.len(), 2); let (_, body) = &pushes[1]; assert_eq!(body["before"], c1.as_str()); assert_eq!(body["after"], c2.as_str()); assert_eq!(body["total_commits"], 1); assert_eq!(body["commits"][0]["id"], c2.as_str()); // A topic branch: create, then delete. Git will not delete the bare repo's // current branch, so the delete uses a branch that is not HEAD. fx.update_ref("refs/heads/topic", ZERO, &c2).await; fx.update_ref("refs/heads/topic", &c2, ZERO).await; let deletes = fx.deliveries(WebhookEvent::Delete).await; assert_eq!(deletes.len(), 1); assert_eq!(deletes[0].1["ref"], "topic"); assert_eq!(deletes[0].1["ref_type"], "branch"); let pushes = fx.deliveries(WebhookEvent::Push).await; assert_eq!(pushes.len(), 4); assert_eq!(pushes[3].1["ref"], "refs/heads/topic"); assert_eq!(pushes[3].1["after"], ZERO); assert_eq!(pushes[3].1["total_commits"], 0); } #[tokio::test] async fn refs_outside_heads_and_tags_produce_no_events() { let fx = fixture().await; let c1 = commit(&fx.work, "a.txt", "one"); let bare = fx.bare.to_str().expect("utf8"); git(&fx.work, &["push", bare, &format!("{c1}:refs/notes/x")]); after_ref_update(&fx.state, &fx.repo, &fx.owner_id, "refs/notes/x", ZERO, &c1) .await .expect("post-update"); assert!(fx.deliveries(WebhookEvent::Push).await.is_empty()); assert!(fx.deliveries(WebhookEvent::Create).await.is_empty()); } }